From 5935be09defd2476d21ab7d2c3356c77adf0dfa3 Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Sat, 27 Jun 2026 22:46:43 -0700 Subject: [PATCH 1/3] Add native simulator watch and grid SSE updates Introduces a new native CoreSimulator device monitor and exposes `listDevices`/`SimWatch` through the addon, then wires TypeScript to use that live snapshot instead of repeatedly spawning `xcrun simctl list`. Device resolution, booted checks, and simulator listing now share this reactive source with `simctl` fallback when native access fails. The client grid hook is switched from interval polling to server-pushed SSE updates, and middleware now serves `/grid/api/events` plus native device-change subscriptions so external boot/shutdown changes propagate immediately. --- .../Sources/SimNative/SimDeviceMonitor.swift | 225 ++++++++++++++++++ .../Sources/SimNative/sim-module.swift | 67 ++++++ packages/serve-sim/src/client/client.tsx | 4 +- .../src/client/hooks/use-grid-devices.ts | 44 ++-- packages/serve-sim/src/device.ts | 111 ++++++--- packages/serve-sim/src/index.ts | 106 +++------ packages/serve-sim/src/middleware.ts | 206 +++++++++++----- packages/serve-sim/src/native.ts | 49 ++++ 8 files changed, 627 insertions(+), 185 deletions(-) create mode 100644 packages/serve-sim/Sources/SimNative/SimDeviceMonitor.swift diff --git a/packages/serve-sim/Sources/SimNative/SimDeviceMonitor.swift b/packages/serve-sim/Sources/SimNative/SimDeviceMonitor.swift new file mode 100644 index 00000000..0711097d --- /dev/null +++ b/packages/serve-sim/Sources/SimNative/SimDeviceMonitor.swift @@ -0,0 +1,225 @@ +import Foundation + +// Reactive replacement for `xcrun simctl list devices`. +// +// `simctl list` is a thin CLI over CoreSimulator.framework, which itself holds +// no state: it talks to the `com.apple.CoreSimulator.CoreSimulatorService` +// launchd daemon over XPC. When CoreSimulator hands you a `SimDeviceSet` it has +// already opened an XPC subscription with that daemon, and routes pushed +// notifications through `-[SimDeviceSet handleXPCNotification:]`, which keeps +// the cached `SimDevice` objects (and their `state`) live. So once we hold the +// default device set, reading `-[SimDeviceSet devices]` / `-[SimDevice +// stateString]` is always current with zero `simctl` spawns. +// +// On top of that we register notification handlers so we can *push* changes to +// JS instead of polling: +// • the set-level handler fires on device add / remove, +// • a per-device handler fires on state changes (Shutdown↔Booting↔Booted). +// Any notification just triggers a full rescan of the (already-live) set — far +// simpler than decoding each XPC payload, and the set is in-memory so a rescan +// is cheap. +// +// CoreSimulator is never linked or imported (its install location is +// Xcode-version specific); it is dlopen'd by `SimFrameworks.load()` and every +// type crosses the bridge as `AnyObject`, reached through these `@objc` +// protocol shims via `unsafeBitCast` — the same runtime-only approach the rest +// of this addon (HIDInjector / AccessibilityBridge) uses. + +private typealias ErrPtr = AutoreleasingUnsafeMutablePointer? + +// `+[SimServiceContext sharedServiceContextForDeveloperDir:error:]` is a class +// method, so it's invoked on the class object (its metaclass responds to the +// selector exactly as an instance would). +@objc private protocol CSServiceContextStatics { + @objc(sharedServiceContextForDeveloperDir:error:) + func sharedServiceContext(forDeveloperDir dir: String, error: ErrPtr) -> AnyObject? +} + +@objc private protocol CSServiceContext { + @objc(defaultDeviceSetWithError:) + func defaultDeviceSet(error: ErrPtr) -> AnyObject? +} + +@objc private protocol CSDeviceSet { + @objc var devices: [AnyObject] { get } + @objc(registerNotificationHandlerOnQueue:handler:) + func registerNotificationHandler(onQueue queue: DispatchQueue, + handler: @escaping (AnyObject) -> Void) -> UInt64 +} + +@objc private protocol CSDevice { + @objc(UDID) var udid: NSUUID { get } + @objc var name: String { get } + @objc var stateString: String { get } + @objc var available: Bool { get } + @objc var runtime: AnyObject? { get } + @objc var deviceType: AnyObject? { get } + @objc(registerNotificationHandlerOnQueue:handler:) + func registerNotificationHandler(onQueue queue: DispatchQueue, + handler: @escaping (AnyObject) -> Void) -> UInt64 +} + +@objc private protocol CSRuntime { + @objc var identifier: String { get } +} + +@objc private protocol CSDeviceType { + @objc var identifier: String { get } +} + +/// Raised when the CoreSimulator subscription could not be established, so the +/// N-API layer can surface a thrown error (the TS side then falls back to +/// `simctl` rather than mistaking "couldn't look up" for "no devices"). +enum SimMonitorError: Error { case unavailable } + +/// One simulator, in the same shape `simctl list devices -j` reports per device. +struct SimDeviceInfo { + let udid: String + let name: String + /// "Creating" | "Shutdown" | "Booting" | "Booted" | "Shutting Down". + let state: String + let isAvailable: Bool + /// e.g. "com.apple.CoreSimulator.SimRuntime.iOS-26-5" — the grouping key. + let runtimeIdentifier: String + let deviceTypeIdentifier: String +} + +/// Process-global, lazily-started subscriber to the default CoreSimulator +/// device set. Pure Foundation (no NodeAPI) so it can be unit-reasoned about; +/// the N-API surface in sim-module.swift adapts it. +final class SimDeviceMonitor { + static let shared = SimDeviceMonitor() + + /// Serializes every CoreSimulator interaction and snapshot mutation. It is + /// also the queue notification handlers are delivered on, so handler bodies + /// run mutually exclusive with rescans for free. + private let queue = DispatchQueue(label: "serve-sim.simmonitor") + /// Guards `snapshot` + `observers` for cross-thread reads (the sync + /// `listDevices()` path reads `snapshot` from the JS thread). + private let lock = NSLock() + + private var started = false + private var deviceSet: AnyObject? + private var snapshot: [SimDeviceInfo] = [] + /// Per-device handler registrations, keyed by UDID. We retain the device so + /// its notification manager (and our handler) stays alive while it's in the + /// set; dropping the entry on removal lets it deallocate. + private var deviceRegs: [String: (device: AnyObject, regID: UInt64)] = [:] + private var observers: [Int: () -> Void] = [:] + private var nextObserverID = 0 + + private init() {} + + /// Subscribe + do the initial scan. Idempotent and synchronous: the first + /// call blocks briefly on one CoreSimulator round-trip, every later call is + /// a no-op. Safe to call from any thread. + func start() { + queue.sync { self.startLocked() } + } + + private func startLocked() { + if started { return } + SimFrameworks.load() + + guard let ctxClass: AnyObject = NSClassFromString("SimServiceContext") else { return } + let statics = unsafeBitCast(ctxClass, to: CSServiceContextStatics.self) + var err: NSError? + guard let ctxObj = statics.sharedServiceContext(forDeveloperDir: Xcode.developerDir(), + error: &err) else { return } + let ctx = unsafeBitCast(ctxObj, to: CSServiceContext.self) + guard let setObj = ctx.defaultDeviceSet(error: &err) else { return } + + deviceSet = setObj + // Fires on device add / remove. State changes arrive on the per-device + // handlers wired up in `rescanLocked()`. + _ = unsafeBitCast(setObj, to: CSDeviceSet.self) + .registerNotificationHandler(onQueue: queue) { [weak self] _ in + self?.rescanLocked() + } + started = true + rescanLocked() + } + + /// Re-read the (live, in-memory) device set, refresh per-device handler + /// registrations, and publish + notify if anything changed. Must run on + /// `queue` — every caller is either `startLocked()` or a handler delivered + /// on `queue`. + private func rescanLocked() { + guard let setObj = deviceSet else { return } + let devices = unsafeBitCast(setObj, to: CSDeviceSet.self).devices + + var infos: [SimDeviceInfo] = [] + infos.reserveCapacity(devices.count) + var present = Set() + + for obj in devices { + let dev = unsafeBitCast(obj, to: CSDevice.self) + let udid = dev.udid.uuidString + present.insert(udid) + + var runtimeID = "unknown" + if let rt = dev.runtime { runtimeID = unsafeBitCast(rt, to: CSRuntime.self).identifier } + var deviceTypeID = "" + if let dt = dev.deviceType { deviceTypeID = unsafeBitCast(dt, to: CSDeviceType.self).identifier } + + infos.append(SimDeviceInfo(udid: udid, name: dev.name, state: dev.stateString, + isAvailable: dev.available, runtimeIdentifier: runtimeID, + deviceTypeIdentifier: deviceTypeID)) + + // Subscribe to this device's state changes exactly once. + if deviceRegs[udid] == nil { + let regID = dev.registerNotificationHandler(onQueue: queue) { [weak self] _ in + self?.rescanLocked() + } + deviceRegs[udid] = (obj, regID) + } + } + + // Forget devices that left the set (releases the device + its handler). + for udid in deviceRegs.keys where !present.contains(udid) { + deviceRegs.removeValue(forKey: udid) + } + + lock.lock() + let changed = !Self.sameSnapshot(snapshot, infos) + snapshot = infos + let toNotify = changed ? Array(observers.values) : [] + lock.unlock() + + for cb in toNotify { cb() } + } + + private static func sameSnapshot(_ a: [SimDeviceInfo], _ b: [SimDeviceInfo]) -> Bool { + if a.count != b.count { return false } + // `devices` is sorted by CoreSimulator, so index-wise compare is stable. + for (x, y) in zip(a, b) where x.udid != y.udid || x.state != y.state + || x.name != y.name || x.isAvailable != y.isAvailable { return false } + return true + } + + /// Whether the subscription was established successfully (set only after a + /// full `startLocked()`). A failed start leaves this false so callers can + /// distinguish "no devices" from "couldn't reach CoreSimulator". + var isReady: Bool { queue.sync { started } } + + /// Current device snapshot. Call `start()` first (the N-API layer does). + func currentDevices() -> [SimDeviceInfo] { + lock.lock(); defer { lock.unlock() } + return snapshot + } + + /// Register a change observer; returns a token for `removeObserver`. The + /// callback fires on the monitor's internal queue when the set changes. + func addObserver(_ callback: @escaping () -> Void) -> Int { + lock.lock(); defer { lock.unlock() } + let id = nextObserverID + nextObserverID += 1 + observers[id] = callback + return id + } + + func removeObserver(_ token: Int) { + lock.lock(); defer { lock.unlock() } + observers.removeValue(forKey: token) + } +} diff --git a/packages/serve-sim/Sources/SimNative/sim-module.swift b/packages/serve-sim/Sources/SimNative/sim-module.swift index 9559f9e6..5f7b6c63 100644 --- a/packages/serve-sim/Sources/SimNative/sim-module.swift +++ b/packages/serve-sim/Sources/SimNative/sim-module.swift @@ -154,6 +154,62 @@ private func u32(_ v: Int) -> UInt32 { } } +// MARK: - Device list / watch + +// `[[String: ...]]` isn't directly NodeValueConvertible (Array only conforms +// when its element is `any NodeValueConvertible`), so each device object is +// produced as `any NodeValueConvertible` and the methods return an array of those. +private func deviceList(_ devices: [SimDeviceInfo]) -> [any NodeValueConvertible] { + devices.map { d in + [ + "udid": d.udid, + "name": d.name, + "state": d.state, + "isAvailable": d.isAvailable, + "runtimeIdentifier": d.runtimeIdentifier, + "deviceTypeIdentifier": d.deviceTypeIdentifier, + ] as [String: any NodePropertyConvertible] + } +} + +/// Reactive view of the CoreSimulator device set. Construction subscribes to +/// the process-global `SimDeviceMonitor` and fires `onChange` (no args) on the +/// JS thread whenever devices are added/removed or change state. `list()` +/// returns the current snapshot synchronously. The subscription is dropped when +/// the JS handle is garbage-collected (or via `stop()`). +@NodeClass @NodeActor final class SimWatch { + private let nodeQueue: NodeAsyncQueue + private var token: Int? + + @NodeConstructor init(_ onChange: NodeFunction) throws { + // unref'd by NodeAsyncQueue's init so the subscription alone won't keep + // the event loop alive. Coalesced changes are cheap; a small bound is + // plenty and drops are harmless (the next event reflects latest state). + let queue = try NodeAsyncQueue(label: "simWatch", maxQueueSize: 8) + self.nodeQueue = queue + SimDeviceMonitor.shared.start() + // Capture only the locals (not self): the observer fires on the + // monitor's native queue and marshals onto the JS thread via `queue`. + token = SimDeviceMonitor.shared.addObserver { + try? queue.run { _ = try? onChange.call([]) } + } + } + + @NodeMethod func list() -> [any NodeValueConvertible] { + deviceList(SimDeviceMonitor.shared.currentDevices()) + } + + @NodeMethod func stop() { + if let token { SimDeviceMonitor.shared.removeObserver(token) } + token = nil + } + + deinit { + if let token { SimDeviceMonitor.shared.removeObserver(token) } + try? nodeQueue.close() + } +} + // MARK: - Accessibility /// Run a blocking accessibility query off the JS event loop (on a background @@ -175,6 +231,17 @@ private func axQuery( #NodeModule(exports: [ "SimHID": SimHID.deferredConstructor, "SimCapture": SimCapture.deferredConstructor, + "SimWatch": SimWatch.deferredConstructor, + // listDevices(): the current device snapshot, synchronously, from the + // reactive CoreSimulator subscriber (lazy-started on first call). Replaces + // spawning `xcrun simctl list devices -j`. + "listDevices": try NodeFunction { () throws -> [any NodeValueConvertible] in + SimDeviceMonitor.shared.start() + // Throw (rather than return []) when the subscription failed, so the TS + // caller falls back to `simctl` instead of treating it as "no devices". + guard SimDeviceMonitor.shared.isReady else { throw SimMonitorError.unavailable } + return deviceList(SimDeviceMonitor.shared.currentDevices()) + }, // axDescribe(udid): Promise — axe-shaped accessibility JSON. "axDescribe": try NodeFunction { (udid: String) async throws -> String in try await axQuery(udid) { udid in diff --git a/packages/serve-sim/src/client/client.tsx b/packages/serve-sim/src/client/client.tsx index fb278b8e..3bf1dd48 100644 --- a/packages/serve-sim/src/client/client.tsx +++ b/packages/serve-sim/src/client/client.tsx @@ -137,8 +137,6 @@ function App() { // Skip the H.264 path for them so the stream paints over MJPEG immediately // instead of stalling on the 4s AVCC-fallback window. const [uiStarted, setUiStarted] = useState>(() => new Set()); - const hasPending = - Object.values(starting).some(Boolean) || Object.values(shuttingDown).some(Boolean); const { devices: gridDevices, total: gridTotal, @@ -147,7 +145,7 @@ function App() { loadAll: loadAllGrid, resetPage: resetGridPage, hasMore: gridHasMore, - } = useGridDevices(gridApiEndpoint, true, hasPending); + } = useGridDevices(gridApiEndpoint, true); // Re-subscribe the stream SSE the instant the selected device gains (or loses) // a helper, so its config lands as soon as it boots rather than waiting on the // next filesystem-watch tick — the stream appears sooner after boot. diff --git a/packages/serve-sim/src/client/hooks/use-grid-devices.ts b/packages/serve-sim/src/client/hooks/use-grid-devices.ts index a4c6e1ee..fd7e0c60 100644 --- a/packages/serve-sim/src/client/hooks/use-grid-devices.ts +++ b/packages/serve-sim/src/client/hooks/use-grid-devices.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import type { GridDevice } from "../utils/grid"; +import { openHostEventStream } from "../utils/exec"; /** Devices fetched up front; the long tail loads as the sidebar scrolls. */ const DEFAULT_PAGE_SIZE = 60; @@ -7,21 +8,22 @@ const DEFAULT_PAGE_SIZE = 60; const LOAD_ALL_LIMIT = 1000; /** - * Fetches the grid device list with server-side pagination. The most relevant - * devices (streaming → booted → last-opened) sort first, so the initial page is - * the useful one; `loadMore`/`loadAll` grow the window. Over a tunnel this - * keeps the first paint small instead of pulling the whole simulator catalog - * (and its DeviceKit chrome) on every poll. + * Live device grid over the exec websocket. The server pushes the same payload + * as `GET /grid/api` (sorted, paginated `[0, limit)` window) whenever the + * device set changes — boot, shutdown, erase, including transitions driven from + * outside serve-sim — or a helper starts/stops. There's no polling interval: + * updates are event-driven, and every (re)connect re-sends the full window so a + * dropped socket re-syncs automatically. * * `limit` is always requested from offset 0 (not a sliding window): the top of - * the list is what changes — boots, shutdowns, the active stream — so refetching - * `[0, limit)` keeps those fresh while merging is trivial (the response *is* the - * visible list). + * the list is what changes — boots, shutdowns, the active stream — so streaming + * `[0, limit)` keeps those fresh while merging is trivial (the payload *is* the + * visible list). `loadMore`/`loadAll` grow the window by reconnecting with a + * larger `limit`. */ export function useGridDevices( endpoint: string | undefined, enabled: boolean, - fast: boolean, pageSize: number = DEFAULT_PAGE_SIZE, ) { const [devices, setDevices] = useState(null); @@ -30,28 +32,26 @@ export function useGridDevices( const [refreshKey, setRefreshKey] = useState(0); useEffect(() => { if (!enabled || !endpoint) return; - let cancelled = false; - const sep = endpoint.includes("?") ? "&" : "?"; - const tick = async () => { + const eventsPath = `${endpoint}/events?limit=${limit}`; + const es = openHostEventStream(eventsPath); + es.onmessage = (event) => { try { - const res = await fetch(`${endpoint}${sep}limit=${limit}&offset=0`, { cache: "no-store" }); - const json = await res.json(); - if (cancelled) return; + const json = JSON.parse(event.data) as { devices?: GridDevice[]; total?: number }; setDevices(json.devices ?? []); if (typeof json.total === "number") setTotal(json.total); } catch { - if (!cancelled) setDevices([]); + // Ignore malformed frames; the next push (or a reconnect) re-syncs. } }; - tick(); - const id = setInterval(tick, fast ? 750 : 3000); - return () => { cancelled = true; clearInterval(id); }; - }, [endpoint, enabled, refreshKey, fast, limit]); + return () => es.close(); + }, [endpoint, enabled, refreshKey, limit]); + // Force a reconnect → immediate re-send (e.g. right after a UI boot/shutdown, + // so the list reflects the action without waiting on the server-side debounce). const refresh = useCallback(() => setRefreshKey((k) => k + 1), []); const loadMore = useCallback(() => setLimit((l) => l + pageSize), [pageSize]); const loadAll = useCallback(() => setLimit(LOAD_ALL_LIMIT), []); - // Return to the paged window — e.g. when search is cleared — so the poll stops - // pulling the whole catalog every interval after a one-off `loadAll`. + // Return to the paged window — e.g. when search is cleared — after a one-off + // `loadAll`, so the stream stops pushing the whole catalog. const resetPage = useCallback(() => setLimit(pageSize), [pageSize]); const hasMore = total > (devices?.length ?? 0); return { devices, total, refresh, loadMore, loadAll, resetPage, hasMore }; diff --git a/packages/serve-sim/src/device.ts b/packages/serve-sim/src/device.ts index b9d6a51e..591fc69b 100644 --- a/packages/serve-sim/src/device.ts +++ b/packages/serve-sim/src/device.ts @@ -1,4 +1,74 @@ import { execSync } from "child_process"; +import { listDevicesNative } from "./native"; + +/** A device entry in the shape `simctl list devices -j` reports per device. */ +export interface SimctlDevice { + udid: string; + name: string; + /** "Creating" | "Shutdown" | "Booting" | "Booted" | "Shutting Down". */ + state: string; + isAvailable?: boolean; + /** e.g. "com.apple.CoreSimulator.SimDeviceType.iPhone-17". */ + deviceTypeIdentifier?: string; +} + +/** Devices keyed by runtime identifier — `simctl list devices -j`'s `.devices`. */ +export type SimctlDevicesByRuntime = Record; + +/** + * Devices grouped by runtime identifier, matching `simctl list devices -j`'s + * `.devices` map — but sourced from the in-process reactive CoreSimulator + * subscriber (`native.listDevicesNative`), which keeps a live snapshot via XPC + * push notifications from `com.apple.CoreSimulator.CoreSimulatorService` instead + * of spawning `simctl` per call. + * + * Falls back to a one-shot `xcrun simctl list devices -j` when the native addon + * isn't available (e.g. the prebuilt `.node` is missing), so callers keep + * working with identical output. + */ +export function listDevicesByRuntime(): SimctlDevicesByRuntime { + return tryListDevicesByRuntime() ?? {}; +} + +/** + * Like {@link listDevicesByRuntime} but returns `null` when the device set + * could not be read at all (native subscription unavailable *and* `simctl` + * failed). Callers that act destructively on "no booted device" (e.g. killing a + * stale helper) must use this so a transient lookup failure isn't mistaken for + * an empty device set. + */ +export function tryListDevicesByRuntime(): SimctlDevicesByRuntime | null { + // Reactive in-process subscriber first. + try { + const grouped: SimctlDevicesByRuntime = {}; + for (const d of listDevicesNative()) { + (grouped[d.runtimeIdentifier] ??= []).push({ + udid: d.udid, + name: d.name, + state: d.state, + isAvailable: d.isAvailable, + deviceTypeIdentifier: d.deviceTypeIdentifier || undefined, + }); + } + return grouped; + } catch { + // Native addon missing or subscription failed — fall through to simctl. + } + // One-shot `xcrun simctl list devices -j` fallback. + try { + const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); + return (JSON.parse(output) as { devices: SimctlDevicesByRuntime }).devices ?? {}; + } catch { + return null; + } +} + +/** Iterate `[runtimeIdentifier, device]` over the current device set. */ +function* eachDevice(): Generator<[string, SimctlDevice]> { + for (const [runtime, devices] of Object.entries(listDevicesByRuntime())) { + for (const device of devices) yield [runtime, device]; + } +} /** * UDID of a booted simulator, or null if none is booted. Prefers an iOS device @@ -6,44 +76,27 @@ import { execSync } from "child_process"; * tooling doesn't target. */ export function findBootedDevice(): string | null { - try { - const output = execSync("xcrun simctl list devices booted -j", { encoding: "utf-8" }); - const data = JSON.parse(output) as { - devices: Record>; - }; - let fallback: string | null = null; - for (const [runtime, devices] of Object.entries(data.devices)) { - for (const device of devices) { - if (device.state !== "Booted") continue; - if (/iOS/i.test(runtime)) return device.udid; - fallback ??= device.udid; - } - } - return fallback; - } catch {} - return null; + let fallback: string | null = null; + for (const [runtime, device] of eachDevice()) { + if (device.state !== "Booted") continue; + if (/iOS/i.test(runtime)) return device.udid; + fallback ??= device.udid; + } + return fallback; } /** * Resolve a device name or UDID to a UDID. A UDID is returned as-is; a name is - * matched case-insensitively against `simctl list devices`. Exits the process - * with a clear error when the name cannot be resolved. + * matched case-insensitively against the device set. Exits the process with a + * clear error when the name cannot be resolved. */ export function resolveDevice(nameOrUDID: string): string { if (/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(nameOrUDID)) { return nameOrUDID; } - try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); - const data = JSON.parse(output) as { - devices: Record>; - }; - for (const runtime of Object.values(data.devices)) { - for (const device of runtime) { - if (device.name.toLowerCase() === nameOrUDID.toLowerCase()) return device.udid; - } - } - } catch {} + for (const [, device] of eachDevice()) { + if (device.name.toLowerCase() === nameOrUDID.toLowerCase()) return device.udid; + } console.error(`Could not resolve device: ${nameOrUDID}`); process.exit(1); } diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index 31ffda81..7f057d61 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -9,7 +9,7 @@ import { STATE_DIR, stateFileForDevice, listStateFiles, inProcessServeSimState, import { textToKeyEvents, UnsupportedCharacterError, sendKeyEventsToWs } from "./text-to-keys"; import { dirnameOf, sleepSync, isPortFree, servePreview } from "./runtime"; import { killPortHolder } from "./ports"; -import { findBootedDevice, resolveDevice } from "./device"; +import { findBootedDevice, resolveDevice, listDevicesByRuntime, tryListDevicesByRuntime } from "./device"; import { permissions } from "./permissions"; import { uiSettings } from "./ui-settings"; import { debugCli, debugHelper, debugState } from "./debug"; @@ -58,40 +58,22 @@ function readState(udid?: string): ServerState | null { } /** - * Snapshot simctl's boot state once per `readStateFile` batch. A full - * `simctl list devices -j` is ~50ms; doing it per-state multiplied the cost - * by the number of running helpers. We cache for 1 second so a flurry of - * readStateFile() calls (e.g. readAllStates loop) shares one lookup. + * Set of booted device UDIDs, or null when the device set couldn't be read at + * all (so callers don't mistake a lookup failure for "nothing booted" and kill + * live helpers). Sourced from the in-process reactive CoreSimulator subscriber, + * which keeps a live snapshot via XPC push — no per-call `simctl` spawn — so the + * old time-based cache is gone. */ -let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null }; function getBootedUdids(): Set | null { - const now = Date.now(); - if (bootedSnapshot.booted && now - bootedSnapshot.at < 1000) { - return bootedSnapshot.booted; - } - try { - const output = execSync("xcrun simctl list devices booted -j", { - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 3_000, - }); - const data = JSON.parse(output) as { - devices: Record>; - }; - const booted = new Set(); - for (const runtime of Object.values(data.devices)) { - for (const device of runtime) { - if (device.state === "Booted") booted.add(device.udid); - } + const grouped = tryListDevicesByRuntime(); + if (grouped === null) return null; + const booted = new Set(); + for (const devices of Object.values(grouped)) { + for (const device of devices) { + if (device.state === "Booted") booted.add(device.udid); } - bootedSnapshot = { at: now, booted }; - return booted; - } catch { - // simctl lookup failed (Xcode offline, etc.) — we can't prove the device - // is shutdown, so don't treat as stale. Returns null so caller skips the - // booted check for this invocation. - return null; } + return booted; } function readStateFile(file: string): ServerState | null { @@ -170,56 +152,38 @@ function clearState(udid?: string) { * no booted simulator. Prefers an available iPhone on the newest iOS runtime. */ function pickDefaultDevice(): { udid: string; name: string } | null { - try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); - const data = JSON.parse(output) as { - devices: Record>; - }; - const iosRuntimes = Object.keys(data.devices) - .filter((k) => /SimRuntime\.iOS-/i.test(k)) - .sort((a, b) => { - const va = (a.match(/iOS-(\d+)-(\d+)/) ?? []).slice(1).map(Number); - const vb = (b.match(/iOS-(\d+)-(\d+)/) ?? []).slice(1).map(Number); - return (vb[0] ?? 0) - (va[0] ?? 0) || (vb[1] ?? 0) - (va[1] ?? 0); - }); - for (const runtime of iosRuntimes) { - const devices = data.devices[runtime] ?? []; - const iphone = devices.find( - (d) => d.isAvailable !== false && /^iPhone\b/i.test(d.name), - ); - if (iphone) return { udid: iphone.udid, name: iphone.name }; - } - } catch {} + const devices = listDevicesByRuntime(); + const iosRuntimes = Object.keys(devices) + .filter((k) => /SimRuntime\.iOS-/i.test(k)) + .sort((a, b) => { + const va = (a.match(/iOS-(\d+)-(\d+)/) ?? []).slice(1).map(Number); + const vb = (b.match(/iOS-(\d+)-(\d+)/) ?? []).slice(1).map(Number); + return (vb[0] ?? 0) - (va[0] ?? 0) || (vb[1] ?? 0) - (va[1] ?? 0); + }); + for (const runtime of iosRuntimes) { + const iphone = (devices[runtime] ?? []).find( + (d) => d.isAvailable !== false && /^iPhone\b/i.test(d.name), + ); + if (iphone) return { udid: iphone.udid, name: iphone.name }; + } return null; } function getDeviceName(udid: string): string | null { - try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); - const data = JSON.parse(output) as { - devices: Record>; - }; - for (const runtime of Object.values(data.devices)) { - for (const device of runtime) { - if (device.udid === udid) return device.name; - } + for (const devices of Object.values(listDevicesByRuntime())) { + for (const device of devices) { + if (device.udid === udid) return device.name; } - } catch {} + } return null; } function isDeviceBooted(udid: string): boolean { - try { - const output = execSync("xcrun simctl list devices -j", { encoding: "utf-8" }); - const data = JSON.parse(output) as { - devices: Record>; - }; - for (const runtime of Object.values(data.devices)) { - for (const device of runtime) { - if (device.udid === udid) return device.state === "Booted"; - } + for (const devices of Object.values(listDevicesByRuntime())) { + for (const device of devices) { + if (device.udid === udid) return device.state === "Booted"; } - } catch {} + } return false; } diff --git a/packages/serve-sim/src/middleware.ts b/packages/serve-sim/src/middleware.ts index 5f3737a6..e7bbe0f2 100644 --- a/packages/serve-sim/src/middleware.ts +++ b/packages/serve-sim/src/middleware.ts @@ -13,7 +13,7 @@ import type { Socket } from "net"; import { WebSocket } from "ws"; import { createAxStreamerCache } from "./ax"; import { getDeviceSession, type HidSocket } from "./device-session"; -import { axFrontmostAsync } from "./native"; +import { axFrontmostAsync, watchDevices } from "./native"; import { inProcessServeSimState, writeServeSimState, type ServeSimDeviceState } from "./state"; import { debugMw } from "./debug"; import { @@ -24,6 +24,7 @@ import { } from "./devicekit-chrome"; import { createExecUpgradeHandler, type UiRequestHandler } from "./exec-ws"; import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings"; +import { listDevicesByRuntime, tryListDevicesByRuntime } from "./device"; type SimReq = IncomingMessage; type SimRes = ServerResponse; @@ -83,14 +84,6 @@ type CdpHttpListEntry = { type CdpHttpVersion = { Browser?: string }; -type SimctlBootedList = { - devices: Record>; -}; - -type SimctlAllList = { - devices: Record>>; -}; - type ShutdownRequestBody = { udid?: string }; type StartRequestBody = { udid?: string }; type ReleaseRequestBody = { targetId?: string }; @@ -192,33 +185,23 @@ export function matchInstalledAppByDisplayName( return null; } -// Cache simctl's booted-device set briefly so per-request cost stays bounded. -// The middleware runs inside the user's dev server (Metro etc.) and -// readServeSimStates() is called on every /api and every page load. -let bootedSnapshot: { at: number; booted: Set | null } = { at: 0, booted: null }; +// Booted-device set from the in-process reactive CoreSimulator subscriber. The +// middleware runs inside the user's dev server (Metro etc.) and +// readServeSimStates() is called on every /api and page load — the subscriber +// keeps a live snapshot via XPC push, so each read is in-process (no per-request +// `simctl` spawn) and the old time-based cache is unnecessary. Returns null when +// the device set couldn't be read at all, so callers don't treat a lookup +// failure as "nothing booted". function getBootedUdids(): Set | null { - const now = Date.now(); - if (bootedSnapshot.booted && now - bootedSnapshot.at < 1500) { - return bootedSnapshot.booted; - } - try { - const output = execSync("xcrun simctl list devices booted -j", { - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - timeout: 3_000, - }); - const data = JSON.parse(output) as SimctlBootedList; - const booted = new Set(); - for (const runtime of Object.values(data.devices)) { - for (const device of runtime) { - if (device.state === "Booted") booted.add(device.udid); - } + const grouped = tryListDevicesByRuntime(); + if (grouped === null) return null; + const booted = new Set(); + for (const devices of Object.values(grouped)) { + for (const device of devices) { + if (device.state === "Booted") booted.add(device.udid); } - bootedSnapshot = { at: now, booted }; - return booted; - } catch { - return null; } + return booted; } // The device the user most recently opened in Simulator.app, regardless of @@ -956,27 +939,24 @@ interface SimctlDevice { } function listAllSimulators(): SimctlDevice[] { - try { - const output = execSync("xcrun simctl list devices -j", { - encoding: "utf-8", - stdio: ["ignore", "pipe", "ignore"], - timeout: 3_000, - }); - const data = JSON.parse(output) as SimctlAllList; - const out: SimctlDevice[] = []; - for (const [runtime, devices] of Object.entries(data.devices)) { - // Keep this to touch-capable simulator families that serve-sim can frame - // and inject into. tvOS is intentionally left out for now. - if (!/SimRuntime\.(iOS|watchOS|visionOS|xrOS)-/i.test(runtime)) continue; - for (const d of devices) { - if (d.isAvailable === false) continue; - out.push({ ...d, runtime: runtime.replace(/^.*SimRuntime\./, "") }); - } + const out: SimctlDevice[] = []; + for (const [runtime, devices] of Object.entries(listDevicesByRuntime())) { + // Keep this to touch-capable simulator families that serve-sim can frame + // and inject into. tvOS is intentionally left out for now. + if (!/SimRuntime\.(iOS|watchOS|visionOS|xrOS)-/i.test(runtime)) continue; + for (const d of devices) { + if (d.isAvailable === false) continue; + out.push({ + udid: d.udid, + name: d.name, + state: d.state, + isAvailable: d.isAvailable, + deviceTypeIdentifier: d.deviceTypeIdentifier, + runtime: runtime.replace(/^.*SimRuntime\./, ""), + }); } - return out; - } catch { - return []; } + return out; } // Default per-simulator footprint when we have no running sim to measure @@ -1282,7 +1262,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { } // Grid JSON: every supported simulator, annotated with running helper info if any. - if (url === base + "/grid/api") { + // Build the `/grid/api` JSON payload for a given paging URL. Factored out so + // the GET route and the `/grid/api/events` push stream render identically; + // it re-reads helper state + the (reactive) simulator snapshot on each call, + // so each invocation reflects current state. + const buildGridPayload = (pagingUrl: string): string => { const states = readServeSimStates(); const helperByUdid = new Map(states.map((s) => [s.device, s] as const)); const sims = listAllSimulators(); @@ -1330,7 +1314,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { ); const total = sims.length; - const { limit, offset } = parseGridPaging(rawUrl); + const { limit, offset } = parseGridPaging(pagingUrl); const page = limit == null ? sims : sims.slice(offset, offset + limit); const devices = page.map((d) => { const helper = helperByUdid.get(d.udid); @@ -1352,13 +1336,102 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { : null, }; }); + // `total` lets the client show "X of Y" and know when to stop paging; + // older clients that read only `devices` are unaffected. + return JSON.stringify({ devices, total, offset: limit == null ? 0 : offset, limit: limit ?? total }); + }; + + if (url === base + "/grid/api") { res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-store", }); - // `total` lets the client show "X of Y" and know when to stop paging; - // older clients that read only `devices` are unaffected. - res.end(JSON.stringify({ devices, total, offset: limit == null ? 0 : offset, limit: limit ?? total })); + res.end(buildGridPayload(rawUrl)); + return; + } + + // SSE: live device grid. Pushes the same payload as `GET /grid/api` whenever + // the device set changes (boot/shutdown/erase — via the reactive + // CoreSimulator subscriber) or a serve-sim helper starts/stops (state files). + // Replaces the client's fixed-interval `/grid/api` polling with event-driven + // updates. Tunnelled to the browser over the exec websocket (see exec-ws). + if (url === base + "/grid/api/events") { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(":\n\n"); + + let lastSent = buildGridPayload(rawUrl); + res.write("data: " + lastSent + "\n\n"); + + let closed = false; + const sendIfChanged = () => { + if (closed || res.writableEnded) return; + const next = buildGridPayload(rawUrl); + if (next === lastSent) return; + lastSent = next; + res.write("data: " + next + "\n\n"); + }; + + // Coalesce bursts: a boot rewrites state files and emits several device + // notifications in quick succession; collapse them into one recompute. + let debounce: ReturnType | null = null; + const onChange = () => { + if (debounce) return; + debounce = setTimeout(() => { + debounce = null; + sendIfChanged(); + }, 150); + }; + + // Device add/remove/boot/shutdown — including changes driven entirely + // outside serve-sim (Simulator.app, `simctl`, Xcode). Best-effort. + let unwatchDevices: (() => void) | null = null; + try { + unwatchDevices = watchDevices(onChange); + } catch {} + + // Helper start/stop writes serve-sim state files (no device-set change), + // so watch those too. + let watcher: FSWatcher | null = null; + let watcherRetry: ReturnType | null = null; + const ensureWatcher = () => { + if (closed || res.writableEnded || watcher || watcherRetry) return; + watcherRetry = setTimeout(() => { + watcherRetry = null; + if (closed || res.writableEnded || watcher) return; + try { + watcher = watch(STATE_DIR, onChange); + watcher.on("error", () => { + watcher?.close(); + watcher = null; + ensureWatcher(); + }); + sendIfChanged(); + } catch { + ensureWatcher(); + } + }, 250); + }; + ensureWatcher(); + + const heartbeat = setInterval(() => { + if (closed || res.writableEnded) return; + res.write(":\n\n"); + ensureWatcher(); + }, 15000); + + req.on("close", () => { + closed = true; + if (debounce) clearTimeout(debounce); + if (watcherRetry) clearTimeout(watcherRetry); + clearInterval(heartbeat); + watcher?.close(); + unwatchDevices?.(); + }); return; } @@ -1378,9 +1451,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { res.end(JSON.stringify({ ok: false, error: "Invalid or missing udid" })); return; } - // Drop the snapshot so the next /grid/api call re-queries simctl - // and prunes any helper bound to this now-shutdown device. - bootedSnapshot = { at: 0, booted: null }; + // No cache to invalidate: the reactive CoreSimulator subscriber pushes + // this device's Shutdown transition, so the next /grid/api call sees the + // updated state and prunes any helper bound to it. execFile("xcrun", ["simctl", "shutdown", udid], { timeout: 30_000 }, (err, _stdout, stderr) => { if (err) { res.writeHead(500, { "Content-Type": "application/json" }); @@ -1607,6 +1680,17 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { }, 150); }; + // Device boot/shutdown driven from outside serve-sim (Simulator.app, + // `simctl boot`, Xcode) writes no state file, so the fs watcher alone + // would miss it until the 15s heartbeat. Subscribe to the reactive + // CoreSimulator subscriber so those transitions push to the browser + // immediately. Best-effort: if the native addon is unavailable the fs + // watcher + heartbeat still cover serve-sim-driven changes. + let unwatchDevices: (() => void) | null = null; + try { + unwatchDevices = watchDevices(onFsEvent); + } catch {} + let watcher: FSWatcher | null = null; let watcherRetry: ReturnType | null = null; const ensureWatcher = () => { @@ -1643,6 +1727,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { if (watcherRetry) clearTimeout(watcherRetry); clearInterval(heartbeat); watcher?.close(); + unwatchDevices?.(); }); return; } @@ -1881,6 +1966,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { execToken, ssePrefixes: [ `${base}/api/events`, + `${base}/grid/api/events`, `${base}/appstate`, `${base}/ax`, ], diff --git a/packages/serve-sim/src/native.ts b/packages/serve-sim/src/native.ts index 4bce1418..dda3c8ee 100644 --- a/packages/serve-sim/src/native.ts +++ b/packages/serve-sim/src/native.ts @@ -40,13 +40,35 @@ interface SimCaptureHandle { stop(): void; } +interface SimWatchHandle { + list(): NativeSimDevice[]; + stop(): void; +} + interface NativeAddon { SimHID: new (udid: string) => SimHIDHandle; SimCapture: new (udid: string, onFrame: RawFrameCallback) => SimCaptureHandle; + SimWatch: new (onChange: () => void) => SimWatchHandle; + listDevices(): NativeSimDevice[]; axDescribe(udid: string): Promise; axFrontmost(udid: string): Promise; } +/** + * One simulator as reported by the in-process reactive CoreSimulator subscriber. + * Mirrors a `simctl list devices -j` device entry plus its grouping runtime. + */ +export interface NativeSimDevice { + udid: string; + name: string; + /** "Creating" | "Shutdown" | "Booting" | "Booted" | "Shutting Down". */ + state: string; + isAvailable: boolean; + /** e.g. "com.apple.CoreSimulator.SimRuntime.iOS-26-5". */ + runtimeIdentifier: string; + deviceTypeIdentifier: string; +} + // (codec, data, width, height, flags) — codec 0=MJPEG 1=AVCC; flags bit0=desc bit1=keyframe. type RawFrameCallback = (codec: number, data: Buffer, width: number, height: number, flags: number) => void; @@ -238,3 +260,30 @@ export function axDescribeAsync(udid: string): Promise { export function axFrontmostAsync(udid: string): Promise { return load().axFrontmost(udid); } + +/** + * Current simulator device snapshot from the in-process reactive CoreSimulator + * subscriber. The first call lazily subscribes to the daemon and does one scan; + * subsequent calls read a snapshot kept live by XPC push notifications — no + * `simctl` spawn. Throws if the native addon is unavailable (callers fall back + * to `xcrun simctl list`). + */ +export function listDevicesNative(): NativeSimDevice[] { + return load().listDevices(); +} + +/** + * Subscribe to device-set changes (add/remove/boot/shutdown). `onChange` fires + * — debounced to actual changes — whenever the set differs. Returns an + * unsubscribe function. Keep the returned handle reachable for the lifetime of + * the subscription. + */ +export function watchDevices(onChange: () => void): () => void { + const handle = new (load().SimWatch)(onChange); + let stopped = false; + return () => { + if (stopped) return; + stopped = true; + handle.stop(); + }; +} From dd62b07fe86802f0db755e16c5d5a8bb8bfd1aea Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Mon, 29 Jun 2026 10:05:14 -0700 Subject: [PATCH 2/3] refactor(serve-sim): dedupe device-state SSE into shared helper --- packages/serve-sim/src/middleware.ts | 252 ++++++++++----------------- 1 file changed, 93 insertions(+), 159 deletions(-) diff --git a/packages/serve-sim/src/middleware.ts b/packages/serve-sim/src/middleware.ts index e7bbe0f2..4b6815ee 100644 --- a/packages/serve-sim/src/middleware.ts +++ b/packages/serve-sim/src/middleware.ts @@ -37,6 +37,97 @@ export type SimMiddleware = { // Injected at build time as a base64-encoded string via `define` declare const __PREVIEW_HTML_B64__: string; const STATE_DIR = join(tmpdir(), "serve-sim"); + +/** + * Serve a long-lived Server-Sent Events stream that re-emits `compute()` (a JSON + * string) whenever device state changes. It sends the initial value immediately, + * then re-sends only when the payload actually differs, driven by two pushes: + * • `watchDevices` — the reactive CoreSimulator subscriber (boot/shutdown/ + * erase, including transitions from Simulator.app / `simctl` / Xcode), and + * • `watch(STATE_DIR)` — serve-sim helper start/stop (state-file writes). + * Bursts are debounced so a boot's flurry of notifications collapses into one + * recompute. Tunnelled to the browser over the exec websocket (see exec-ws). + * + * The 15s interval is a keepalive — it writes an SSE comment so idle proxies + * don't drop the connection and re-arms the fs watcher as a safety net. It does + * NOT poll for changes; those arrive via the two watchers above. + */ +function streamDeviceStateSse(req: SimReq, res: SimRes, compute: () => string): void { + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }); + res.write(":\n\n"); + + let lastSent = compute(); + res.write("data: " + lastSent + "\n\n"); + + let closed = false; + const sendIfChanged = () => { + if (closed || res.writableEnded) return; + const next = compute(); + if (next === lastSent) return; + lastSent = next; + res.write("data: " + next + "\n\n"); + }; + + // Coalesce bursts: a boot rewrites state files and emits several device + // notifications in quick succession; collapse them into one recompute. + let debounce: ReturnType | null = null; + const onChange = () => { + if (debounce) return; + debounce = setTimeout(() => { + debounce = null; + sendIfChanged(); + }, 150); + }; + + // Best-effort: if the native addon is unavailable the fs watcher still covers + // serve-sim-driven changes. + let unwatchDevices: (() => void) | null = null; + try { + unwatchDevices = watchDevices(onChange); + } catch {} + + let watcher: FSWatcher | null = null; + let watcherRetry: ReturnType | null = null; + const ensureWatcher = () => { + if (closed || res.writableEnded || watcher || watcherRetry) return; + watcherRetry = setTimeout(() => { + watcherRetry = null; + if (closed || res.writableEnded || watcher) return; + try { + watcher = watch(STATE_DIR, onChange); + watcher.on("error", () => { + watcher?.close(); + watcher = null; + ensureWatcher(); + }); + sendIfChanged(); + } catch { + ensureWatcher(); + } + }, 250); + }; + ensureWatcher(); + + const heartbeat = setInterval(() => { + if (closed || res.writableEnded) return; + res.write(":\n\n"); + ensureWatcher(); + }, 15000); + + req.on("close", () => { + closed = true; + if (debounce) clearTimeout(debounce); + if (watcherRetry) clearTimeout(watcherRetry); + clearInterval(heartbeat); + watcher?.close(); + unwatchDevices?.(); + }); +} // Last logged result of a GET /api selection, used to suppress the // once-every-poll duplicate debugMw lines (the UI polls /api every ~2s). let lastApiLogKey: string | undefined; @@ -1356,82 +1447,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { // Replaces the client's fixed-interval `/grid/api` polling with event-driven // updates. Tunnelled to the browser over the exec websocket (see exec-ws). if (url === base + "/grid/api/events") { - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.write(":\n\n"); - - let lastSent = buildGridPayload(rawUrl); - res.write("data: " + lastSent + "\n\n"); - - let closed = false; - const sendIfChanged = () => { - if (closed || res.writableEnded) return; - const next = buildGridPayload(rawUrl); - if (next === lastSent) return; - lastSent = next; - res.write("data: " + next + "\n\n"); - }; - - // Coalesce bursts: a boot rewrites state files and emits several device - // notifications in quick succession; collapse them into one recompute. - let debounce: ReturnType | null = null; - const onChange = () => { - if (debounce) return; - debounce = setTimeout(() => { - debounce = null; - sendIfChanged(); - }, 150); - }; - - // Device add/remove/boot/shutdown — including changes driven entirely - // outside serve-sim (Simulator.app, `simctl`, Xcode). Best-effort. - let unwatchDevices: (() => void) | null = null; - try { - unwatchDevices = watchDevices(onChange); - } catch {} - - // Helper start/stop writes serve-sim state files (no device-set change), - // so watch those too. - let watcher: FSWatcher | null = null; - let watcherRetry: ReturnType | null = null; - const ensureWatcher = () => { - if (closed || res.writableEnded || watcher || watcherRetry) return; - watcherRetry = setTimeout(() => { - watcherRetry = null; - if (closed || res.writableEnded || watcher) return; - try { - watcher = watch(STATE_DIR, onChange); - watcher.on("error", () => { - watcher?.close(); - watcher = null; - ensureWatcher(); - }); - sendIfChanged(); - } catch { - ensureWatcher(); - } - }, 250); - }; - ensureWatcher(); - - const heartbeat = setInterval(() => { - if (closed || res.writableEnded) return; - res.write(":\n\n"); - ensureWatcher(); - }, 15000); - - req.on("close", () => { - closed = true; - if (debounce) clearTimeout(debounce); - if (watcherRetry) clearTimeout(watcherRetry); - clearInterval(heartbeat); - watcher?.close(); - unwatchDevices?.(); - }); + streamDeviceStateSse(req, res, () => buildGridPayload(rawUrl)); return; } @@ -1639,95 +1655,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware { // or the device selection changes, so we watch the state dir and emit only // on change instead of re-sending identical JSON on a fixed interval. if (url === base + "/api/events") { - const computeConfig = (): string => { + streamDeviceStateSse(req, res, () => { const states = readServeSimStates(); const state = selectServeSimState(states, selectedDevice); const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null; return JSON.stringify( remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken, options?.codec, proxyHelpers) : null, ); - }; - - res.writeHead(200, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }); - res.write(":\n\n"); - - let lastSent = computeConfig(); - res.write("data: " + lastSent + "\n\n"); - - let closed = false; - const sendIfChanged = () => { - if (closed || res.writableEnded) return; - const next = computeConfig(); - if (next === lastSent) return; - lastSent = next; - res.write("data: " + next + "\n\n"); - }; - - // Debounce filesystem events: a helper boot rewrites the state file a few - // times in quick succession, and selectServeSimState also shells out to - // refresh booted devices, so coalesce bursts into one recompute. - let debounce: ReturnType | null = null; - const onFsEvent = () => { - if (debounce) return; - debounce = setTimeout(() => { - debounce = null; - sendIfChanged(); - }, 150); - }; - - // Device boot/shutdown driven from outside serve-sim (Simulator.app, - // `simctl boot`, Xcode) writes no state file, so the fs watcher alone - // would miss it until the 15s heartbeat. Subscribe to the reactive - // CoreSimulator subscriber so those transitions push to the browser - // immediately. Best-effort: if the native addon is unavailable the fs - // watcher + heartbeat still cover serve-sim-driven changes. - let unwatchDevices: (() => void) | null = null; - try { - unwatchDevices = watchDevices(onFsEvent); - } catch {} - - let watcher: FSWatcher | null = null; - let watcherRetry: ReturnType | null = null; - const ensureWatcher = () => { - if (closed || res.writableEnded || watcher || watcherRetry) return; - watcherRetry = setTimeout(() => { - watcherRetry = null; - if (closed || res.writableEnded || watcher) return; - try { - watcher = watch(STATE_DIR, onFsEvent); - watcher.on("error", () => { - watcher?.close(); - watcher = null; - ensureWatcher(); - }); - sendIfChanged(); - } catch { - ensureWatcher(); - } - }, 250); - }; - ensureWatcher(); - - // Keep the connection alive through buffering proxies + catch any change - // an fs event missed (e.g. dir created after we failed to watch it). - const heartbeat = setInterval(() => { - if (closed || res.writableEnded) return; - res.write(":\n\n"); - ensureWatcher(); - }, 15000); - - req.on("close", () => { - closed = true; - if (debounce) clearTimeout(debounce); - if (watcherRetry) clearTimeout(watcherRetry); - clearInterval(heartbeat); - watcher?.close(); - unwatchDevices?.(); }); return; } From 81925016d3933427935e9ea1dd85454b04babdbe Mon Sep 17 00:00:00 2001 From: Evan Bacon Date: Mon, 29 Jun 2026 10:18:14 -0700 Subject: [PATCH 3/3] Refactor booted-device helpers in device module Move booted-UDID lookup logic into `device.ts` as `listBootedUdids()` and export `eachDevice()` for shared iteration, then update CLI and middleware to use the centralized helpers. This removes duplicate fallback logic around device-set reads, keeps the null-on-read-failure behavior for destructive paths, and slightly simplifies simulator listing by spreading existing device fields. --- packages/serve-sim/src/device.ts | 30 ++++++++++++++++++------ packages/serve-sim/src/index.ts | 35 +++++----------------------- packages/serve-sim/src/middleware.ts | 32 +++---------------------- 3 files changed, 32 insertions(+), 65 deletions(-) diff --git a/packages/serve-sim/src/device.ts b/packages/serve-sim/src/device.ts index 591fc69b..d28df6f7 100644 --- a/packages/serve-sim/src/device.ts +++ b/packages/serve-sim/src/device.ts @@ -31,13 +31,12 @@ export function listDevicesByRuntime(): SimctlDevicesByRuntime { } /** - * Like {@link listDevicesByRuntime} but returns `null` when the device set - * could not be read at all (native subscription unavailable *and* `simctl` - * failed). Callers that act destructively on "no booted device" (e.g. killing a - * stale helper) must use this so a transient lookup failure isn't mistaken for - * an empty device set. + * Like {@link listDevicesByRuntime} but returns `null` when the device set could + * not be read at all (native subscription unavailable *and* `simctl` failed). + * `listBootedUdids` (and other destructive callers) rely on the `null` so a + * transient lookup failure isn't mistaken for an empty device set. */ -export function tryListDevicesByRuntime(): SimctlDevicesByRuntime | null { +function tryListDevicesByRuntime(): SimctlDevicesByRuntime | null { // Reactive in-process subscriber first. try { const grouped: SimctlDevicesByRuntime = {}; @@ -64,12 +63,29 @@ export function tryListDevicesByRuntime(): SimctlDevicesByRuntime | null { } /** Iterate `[runtimeIdentifier, device]` over the current device set. */ -function* eachDevice(): Generator<[string, SimctlDevice]> { +export function* eachDevice(): Generator<[string, SimctlDevice]> { for (const [runtime, devices] of Object.entries(listDevicesByRuntime())) { for (const device of devices) yield [runtime, device]; } } +/** + * Set of booted device UDIDs, or null when the device set couldn't be read at + * all — so destructive callers (e.g. pruning a "stale" helper) don't mistake a + * lookup failure for "nothing booted". Backed by the reactive snapshot. + */ +export function listBootedUdids(): Set | null { + const grouped = tryListDevicesByRuntime(); + if (grouped === null) return null; + const booted = new Set(); + for (const devices of Object.values(grouped)) { + for (const device of devices) { + if (device.state === "Booted") booted.add(device.udid); + } + } + return booted; +} + /** * UDID of a booted simulator, or null if none is booted. Prefers an iOS device * — a machine may also have a booted watchOS/tvOS sim, which `serve-sim`'s diff --git a/packages/serve-sim/src/index.ts b/packages/serve-sim/src/index.ts index 7f057d61..0ee69891 100755 --- a/packages/serve-sim/src/index.ts +++ b/packages/serve-sim/src/index.ts @@ -9,7 +9,7 @@ import { STATE_DIR, stateFileForDevice, listStateFiles, inProcessServeSimState, import { textToKeyEvents, UnsupportedCharacterError, sendKeyEventsToWs } from "./text-to-keys"; import { dirnameOf, sleepSync, isPortFree, servePreview } from "./runtime"; import { killPortHolder } from "./ports"; -import { findBootedDevice, resolveDevice, listDevicesByRuntime, tryListDevicesByRuntime } from "./device"; +import { findBootedDevice, resolveDevice, listDevicesByRuntime, listBootedUdids, eachDevice } from "./device"; import { permissions } from "./permissions"; import { uiSettings } from "./ui-settings"; import { debugCli, debugHelper, debugState } from "./debug"; @@ -57,25 +57,6 @@ function readState(udid?: string): ServerState | null { return null; } -/** - * Set of booted device UDIDs, or null when the device set couldn't be read at - * all (so callers don't mistake a lookup failure for "nothing booted" and kill - * live helpers). Sourced from the in-process reactive CoreSimulator subscriber, - * which keeps a live snapshot via XPC push — no per-call `simctl` spawn — so the - * old time-based cache is gone. - */ -function getBootedUdids(): Set | null { - const grouped = tryListDevicesByRuntime(); - if (grouped === null) return null; - const booted = new Set(); - for (const devices of Object.values(grouped)) { - for (const device of devices) { - if (device.state === "Booted") booted.add(device.udid); - } - } - return booted; -} - function readStateFile(file: string): ServerState | null { try { if (!existsSync(file)) { @@ -96,7 +77,7 @@ function readStateFile(file: string): ServerState | null { // When that happens the helper keeps accepting /stream.mjpeg connections // but never emits frames, so clients hang on "Connecting...". Detect and // recycle here so --detach / --list always return a working stream. - const booted = getBootedUdids(); + const booted = listBootedUdids(); if (booted && !booted.has(state.device)) { debugState( "helper pid %d bound to non-booted device %s — killing stale helper", @@ -170,19 +151,15 @@ function pickDefaultDevice(): { udid: string; name: string } | null { } function getDeviceName(udid: string): string | null { - for (const devices of Object.values(listDevicesByRuntime())) { - for (const device of devices) { - if (device.udid === udid) return device.name; - } + for (const [, device] of eachDevice()) { + if (device.udid === udid) return device.name; } return null; } function isDeviceBooted(udid: string): boolean { - for (const devices of Object.values(listDevicesByRuntime())) { - for (const device of devices) { - if (device.udid === udid) return device.state === "Booted"; - } + for (const [, device] of eachDevice()) { + if (device.udid === udid) return device.state === "Booted"; } return false; } diff --git a/packages/serve-sim/src/middleware.ts b/packages/serve-sim/src/middleware.ts index 4b6815ee..c9c0149d 100644 --- a/packages/serve-sim/src/middleware.ts +++ b/packages/serve-sim/src/middleware.ts @@ -24,7 +24,7 @@ import { } from "./devicekit-chrome"; import { createExecUpgradeHandler, type UiRequestHandler } from "./exec-ws"; import { UI_OPTIONS, getUiStatus, normalizeUiValue, setUiOption } from "./ui-settings"; -import { listDevicesByRuntime, tryListDevicesByRuntime } from "./device"; +import { listDevicesByRuntime, listBootedUdids } from "./device"; type SimReq = IncomingMessage; type SimRes = ServerResponse; @@ -276,25 +276,6 @@ export function matchInstalledAppByDisplayName( return null; } -// Booted-device set from the in-process reactive CoreSimulator subscriber. The -// middleware runs inside the user's dev server (Metro etc.) and -// readServeSimStates() is called on every /api and page load — the subscriber -// keeps a live snapshot via XPC push, so each read is in-process (no per-request -// `simctl` spawn) and the old time-based cache is unnecessary. Returns null when -// the device set couldn't be read at all, so callers don't treat a lookup -// failure as "nothing booted". -function getBootedUdids(): Set | null { - const grouped = tryListDevicesByRuntime(); - if (grouped === null) return null; - const booted = new Set(); - for (const devices of Object.values(grouped)) { - for (const device of devices) { - if (device.state === "Booted") booted.add(device.udid); - } - } - return booted; -} - // The device the user most recently opened in Simulator.app, regardless of // which tool launched it. Simulator.app persists this as CurrentDeviceUDID, so // it's the best signal for "the device this user actually cares about" — we @@ -327,7 +308,7 @@ export function readServeSimStates(): ServeSimState[] { } catch { return []; } - const booted = getBootedUdids(); + const booted = listBootedUdids(); const states: ServeSimState[] = []; for (const f of files) { const path = join(STATE_DIR, f); @@ -1037,14 +1018,7 @@ function listAllSimulators(): SimctlDevice[] { if (!/SimRuntime\.(iOS|watchOS|visionOS|xrOS)-/i.test(runtime)) continue; for (const d of devices) { if (d.isAvailable === false) continue; - out.push({ - udid: d.udid, - name: d.name, - state: d.state, - isAvailable: d.isAvailable, - deviceTypeIdentifier: d.deviceTypeIdentifier, - runtime: runtime.replace(/^.*SimRuntime\./, ""), - }); + out.push({ ...d, runtime: runtime.replace(/^.*SimRuntime\./, "") }); } } return out;