From 0d85c841e48ee4ad14e610bd407caeb2d26d2dcc Mon Sep 17 00:00:00 2001 From: Jason Prasad Date: Sun, 21 Jun 2026 10:39:40 -0400 Subject: [PATCH] iOS shell-owns-agent: flash-free respawn + review fixes + polish A long-lived foreground shell hosts the agent's cross-process scene and holds a cached frame across an agent-only respawn, so the device display never blanks (flash-free). Includes the /simplify and /code-review passes and the respawn-overlay polish. Shell (ios-host/app/Shell/ShellMain.m): - Cache the live hosted frame, detect agent death via agent-UDS EOF, hold the frame under a dim scrim, reconnect on a stable sock, re-host. - Overlay escalates: held frame immediately, spinner after ~1s, wifi.slash disconnected icon after ~10s; a successful re-host cancels both. - Reconnect retries instead of giving up silently after the 30s loop. - Re-host on sceneWillEnterForeground (background return was blank). Daemon (PreviewsIOS): - _relaunch relaunches only the agent on a stable agentSockPath. - stop() terminates the shell and host so neither lingers on the sim. - Host and shell apps build concurrently (async let). Verified live on dev sim C4B7B46A: flash-free gap, overlay escalation (held/spinner/icon), and re-host. Rebased onto main (#237 ios-host/ layout, #238 dark icon, #233 dylib retirement); host-app hash re-pinned. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0116J4m9KC8FVJRjAqeqja32 --- .../EmbedHostAppSource.swift | 12 +- .../EmbedHostAppSourceTool.swift | 20 +- Sources/PreviewsCore/BridgeGenerator.swift | 10 +- Sources/PreviewsCore/Toolchain.swift | 7 + Sources/PreviewsIOS/IOSHostBuilder.swift | 118 ++++- Sources/PreviewsIOS/IOSHostChannel.swift | 14 + Sources/PreviewsIOS/IOSPreviewSession.swift | 77 +++- Sources/PreviewsIOS/SimulatorManager.swift | 30 ++ Sources/SimulatorBridge/SimulatorBridge.m | 20 +- .../SimulatorBridge/include/SimulatorBridge.h | 20 +- .../IOSHostBuilderHashTests.swift | 9 +- .../IOSSimSpikeTests.swift | 222 ++++++++++ ios-host/app/HostApp.swift | 156 ++++++- ios-host/app/Info.plist | 7 + ios-host/app/Shell/Info.plist | 37 ++ ios-host/app/Shell/Shell.entitlements | 26 ++ ios-host/app/Shell/ShellMain.m | 414 ++++++++++++++++++ 17 files changed, 1135 insertions(+), 64 deletions(-) create mode 100644 ios-host/app/Shell/Info.plist create mode 100644 ios-host/app/Shell/Shell.entitlements create mode 100644 ios-host/app/Shell/ShellMain.m diff --git a/Plugins/EmbedHostAppSource/EmbedHostAppSource.swift b/Plugins/EmbedHostAppSource/EmbedHostAppSource.swift index 2340bcb..9706352 100644 --- a/Plugins/EmbedHostAppSource/EmbedHostAppSource.swift +++ b/Plugins/EmbedHostAppSource/EmbedHostAppSource.swift @@ -29,6 +29,10 @@ struct EmbedHostAppSource: BuildToolPlugin { let hostAppSwift = hostAppDir.appending(path: "HostApp.swift") let infoPlist = hostAppDir.appending(path: "Info.plist") let appIconPng = hostAppDir.appending(path: "AppIcon.png") + let shellDir = hostAppDir.appending(path: "Shell") + let shellSource = shellDir.appending(path: "ShellMain.m") + let shellInfoPlist = shellDir.appending(path: "Info.plist") + let shellEntitlements = shellDir.appending(path: "Shell.entitlements") let output = context.pluginWorkDirectoryURL.appending(path: "IOSHostAppSource.generated.swift") return [ @@ -39,9 +43,15 @@ struct EmbedHostAppSource: BuildToolPlugin { hostAppSwift.path(), infoPlist.path(), appIconPng.path(), + shellSource.path(), + shellInfoPlist.path(), + shellEntitlements.path(), output.path(), ], - inputFiles: [hostAppSwift, infoPlist, appIconPng], + inputFiles: [ + hostAppSwift, infoPlist, appIconPng, + shellSource, shellInfoPlist, shellEntitlements, + ], outputFiles: [output] ) ] diff --git a/Sources/EmbedHostAppSourceTool/EmbedHostAppSourceTool.swift b/Sources/EmbedHostAppSourceTool/EmbedHostAppSourceTool.swift index b7be53b..e606445 100644 --- a/Sources/EmbedHostAppSourceTool/EmbedHostAppSourceTool.swift +++ b/Sources/EmbedHostAppSourceTool/EmbedHostAppSourceTool.swift @@ -23,14 +23,19 @@ import Foundation enum EmbedHostAppSourceTool { static func main() throws { let args = CommandLine.arguments - guard args.count == 5 else { - fail("usage: \(args.first ?? "tool") ") + guard args.count == 8 else { + fail( + "usage: \(args.first ?? "tool") " + + " ") } let hostAppB64 = readBase64(args[1]) let infoPlistB64 = readBase64(args[2]) let iconB64 = readBase64(args[3]) - let outputPath = args[4] + let shellCodeB64 = readBase64(args[4]) + let shellInfoPlistB64 = readBase64(args[5]) + let shellEntitlementsB64 = readBase64(args[6]) + let outputPath = args[7] // Interpolating these directly into a Swift source string is safe // because the base64 alphabet (`A-Z a-z 0-9 + /` plus `=` padding) is @@ -52,9 +57,18 @@ enum EmbedHostAppSourceTool { static let bytes: Data = _decodeData(_iconBase64, label: "AppIcon.png") } + enum IOSShellAppSource { + static let code: String = _decodeUTF8(_shellCodeBase64, label: "Shell/ShellMain.m") + static let infoPlist: String = _decodeUTF8(_shellInfoPlistBase64, label: "Shell/Info.plist") + static let entitlements: String = _decodeUTF8(_shellEntitlementsBase64, label: "Shell/Shell.entitlements") + } + private let _hostAppCodeBase64 = "\(hostAppB64)" private let _infoPlistBase64 = "\(infoPlistB64)" private let _iconBase64 = "\(iconB64)" + private let _shellCodeBase64 = "\(shellCodeB64)" + private let _shellInfoPlistBase64 = "\(shellInfoPlistB64)" + private let _shellEntitlementsBase64 = "\(shellEntitlementsB64)" private func _decodeData(_ b64: String, label: String) -> Data { guard let data = Data(base64Encoded: b64) else { diff --git a/Sources/PreviewsCore/BridgeGenerator.swift b/Sources/PreviewsCore/BridgeGenerator.swift index 273659e..cbd70fd 100644 --- a/Sources/PreviewsCore/BridgeGenerator.swift +++ b/Sources/PreviewsCore/BridgeGenerator.swift @@ -319,18 +319,16 @@ public enum BridgeGenerator { private static func iosRenderEntryPoint(viewCode: String, valuesPath: String?) -> String { let seed = designTimeSeed(valuesPath) return """ + @_silgen_name("previewsmcp_set_preview_vc") + func _previewsmcp_set_preview_vc(_ pointer: UnsafeRawPointer) + @_cdecl("renderPreviewToFile") public func renderPreviewToFile() -> Int32 { MainActor.assumeIsolated { \(seed) let view = \(viewCode) let hosting = UIHostingController(rootView: view) - let windows = UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .flatMap { $0.windows } - guard let window = windows.first(where: { $0.isKeyWindow }) ?? windows.first - else { return Int32(-1) } - window.rootViewController = hosting + _previewsmcp_set_preview_vc(Unmanaged.passRetained(hosting).toOpaque()) return 0 } } diff --git a/Sources/PreviewsCore/Toolchain.swift b/Sources/PreviewsCore/Toolchain.swift index 4bdc8e7..6f28588 100644 --- a/Sources/PreviewsCore/Toolchain.swift +++ b/Sources/PreviewsCore/Toolchain.swift @@ -51,6 +51,13 @@ public enum Toolchain { } } + /// Absolute path to the active clang binary (compiles the ObjC shell app). + public static func clangPath() async throws -> String { + try await cached(key: "find:clang") { + try await xcrun(["--find", "clang"], discardStderr: true) + } + } + /// Absolute path to the compiler-rt builtins archive (`libclang_rt.osx.a`), or nil if not /// found. The JIT agent links this so compiler builtins like `__isPlatformVersionAtLeast` /// (emitted by `#available`) resolve at JIT-link time (#191 G3-c). diff --git a/Sources/PreviewsIOS/IOSHostBuilder.swift b/Sources/PreviewsIOS/IOSHostBuilder.swift index b0e1c7c..e323f7a 100644 --- a/Sources/PreviewsIOS/IOSHostBuilder.swift +++ b/Sources/PreviewsIOS/IOSHostBuilder.swift @@ -7,10 +7,12 @@ import PreviewsCore public actor IOSHostBuilder { private let workDir: URL private let swiftcPath: String + private let clangPath: String private let sdkPath: String private let codesignPath: String private let moduleCachePath: URL private var cachedAppPath: URL? + private var cachedShellAppPath: URL? public init(workDir: URL? = nil) async throws { let dir = @@ -23,6 +25,7 @@ public actor IOSHostBuilder { self.sdkPath = try await Toolchain.sdkPath(for: .iOS) self.swiftcPath = try await Toolchain.swiftcPath() + self.clangPath = try await Toolchain.clangPath() self.codesignPath = try await Toolchain.codesignPath() let cacheDir = dir.appendingPathComponent("ModuleCache", isDirectory: true) @@ -34,35 +37,59 @@ public actor IOSHostBuilder { /// Caches the result — subsequent calls return the cached path. /// Rebuilds if the host app source has changed (detected via hash marker). public func ensureHostApp() async throws -> URL { - if let cached = cachedAppPath { - // Check if source hash still matches - let hashFile = cached.appendingPathComponent(".source-hash") - let currentHash = Self.sourceHash - if let savedHash = try? String(contentsOf: hashFile, encoding: .utf8), - savedHash == currentHash - { - return cached - } - // Source changed — invalidate cache and rebuild - cachedAppPath = nil + if let fresh = Self.cachedAppIfFresh(cachedAppPath, hash: Self.sourceHash) { + return fresh } let path = try await buildHostApp() cachedAppPath = path return path } + /// Build the foreground shell app that hosts the agent's cross-process + /// scene, returning the path to the .app bundle. Caches like `ensureHostApp`. + public func ensureShellApp() async throws -> URL { + if let fresh = Self.cachedAppIfFresh(cachedShellAppPath, hash: Self.shellSourceHash) { + return fresh + } + let path = try await buildShellApp() + cachedShellAppPath = path + return path + } + + /// Returns the cached .app only if its on-disk `.source-hash` still matches + /// `hash`; otherwise nil so the caller rebuilds. + private static func cachedAppIfFresh(_ cached: URL?, hash: String) -> URL? { + guard let cached else { return nil } + let hashFile = cached.appendingPathComponent(".source-hash") + if let savedHash = try? String(contentsOf: hashFile, encoding: .utf8), savedHash == hash { + return cached + } + return nil + } + + private static func hashHex(_ chunks: [Data]) -> String { + var hasher = SHA256() + for chunk in chunks { hasher.update(data: chunk) } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + + /// SHA-256 of the shell app source, plist, and entitlements, for cache + /// invalidation (mirrors `sourceHash` for the host app). + private static let shellSourceHash: String = hashHex([ + Data(IOSShellAppSource.code.utf8), + Data(IOSShellAppSource.infoPlist.utf8), + Data(IOSShellAppSource.entitlements.utf8), + ]) + /// SHA-256 hash of the host app source, info plist, and embedded /// AppIcon.png, used for cache invalidation. Hashing only the Swift /// source would miss icon changes — replacing the PNG with a new /// design wouldn't rebuild the cached .app. - private static let sourceHash: String = { - var hasher = SHA256() - hasher.update(data: Data(IOSHostAppSource.code.utf8)) - hasher.update(data: Data(IOSHostAppSource.infoPlist.utf8)) - hasher.update(data: IOSAppIconData.bytes) - let digest = hasher.finalize() - return digest.map { String(format: "%02x", $0) }.joined() - }() + private static let sourceHash: String = hashHex([ + Data(IOSHostAppSource.code.utf8), + Data(IOSHostAppSource.infoPlist.utf8), + IOSAppIconData.bytes, + ]) /// Compile and package the iOS host app. private func buildHostApp() async throws -> URL { @@ -109,6 +136,9 @@ public actor IOSHostBuilder { "-lLLVMTargetParser", "-lLLVMDemangle", "-lc++", + // Export the agent's symbols dynamically so the in-app JIT can + // resolve previewsmcp_set_preview_vc (called by the render entry). + "-Xlinker", "-export_dynamic", ] try await run(compileArgs) @@ -139,6 +169,56 @@ public actor IOSHostBuilder { return appDir } + /// Compile and package the ObjC shell app. + private func buildShellApp() async throws -> URL { + let sourceFile = workDir.appendingPathComponent("PreviewsMCPShell.m") + let entitlementsFile = workDir.appendingPathComponent("PreviewsMCPShell.entitlements") + let binaryPath = workDir.appendingPathComponent("PreviewsMCPShell") + let appDir = workDir.appendingPathComponent("PreviewsMCPShell.app") + let appBinary = appDir.appendingPathComponent("PreviewsMCPShell") + let plistPath = appDir.appendingPathComponent("Info.plist") + + try IOSShellAppSource.code.write(to: sourceFile, atomically: true, encoding: .utf8) + try IOSShellAppSource.entitlements.write( + to: entitlementsFile, atomically: true, encoding: .utf8) + + // The shell carries restricted RunningBoard entitlements. The simulator + // honors them only when embedded in the Mach-O (__TEXT,__entitlements) + // section at link time — a `codesign --entitlements` blob on an ad-hoc + // signature is rejected with errno 163 for any key. + let compileArgs = [ + clangPath, + "-arch", "arm64", + "-mios-simulator-version-min=17.0", + "-isysroot", sdkPath, + "-framework", "UIKit", + "-framework", "Foundation", + "-fobjc-arc", + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__entitlements", + "-Xlinker", entitlementsFile.path, + "-o", binaryPath.path, + sourceFile.path, + ] + try await run(compileArgs) + + try FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) + if FileManager.default.fileExists(atPath: appBinary.path) { + try FileManager.default.removeItem(at: appBinary) + } + try FileManager.default.copyItem(at: binaryPath, to: appBinary) + try IOSShellAppSource.infoPlist.write(to: plistPath, atomically: true, encoding: .utf8) + + // Ad-hoc sign WITHOUT --entitlements (they are section-embedded above). + try await run(codesignPath, "-s", "-", "--force", appDir.path) + + let hashFile = appDir.appendingPathComponent(".source-hash") + try Self.shellSourceHash.write(to: hashFile, atomically: true, encoding: .utf8) + + return appDir + } + // MARK: - Private @discardableResult diff --git a/Sources/PreviewsIOS/IOSHostChannel.swift b/Sources/PreviewsIOS/IOSHostChannel.swift index 816c567..402c70d 100644 --- a/Sources/PreviewsIOS/IOSHostChannel.swift +++ b/Sources/PreviewsIOS/IOSHostChannel.swift @@ -40,6 +40,13 @@ public actor IOSHostChannel { private var latestRSSBytes: UInt64 = 0 public var latestRSS: UInt64 { latestRSSBytes } + /// Latest `applicationState` the host app reported over its `lifecycle` + /// message (`active` / `inactive` / `background`). Nil until the first + /// breadcrumb arrives or after a disconnect. Used as the flash detector: + /// a shell-hosted agent must never report `active`. + private var latestApplicationStateValue: String? + public var latestApplicationState: String? { latestApplicationStateValue } + public init() {} /// Whether the channel has an established connection to the host @@ -326,6 +333,12 @@ public actor IOSHostChannel { continue } + // Unsolicited lifecycle breadcrumb (no id) — record and move on. + if message["type"] as? String == "lifecycle", let state = message["state"] as? String { + latestApplicationStateValue = state + continue + } + // Everything else is a response routed by id. guard let id = message["id"] as? String else { continue @@ -342,6 +355,7 @@ public actor IOSHostChannel { private func handleDisconnect() { connectedFD = -1 latestRSSBytes = 0 + latestApplicationStateValue = nil for (_, continuation) in pendingDataResponses { continuation.resume(throwing: IOSPreviewSessionError.connectionLost) } diff --git a/Sources/PreviewsIOS/IOSPreviewSession.swift b/Sources/PreviewsIOS/IOSPreviewSession.swift index 18f2d29..7fc1b8e 100644 --- a/Sources/PreviewsIOS/IOSPreviewSession.swift +++ b/Sources/PreviewsIOS/IOSPreviewSession.swift @@ -45,6 +45,11 @@ public actor IOSPreviewSession { private var jitListenFD: Int32 = -1 public static let hostBundleID = "com.previewsmcp.host" + public static let shellBundleID = "com.previewsmcp.shell" + + private static func agentLaunchArgs(port: Int, jitPort: Int, agentSockPath: String) -> [String] { + ["--port", String(port), "--jit-port", String(jitPort), "--agent-sock", agentSockPath] + } public init( sourceFile: URL, @@ -95,6 +100,11 @@ public actor IOSPreviewSession { /// Number of memory-triggered host relaunches this session has performed. public private(set) var relaunchCount = 0 + /// Stable agent handshake socket path, set at `start()` and reused across + /// respawns so the long-lived shell reconnects to the same path the new + /// agent rebinds (the shell is never relaunched). + private var agentSockPath: String? + /// Serializes the mutating render entry points (`reload`, `handleSourceChange`). The file /// watcher fires a Task per change, so without this two edits could interleave at an /// `await` and one could render or relaunch while the other has the channel torn down. @@ -143,10 +153,14 @@ public actor IOSPreviewSession { let previewSession = makePreviewSession() self.session = previewSession - // 2. Build host app. + // 2. Build host (agent) app and the foreground shell app that hosts + // its cross-process scene. stage("building host app") await progress?.report(.compilingHostApp, message: "Building iOS host app...") - let appPath = try await hostBuilder.ensureHostApp() + async let hostBuild = hostBuilder.ensureHostApp() + async let shellBuild = hostBuilder.ensureShellApp() + let appPath = try await hostBuild + let shellPath = try await shellBuild stage("host app ready") // 4. Create TCP server on loopback, bind to ephemeral port @@ -169,7 +183,13 @@ public actor IOSPreviewSession { await progress?.report(.installingApp, message: "Installing host app...") await progress?.report(.launchingApp, message: "Launching host app...") - let launchArgs = ["--port", String(port), "--jit-port", String(jitPort)] + // The agent binds this Unix-domain socket; the shell connects to it to + // read the agent's audit token for the hosting handshake. It lives in + // the simulator's shared /tmp, keyed by port so concurrent sessions on + // one simulator don't collide. + let agentSockPath = "/tmp/previewsmcp-agent-\(port).sock" + self.agentSockPath = agentSockPath + let launchArgs = Self.agentLaunchArgs(port: port, jitPort: jitPort, agentSockPath: agentSockPath) var launchedPid: Int? var lastError: Error? @@ -186,6 +206,7 @@ public actor IOSPreviewSession { } stage("attempt \(attempt): installApp") try await simulatorManager.installApp(udid: deviceUDID, appPath: appPath.path) + try await simulatorManager.installApp(udid: deviceUDID, appPath: shellPath.path) stage("attempt \(attempt): install ok") // Open Simulator.app GUI if not headless (only on successful @@ -200,7 +221,9 @@ public actor IOSPreviewSession { // Terminate any stale host instance first (orphan from a // prior test or retry). simctl terminate is a no-op when // the app isn't running and bounds any hang at 30s. - stage("attempt \(attempt): pre-launch terminate stale host") + stage("attempt \(attempt): pre-launch terminate stale host + shell") + await simulatorManager.terminateAppIfRunning( + udid: deviceUDID, bundleID: Self.shellBundleID) await simulatorManager.terminateAppIfRunning( udid: deviceUDID, bundleID: Self.hostBundleID) @@ -229,11 +252,24 @@ public actor IOSPreviewSession { if let lastError { throw lastError } guard let pid = launchedPid else { throw lastError ?? IOSPreviewSessionError.socketCreateFailed } - // 6. Accept connection from host app (up to 10 seconds) + // 6. Accept connection from host app (up to 10 seconds). This also + // confirms the agent's didFinishLaunchingWithOptions has run, so its + // handshake socket is bound before the shell connects to it below. stage("launched pid=\(pid); awaiting socket connection") await progress?.report(.connectingToApp, message: "Waiting for host app connection...") try await channel.awaitConnect(timeout: .seconds(10)) + // 6b. Launch the foreground shell that hosts the agent's scene. The + // shell reads the agent's audit token off the now-bound handshake socket + // and routes a cross-process scene back to the agent. The shell is + // long-lived and survives agent respawns (no relaunch flash). + stage("launching shell app") + _ = try await simulatorManager.launchApp( + udid: deviceUDID, + bundleID: Self.shellBundleID, + arguments: ["--agent-sock", agentSockPath] + ) + // 8. Accept the executor's EPC connection and stand up the remote session // over it. The host connects --port and --jit-port independently, so this // accept is ordered after the JSON connect but either may arrive first @@ -242,8 +278,17 @@ public actor IOSPreviewSession { let epcFD = try acceptJIT(timeoutSeconds: 10) let reloader = try makeJITReloader(epcFD, orcPath) jitReloader = reloader - stage("JIT executor connected; rendering initial preview") + stage("JIT executor connected; compiling initial preview") let build = try await previewSession.compileObjectForJIT() + + // The render entry installs the SwiftUI hosting controller into the + // agent's key window, which only exists once the shell completes the + // hosting handshake and the agent's scene connects. Wait for the agent's + // sceneReady signal so the render targets a real window deterministically. + // The agent is a SwiftUI App; the render hands its controller to a store + // the WindowGroup observes, so the render can run before the hosted + // scene attaches (the store buffers it until SwiftUI's window is up). + stage("rendering initial preview") try await reloader.render(build) stage("initial JIT render complete") @@ -261,6 +306,8 @@ public actor IOSPreviewSession { Darwin.close(jitListenFD) jitListenFD = -1 } + await simulatorManager.terminateAppIfRunning(udid: deviceUDID, bundleID: Self.shellBundleID) + await simulatorManager.terminateAppIfRunning(udid: deviceUDID, bundleID: Self.hostBundleID) } // MARK: - JIT EPC socket @@ -346,6 +393,14 @@ public actor IOSPreviewSession { get async { await channel.latestRSS } } + /// Latest `applicationState` (`active` / `inactive` / `background`) the host + /// app reported over the JSON channel. Nil before the first breadcrumb or + /// after a disconnect. The flash detector: a shell-hosted agent must never + /// report `active`. + public var agentApplicationState: String? { + get async { await channel.latestApplicationState } + } + // MARK: - Communication /// Recompile the preview to an object and re-link it into the live host over @@ -434,15 +489,23 @@ public actor IOSPreviewSession { let port = try await channel.bindAndListen() let jitPort = try bindJITListener() + guard let agentSockPath = self.agentSockPath else { + throw IOSPreviewSessionError.notStarted + } await simulatorManager.terminateAppIfRunning(udid: deviceUDID, bundleID: Self.hostBundleID) let pid = try await simulatorManager.launchApp( udid: deviceUDID, bundleID: Self.hostBundleID, - arguments: ["--port", String(port), "--jit-port", String(jitPort)] + arguments: Self.agentLaunchArgs(port: port, jitPort: jitPort, agentSockPath: agentSockPath) ) try await channel.awaitConnect(timeout: .seconds(10)) + + // Flash-free respawn: the long-lived shell detects the old agent's death + // (its agent-UDS EOFs), holds its cached frame + spinner, then reconnects + // to the same stable sock path the new agent rebinds and re-hosts. No + // shell relaunch, so the device display never blanks. let epcFD = try acceptJIT(timeoutSeconds: 10) let reloader = try makeJITReloader(epcFD, orcPath) self.jitReloader = reloader diff --git a/Sources/PreviewsIOS/SimulatorManager.swift b/Sources/PreviewsIOS/SimulatorManager.swift index 1633318..f505a43 100644 --- a/Sources/PreviewsIOS/SimulatorManager.swift +++ b/Sources/PreviewsIOS/SimulatorManager.swift @@ -255,6 +255,36 @@ public actor SimulatorManager { return pid } + /// Spawn a program inside the device's boot session, the way `simctl spawn` + /// does, with no `xcrun` subprocess. An in-session spawn shares the host + /// loopback network, so the child can connect back to a TCP listener on the + /// host — a bare `SimDevice spawnWithPath:` does not. Returns the PID. + /// + /// `onExit`, if given, is invoked on a background queue with the child's + /// exit status when it terminates. + public func spawnInSession( + udid: String, + program: String, + arguments: [String] = [], + environment: [String: String] = [:], + onExit: (@Sendable (Int32) -> Void)? = nil + ) throws -> Int { + try ensureLoaded() + let sbDevice = try findSBDevice(udid: udid) + var error: NSError? + let pid = sbDevice.spawnInSession( + withPath: program, + arguments: arguments, + environment: environment, + terminationHandler: onExit.map { cb in { status in cb(status) } }, + error: &error) + guard pid >= 0 else { + throw SimulatorError.launchFailed( + error?.localizedDescription ?? "spawnInSession failed for \(program)") + } + return pid + } + // MARK: - Screenshots /// Capture a screenshot using direct IOSurface access. diff --git a/Sources/SimulatorBridge/SimulatorBridge.m b/Sources/SimulatorBridge/SimulatorBridge.m index 9201f2f..4d3a5ee 100644 --- a/Sources/SimulatorBridge/SimulatorBridge.m +++ b/Sources/SimulatorBridge/SimulatorBridge.m @@ -258,11 +258,17 @@ - (NSInteger)launchAppWithBundleID:(NSString *)bundleID } } -- (NSInteger)spawnProcess:(NSString *)path - arguments:(NSArray *)args - environment:(NSDictionary *)env - error:(NSError **)error { +- (NSInteger)spawnInSessionWithPath:(NSString *)path + arguments:(NSArray *)args + environment:(NSDictionary *)env + terminationHandler:(void (^)(int status))handler + error:(NSError **)error { @try { + // Default options (no kSimDeviceSpawnStandalone) → in-session spawn on a + // booted device, matching `simctl spawn` without `--standalone`. The + // SimLaunchHostClient `spawnInSession:` API is not used: it requires the + // device's bootSessionUUID, which is set only in the process that performed + // the boot and is nil for a device booted elsewhere. NSMutableDictionary *options = [NSMutableDictionary dictionary]; if (args) options[@"arguments"] = args; @@ -271,8 +277,10 @@ - (NSInteger)spawnProcess:(NSString *)path int pid = [(id<_SimDevice>)_simDevice spawnWithPath:path options:options - terminationQueue:nil - terminationHandler:nil + terminationQueue: + dispatch_get_global_queue( + QOS_CLASS_USER_INITIATED, 0) + terminationHandler:handler error:error]; return (NSInteger)pid; } @catch (NSException *exception) { diff --git a/Sources/SimulatorBridge/include/SimulatorBridge.h b/Sources/SimulatorBridge/include/SimulatorBridge.h index a847c37..2438d5a 100644 --- a/Sources/SimulatorBridge/include/SimulatorBridge.h +++ b/Sources/SimulatorBridge/include/SimulatorBridge.h @@ -36,12 +36,20 @@ typedef NS_ENUM(NSInteger, SBDeviceState) { (nullable NSDictionary *)env error:(NSError *_Nullable *_Nullable)error; -/// Spawn a process inside the simulator. Returns the PID on success, or -1 on -/// failure. -- (NSInteger)spawnProcess:(NSString *)path - arguments:(nullable NSArray *)args - environment:(nullable NSDictionary *)env - error:(NSError *_Nullable *_Nullable)error; +/// Spawn a process inside the device's boot session ("in-session"), the way +/// `simctl spawn` does (without `--standalone`). On a booted device this is the +/// default `SimDevice spawnWithPath:` behavior: the child shares the boot +/// session, including the host loopback network, so it can reach a TCP listener +/// on the host. (Setting the standalone option, by contrast, gives an isolated +/// process with no in-session networking.) Returns the PID on success, or -1 on +/// failure. `terminationHandler`, if given, is called with the child's exit +/// status when it exits. +- (NSInteger)spawnInSessionWithPath:(NSString *)path + arguments:(nullable NSArray *)args + environment: + (nullable NSDictionary *)env + terminationHandler:(nullable void (^)(int status))handler + error:(NSError *_Nullable *_Nullable)error; @end diff --git a/Tests/PreviewsIOSTests/IOSHostBuilderHashTests.swift b/Tests/PreviewsIOSTests/IOSHostBuilderHashTests.swift index 079cd31..26f93e7 100644 --- a/Tests/PreviewsIOSTests/IOSHostBuilderHashTests.swift +++ b/Tests/PreviewsIOSTests/IOSHostBuilderHashTests.swift @@ -47,7 +47,14 @@ struct IOSHostBuilderHashTests { // to the daemon once a second over the JSON channel (startMemoryReporting). // Updated 2026-06-20 for icon rebrand: AppIcon.png redrawn in the dark // Xcode color scheme (assets/icon.svg). - let expected = "ccd7d1483a11ad7e6b7ad78df9c796e276dc8450b00526fcae7001c542c47656" + // Updated 2026-06-20 for Option 2: HostApp.swift is now a SwiftUI App + // (WindowGroup + PreviewStore) hosted cross-process by the shell, the + // Info.plist scene manifest uses empty UISceneConfigurations, and the + // render installs via previewsmcp_set_preview_vc. + // Updated 2026-06-20 for shell-owns-agent Stage 0: HostApp.swift gained the + // lifecycle breadcrumb (sendLifecycle + applicationDidBecomeActive/ + // applicationDidEnterBackground reporting applicationState over the channel). + let expected = "07b2e439789a8916b5e8525a650748efb3abdaa743ae44ec0589616d32256798" #expect(hash == expected, "host-app artifact hash drifted (was \(expected), now \(hash))") } } diff --git a/Tests/PreviewsJITLinkTests/IOSSimSpikeTests.swift b/Tests/PreviewsJITLinkTests/IOSSimSpikeTests.swift index 31b27b3..2115289 100644 --- a/Tests/PreviewsJITLinkTests/IOSSimSpikeTests.swift +++ b/Tests/PreviewsJITLinkTests/IOSSimSpikeTests.swift @@ -76,6 +76,85 @@ struct IOSSimSpikeTests { } } + /// Stage 0 (shell-owns-agent): the xcrun-free in-session spawn primitive. + /// `SimulatorManager.spawnInSession` drives the default `SimDevice + /// spawnWithPath:` (via SimulatorBridge) to run a program inside the device's + /// boot session — the way `simctl spawn` does, but with no subprocess. A + /// trivial executable that returns immediately must yield a real PID and fire + /// the termination handler, proving in-session spawn works without xcrun. + @Test(.enabled(if: jitOrcRuntimePresent), .timeLimit(.minutes(5))) + func spawnInSessionRunsProgramAndReportsExit() async throws { + let udid = try SimSpikeSupport.bootSimulator() + let exe = try SimSpikeSupport.compileExecutableForIOSSim( + source: "int main(void) { return 0; }", name: "trivial_exit") + + let manager = SimulatorManager() + let exitStatus = ResultBox() + let pid = try await manager.spawnInSession( + udid: udid, + program: exe.path, + onExit: { status in exitStatus.set(status) }) + #expect(pid > 0) + + // The termination handler fires once the trivial program returns. + let deadline = Date().addingTimeInterval(30) + while exitStatus.get() == nil && Date() < deadline { + try await Task.sleep(for: .milliseconds(100)) + } + #expect(exitStatus.get() != nil) + } + + /// Stage 1 keystone (R1, networking half): an in-session-spawned process gets + /// host-loopback networking. The spawned program connects to a 127.0.0.1 + /// listener on the host and writes a byte; receiving it proves `spawnInSession` + /// (default `spawnWithPath:`) shares the boot session's network — refuting the + /// prior claim that bare `spawnWithPath` is not in-session. This is what makes + /// the daemon-side spawn a viable launch primitive (the plan's fallback). + @Test(.enabled(if: jitOrcRuntimePresent), .timeLimit(.minutes(5))) + func inSessionSpawnGetsHostLoopbackNetworking() async throws { + let udid = try SimSpikeSupport.bootSimulator() + let exe = try SimSpikeSupport.compileExecutableForIOSSim( + source: """ + #include + #include + #include + #include + #include + int main(int argc, char **argv) { + if (argc < 2) return 1; + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) return 2; + struct sockaddr_in addr; + addr.sin_family = AF_INET; + addr.sin_port = htons((unsigned short)atoi(argv[1])); + addr.sin_addr.s_addr = inet_addr("127.0.0.1"); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) return 3; + char b = 42; + write(fd, &b, 1); + close(fd); + return 0; + } + """, + name: "loopback_connect") + + let listener = try SimSpikeSupport.openLoopbackListener() + defer { close(listener.fd) } + + let manager = SimulatorManager() + let pid = try await manager.spawnInSession( + udid: udid, + program: exe.path, + arguments: [exe.path, String(listener.port)]) + #expect(pid > 0) + + let conn = try SimSpikeSupport.acceptOne(listenFD: listener.fd, timeoutSeconds: 30) + defer { close(conn) } + var byte: UInt8 = 0 + let n = read(conn, &byte, 1) + #expect(n == 1) + #expect(byte == 42) + } + /// Phase 2 fold: the REAL production host app (built by IOSHostBuilder with /// the executor linked from PreviewsIOS resources) hosts the ORC executor /// and the daemon links + runs an object inside it over the EPC socket. JIT @@ -201,6 +280,56 @@ struct IOSSimSpikeTests { await session.stop() } + /// Stage 0 (shell-owns-agent): the agent lifecycle breadcrumb. The host app + /// reports its `applicationState` over the JSON channel; `IOSPreviewSession` + /// exposes the latest as `agentApplicationState`. After `start()` the daemon + /// must observe a valid breadcrumb, proving the flash detector is wired end + /// to end. The exact state is environment-dependent (a headless simctl launch + /// reports `background`, not `active`); later shell-hosted stages assert the + /// agent stays non-`active` across respawn, where the value is meaningful. + @Test(.enabled(if: jitOrcRuntimePresent), .timeLimit(.minutes(10))) + func agentReportsForegroundLifecycleState() async throws { + let udid = try SimSpikeSupport.bootSimulator() + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-ios-jit-lifecycle-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let sourceFile = tempDir.appendingPathComponent("HelloView.swift") + try Self.helloViewSource.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler(platform: .iOS) + let hostBuilder = try await IOSHostBuilder() + let simulatorManager = SimulatorManager() + + let session = IOSPreviewSession( + sourceFile: sourceFile, + deviceUDID: udid, + compiler: compiler, + hostBuilder: hostBuilder, + simulatorManager: simulatorManager, + makeJITReloader: { fd, orcPath in + try IOSJITStructuralReloader(remoteFD: fd, orcRuntimePath: orcPath) + } + ) + defer { + SimSpikeSupport.terminateApp(udid: udid, bundleID: IOSPreviewSession.hostBundleID) + } + + let pid = try await session.start() + #expect(pid > 0) + + var state: String? + let deadline = Date().addingTimeInterval(10) + while Date() < deadline { + state = await session.agentApplicationState + if state != nil { break } + try await Task.sleep(for: .milliseconds(200)) + } + #expect(["active", "inactive", "background"].contains(state)) + await session.stop() + } + /// Keystone for #221 reclaim: `relaunch()` must terminate and relaunch the host, re-accept /// both the JSON and EPC channels, rebuild the reloader, and re-render the current source. /// After the relaunch the accessibility tree fetched over the (re-established) JSON channel @@ -251,9 +380,80 @@ struct IOSSimSpikeTests { let after = try await session.fetchElements() #expect(after.contains("Hello from iOS JIT!")) + + // Stage 2 re-host evidence: save the post-relaunch DEVICE-DISPLAY frame + // (the shell composite) for visual inspection. A JPEG-size assertion + // cannot tell a re-hosted frame from a dead-black one (both ~70KB), so + // the re-host check is the saved image, not an automated threshold. + let afterShot = try await simulatorManager.screenshotData(udid: udid) + let shotPath = FileManager.default.temporaryDirectory + .appendingPathComponent("stage2-rehost.jpg") + try afterShot.write(to: shotPath) + print("[stage2] post-relaunch device display → \(shotPath.path) (\(afterShot.count) bytes)") await session.stop() } + /// Flash-free gap verification: capture the device display rapidly WHILE a + /// respawn happens. During the gap the shell should hold its cached frame + + /// spinner (never black / springboard), then show the re-hosted content. No + /// assertion (the signal is visual); saves frames to /tmp/flashfree-gap. + @Test(.enabled(if: jitOrcRuntimePresent), .timeLimit(.minutes(10))) + func flashFreeRespawnGap() async throws { + let udid = try SimSpikeSupport.bootSimulator() + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("previewsmcp-gap-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let sourceFile = tempDir.appendingPathComponent("HelloView.swift") + try Self.helloViewSource.write(to: sourceFile, atomically: true, encoding: .utf8) + + let compiler = try await Compiler(platform: .iOS) + let hostBuilder = try await IOSHostBuilder() + let simulatorManager = SimulatorManager() + let session = IOSPreviewSession( + sourceFile: sourceFile, + deviceUDID: udid, + compiler: compiler, + hostBuilder: hostBuilder, + simulatorManager: simulatorManager, + makeJITReloader: { fd, orcPath in + try IOSJITStructuralReloader(remoteFD: fd, orcRuntimePath: orcPath) + } + ) + defer { + SimSpikeSupport.terminateApp(udid: udid, bundleID: IOSPreviewSession.shellBundleID) + SimSpikeSupport.terminateApp(udid: udid, bundleID: IOSPreviewSession.hostBundleID) + } + + _ = try await session.start() + let before = try await session.fetchElements() + #expect(before.contains("Hello from iOS JIT!")) + + let outDir = URL(fileURLWithPath: "/tmp/flashfree-gap") + try? FileManager.default.removeItem(at: outDir) + try? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + let baseline = try await simulatorManager.screenshotData(udid: udid) + try baseline.write(to: outDir.appendingPathComponent("00-baseline.jpg")) + + // Kill ONLY the agent and do NOT relaunch it, so the shell sits in the + // gap (retrying reconnect) — that is exactly when the cached-frame + + // spinner overlay should be on screen. Capture it serially. With the fix + // these frames show the held "Hello" + spinner; pre-fix they were black. + await simulatorManager.terminateAppIfRunning( + udid: udid, bundleID: IOSPreviewSession.hostBundleID) + for i in 1...8 { + try await Task.sleep(for: .milliseconds(700)) + let shot = try await simulatorManager.screenshotData(udid: udid) + try shot.write(to: outDir.appendingPathComponent(String(format: "%02d-gap.jpg", i))) + print("[flashfree] gap frame \(i) (\(shot.count) bytes)") + // Measured bands for this fixed preview: a black/dead-scene frame is + // ~61KB, the held cached frame + dim + spinner is ~86KB, and the + // springboard (shell crashed/backgrounded) is ~364KB. Require the + // held band: not black (too small) and not springboard (too big). + #expect(shot.count > 70_000 && shot.count < 200_000) + } + } + /// Chunk 3 gating: a structural edit past the memory threshold relaunches the host /// instead of relinking in place. With the threshold forced to 0, the first structural /// edit seeds the RSS baseline (no relaunch) and the second crosses it, so `relaunchCount` @@ -697,6 +897,28 @@ enum SimSpikeSupport { return output } + /// Compile + link a trivial executable for the iphonesimulator so a test can + /// spawn it inside a booted device. Returns the host path to the binary. + static func compileExecutableForIOSSim(source: String, name: String) throws -> URL { + let outDir = FileManager.default.temporaryDirectory + .appendingPathComponent("PreviewsJITLinkIOSSimExe", isDirectory: true) + try FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true) + let src = outDir.appendingPathComponent("\(name).c") + try source.write(to: src, atomically: true, encoding: .utf8) + let exe = outDir.appendingPathComponent(name) + + let sdk = try run("/usr/bin/xcrun", ["--sdk", "iphonesimulator", "--show-sdk-path"]) + .output.trimmingCharacters(in: .whitespacesAndNewlines) + let target = "arm64-apple-ios16.0-simulator" + let result = try run( + "/usr/bin/xcrun", + ["clang", "-target", target, "-isysroot", sdk, src.path, "-o", exe.path]) + guard result.status == 0 else { + throw SpikeError.message("compiling executable \(name) for iossim failed:\n\(result.output)") + } + return exe + } + static func bootSimulator() throws -> String { if let udid = firstUDID( in: try run( diff --git a/ios-host/app/HostApp.swift b/ios-host/app/HostApp.swift index 69cfc65..bf035de 100644 --- a/ios-host/app/HostApp.swift +++ b/ios-host/app/HostApp.swift @@ -1,10 +1,56 @@ import SwiftUI import UIKit +/// Holds the rendered preview controller for the SwiftUI window to display. +/// The agent is a SwiftUI `App` so SwiftUI owns the scene -> window -> render +/// binding (matching Apple's XCPreviewAgent); a manually built UIKit window +/// installed after the hosted scene activates renders only intermittently. +final class PreviewStore: ObservableObject { + static let shared = PreviewStore() + @Published var contentViewController: UIViewController? +} + +/// Called by the JIT'd render entry (over the in-app executor) to install the +/// freshly rendered preview. Exported as a C symbol so the JIT can resolve it. +@_cdecl("previewsmcp_set_preview_vc") +public func previewsmcp_set_preview_vc(_ pointer: UnsafeRawPointer) { + let viewController = Unmanaged.fromOpaque(pointer).takeRetainedValue() + if Thread.isMainThread { + PreviewStore.shared.contentViewController = viewController + } else { + DispatchQueue.main.async { PreviewStore.shared.contentViewController = viewController } + } +} + +private struct PreviewContainer: UIViewControllerRepresentable { + let viewController: UIViewController + func makeUIViewController(context: Context) -> UIViewController { viewController } + func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} +} + +private struct PreviewRootView: View { + @ObservedObject private var store = PreviewStore.shared + var body: some View { + ZStack { + Color.white.ignoresSafeArea() + if let viewController = store.contentViewController { + PreviewContainer(viewController: viewController) + .id(ObjectIdentifier(viewController)) + .ignoresSafeArea() + } + } + } +} + @main -class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { - var window: UIWindow? +struct PreviewAgentApp: App { + @UIApplicationDelegateAdaptor(PreviewHostAppDelegate.self) var appDelegate + var body: some Scene { + WindowGroup { PreviewRootView() } + } +} +class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { // TCP socket state private var socketFD: Int32 = -1 private var socketReadSource: DispatchSourceRead? @@ -14,10 +60,6 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - let window = UIWindow(frame: UIScreen.main.bounds) - window.backgroundColor = .white - self.window = window - let args = ProcessInfo.processInfo.arguments // The in-process ORC executor links objects pushed by the daemon over the @@ -29,15 +71,18 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { startJITExecutor(port: jitPort) } - // No preview is loaded at launch; the daemon's first render over EPC - // installs the real hosting controller. Until then, give the window a - // placeholder root view controller so UIKit's end-of-launch assertion - // ("windows are expected to have a root view controller") doesn't abort. - window.rootViewController = UIViewController() - initTouchInjection() activateAccessibility() + // Bind the hosting-handshake socket before connecting the JSON channel. + // The daemon waits for that JSON connection before launching the shell, + // so binding first guarantees the socket exists when the shell connects. + if let sockIndex = args.firstIndex(of: "--agent-sock"), + sockIndex + 1 < args.count + { + startAgentSocket(path: args[sockIndex + 1]) + } + if let portIndex = args.firstIndex(of: "--port"), portIndex + 1 < args.count, let port = UInt16(args[portIndex + 1]) @@ -45,10 +90,41 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { connectToServer(port: port) } - window.makeKeyAndVisible() + sendLifecycle("didFinishLaunching") return true } + // Lifecycle breadcrumb (flash detector): report whether the agent comes to + // the foreground. A shell-hosted agent must stay non-foreground; a self- + // foregrounding launch (the relaunch flash) shows up here as `active`. + func applicationDidBecomeActive(_ application: UIApplication) { + sendLifecycle("didBecomeActive") + } + + func applicationDidEnterBackground(_ application: UIApplication) { + sendLifecycle("didEnterBackground") + } + + private func sendLifecycle(_ phase: String) { + let state: String + switch UIApplication.shared.applicationState { + case .active: state = "active" + case .inactive: state = "inactive" + case .background: state = "background" + @unknown default: state = "unknown" + } + sendResponse(["type": "lifecycle", "phase": phase, "state": state]) + } + + // The visible window is owned by SwiftUI's WindowGroup. The touch and + // accessibility paths look it up dynamically rather than holding a ref. + func currentWindow() -> UIWindow? { + let windows = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + return windows.first { $0.isKeyWindow } ?? windows.first + } + // Connect back to the daemon's EPC listener and run the in-process ORC // executor. Runs on a detached thread so the SimpleRemoteEPCServer can // block on the socket while the UIApplication run loop stays live on main @@ -119,6 +195,56 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { startMemoryReporting() } + // MARK: - Hosted-scene handshake socket + + // Unix-domain socket the shell connects to during the cross-process + // scene-hosting handshake. The shell reads this process's audit token off + // the peer connection (getsockopt LOCAL_PEERTOKEN) and registers it with + // FrontBoard so it can route a hosted scene to us. We only accept and hold + // the connection open; no bytes are exchanged. + private var agentSocketFD: Int32 = -1 + + private func startAgentSocket(path: String) { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + NSLog("PreviewHost: agent socket create failed") + return + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let pathCapacity = MemoryLayout.size(ofValue: addr.sun_path) + withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in + pathPtr.withMemoryRebound(to: CChar.self, capacity: pathCapacity) { dst in + _ = path.withCString { strncpy(dst, $0, pathCapacity - 1) } + } + } + + let bound = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in + Darwin.bind(fd, sockPtr, socklen_t(MemoryLayout.size)) + } + } + guard bound == 0 else { + NSLog("PreviewHost: agent socket bind failed errno=\(errno)") + Darwin.close(fd) + return + } + + Darwin.listen(fd, 8) + agentSocketFD = fd + NSLog("PreviewHost: agent socket listening at \(path)") + + Thread.detachNewThread { + while true { + let client = Darwin.accept(fd, nil, nil) + if client < 0 { break } + NSLog("PreviewHost: agent socket client accepted") + } + } + } + // Report resident memory to the daemon once a second over the JSON channel. // The daemon gates host relaunch (to reclaim leaked JIT'd `__swift5_*`/ObjC // metadata it cannot free in-process) on this value. Sends run on the main @@ -190,7 +316,7 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { handleTouchCommand(msg) case "elements": - guard let window = self.window else { return } + guard let window = currentWindow() else { return } let tree = snapshotElement(window, window: window) ?? ["children": [] as [Any]] var response: [String: Any] = ["type": "elementsResponse", "tree": tree] if let id = msg["id"] { response["id"] = id } @@ -413,7 +539,7 @@ class PreviewHostAppDelegate: UIResponder, UIApplicationDelegate { // Get window context ID via private _contextId property var contextId: UInt32 = 0 - if let w = self.window { + if let w = currentWindow() { let sel = NSSelectorFromString("_contextId") if w.responds(to: sel) { contextId = UInt32( diff --git a/ios-host/app/Info.plist b/ios-host/app/Info.plist index 0453a65..ba191d7 100644 --- a/ios-host/app/Info.plist +++ b/ios-host/app/Info.plist @@ -30,5 +30,12 @@ LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + \ No newline at end of file diff --git a/ios-host/app/Shell/Info.plist b/ios-host/app/Shell/Info.plist new file mode 100644 index 0000000..d660c25 --- /dev/null +++ b/ios-host/app/Shell/Info.plist @@ -0,0 +1,37 @@ + + + + + CFBundleIdentifier + com.previewsmcp.shell + CFBundleExecutable + PreviewsMCPShell + CFBundleName + PreviewsMCPShell + CFBundlePackageType + APPL + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + LSRequiresIPhoneOS + + UILaunchScreen + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default + + + + + + diff --git a/ios-host/app/Shell/Shell.entitlements b/ios-host/app/Shell/Shell.entitlements new file mode 100644 index 0000000..3cadb93 --- /dev/null +++ b/ios-host/app/Shell/Shell.entitlements @@ -0,0 +1,26 @@ + + + + + com.apple.dt.previewsd.allowed + + com.apple.runningboard.assertions.frontboard + + com.apple.runningboard.assertions.xcodepreviews + + com.apple.runningboard.hereditarygrantoriginator + + com.apple.runningboard.launchprocess + + com.apple.runningboard.primitiveattribute + + com.apple.runningboard.process-state + + com.apple.runningboard.terminateprocess + + com.apple.runningboard.trustedtarget + + com.apple.springboard.keyboardfocusservice + + + diff --git a/ios-host/app/Shell/ShellMain.m b/ios-host/app/Shell/ShellMain.m new file mode 100644 index 0000000..76910f8 --- /dev/null +++ b/ios-host/app/Shell/ShellMain.m @@ -0,0 +1,414 @@ +#import +#import +#include +#include +#include +#include + +typedef struct { + unsigned int val[8]; +} pvm_audit_token_t; +#ifndef LOCAL_PEERTOKEN +#define LOCAL_PEERTOKEN 0x006 +#endif + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + +static NSString *argval(NSString *key, NSString *def) { + NSArray *a = NSProcessInfo.processInfo.arguments; + for (NSUInteger i = 0; i + 1 < a.count; i++) + if ([a[i] isEqualToString:key]) return a[i + 1]; + return def; +} + +@interface ShellSceneDelegate : UIResponder +@property(nonatomic, strong) UIWindow *window; +@property(nonatomic, strong) id host; +@property(nonatomic, strong) id fgAssertion; +@property(nonatomic, strong) UIViewController *hostedVC; +@property(nonatomic, copy) NSString *agentSock; +@property(nonatomic, assign) int agentFD; +@property(nonatomic, strong) UIImage *cachedFrame; +@property(nonatomic, strong) UIView *overlay; +@property(nonatomic, strong) UIActivityIndicatorView *spinner; +@property(nonatomic, strong) UIImageView *disconnectedIcon; +@property(nonatomic, copy) dispatch_block_t spinnerBlock; +@property(nonatomic, copy) dispatch_block_t disconnectBlock; +@property(nonatomic, strong) dispatch_source_t deathSource; +@property(nonatomic, strong) dispatch_source_t snapTimer; +@property(nonatomic, assign) BOOL wasBackgrounded; +- (void)showSpinner; +- (void)showDisconnected; +@end + +@implementation ShellSceneDelegate +- (void)scene:(UIScene *)scene + willConnectToSession:(UISceneSession *)session + options:(UISceneConnectionOptions *)opts { + UIWindowScene *ws = (UIWindowScene *)scene; + self.window = [[UIWindow alloc] initWithWindowScene:ws]; + UIViewController *root = [UIViewController new]; + root.view.backgroundColor = [UIColor blackColor]; + self.window.rootViewController = root; + [self.window makeKeyAndVisible]; + self.agentSock = argval(@"--agent-sock", @""); + self.agentFD = -1; + [self connectAndHost]; +} + +// Connect to the agent's UDS (retrying — on respawn the new agent binds it +// asynchronously), read its audit token, then host its scene on the main queue. +// The held connection doubles as the death detector (watchAgentDeath). +- (void)connectAndHost { + NSString *sock = self.agentSock; + if (sock.length == 0) { + NSLog(@"[SHELL] no --agent-sock"); + return; + } + [NSThread detachNewThreadWithBlock:^{ + int fd = -1; + for (int i = 0; i < 300; i++) { + fd = socket(AF_UNIX, SOCK_STREAM, 0); + struct sockaddr_un a; + memset(&a, 0, sizeof a); + a.sun_family = AF_UNIX; + strncpy(a.sun_path, sock.UTF8String, sizeof(a.sun_path) - 1); + if (connect(fd, (struct sockaddr *)&a, sizeof a) == 0) break; + close(fd); + fd = -1; + usleep(100 * 1000); + } + if (fd < 0) { + NSLog(@"[SHELL] agent UDS connect failed; retrying"); + dispatch_async(dispatch_get_main_queue(), ^{ + [self connectAndHost]; + }); + return; + } + pvm_audit_token_t tok; + socklen_t len = sizeof tok; + if (getsockopt(fd, SOL_LOCAL, LOCAL_PEERTOKEN, &tok, &len) != 0) { + NSLog(@"[SHELL] LOCAL_PEERTOKEN errno=%d", errno); + close(fd); + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ + self.agentFD = fd; + [self hostWithToken:tok]; + [self watchAgentDeath:fd]; + [self startSnapshotCache]; + [self hideOverlay]; + }); + }]; +} + +// Resolve the agent's audit token into a routable FrontBoard scene identity. +- (id)clientIdentityForToken:(pvm_audit_token_t)tok { + dlopen("/System/Library/PrivateFrameworks/FrontBoard.framework/FrontBoard", RTLD_NOW); + Class FBPM = NSClassFromString(@"FBProcessManager"); + id mgr = [FBPM performSelector:@selector(sharedInstance)]; + id fbproc = ((id (*)(id, SEL, pvm_audit_token_t))objc_msgSend)( + mgr, @selector(registerProcessForAuditToken:), tok); + id procIdentity = fbproc ? [fbproc performSelector:@selector(identity)] : nil; + if (!procIdentity) { + NSLog(@"[SHELL] registerProcessForAuditToken failed"); + return nil; + } + Class ClientId = NSClassFromString(@"FBSSceneClientIdentity"); + return [ClientId performSelector:@selector(identityForProcessIdentity:) withObject:procIdentity]; +} + +- (void)hostWithToken:(pvm_audit_token_t)tok { + id clientId = [self clientIdentityForToken:tok]; + if (!clientId) return; + Class AdvCfg = NSClassFromString(@"_UISceneHostingControllerAdvancedConfiguration"); + Class HostC = NSClassFromString(@"_UISceneHostingController"); + Class SpecC = NSClassFromString(@"UIApplicationSceneSpecification"); + id adv = [[AdvCfg alloc] performSelector:@selector(initWithClientIdentity:) withObject:clientId]; + id spec = [[SpecC alloc] init]; + [adv performSelector:@selector(setSceneSpecification:) withObject:spec]; + void (^settingsUpdater)(id) = ^(id settings) { + if ([settings respondsToSelector:@selector(setDeactivationReasons:)]) + ((void (*)(id, SEL, unsigned long long))objc_msgSend)( + settings, @selector(setDeactivationReasons:), 0ULL); + if ([settings respondsToSelector:@selector(setForeground:)]) + ((void (*)(id, SEL, BOOL))objc_msgSend)(settings, @selector(setForeground:), YES); + if ([settings respondsToSelector:@selector(setBackgrounded:)]) + ((void (*)(id, SEL, BOOL))objc_msgSend)(settings, @selector(setBackgrounded:), NO); + }; + if ([adv respondsToSelector:@selector(setInitialSettingsUpdater:)]) + [adv performSelector:@selector(setInitialSettingsUpdater:) withObject:settingsUpdater]; + self.host = [[HostC alloc] performSelector:@selector(initWithAdvancedConfiguration:) + withObject:adv]; + UIViewController *root = self.window.rootViewController; + id svc = [self.host performSelector:@selector(sceneViewController)]; + if ([svc isKindOfClass:[UIViewController class]]) { + UIViewController *vc = svc; + self.hostedVC = vc; + [root addChildViewController:vc]; + UIView *vv = vc.view; + vv.frame = self.window.bounds; + vv.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + vv.backgroundColor = [UIColor clearColor]; + // Insert below any held-frame overlay so the live scene appears only + // once we drop the overlay. + [root.view insertSubview:vv atIndex:0]; + [vc didMoveToParentViewController:root]; + } + [self activateHosted]; + NSLog(@"[SHELL] hosted agent"); +} + +// (Re)assert the hosted scene's foreground activation. Called at host time and +// again on every foreground transition — the hosted scene loses its foreground +// activation when the shell backgrounds and would otherwise come back blank. +- (void)activateHosted { + if (!self.host) return; + id comp = [self.host performSelector:@selector(activationStateComponent)]; + @try { + // Invalidate any prior assertion before replacing it (a BaseBoard + // assertion traps in -dealloc if released while still active). + if (self.fgAssertion) { + if ([self.fgAssertion respondsToSelector:@selector(invalidate)]) + ((void (*)(id, SEL))objc_msgSend)(self.fgAssertion, @selector(invalidate)); + self.fgAssertion = nil; + } + self.fgAssertion = [comp performSelector:@selector(foregroundAssertionForReason:) + withObject:@"previewsmcp-shell"]; + ((void (*)(id, SEL, id))objc_msgSend)(comp, @selector(activate:), ^{ + NSLog(@"[SHELL] activate done"); + }); + } @catch (NSException *e) { + NSLog(@"[SHELL] EXC activate: %@", e); + } +} + + +// Cache the live hosted frame ~1x/sec so a recent frame is available to hold +// across an agent respawn. The hosted view goes black the instant the agent +// dies, so the cache must be taken while it is alive (afterScreenUpdates:YES +// rasterizes the cross-process scene — proven by the derisk probe). +- (void)startSnapshotCache { + [self stopSnapshotCache]; + dispatch_source_t t = dispatch_source_create( + DISPATCH_SOURCE_TYPE_TIMER, 0, 0, dispatch_get_main_queue()); + dispatch_source_set_timer(t, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + (uint64_t)(1.0 * NSEC_PER_SEC), (uint64_t)(0.3 * NSEC_PER_SEC)); + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(t, ^{ + typeof(self) self2 = weakSelf; + if (!self2 || !self2.window || self2.overlay) return; + UIView *v = self2.window; + UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithBounds:v.bounds]; + self2.cachedFrame = [r imageWithActions:^(UIGraphicsImageRendererContext *_Nonnull ctx) { + (void)ctx; + [v drawViewHierarchyInRect:v.bounds afterScreenUpdates:YES]; + }]; + }); + dispatch_resume(t); + self.snapTimer = t; +} + +- (void)stopSnapshotCache { + if (self.snapTimer) { + dispatch_source_cancel(self.snapTimer); + self.snapTimer = nil; + } +} + +// Watch the held agent UDS for EOF: when the agent process dies the socket +// becomes readable and recv() returns 0. That is the flash-free respawn trigger. +- (void)watchAgentDeath:(int)fd { + dispatch_source_t s = dispatch_source_create( + DISPATCH_SOURCE_TYPE_READ, fd, 0, dispatch_get_main_queue()); + __weak typeof(self) weakSelf = self; + dispatch_source_set_event_handler(s, ^{ + char buf[64]; + ssize_t n = recv(fd, buf, sizeof buf, 0); + if (n > 0) return; // unexpected data from the agent; ignore + typeof(self) self2 = weakSelf; + if (!self2 || self2.agentFD != fd) return; + [self2 onAgentDeath]; + }); + dispatch_resume(s); + self.deathSource = s; +} + +// Agent died: freeze the cached frame + spinner over the dead scene, tear the +// dead host down, and reconnect to the same sock (retrying) to re-host the new +// agent. No shell restart, so the device display never blanks (flash-free). +// Tear down the current hosting so a fresh connectAndHost can rebuild it. +- (void)teardownHost { + [self stopSnapshotCache]; + if (self.deathSource) { + dispatch_source_cancel(self.deathSource); + self.deathSource = nil; + } + if (self.agentFD >= 0) { + close(self.agentFD); + self.agentFD = -1; + } + if (self.hostedVC) { + [self.hostedVC willMoveToParentViewController:nil]; + [self.hostedVC.view removeFromSuperview]; + [self.hostedVC removeFromParentViewController]; + self.hostedVC = nil; + } + // A BaseBoard assertion traps in -dealloc if released while still active, + // so invalidate it before dropping the reference. + if (self.fgAssertion) { + @try { + if ([self.fgAssertion respondsToSelector:@selector(invalidate)]) + ((void (*)(id, SEL))objc_msgSend)(self.fgAssertion, @selector(invalidate)); + } @catch (NSException *e) { + NSLog(@"[SHELL] fgAssertion invalidate: %@", e); + } + self.fgAssertion = nil; + } + self.host = nil; +} + +- (void)rehost { + [self showOverlay]; + [self teardownHost]; + [self connectAndHost]; +} + +- (void)onAgentDeath { + NSLog(@"[SHELL] agent died; holding cached frame + spinner, re-hosting"); + [self rehost]; +} + +// The cross-process hosted scene loses its content when the shell backgrounds +// and does not recover by itself, so re-host on return. Stop caching while +// backgrounded so the last good frame stays in the overlay. +- (void)sceneDidEnterBackground:(UIScene *)scene { + self.wasBackgrounded = YES; + [self stopSnapshotCache]; +} + +- (void)sceneWillEnterForeground:(UIScene *)scene { + if (self.host && self.wasBackgrounded) { + self.wasBackgrounded = NO; + NSLog(@"[SHELL] foreground after background; re-hosting"); + [self rehost]; + } +} + +// Hold the cached frame under a dim scrim immediately (flash-free), then escalate: +// a spinner appears only if the respawn is slow, and a disconnected icon replaces +// it if the agent never comes back. A successful re-host calls hideOverlay, which +// cancels both pending transitions. +- (void)showOverlay { + if (self.overlay) return; + UIView *root = self.window.rootViewController.view; + UIView *ov = [[UIView alloc] initWithFrame:root.bounds]; + ov.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + ov.backgroundColor = [UIColor blackColor]; + if (self.cachedFrame) { + UIImageView *iv = [[UIImageView alloc] initWithFrame:ov.bounds]; + iv.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + iv.contentMode = UIViewContentModeScaleAspectFill; + iv.image = self.cachedFrame; + [ov addSubview:iv]; + } + // Subtle dim scrim so the held frame reads as "reloading" and the spinner + // stays visible over any background color. + UIView *scrim = [[UIView alloc] initWithFrame:ov.bounds]; + scrim.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + scrim.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.25]; + [ov addSubview:scrim]; + [root addSubview:ov]; + self.overlay = ov; + + __weak typeof(self) weakSelf = self; + dispatch_block_t spin = dispatch_block_create(0, ^{ + [weakSelf showSpinner]; + }); + dispatch_block_t disc = dispatch_block_create(0, ^{ + [weakSelf showDisconnected]; + }); + self.spinnerBlock = spin; + self.disconnectBlock = disc; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), spin); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(10.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), disc); +} + +- (UIActivityIndicatorView *)centeredSpinner { + UIActivityIndicatorView *spin = [[UIActivityIndicatorView alloc] + initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge]; + spin.color = [UIColor whiteColor]; + spin.center = CGPointMake(self.overlay.bounds.size.width / 2.0, + self.overlay.bounds.size.height / 2.0); + spin.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | + UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + return spin; +} + +- (void)showSpinner { + if (!self.overlay || self.spinner) return; + UIActivityIndicatorView *spin = [self centeredSpinner]; + [spin startAnimating]; + [self.overlay addSubview:spin]; + self.spinner = spin; +} + +- (void)showDisconnected { + if (!self.overlay || self.disconnectedIcon) return; + if (self.spinner) { + [self.spinner removeFromSuperview]; + self.spinner = nil; + } + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:48]; + UIImage *img = [UIImage systemImageNamed:@"wifi.slash" withConfiguration:cfg]; + UIImageView *iv = [[UIImageView alloc] initWithImage:img]; + iv.tintColor = [UIColor whiteColor]; + iv.contentMode = UIViewContentModeCenter; + [iv sizeToFit]; + iv.center = CGPointMake(self.overlay.bounds.size.width / 2.0, + self.overlay.bounds.size.height / 2.0); + iv.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin | + UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin; + [self.overlay addSubview:iv]; + self.disconnectedIcon = iv; +} + +- (void)hideOverlay { + if (self.spinnerBlock) { + dispatch_block_cancel(self.spinnerBlock); + self.spinnerBlock = nil; + } + if (self.disconnectBlock) { + dispatch_block_cancel(self.disconnectBlock); + self.disconnectBlock = nil; + } + self.spinner = nil; + self.disconnectedIcon = nil; + if (!self.overlay) return; + [self.overlay removeFromSuperview]; + self.overlay = nil; +} +@end + +@interface ShellAppDelegate : UIResponder +@end +@implementation ShellAppDelegate +- (UISceneConfiguration *)application:(UIApplication *)app + configurationForConnectingSceneSession:(UISceneSession *)s + options:(UISceneConnectionOptions *)o { + UISceneConfiguration *c = [UISceneConfiguration configurationWithName:@"Default" + sessionRole:s.role]; + c.delegateClass = [ShellSceneDelegate class]; + return c; +} +@end + +int main(int argc, char **argv) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([ShellAppDelegate class])); + } +} +#pragma clang diagnostic pop