Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions .github/workflows/simdeck-provider.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`.

Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ simdeck chrome-profile <udid>
simdeck logs <udid> --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,
Expand Down
167 changes: 123 additions & 44 deletions cli/DFPrivateSimulatorDisplayBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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(
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading