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
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,21 +116,21 @@ Run the local daemon:

```sh
./build/simdeck
./build/simdeck daemon start --port 4310
./build/simdeck daemon start --port 4311
```

Running without a subcommand starts a foreground workspace daemon, prints local and LAN HTTP URLs, prints a six-digit pairing code for LAN browsers, and stops when the command exits, when you press `q`, or when you press Ctrl-C. Pass a simulator name or UDID as the only argument to select it by default in the UI. Use `./build/simdeck -d`, `./build/simdeck -k`, and `./build/simdeck -r` as detached start, kill, and restart shortcuts.
Running without a subcommand starts a foreground workspace daemon, prints local and LAN HTTP URLs, prints a six-digit pairing code for LAN browsers, and stops when the command exits, when you press `q`, or when you press Ctrl-C. If the always-on service is active on 4310, running without a subcommand or running `simdeck ui` prints the existing service endpoints instead of starting a project daemon. Pass a simulator name or UDID as the only argument to select it by default in the UI. Use `./build/simdeck -d`, `./build/simdeck -k`, and `./build/simdeck -r` as detached start, kill, and restart shortcuts.

Use software H.264 when macOS screen recording starves the hardware encoder:

```sh
./build/simdeck daemon start --port 4310 --video-codec h264-software
./build/simdeck daemon start --port 4311 --video-codec h264-software
```

For LAN access:

```sh
./build/simdeck daemon start --port 4310 --bind 0.0.0.0 --advertise-host 192.168.1.50
./build/simdeck daemon start --port 4311 --bind 0.0.0.0 --advertise-host 192.168.1.50
```

Useful direct commands:
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ routes with the same service token.
Normal service restarts preserve that token so paired clients stay connected.
Use `simdeck service reset` only when you want to rotate the service token and
restart the LaunchAgent.
If port 4310 is already owned by a workspace daemon, the LaunchAgent uses the
next available service-discovery port, up to 4320, instead of stopping that
daemon.
The LaunchAgent service uses port 4310. Project daemons start at port 4311 and
probe upward when that port is busy. When the service is active, `simdeck` and
`simdeck ui` print the existing service endpoints instead of starting a project
daemon; use the `daemon` subcommand when you explicitly want a workspace daemon.

CLI commands automatically use the same warm daemon:

