diff --git a/Package.resolved b/Package.resolved index 42b514b..dcf5136 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "719e2a9c2073e0d449a81e92369494d7d9ac5a83957a66f5e497424604465b76", + "originHash" : "c293f4c78cf631fd248fb31d7664f61c5c3618d0bfbfcf862e6f3712bb8925d3", "pins" : [ { "identity" : "dlkit", @@ -10,6 +10,15 @@ "version" : "3.5.7" } }, + { + "identity" : "ellekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kabiroberai/ellekit", + "state" : { + "branch" : "swiftpm-fixes", + "revision" : "3468e6b2261f059c784c6d37ac3425671c018a1d" + } + }, { "identity" : "fishhook", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d7743fe..f82e8fb 100644 --- a/Package.swift +++ b/Package.swift @@ -5,8 +5,8 @@ import PackageDescription let package = Package( name: "xload", platforms: [ - .macOS(.v14), - .iOS(.v17), + .macOS(.v15), + .iOS(.v18), ], products: [ .library( @@ -25,6 +25,10 @@ let package = Package( branch: "debug-trait", traits: [], // disable DEBUG_ONLY ), + .package( + url: "https://github.com/kabiroberai/ellekit", + branch: "swiftpm-fixes", + ), ], targets: [ .target( @@ -35,6 +39,7 @@ let package = Package( dependencies: [ "CXLoad", .product(name: "InjectionImpl", package: "InjectionLite"), + .product(name: "ellekit", package: "ellekit"), ] ), ], diff --git a/Sources/CXLoad/include/CXLoad.h b/Sources/CXLoad/include/CXLoad.h index febe1c8..2cbba2b 100644 --- a/Sources/CXLoad/include/CXLoad.h +++ b/Sources/CXLoad/include/CXLoad.h @@ -1,6 +1,13 @@ #ifndef CXLoad_h #define CXLoad_h +#include + +typedef struct { + const void *value; + uint8_t state; +} AGValue; + void xload_load(); #endif diff --git a/Sources/XLoad/InjectionWatcher.swift b/Sources/XLoad/InjectionWatcher.swift new file mode 100644 index 0000000..c234c61 --- /dev/null +++ b/Sources/XLoad/InjectionWatcher.swift @@ -0,0 +1,63 @@ +import Foundation +import CXLoad +import Synchronization +import ellekit + +enum SwiftUIInterceptor { + fileprivate typealias CGetValue = @convention(c) (UInt32, UInt32, UnsafeRawPointer?) -> AGValue + + static func install() { + let target = dlsym(dlopen(nil, 0), "AGGraphGetValue")! + let hooked = unsafeBitCast(hookedGetValue as CGetValue, to: UnsafeMutableRawPointer.self) + + _orig.withLock { + // we have to hook inside the lock otherwise there's + // a brief period between when we install the hook and + // when we set orig. if someone calls AGGraphGetValue during that time, + // we'll crash. + let res = ellekit::hook(target, hooked) + $0 = unsafeBitCast(res, to: CGetValue.self) + } + } + + fileprivate static let _orig = Mutex(nil) + // "upgrades" the mutex to an atomic. safe because we never read it before it's set. + fileprivate static let orig = SwiftUIInterceptor._orig.withLock(\.self)! +} + +@c private func hookedGetValue( + _ attribute: UInt32, + _ options: UInt32, + _ type: UnsafeRawPointer?, +) -> AGValue { + // AGGraphGetValue is called on basically every view render (afaict; I hope). + // by registering an Observation access in this method, we make it so any time + // we call notify(), it triggers an update on every SwiftUI view in the app. + InjectionWatcher.shared.subscribe() + let origFunc = SwiftUIInterceptor.orig + return origFunc(attribute, options, type) +} + +private struct InjectionWatcher: Observable, Sendable { + private let registrar = ObservationRegistrar() + + static let shared = InjectionWatcher() + + private init() { + Task { [self] in await watch() } + } + + private func watch() async { + for await _ in NotificationCenter.default.notifications(named: .init("INJECTION_BUNDLE_NOTIFICATION")) { + notify() + } + } + + private func notify() { + registrar.withMutation(of: self, keyPath: \.self) {} + } + + func subscribe() { + registrar.access(self, keyPath: \.self) + } +} diff --git a/Sources/XLoad/XLoad.swift b/Sources/XLoad/XLoad.swift index 242308b..e056c77 100644 --- a/Sources/XLoad/XLoad.swift +++ b/Sources/XLoad/XLoad.swift @@ -16,9 +16,21 @@ import System } private func load() async throws { - guard let watchPath = ProcessInfo.processInfo.environment["XLOAD_WATCH_DIR"] else { - throw StringError("Did not receive XLOAD_WATCH_DIR") - } + loadInterceptor() + try await loadWatcher() +} + +private func loadInterceptor() { + guard ProcessInfo.processInfo.environment["XLOAD_INTERCEPT"] == "1" else { return } + print("[XLoad] Loading interceptor...") + SwiftUIInterceptor.install() +} + +private func loadWatcher() async throws { + guard let watchPath = ProcessInfo.processInfo.environment["XLOAD_WATCH_DIR"] + else { return } + + print("[XLoad] Loading watcher...") let changes = try await watchDirectory(watchPath)