diff --git a/.github/workflows/simdeck-provider.yml b/.github/workflows/simdeck-provider.yml index 4dc2f6d..8218575 100644 --- a/.github/workflows/simdeck-provider.yml +++ b/.github/workflows/simdeck-provider.yml @@ -178,13 +178,6 @@ jobs: raise SystemExit("No available iOS simulator") ')" - xcrun simctl boot "$ios_udid" || true - if ! run_with_timeout 180 xcrun simctl bootstatus "$ios_udid" -b; then - echo "simctl bootstatus timed out" - xcrun simctl list devices "$ios_udid" || true - exit 1 - fi - dump_logs() { echo "--- simdeck.log ---" cat simdeck.log || true @@ -233,6 +226,12 @@ jobs: : > simdeck.log simdeck_pid="" start_simdeck_server + ./build/simdeck --server-url http://127.0.0.1:4310 boot "$ios_udid" + if ! run_with_timeout 180 xcrun simctl bootstatus "$ios_udid" -b; then + echo "simctl bootstatus timed out" + xcrun simctl list devices "$ios_udid" || true + exit 1 + fi echo "Skipping private stream prewarm; the first browser WebRTC connection will attach the stream." provider_base_url="$SIMDECK_CLOUD_URL/simulator/$PREVIEW_ID" diff --git a/AGENTS.md b/AGENTS.md index bf2c328..452ffee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ The native side should own anything that depends on macOS frameworks, `xcrun sim - `cli/native/XCWNativeSession.*` Wraps one Objective-C private simulator session handle for the Rust registry. - `cli/XCWPrivateSimulatorBooter.*` - Uses private `CoreSimulator` APIs for direct simulator boot when available, with `simctl` as the fallback path. + Uses private `CoreSimulator` APIs for direct simulator boot without launching Simulator.app. - `cli/XCWChromeRenderer.*` Renders Apple’s CoreSimulator device-type PDF chrome assets into PNGs for the browser. - `client/src/app/App.tsx` @@ -78,6 +78,7 @@ Private simulator behavior is implemented locally in: - Accessibility bridge: `cli/XCWAccessibilityBridge.*` The current repo uses the private boot path, private display bridge, and private accessibility translation bridge directly. The browser streams frames from that bridge, injects touch and keyboard events through the same native session layer, inspects accessibility through `AccessibilityPlatformTranslation`, and renders device chrome from `cli/XCWChromeRenderer.*`. +CoreSimulator service contexts resolve the active developer directory from `DEVELOPER_DIR`, then `xcode-select -p`, then `/Applications/Xcode.app/Contents/Developer`. The display bridge prefers direct CoreSimulator screen IOSurface callbacks and activates the SimulatorKit offscreen renderable view only if direct callbacks are unavailable. Physical chrome button support uses DeviceKit `chrome.json` input geometry for browser hit targets. Volume, action, mute, Apple Watch digital crown, Watch side button, and Watch left-side button dispatch through `IndigoHIDMessageForHIDArbitrary` with consumer/telephony/vendor HID usage pairs from the device chrome metadata; home, lock, and app-switcher remain on the existing SimulatorKit button paths. Apple Watch Digital Crown rotation dispatches through `IndigoHIDMessageForScrollEvent` with the same digitizer target as touch input. WebKit inspection uses the simulator `webinspectord` Unix socket named `com.apple.webinspectord_sim.socket` and WebKit's binary-plist Remote Inspector selectors. It lists only WebKit content that the runtime exposes as inspectable. For app-owned `WKWebView` on iOS 16.4 and newer, the app must set `isInspectable = true`. diff --git a/README.md b/README.md index bafd891..9057cd9 100644 --- a/README.md +++ b/README.md @@ -162,9 +162,10 @@ simdeck chrome-profile simdeck logs --seconds 30 --limit 200 ``` -`boot` prefers SimDeck's private CoreSimulator boot path so it can start devices -without launching Simulator.app, then falls back to `xcrun simctl` when private -booting is unavailable. +`boot` uses SimDeck's private CoreSimulator boot path so it can start devices +without launching Simulator.app. If that private path is unavailable, the +command returns the CoreSimulator error instead of falling back to +`xcrun simctl boot`. Android emulators appear in `simdeck list` with IDs like `android:SimDeck_Pixel_8_API_36`. For Android IDs, lifecycle, install, launch, diff --git a/cli/DFPrivateSimulatorDisplayBridge.m b/cli/DFPrivateSimulatorDisplayBridge.m index ce1b0a4..a35aedd 100644 --- a/cli/DFPrivateSimulatorDisplayBridge.m +++ b/cli/DFPrivateSimulatorDisplayBridge.m @@ -120,10 +120,10 @@ static const NSUInteger DFKeyboardModifierCommand = 1 << 3; static const NSUInteger DFKeyboardModifierCapsLock = 1 << 4; -static NSString *DFSimulatorKitExecutablePath(void) { +static NSString *DFActiveDeveloperDirectory(void) { const char *developerDir = getenv("DEVELOPER_DIR"); if (developerDir != NULL && developerDir[0] != '\0') { - return [[NSString stringWithUTF8String:developerDir] stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"]; + return [NSString stringWithUTF8String:developerDir]; } FILE *pipe = popen("/usr/bin/xcode-select -p 2>/dev/null", "r"); @@ -133,31 +133,26 @@ NSString *selected = [[NSString stringWithUTF8String:buffer] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; pclose(pipe); if (selected.length > 0) { - return [selected stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"]; + return selected; } } else { pclose(pipe); } } + return @"/Applications/Xcode.app/Contents/Developer"; +} + +static NSString *DFSimulatorKitExecutablePath(void) { + NSString *developerDir = DFActiveDeveloperDirectory(); + if (developerDir.length > 0) { + return [developerDir stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"]; + } return @"/Applications/Xcode.app/Contents/Developer/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"; } static NSInteger DFXcodeMajorVersion(void) { - NSString *developerPath = nil; - const char *developerDir = getenv("DEVELOPER_DIR"); - if (developerDir != NULL && developerDir[0] != '\0') { - developerPath = [NSString stringWithUTF8String:developerDir]; - } else { - FILE *pipe = popen("/usr/bin/xcode-select -p 2>/dev/null", "r"); - if (pipe != NULL) { - char buffer[PATH_MAX] = {0}; - if (fgets(buffer, sizeof(buffer), pipe) != NULL) { - developerPath = [[NSString stringWithUTF8String:buffer] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; - } - pclose(pipe); - } - } + NSString *developerPath = DFActiveDeveloperDirectory(); if (developerPath.length == 0) { return 0; } @@ -590,6 +585,103 @@ static id DFSendObject(id target, const char *selectorName) { return trimmed.length > 0 ? trimmed : nil; } +static id DFCreateCoreSimulatorServiceContext(NSError **error) { + Class serviceContextClass = NSClassFromString(@"SimServiceContext"); + if (serviceContextClass == Nil) { + if (error != NULL) { + *error = DFMakeError( + DFPrivateSimulatorErrorCodeServiceContextFailed, + @"CoreSimulator did not expose SimServiceContext in this Xcode runtime." + ); + } + return nil; + } + + NSString *developerDir = DFActiveDeveloperDirectory(); + NSError *serviceError = nil; + SEL sharedSelector = sel_registerName("sharedServiceContextForDeveloperDir:error:"); + if ([serviceContextClass respondsToSelector:sharedSelector]) { + id serviceContext = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + serviceContextClass, + sharedSelector, + developerDir, + &serviceError + ); + if (serviceContext != nil) { + return serviceContext; + } + DFLog(@"sharedServiceContextForDeveloperDir:error: failed for %@: %@", developerDir, serviceError.localizedDescription ?: @"unknown error"); + } + + serviceError = nil; + SEL initSelector = sel_registerName("initWithDeveloperDir:connectionType:error:"); + id contextAlloc = ((id(*)(id, SEL))objc_msgSend)(serviceContextClass, sel_registerName("alloc")); + if (![contextAlloc respondsToSelector:initSelector]) { + if (error != NULL) { + *error = DFMakeError( + DFPrivateSimulatorErrorCodeServiceContextFailed, + @"CoreSimulator did not expose a supported SimServiceContext initializer." + ); + } + return nil; + } + id serviceContext = ((id(*)(id, SEL, id, long long, NSError **))objc_msgSend)( + contextAlloc, + initSelector, + developerDir, + 0LL, + &serviceError + ); + if (serviceContext == nil && error != NULL) { + *error = serviceError ?: DFMakeError( + DFPrivateSimulatorErrorCodeServiceContextFailed, + [NSString stringWithFormat:@"Unable to create a CoreSimulator service context for %@.", developerDir] + ); + } + return serviceContext; +} + +static NSArray *DFFlattenCoreSimulatorDevices(id devicesPayload) { + if ([devicesPayload isKindOfClass:[NSArray class]]) { + return devicesPayload; + } + if ([devicesPayload isKindOfClass:[NSSet class]]) { + return [devicesPayload allObjects]; + } + if ([devicesPayload isKindOfClass:[NSDictionary class]]) { + NSMutableArray *devices = [NSMutableArray array]; + for (id value in [(NSDictionary *)devicesPayload allValues]) { + [devices addObjectsFromArray:DFFlattenCoreSimulatorDevices(value)]; + } + return devices; + } + return @[]; +} + +static NSArray *DFCoreSimulatorDevicesForDeviceSet(id deviceSet) { + SEL availableSelector = sel_registerName("availableDevices"); + if ([deviceSet respondsToSelector:availableSelector]) { + NSArray *availableDevices = DFFlattenCoreSimulatorDevices(((id(*)(id, SEL))objc_msgSend)(deviceSet, availableSelector)); + if (availableDevices.count > 0) { + return availableDevices; + } + } + + SEL devicesSelector = sel_registerName("devices"); + if ([deviceSet respondsToSelector:devicesSelector]) { + return DFFlattenCoreSimulatorDevices(((id(*)(id, SEL))objc_msgSend)(deviceSet, devicesSelector)); + } + return @[]; +} + +static NSString *DFUDIDForCoreSimulatorDevice(id device) { + id deviceUDID = DFSendObject(device, "UDID"); + if ([deviceUDID respondsToSelector:sel_registerName("UUIDString")]) { + return DFSendObject(deviceUDID, "UUIDString"); + } + return [deviceUDID description]; +} + static id DFAllocInitRect(Class cls, NSRect rect) { id instance = ((id(*)(id, SEL))objc_msgSend)(cls, sel_registerName("alloc")); return ((id(*)(id, SEL, NSRect))objc_msgSend)(instance, sel_registerName("initWithFrame:"), rect); @@ -2565,26 +2657,8 @@ - (nullable instancetype)initWithUDID:(NSString *)udid dispatch_queue_set_specific(_callbackQueue, DFPrivateSimulatorCallbackQueueKey, (void *)DFPrivateSimulatorCallbackQueueKey, NULL); [self updateStatus:[NSString stringWithFormat:@"Starting private CoreSimulator attach for %@", udid]]; - Class serviceContextClass = NSClassFromString(@"SimServiceContext"); - if (serviceContextClass == Nil) { - if (error != NULL) { - *error = DFMakeError( - DFPrivateSimulatorErrorCodeServiceContextFailed, - @"CoreSimulator did not expose SimServiceContext in this Xcode runtime." - ); - } - return nil; - } - NSError *serviceError = nil; - id contextAlloc = ((id(*)(id, SEL))objc_msgSend)(serviceContextClass, sel_registerName("alloc")); - _serviceContext = ((id(*)(id, SEL, id, long long, NSError **))objc_msgSend)( - contextAlloc, - sel_registerName("initWithDeveloperDir:connectionType:error:"), - nil, - 0LL, - &serviceError - ); + _serviceContext = DFCreateCoreSimulatorServiceContext(&serviceError); if (_serviceContext == nil) { if (error != NULL) { *error = serviceError ?: DFMakeError( @@ -2607,13 +2681,9 @@ - (nullable instancetype)initWithUDID:(NSString *)udid return nil; } - NSArray *devices = DFSendObject(deviceSet, "devices"); + NSArray *devices = DFCoreSimulatorDevicesForDeviceSet(deviceSet); for (id candidate in devices) { - id deviceUDID = DFSendObject(candidate, "UDID"); - NSString *candidateUDID = [deviceUDID respondsToSelector:sel_registerName("UUIDString")] - ? DFSendObject(deviceUDID, "UUIDString") - : [deviceUDID description]; - if ([candidateUDID isEqualToString:udid]) { + if ([DFUDIDForCoreSimulatorDevice(candidate) isEqualToString:udid]) { _device = candidate; break; } @@ -2894,8 +2964,17 @@ - (nullable instancetype)initWithUDID:(NSString *)udid _displayView = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 430, 932)]; _displayView.wantsLayer = YES; } - [self updateStatus:@"Waiting for IOSurface callback"]; - [self activateDisplayIfNeeded]; + [self updateStatus:@"Waiting for direct CoreSimulator IOSurface callback"]; + NSDate *directFrameDeadline = [NSDate dateWithTimeIntervalSinceNow:1.0]; + while (_latestPixelBuffer == nil && [directFrameDeadline timeIntervalSinceNow] > 0) { + DFSpinRunLoop(0.05); + } + if (_latestPixelBuffer == nil) { + [self updateStatus:@"Direct CoreSimulator frames unavailable; attaching SimulatorKit fallback display"]; + [self activateDisplayIfNeeded]; + } else { + DFLog(@"Using direct CoreSimulator screen callbacks without activating SimulatorKit fallback display."); + } DFSpinRunLoop(0.25); return self; diff --git a/cli/XCWPrivateSimulatorBooter.m b/cli/XCWPrivateSimulatorBooter.m index 11d76e6..bc0df1a 100644 --- a/cli/XCWPrivateSimulatorBooter.m +++ b/cli/XCWPrivateSimulatorBooter.m @@ -1,7 +1,9 @@ #import "XCWPrivateSimulatorBooter.h" #import +#import #import +#import static NSString * const XCWPrivateSimulatorBooterErrorDomain = @"SimDeck.PrivateSimulatorBooter"; static NSString * const XCWCoreSimulatorPath = @"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"; @@ -21,6 +23,130 @@ typedef NS_ENUM(NSInteger, XCWPrivateSimulatorBooterErrorCode) { }]; } +static NSString *XCWActiveDeveloperDirectory(void) { + const char *developerDir = getenv("DEVELOPER_DIR"); + if (developerDir != NULL && developerDir[0] != '\0') { + return [NSString stringWithUTF8String:developerDir]; + } + + FILE *pipe = popen("/usr/bin/xcode-select -p 2>/dev/null", "r"); + if (pipe != NULL) { + char buffer[PATH_MAX] = {0}; + if (fgets(buffer, sizeof(buffer), pipe) != NULL) { + NSString *selected = [[NSString stringWithUTF8String:buffer] stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; + pclose(pipe); + if (selected.length > 0) { + return selected; + } + } else { + pclose(pipe); + } + } + + return @"/Applications/Xcode.app/Contents/Developer"; +} + +static id XCWCreateCoreSimulatorServiceContext(NSError **error) { + Class serviceContextClass = NSClassFromString(@"SimServiceContext"); + if (serviceContextClass == Nil) { + if (error != NULL) { + *error = XCWPrivateSimulatorBooterMakeError( + XCWPrivateSimulatorBooterErrorCodeServiceContextFailed, + @"CoreSimulator did not expose SimServiceContext." + ); + } + return nil; + } + + NSString *developerDir = XCWActiveDeveloperDirectory(); + NSError *serviceError = nil; + SEL sharedSelector = sel_registerName("sharedServiceContextForDeveloperDir:error:"); + if ([serviceContextClass respondsToSelector:sharedSelector]) { + id context = ((id(*)(id, SEL, id, NSError **))objc_msgSend)( + serviceContextClass, + sharedSelector, + developerDir, + &serviceError + ); + if (context != nil) { + return context; + } + } + + serviceError = nil; + SEL initSelector = sel_registerName("initWithDeveloperDir:connectionType:error:"); + id contextAlloc = ((id(*)(id, SEL))objc_msgSend)(serviceContextClass, sel_registerName("alloc")); + if (![contextAlloc respondsToSelector:initSelector]) { + if (error != NULL) { + *error = XCWPrivateSimulatorBooterMakeError( + XCWPrivateSimulatorBooterErrorCodeServiceContextFailed, + @"CoreSimulator did not expose a supported SimServiceContext initializer." + ); + } + return nil; + } + id context = ((id(*)(id, SEL, id, long long, NSError **))objc_msgSend)( + contextAlloc, + initSelector, + developerDir, + 0LL, + &serviceError + ); + if (context == nil && error != NULL) { + *error = serviceError ?: XCWPrivateSimulatorBooterMakeError( + XCWPrivateSimulatorBooterErrorCodeServiceContextFailed, + [NSString stringWithFormat:@"Unable to create a CoreSimulator service context for %@.", developerDir] + ); + } + return context; +} + +static NSArray *XCWFlattenCoreSimulatorDevices(id devicesPayload) { + if ([devicesPayload isKindOfClass:[NSArray class]]) { + return devicesPayload; + } + if ([devicesPayload isKindOfClass:[NSSet class]]) { + return [devicesPayload allObjects]; + } + if ([devicesPayload isKindOfClass:[NSDictionary class]]) { + NSMutableArray *devices = [NSMutableArray array]; + for (id value in [(NSDictionary *)devicesPayload allValues]) { + [devices addObjectsFromArray:XCWFlattenCoreSimulatorDevices(value)]; + } + return devices; + } + return @[]; +} + +static NSArray *XCWDevicesForDeviceSet(id deviceSet) { + SEL availableSelector = sel_registerName("availableDevices"); + if ([deviceSet respondsToSelector:availableSelector]) { + NSArray *availableDevices = XCWFlattenCoreSimulatorDevices(((id(*)(id, SEL))objc_msgSend)(deviceSet, availableSelector)); + if (availableDevices.count > 0) { + return availableDevices; + } + } + + SEL devicesSelector = sel_registerName("devices"); + if ([deviceSet respondsToSelector:devicesSelector]) { + return XCWFlattenCoreSimulatorDevices(((id(*)(id, SEL))objc_msgSend)(deviceSet, devicesSelector)); + } + return @[]; +} + +static NSString *XCWUDIDForDevice(id device) { + id deviceUDID = ((id(*)(id, SEL))objc_msgSend)(device, sel_registerName("UDID")); + if ([deviceUDID respondsToSelector:sel_registerName("UUIDString")]) { + return ((id(*)(id, SEL))objc_msgSend)(deviceUDID, sel_registerName("UUIDString")); + } + return [deviceUDID description]; +} + +static BOOL XCWPrivateBootErrorMeansAlreadyBooted(NSError *error) { + NSString *message = error.localizedDescription.lowercaseString ?: @""; + return [message containsString:@"already booted"] || [message containsString:@"current state: booted"]; +} + @implementation XCWPrivateSimulatorBooter + (BOOL)bootDeviceWithUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error { @@ -43,26 +169,8 @@ + (BOOL)bootDeviceWithUDID:(NSString *)udid error:(NSError * _Nullable __autorel return NO; } - Class serviceContextClass = NSClassFromString(@"SimServiceContext"); - if (serviceContextClass == Nil) { - if (error != NULL) { - *error = XCWPrivateSimulatorBooterMakeError( - XCWPrivateSimulatorBooterErrorCodeServiceContextFailed, - @"CoreSimulator did not expose SimServiceContext." - ); - } - return NO; - } - NSError *serviceError = nil; - id contextAlloc = ((id(*)(id, SEL))objc_msgSend)(serviceContextClass, sel_registerName("alloc")); - id serviceContext = ((id(*)(id, SEL, id, long long, NSError **))objc_msgSend)( - contextAlloc, - sel_registerName("initWithDeveloperDir:connectionType:error:"), - nil, - 0LL, - &serviceError - ); + id serviceContext = XCWCreateCoreSimulatorServiceContext(&serviceError); if (serviceContext == nil) { if (error != NULL) { *error = serviceError ?: XCWPrivateSimulatorBooterMakeError( @@ -86,13 +194,9 @@ + (BOOL)bootDeviceWithUDID:(NSString *)udid error:(NSError * _Nullable __autorel } id targetDevice = nil; - NSArray *devices = ((id(*)(id, SEL))objc_msgSend)(deviceSet, sel_registerName("devices")); + NSArray *devices = XCWDevicesForDeviceSet(deviceSet); for (id candidate in devices) { - id deviceUDID = ((id(*)(id, SEL))objc_msgSend)(candidate, sel_registerName("UDID")); - NSString *candidateUDID = [deviceUDID respondsToSelector:sel_registerName("UUIDString")] - ? ((id(*)(id, SEL))objc_msgSend)(deviceUDID, sel_registerName("UUIDString")) - : [deviceUDID description]; - if ([candidateUDID isEqualToString:udid]) { + if ([XCWUDIDForDevice(candidate) isEqualToString:udid]) { targetDevice = candidate; break; } @@ -109,19 +213,45 @@ + (BOOL)bootDeviceWithUDID:(NSString *)udid error:(NSError * _Nullable __autorel } NSError *bootError = nil; - BOOL booted = ((BOOL(*)(id, SEL, id, NSError **))objc_msgSend)( - targetDevice, - sel_registerName("bootWithOptions:error:"), - @{}, - &bootError - ); + BOOL booted = NO; + SEL bootWithOptionsSelector = sel_registerName("bootWithOptions:error:"); + if ([targetDevice respondsToSelector:bootWithOptionsSelector]) { + booted = ((BOOL(*)(id, SEL, id, NSError **))objc_msgSend)( + targetDevice, + bootWithOptionsSelector, + // Keep the boot session owned by SimDeck's daemon instead of asking + // CoreSimulator for a persistent GUI-visible session. + @{ @"persist": @NO }, + &bootError + ); + } else { + bootError = XCWPrivateSimulatorBooterMakeError( + XCWPrivateSimulatorBooterErrorCodeBootFailed, + @"CoreSimulator device did not expose bootWithOptions:error:." + ); + } if (!booted) { - NSString *message = bootError.localizedDescription.lowercaseString; - if ([message containsString:@"already booted"] || [message containsString:@"current state: booted"]) { + if (XCWPrivateBootErrorMeansAlreadyBooted(bootError)) { return YES; } + SEL bootWithErrorSelector = sel_registerName("bootWithError:"); + if ([targetDevice respondsToSelector:bootWithErrorSelector]) { + NSError *legacyBootError = nil; + booted = ((BOOL(*)(id, SEL, NSError **))objc_msgSend)( + targetDevice, + bootWithErrorSelector, + &legacyBootError + ); + if (booted || XCWPrivateBootErrorMeansAlreadyBooted(legacyBootError)) { + return YES; + } + if (legacyBootError != nil) { + bootError = legacyBootError; + } + } + if (error != NULL) { *error = bootError ?: XCWPrivateSimulatorBooterMakeError( XCWPrivateSimulatorBooterErrorCodeBootFailed, diff --git a/cli/XCWSimctl.m b/cli/XCWSimctl.m index ba25822..2f77004 100644 --- a/cli/XCWSimctl.m +++ b/cli/XCWSimctl.m @@ -186,22 +186,8 @@ - (BOOL)bootSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable __auto return YES; } - XCWProcessResult *result = [self.class runSimctl:@[@"boot", udid] error:error]; - if (result == nil) { - return NO; - } - if (result.terminationStatus == 0) { - return YES; - } - - NSString *stderrString = result.stderrString.lowercaseString; - if ([stderrString containsString:@"unable to boot device in current state: booted"] || [stderrString containsString:@"already booted"]) { - return YES; - } - if (error != NULL) { - NSString *description = result.stderrString.length > 0 ? result.stderrString : privateError.localizedDescription ?: @"Unable to boot simulator."; - *error = [self.class errorWithDescription:description code:4]; + *error = privateError ?: [self.class errorWithDescription:@"Private CoreSimulator boot failed. SimDeck does not fall back to `xcrun simctl boot` because that can launch Simulator.app." code:4]; } return NO; } diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 1175911..d7136e1 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -179,9 +179,10 @@ simdeck erase `list` returns the same simulator inventory the browser UI renders, including Android AVDs as IDs like `android:Pixel_8_API_36`. iOS lifecycle commands use -the native bridge, preferring private CoreSimulator paths when available and -falling back to `xcrun simctl`. Android lifecycle commands use the Android SDK -`emulator` and `adb` tools. +the native bridge. `boot` uses the private CoreSimulator path and does not fall +back to `xcrun simctl boot`; other iOS lifecycle commands still use `xcrun +simctl` where Apple exposes stable subcommands. Android lifecycle commands use +the Android SDK `emulator` and `adb` tools. ## Apps And URLs diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md index 6ba4a19..e81f6c2 100644 --- a/docs/guide/architecture.md +++ b/docs/guide/architecture.md @@ -45,8 +45,8 @@ Anything that depends on macOS frameworks, `xcrun simctl`, or private `CoreSimul Inside the bridge: - **`XCWSimctl.{h,m}`** wraps `xcrun simctl` for discovery, lifecycle management, app launching, URL opening, and screenshot capture. -- **`XCWPrivateSimulatorBooter.{h,m}`** uses private `CoreSimulator` APIs for direct simulator boot when available, with `simctl` as the fallback path. -- **`DFPrivateSimulatorDisplayBridge.{h,m}`** owns headless private display frames plus HID-based touch and keyboard injection. +- **`XCWPrivateSimulatorBooter.{h,m}`** uses private `CoreSimulator` APIs for direct simulator boot without launching Simulator.app. +- **`DFPrivateSimulatorDisplayBridge.{h,m}`** owns headless private display frames plus HID-based touch and keyboard injection. It resolves the active Xcode developer directory explicitly, prefers direct CoreSimulator screen IOSurface callbacks, and activates the older SimulatorKit offscreen renderable view only when direct frame callbacks are unavailable. - **`XCWPrivateSimulatorSession.{h,m}`** owns one private display bridge per booted simulator plus a selectable hardware or software H.264 encoder. - **`XCWPrivateSimulatorChromeBridge.{h,m}`** is an experimental private `SimulatorKit` chrome bridge kept nearby as a reference. - **`XCWChromeRenderer.{h,m}`** renders Apple's CoreSimulator device-type PDF chrome assets into PNGs for the browser. @@ -85,11 +85,11 @@ The client never depends on private APIs and never assumes anything not exposed ### Simulator control -Most control endpoints follow the same path: a typed Rust handler in `server/src/api/routes.rs` calls `SessionRegistry::bridge()`, which dispatches into `cli/native/XCWNativeBridge.*` over the C ABI. From there the call lands in the matching Objective-C unit — for example, `POST /api/simulators/{udid}/boot` ends up in `XCWPrivateSimulatorBooter`, which uses private `CoreSimulator` APIs for direct boot and falls back to `simctl` if that fails. +Most control endpoints follow the same path: a typed Rust handler in `server/src/api/routes.rs` calls `SessionRegistry::bridge()`, which dispatches into `cli/native/XCWNativeBridge.*` over the C ABI. From there the call lands in the matching Objective-C unit. For example, `POST /api/simulators/{udid}/boot` ends up in `XCWPrivateSimulatorBooter`, which uses private `CoreSimulator` APIs for direct boot and returns a clear error if that private path fails. ### Live video -The browser posts an SDP offer to `/api/simulators/{udid}/webrtc/offer`. The handler in `transport::webrtc` starts the selected frame source, waits for the first H.264 keyframe, returns an SDP answer, and writes H.264 samples to a WebRTC video track. For Android, that source is emulator gRPC raw pixels passed through the shared VideoToolbox encoder path. +The browser posts an SDP offer to `/api/simulators/{udid}/webrtc/offer`. The handler in `transport::webrtc` starts the selected frame source, waits for the first H.264 keyframe, returns an SDP answer, and writes H.264 samples to a WebRTC video track. iOS frames come from the private display bridge, which first waits for direct CoreSimulator screen IOSurface callbacks and then falls back to the SimulatorKit offscreen renderable view. For Android, the source is emulator gRPC raw pixels passed through the shared VideoToolbox encoder path. ### Input diff --git a/docs/guide/installation.md b/docs/guide/installation.md index 0fabaa3..c3de741 100644 --- a/docs/guide/installation.md +++ b/docs/guide/installation.md @@ -6,13 +6,13 @@ SimDeck ships as a single npm package that contains the launcher, the client bun SimDeck only runs on macOS. The native bridge links private `CoreSimulator` and `SimulatorKit` frameworks, so it cannot run on Linux or Windows. -| Requirement | Why | -| ---------------------------------- | ------------------------------------------------------------------------------------ | -| **macOS 13+** | Required for current `CoreSimulator` and Apple's VideoToolbox H.264 encoder. | -| **Xcode + iOS Simulator runtimes** | The native bridge invokes `xcrun simctl` and the Simulator app. | -| **Android SDK tools** | Optional. Required for Android emulator support (`emulator`, `adb`, and AVD images). | -| **Node.js ≥ 18** | The launcher (`bin/simdeck.mjs`) and the bundled client tooling. | -| **Rust (stable)** | Required only when building from source. Installed via [rustup](https://rustup.rs/). | +| Requirement | Why | +| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| **macOS 13+** | Required for current `CoreSimulator` and Apple's VideoToolbox H.264 encoder. | +| **Xcode + iOS Simulator runtimes** | The native bridge loads Xcode's private simulator frameworks and invokes `xcrun simctl` for non-boot operations. | +| **Android SDK tools** | Optional. Required for Android emulator support (`emulator`, `adb`, and AVD images). | +| **Node.js ≥ 18** | The launcher (`bin/simdeck.mjs`) and the bundled client tooling. | +| **Rust (stable)** | Required only when building from source. Installed via [rustup](https://rustup.rs/). | The package contains a macOS native binary. Non-macOS installs are unsupported. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index a95fe5b..dc0a437 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -53,7 +53,7 @@ simdeck 9750DF52-0471-48FF-B49A-B184C4BD3A3D ``` ::: tip First-frame delay -On a cold boot the daemon has to launch the Simulator, attach the private display bridge, and wait for a keyframe before video flows. The first frame typically shows up within a second; subsequent reloads of the same Simulator are near-instant. +On a cold boot the daemon has to boot the device through private CoreSimulator APIs, attach the private display bridge, and wait for a keyframe before video flows. The first frame typically shows up within a second; subsequent reloads of the same Simulator are near-instant. ::: ## 3. Drive It diff --git a/docs/guide/troubleshooting.md b/docs/guide/troubleshooting.md index 83509df..b885acc 100644 --- a/docs/guide/troubleshooting.md +++ b/docs/guide/troubleshooting.md @@ -44,15 +44,16 @@ npm install -g simdeck ## Simulator never boots -### `xcrun simctl` errors +### Private CoreSimulator boot errors -The native bridge falls back to `xcrun simctl boot` when private CoreSimulator APIs are unavailable. Try the same command directly to surface the underlying error: +SimDeck boots devices through private CoreSimulator APIs so it does not launch Simulator.app. If booting fails, the CLI returns that private CoreSimulator error directly and does not fall back to `xcrun simctl boot`. Confirm Xcode selection first: ```sh -xcrun simctl boot +xcode-select -p +DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer simdeck boot ``` -If `simctl` succeeds but SimDeck still fails, capture the server log and file an issue. +If Xcode selection is correct but SimDeck still fails, capture the server log and file an issue. Running `xcrun simctl boot ` can still be useful as a manual comparison, but it may launch Simulator.app and is not SimDeck's fallback path. ### CoreSimulator service unhealthy diff --git a/scripts/integration/android.mjs b/scripts/integration/android.mjs index 7317dec..7ba54d5 100644 --- a/scripts/integration/android.mjs +++ b/scripts/integration/android.mjs @@ -127,38 +127,48 @@ async function runCliSurface() { assert.ok(Number(profile.screenWidth) > 0, "missing Android screenWidth"); assert.ok(Number(profile.screenHeight) > 0, "missing Android screenHeight"); }); - const tree = await measuredStep("CLI describe Android tree", () => { - const payload = simdeckJson([ - "describe", - androidUDID, - "--source", - "android-uiautomator", - "--format", - "compact-json", - "--max-depth", - "2", - ]); + const tree = await measuredStep("CLI describe Android tree", async () => { + const payload = await retryAsync( + () => + simdeckJson([ + "describe", + androidUDID, + "--source", + "android-uiautomator", + "--format", + "compact-json", + "--max-depth", + "2", + ]), + "CLI describe Android tree", + { attempts: 8, delayMs: 2_000 }, + ); assertRoots(payload, "CLI describe"); return payload; }); - await measuredStep("CLI describe Android point", () => { + await measuredStep("CLI describe Android point", async () => { const point = centerOfFirstRoot(tree); if (!point) { throw new Error("Unable to derive point from Android tree root."); } assertRoots( - simdeckJson([ - "describe", - androidUDID, - "--source", - "android-uiautomator", - "--format", - "compact-json", - "--point", - `${point.x},${point.y}`, - "--max-depth", - "1", - ]), + await retryAsync( + () => + simdeckJson([ + "describe", + androidUDID, + "--source", + "android-uiautomator", + "--format", + "compact-json", + "--point", + `${point.x},${point.y}`, + "--max-depth", + "1", + ]), + "CLI describe Android point", + { attempts: 4, delayMs: 1_000 }, + ), "CLI point describe", ); }); @@ -317,10 +327,15 @@ async function runJsSurface() { }); await measuredStep("JS Android tree", async () => { assertRoots( - await session.tree(androidUDID, { - source: "android-uiautomator", - maxDepth: 2, - }), + await retryAsync( + () => + session.tree(androidUDID, { + source: "android-uiautomator", + maxDepth: 2, + }), + "JS Android tree", + { attempts: 6, delayMs: 2_000 }, + ), "JS tree", ); }); @@ -583,6 +598,28 @@ async function measuredStep(label, run, options = {}) { } } +async function retryAsync(run, label, options = {}) { + const attempts = options.attempts ?? 3; + const delayMs = options.delayMs ?? 1_000; + let lastError = null; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await run(); + } catch (error) { + lastError = error; + if (attempt < attempts) { + if (verbose) { + console.log( + `${label} attempt ${attempt}/${attempts} failed: ${error.message}`, + ); + } + await sleep(delayMs); + } + } + } + throw lastError; +} + function printTimingSummary() { if (!verbose || stepTimings.length === 0) { return; diff --git a/scripts/integration/js-api.mjs b/scripts/integration/js-api.mjs index 2cacee7..22ed291 100644 --- a/scripts/integration/js-api.mjs +++ b/scripts/integration/js-api.mjs @@ -91,15 +91,25 @@ async function main() { `created ${simulatorUDID} (${deviceType.name}, ${runtime.version}; iphonesimulator SDK ${sdkVersion})`, ); + session = await measuredStep( + "simdeck/test isolated connect", + () => + connect({ + cliPath: simdeck, + projectRoot: root, + isolated: true, + videoCodec: "software", + }), + { phase: phaseSetup }, + ); + console.log(`daemon ${session.endpoint}`); + await measuredStep( - "boot simulator", + "JS boot simulator", () => - retrySync( - () => - runText("xcrun", ["simctl", "boot", simulatorUDID], { - timeoutMs: 180_000, - }), - "boot simulator", + retryAsync( + () => session.boot(simulatorUDID), + "JS boot simulator", 3, 3_000, ), @@ -123,19 +133,6 @@ async function main() { { phase: phaseSetup }, ); - session = await measuredStep( - "simdeck/test isolated connect", - () => - connect({ - cliPath: simdeck, - projectRoot: root, - isolated: true, - videoCodec: "software", - }), - { phase: phaseSetup }, - ); - console.log(`daemon ${session.endpoint}`); - await measuredStep( "JS install fixture", async () => { @@ -160,7 +157,19 @@ async function main() { }); await measuredStep("JS launch fixture", async () => { await retryAsync( - () => session.launch(simulatorUDID, fixtureBundleId), + async () => { + await session.launch(simulatorUDID, fixtureBundleId); + await session.waitFor( + simulatorUDID, + { id: "fixture.continue" }, + { + source: "native-ax", + maxDepth: 3, + timeoutMs: 15_000, + pollMs: 250, + }, + ); + }, "JS launch fixture", 3, 5_000, @@ -199,30 +208,30 @@ async function main() { await expectFixtureText("URL Opened"); }); await measuredStep("JS focus URL and type", async () => { - await session.tapElement( - simulatorUDID, - { id: "fixture.message" }, - { - source: "native-ax", - maxDepth: 3, - waitTimeoutMs: 15_000, - durationMs: 30, - }, - ); await retryAsync( async () => { + await session.tapElement( + simulatorUDID, + { id: "fixture.message" }, + { + source: "native-ax", + maxDepth: 3, + waitTimeoutMs: 15_000, + durationMs: 30, + }, + ); await session.openUrl(simulatorUDID, fixtureFocusUrl); - await expectFixtureText("Message Focused"); + await expectFixtureText("Message Focused", { timeoutMs: 20_000 }); + await sleep(1_000); + await session.batch(simulatorUDID, [ + { action: "type", text: "agent-ready", delayMs: 20 }, + ]); + await expectFixtureText("agent-ready", { timeoutMs: 20_000 }); }, - "JS focus URL", + "JS focus URL and type", 3, 2_000, ); - await sleep(1_000); - await session.batch(simulatorUDID, [ - { action: "type", text: "agent-ready", delayMs: 12 }, - ]); - await expectFixtureText("agent-ready"); }); await measuredStep( diff --git a/server/src/main.rs b/server/src/main.rs index 9f50b3f..ed017bc 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3215,6 +3215,11 @@ fn default_screenshot_path(udid: &str) -> PathBuf { } fn run_stream_stdout(bridge: &NativeBridge, udid: String, frames: u64) -> anyhow::Result<()> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_time() + .build() + .context("create stream runtime")?; + let _runtime_guard = runtime.enter(); let metrics = Arc::new(Metrics::default()); let session = simulators::session::SimulatorSession::new(bridge, udid, metrics) .map_err(|error| anyhow::anyhow!("{error}"))?; @@ -3224,10 +3229,6 @@ fn run_stream_stdout(bridge: &NativeBridge, udid: String, frames: u64) -> anyhow session.request_keyframe(); let mut receiver = session.subscribe(); - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_time() - .build() - .context("create stream runtime")?; let mut stdout = io::stdout().lock(); let mut written = 0u64; runtime.block_on(async {