Expand Down Expand Up @@ -179,6 +180,7 @@ simdeck crown <udid> --delta 50
simdeck button <udid> left-side-button
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
simdeck dismiss-keyboard <udid>
simdeck button <udid> software-keyboard
simdeck home <udid>
simdeck app-switcher <udid>
simdeck rotate-left <udid>
Expand Down
2 changes: 1 addition & 1 deletion actions/run-android-comment-session/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ inputs:
simdeck_port:
description: Local daemon port on the runner.
required: false
default: "4310"
default: "4311"
stream_profile:
description: SimDeck stream quality profile.
required: false
Expand Down
2 changes: 1 addition & 1 deletion actions/run-ios-comment-session/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ inputs:
simdeck_port:
description: Local daemon port on the runner.
required: false
default: "4310"
default: "4311"
stream_profile:
description: SimDeck stream quality profile.
required: false
Expand Down
5 changes: 5 additions & 0 deletions cli/DFPrivateSimulatorDisplayBridge.m
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@
static const uint32_t DFHomeConsumerUsage = 0x65;
// Apple Simulator sends Indigo button code 0x191 for Home; 1 is Lock.
static const uint32_t DFHomeButtonCode = 0x191;
// Simulator.app dispatches Cmd+K through this private button code.
static const uint32_t DFSoftwareKeyboardButtonCode = 0x3f0;
static const NSUInteger DFKeyboardModifierShift = 1 << 0;
static const NSUInteger DFKeyboardModifierControl = 1 << 1;
static const NSUInteger DFKeyboardModifierOption = 1 << 2;
Expand Down Expand Up @@ -3610,6 +3612,9 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
@"side-button": @4,
@"side": @4,
@"siri": @5,
@"software-keyboard": @(DFSoftwareKeyboardButtonCode),
@"software_keyboard": @(DFSoftwareKeyboardButtonCode),
@"keyboard": @(DFSoftwareKeyboardButtonCode),
};
arbitraryButtons = @{
@"power": @[ @(DFConsumerControlUsagePage), @48 ],
Expand Down
4 changes: 4 additions & 0 deletions cli/XCWSimctl.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ NS_ASSUME_NONNULL_BEGIN
- (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
durationSeconds:(NSTimeInterval)durationSeconds
error:(NSError * _Nullable * _Nullable)error;
- (nullable NSString *)startScreenRecordingForSimulatorUDID:(NSString *)udid
error:(NSError * _Nullable * _Nullable)error;
- (nullable NSData *)stopScreenRecordingWithID:(NSString *)recordingID
error:(NSError * _Nullable * _Nullable)error;
- (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
- (BOOL)installAppAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
- (BOOL)uninstallBundleID:(NSString *)bundleID simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
Expand Down
256 changes: 256 additions & 0 deletions cli/XCWSimctl.m
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ - (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid erro

@end

@interface XCWScreenRecordingSession : NSObject

@property (nonatomic, copy) NSString *identifier;
@property (nonatomic, copy) NSString *udid;
@property (nonatomic, copy) NSString *path;
@property (nonatomic, strong) NSTask *task;
@property (nonatomic, strong) NSMutableData *stdoutData;
@property (nonatomic, strong) NSMutableData *stderrData;
@property (nonatomic, strong) NSFileHandle *stdoutHandle;
@property (nonatomic, strong) NSFileHandle *stderrHandle;

@end

@implementation XCWScreenRecordingSession
@end

static NSArray *XCWArrayPayload(id payload, NSString *nestedKey) {
if ([payload isKindOfClass:[NSArray class]]) {
return payload;
Expand Down Expand Up @@ -95,6 +111,58 @@ static BOOL XCWWaitForTaskExit(NSTask *task, NSTimeInterval timeoutSeconds) {
return NO;
}

static NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *XCWScreenRecordingSessions(void) {
static NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
sessions = [NSMutableDictionary dictionary];
});
return sessions;
}

static NSString * _Nullable XCWResolveSimctlPath(NSError * _Nullable __autoreleasing *error) {
XCWProcessResult *resolvedSimctl = [XCWProcessRunner runLaunchPath:@"/usr/bin/xcrun"
arguments:@[@"-f", @"simctl"]
inputData:nil
error:error];
if (resolvedSimctl == nil) {
return nil;
}
NSString *simctlPath = XCWTrimmedString(XCWStringFromData(resolvedSimctl.stdoutData));
if (resolvedSimctl.terminationStatus != 0 || simctlPath.length == 0) {
if (error != NULL) {
NSString *message = resolvedSimctl.stderrString.length > 0 ? resolvedSimctl.stderrString : @"Unable to locate simctl.";
*error = [XCWSimctl errorWithDescription:message code:34];
}
return nil;
}
return simctlPath;
}

static BOOL XCWStopRecordingTask(XCWScreenRecordingSession *session, NSTimeInterval timeoutSeconds) {
NSTask *task = session.task;
if (task.running) {
[task interrupt];
}
if (!XCWWaitForTaskExit(task, timeoutSeconds) && task.running) {
[task terminate];
if (!XCWWaitForTaskExit(task, 2.0) && task.running) {
kill(task.processIdentifier, SIGKILL);
XCWWaitForTaskExit(task, 2.0);
}
}
return !task.running;
}

static void XCWCloseRecordingPipes(XCWScreenRecordingSession *session) {
session.stdoutHandle.readabilityHandler = nil;
session.stderrHandle.readabilityHandler = nil;
if (!session.task.running) {
XCWAppendAvailableData(session.stdoutHandle, session.stdoutData);
XCWAppendAvailableData(session.stderrHandle, session.stderrData);
}
}

static NSString * _Nullable XCWCreateTemporaryDirectory(NSString *prefix, NSError * _Nullable __autoreleasing *error) {
NSString *templatePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-XXXXXX", prefix]];
char *directoryTemplate = strdup(templatePath.fileSystemRepresentation);
Expand Down Expand Up @@ -859,6 +927,194 @@ - (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
return nil;
}

- (nullable NSString *)startScreenRecordingForSimulatorUDID:(NSString *)udid
error:(NSError * _Nullable __autoreleasing *)error {
if (udid.length == 0) {
if (error != NULL) {
*error = [self.class errorWithDescription:@"Screen recording requires a simulator UDID." code:34];
}
return nil;
}

NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions();
@synchronized(sessions) {
for (XCWScreenRecordingSession *session in sessions.objectEnumerator) {
if ([session.udid isEqualToString:udid]) {
if (error != NULL) {
*error = [self.class errorWithDescription:@"A screen recording is already in progress for this simulator." code:34];
}
return nil;
}
}
}

NSString *filename = [NSString stringWithFormat:@"simdeck-%@.mp4", NSUUID.UUID.UUIDString];
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:filename];
NSString *simctlPath = XCWResolveSimctlPath(error);
if (simctlPath.length == 0) {
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
return nil;
}

XCWScreenRecordingSession *session = [[XCWScreenRecordingSession alloc] init];
session.identifier = NSUUID.UUID.UUIDString;
session.udid = udid;
session.path = path;
session.stdoutData = [NSMutableData data];
session.stderrData = [NSMutableData data];

NSTask *task = [[NSTask alloc] init];
task.launchPath = simctlPath;
task.arguments = @[@"io", udid, @"recordVideo", @"--codec=h264", @"--force", path];
task.standardInput = NSFileHandle.fileHandleWithNullDevice;
NSPipe *stdoutPipe = [NSPipe pipe];
NSPipe *stderrPipe = [NSPipe pipe];
task.standardOutput = stdoutPipe;
task.standardError = stderrPipe;
session.task = task;
session.stdoutHandle = stdoutPipe.fileHandleForReading;
session.stderrHandle = stderrPipe.fileHandleForReading;

dispatch_semaphore_t recordingStartedSemaphore = dispatch_semaphore_create(0);
__block BOOL recordingStarted = NO;
__weak XCWScreenRecordingSession *weakSession = session;

session.stdoutHandle.readabilityHandler = ^(NSFileHandle *handle) {
XCWScreenRecordingSession *strongSession = weakSession;
if (strongSession == nil) {
return;
}
XCWAppendAvailableData(handle, strongSession.stdoutData);
};
session.stderrHandle.readabilityHandler = ^(NSFileHandle *handle) {
XCWScreenRecordingSession *strongSession = weakSession;
if (strongSession == nil) {
return;
}
@try {
NSData *chunk = [handle availableData];
if (chunk.length == 0) {
return;
}
BOOL shouldSignal = NO;
@synchronized(strongSession.stderrData) {
[strongSession.stderrData appendData:chunk];
NSString *text = XCWStringFromData(strongSession.stderrData);
if (!recordingStarted && [text rangeOfString:@"Recording started"].location != NSNotFound) {
recordingStarted = YES;
shouldSignal = YES;
}
}
if (shouldSignal) {
dispatch_semaphore_signal(recordingStartedSemaphore);
}
} @catch (NSException *exception) {
}
};

NSError *launchError = nil;
if (![task launchAndReturnError:&launchError]) {
XCWCloseRecordingPipes(session);
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
if (error != NULL) {
*error = launchError ?: [self.class errorWithDescription:@"Unable to launch simulator screen recording." code:34];
}
return nil;
}

NSDate *startDeadline = [NSDate dateWithTimeIntervalSinceNow:10.0];
BOOL didStart = NO;
while ([startDeadline timeIntervalSinceNow] > 0) {
if (dispatch_semaphore_wait(recordingStartedSemaphore, DISPATCH_TIME_NOW) == 0) {
didStart = YES;
break;
}
if (!task.running) {
break;
}
usleep(10 * 1000);
}

if (!didStart) {
XCWStopRecordingTask(session, 2.0);
XCWCloseRecordingPipes(session);
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
if (error != NULL) {
NSString *stderrString = XCWStringFromData(XCWDataSnapshot(session.stderrData));
NSString *stdoutString = XCWStringFromData(XCWDataSnapshot(session.stdoutData));
NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
if (details.length == 0) {
details = @"Simulator screen recording did not start before the timeout.";
}
*error = [self.class errorWithDescription:details code:34];
}
return nil;
}

@synchronized(sessions) {
sessions[session.identifier] = session;
}
return session.identifier;
}

- (nullable NSData *)stopScreenRecordingWithID:(NSString *)recordingID
error:(NSError * _Nullable __autoreleasing *)error {
NSString *trimmedID = XCWTrimmedString(recordingID ?: @"");
if (trimmedID.length == 0) {
if (error != NULL) {
*error = [self.class errorWithDescription:@"Screen recording stop requires a recording ID." code:34];
}
return nil;
}

NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions();
XCWScreenRecordingSession *session = nil;
@synchronized(sessions) {
session = sessions[trimmedID];
if (session != nil) {
[sessions removeObjectForKey:trimmedID];
}
}

if (session == nil) {
if (error != NULL) {
*error = [self.class errorWithDescription:@"No active simulator screen recording matched that ID." code:34];
}
return nil;
}

XCWStopRecordingTask(session, 10.0);
XCWCloseRecordingPipes(session);

NSError *readError = nil;
NSData *data = nil;
NSDate *fileDeadline = [NSDate dateWithTimeIntervalSinceNow:2.0];
do {
data = [NSData dataWithContentsOfFile:session.path options:0 error:&readError];
if (data.length > 0) {
break;
}
usleep(50 * 1000);
} while ([fileDeadline timeIntervalSinceNow] > 0);
[[NSFileManager defaultManager] removeItemAtPath:session.path error:nil];
if (data.length > 0) {
return data;
}

if (error != NULL) {
NSString *stderrString = XCWStringFromData(XCWDataSnapshot(session.stderrData));
NSString *stdoutString = XCWStringFromData(XCWDataSnapshot(session.stdoutData));
NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
if (details.length == 0 && readError.localizedDescription.length > 0) {
details = readError.localizedDescription;
} else if (details.length == 0) {
details = @"Simulator screen recording command produced an empty MP4.";
}
*error = [self.class errorWithDescription:details code:34];
}
return nil;
}

- (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error {
XCWProcessResult *result = [self.class runSimctl:@[@"erase", udid] error:error];
if (result == nil) {
Expand Down
2 changes: 2 additions & 0 deletions cli/native/XCWNativeBridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ xcw_native_owned_bytes xcw_native_render_chrome_button_png(const char * _Nonnull
xcw_native_owned_bytes xcw_native_render_screen_mask_png(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
xcw_native_owned_bytes xcw_native_screenshot_png(const char * _Nonnull udid, bool include_bezel, char * _Nullable * _Nullable error_message);
xcw_native_owned_bytes xcw_native_screen_recording_mp4(const char * _Nonnull udid, double duration_seconds, char * _Nullable * _Nullable error_message);
char * _Nullable xcw_native_start_screen_recording(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
xcw_native_owned_bytes xcw_native_stop_screen_recording(const char * _Nonnull recording_id, char * _Nullable * _Nullable error_message);
char * _Nullable xcw_native_recent_logs(const char * _Nonnull udid, double seconds, size_t limit, char * _Nullable * _Nullable error_message);
char * _Nullable xcw_native_accessibility_snapshot(const char * _Nonnull udid, bool has_point, double x, double y, size_t max_depth, char * _Nullable * _Nullable error_message);
bool xcw_native_send_touch(const char * _Nonnull udid, double x, double y, const char * _Nonnull phase, char * _Nullable * _Nullable error_message);
Expand Down
Loading
Loading