Skip to content

Commit df55899

Browse files
authored
feat: refine simulator controls and daemon/service behavior (#61)
* fix: wire Cmd+K to simulator software keyboard * docs: format VS Code extension docs * fix: add recording symbols to native stubs
1 parent 11db9b5 commit df55899

36 files changed

Lines changed: 1130 additions & 237 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,21 +116,21 @@ Run the local daemon:
116116

117117
```sh
118118
./build/simdeck
119-
./build/simdeck daemon start --port 4310
119+
./build/simdeck daemon start --port 4311
120120
```
121121

122-
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.
122+
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.
123123

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

126126
```sh
127-
./build/simdeck daemon start --port 4310 --video-codec h264-software
127+
./build/simdeck daemon start --port 4311 --video-codec h264-software
128128
```
129129

130130
For LAN access:
131131

132132
```sh
133-
./build/simdeck daemon start --port 4310 --bind 0.0.0.0 --advertise-host 192.168.1.50
133+
./build/simdeck daemon start --port 4311 --bind 0.0.0.0 --advertise-host 192.168.1.50
134134
```
135135

136136
Useful direct commands:

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,9 +85,10 @@ routes with the same service token.
8585
Normal service restarts preserve that token so paired clients stay connected.
8686
Use `simdeck service reset` only when you want to rotate the service token and
8787
restart the LaunchAgent.
88-
If port 4310 is already owned by a workspace daemon, the LaunchAgent uses the
89-
next available service-discovery port, up to 4320, instead of stopping that
90-
daemon.
88+
The LaunchAgent service uses port 4310. Project daemons start at port 4311 and
89+
probe upward when that port is busy. When the service is active, `simdeck` and
90+
`simdeck ui` print the existing service endpoints instead of starting a project
91+
daemon; use the `daemon` subcommand when you explicitly want a workspace daemon.
9192

9293
CLI commands automatically use the same warm daemon:
9394

@@ -179,6 +180,7 @@ simdeck crown <udid> --delta 50
179180
simdeck button <udid> left-side-button
180181
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
181182
simdeck dismiss-keyboard <udid>
183+
simdeck button <udid> software-keyboard
182184
simdeck home <udid>
183185
simdeck app-switcher <udid>
184186
simdeck rotate-left <udid>

actions/run-android-comment-session/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ inputs:
5757
simdeck_port:
5858
description: Local daemon port on the runner.
5959
required: false
60-
default: "4310"
60+
default: "4311"
6161
stream_profile:
6262
description: SimDeck stream quality profile.
6363
required: false

actions/run-ios-comment-session/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ inputs:
5757
simdeck_port:
5858
description: Local daemon port on the runner.
5959
required: false
60-
default: "4310"
60+
default: "4311"
6161
stream_profile:
6262
description: SimDeck stream quality profile.
6363
required: false

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@
111111
static const uint32_t DFHomeConsumerUsage = 0x65;
112112
// Apple Simulator sends Indigo button code 0x191 for Home; 1 is Lock.
113113
static const uint32_t DFHomeButtonCode = 0x191;
114+
// Simulator.app dispatches Cmd+K through this private button code.
115+
static const uint32_t DFSoftwareKeyboardButtonCode = 0x3f0;
114116
static const NSUInteger DFKeyboardModifierShift = 1 << 0;
115117
static const NSUInteger DFKeyboardModifierControl = 1 << 1;
116118
static const NSUInteger DFKeyboardModifierOption = 1 << 2;
@@ -3610,6 +3612,9 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
36103612
@"side-button": @4,
36113613
@"side": @4,
36123614
@"siri": @5,
3615+
@"software-keyboard": @(DFSoftwareKeyboardButtonCode),
3616+
@"software_keyboard": @(DFSoftwareKeyboardButtonCode),
3617+
@"keyboard": @(DFSoftwareKeyboardButtonCode),
36133618
};
36143619
arbitraryButtons = @{
36153620
@"power": @[ @(DFConsumerControlUsagePage), @48 ],

cli/XCWSimctl.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ NS_ASSUME_NONNULL_BEGIN
2525
- (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
2626
durationSeconds:(NSTimeInterval)durationSeconds
2727
error:(NSError * _Nullable * _Nullable)error;
28+
- (nullable NSString *)startScreenRecordingForSimulatorUDID:(NSString *)udid
29+
error:(NSError * _Nullable * _Nullable)error;
30+
- (nullable NSData *)stopScreenRecordingWithID:(NSString *)recordingID
31+
error:(NSError * _Nullable * _Nullable)error;
2832
- (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
2933
- (BOOL)installAppAtPath:(NSString *)appPath simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;
3034
- (BOOL)uninstallBundleID:(NSString *)bundleID simulatorUDID:(NSString *)udid error:(NSError * _Nullable * _Nullable)error;

cli/XCWSimctl.m

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,22 @@ - (BOOL)installIPAAtPath:(NSString *)ipaPath simulatorUDID:(NSString *)udid erro
3737

3838
@end
3939

40+
@interface XCWScreenRecordingSession : NSObject
41+
42+
@property (nonatomic, copy) NSString *identifier;
43+
@property (nonatomic, copy) NSString *udid;
44+
@property (nonatomic, copy) NSString *path;
45+
@property (nonatomic, strong) NSTask *task;
46+
@property (nonatomic, strong) NSMutableData *stdoutData;
47+
@property (nonatomic, strong) NSMutableData *stderrData;
48+
@property (nonatomic, strong) NSFileHandle *stdoutHandle;
49+
@property (nonatomic, strong) NSFileHandle *stderrHandle;
50+
51+
@end
52+
53+
@implementation XCWScreenRecordingSession
54+
@end
55+
4056
static NSArray *XCWArrayPayload(id payload, NSString *nestedKey) {
4157
if ([payload isKindOfClass:[NSArray class]]) {
4258
return payload;
@@ -95,6 +111,58 @@ static BOOL XCWWaitForTaskExit(NSTask *task, NSTimeInterval timeoutSeconds) {
95111
return NO;
96112
}
97113

114+
static NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *XCWScreenRecordingSessions(void) {
115+
static NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = nil;
116+
static dispatch_once_t onceToken;
117+
dispatch_once(&onceToken, ^{
118+
sessions = [NSMutableDictionary dictionary];
119+
});
120+
return sessions;
121+
}
122+
123+
static NSString * _Nullable XCWResolveSimctlPath(NSError * _Nullable __autoreleasing *error) {
124+
XCWProcessResult *resolvedSimctl = [XCWProcessRunner runLaunchPath:@"/usr/bin/xcrun"
125+
arguments:@[@"-f", @"simctl"]
126+
inputData:nil
127+
error:error];
128+
if (resolvedSimctl == nil) {
129+
return nil;
130+
}
131+
NSString *simctlPath = XCWTrimmedString(XCWStringFromData(resolvedSimctl.stdoutData));
132+
if (resolvedSimctl.terminationStatus != 0 || simctlPath.length == 0) {
133+
if (error != NULL) {
134+
NSString *message = resolvedSimctl.stderrString.length > 0 ? resolvedSimctl.stderrString : @"Unable to locate simctl.";
135+
*error = [XCWSimctl errorWithDescription:message code:34];
136+
}
137+
return nil;
138+
}
139+
return simctlPath;
140+
}
141+
142+
static BOOL XCWStopRecordingTask(XCWScreenRecordingSession *session, NSTimeInterval timeoutSeconds) {
143+
NSTask *task = session.task;
144+
if (task.running) {
145+
[task interrupt];
146+
}
147+
if (!XCWWaitForTaskExit(task, timeoutSeconds) && task.running) {
148+
[task terminate];
149+
if (!XCWWaitForTaskExit(task, 2.0) && task.running) {
150+
kill(task.processIdentifier, SIGKILL);
151+
XCWWaitForTaskExit(task, 2.0);
152+
}
153+
}
154+
return !task.running;
155+
}
156+
157+
static void XCWCloseRecordingPipes(XCWScreenRecordingSession *session) {
158+
session.stdoutHandle.readabilityHandler = nil;
159+
session.stderrHandle.readabilityHandler = nil;
160+
if (!session.task.running) {
161+
XCWAppendAvailableData(session.stdoutHandle, session.stdoutData);
162+
XCWAppendAvailableData(session.stderrHandle, session.stderrData);
163+
}
164+
}
165+
98166
static NSString * _Nullable XCWCreateTemporaryDirectory(NSString *prefix, NSError * _Nullable __autoreleasing *error) {
99167
NSString *templatePath = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@-XXXXXX", prefix]];
100168
char *directoryTemplate = strdup(templatePath.fileSystemRepresentation);
@@ -859,6 +927,194 @@ - (nullable NSData *)screenRecordingMP4ForSimulatorUDID:(NSString *)udid
859927
return nil;
860928
}
861929

930+
- (nullable NSString *)startScreenRecordingForSimulatorUDID:(NSString *)udid
931+
error:(NSError * _Nullable __autoreleasing *)error {
932+
if (udid.length == 0) {
933+
if (error != NULL) {
934+
*error = [self.class errorWithDescription:@"Screen recording requires a simulator UDID." code:34];
935+
}
936+
return nil;
937+
}
938+
939+
NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions();
940+
@synchronized(sessions) {
941+
for (XCWScreenRecordingSession *session in sessions.objectEnumerator) {
942+
if ([session.udid isEqualToString:udid]) {
943+
if (error != NULL) {
944+
*error = [self.class errorWithDescription:@"A screen recording is already in progress for this simulator." code:34];
945+
}
946+
return nil;
947+
}
948+
}
949+
}
950+
951+
NSString *filename = [NSString stringWithFormat:@"simdeck-%@.mp4", NSUUID.UUID.UUIDString];
952+
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:filename];
953+
NSString *simctlPath = XCWResolveSimctlPath(error);
954+
if (simctlPath.length == 0) {
955+
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
956+
return nil;
957+
}
958+
959+
XCWScreenRecordingSession *session = [[XCWScreenRecordingSession alloc] init];
960+
session.identifier = NSUUID.UUID.UUIDString;
961+
session.udid = udid;
962+
session.path = path;
963+
session.stdoutData = [NSMutableData data];
964+
session.stderrData = [NSMutableData data];
965+
966+
NSTask *task = [[NSTask alloc] init];
967+
task.launchPath = simctlPath;
968+
task.arguments = @[@"io", udid, @"recordVideo", @"--codec=h264", @"--force", path];
969+
task.standardInput = NSFileHandle.fileHandleWithNullDevice;
970+
NSPipe *stdoutPipe = [NSPipe pipe];
971+
NSPipe *stderrPipe = [NSPipe pipe];
972+
task.standardOutput = stdoutPipe;
973+
task.standardError = stderrPipe;
974+
session.task = task;
975+
session.stdoutHandle = stdoutPipe.fileHandleForReading;
976+
session.stderrHandle = stderrPipe.fileHandleForReading;
977+
978+
dispatch_semaphore_t recordingStartedSemaphore = dispatch_semaphore_create(0);
979+
__block BOOL recordingStarted = NO;
980+
__weak XCWScreenRecordingSession *weakSession = session;
981+
982+
session.stdoutHandle.readabilityHandler = ^(NSFileHandle *handle) {
983+
XCWScreenRecordingSession *strongSession = weakSession;
984+
if (strongSession == nil) {
985+
return;
986+
}
987+
XCWAppendAvailableData(handle, strongSession.stdoutData);
988+
};
989+
session.stderrHandle.readabilityHandler = ^(NSFileHandle *handle) {
990+
XCWScreenRecordingSession *strongSession = weakSession;
991+
if (strongSession == nil) {
992+
return;
993+
}
994+
@try {
995+
NSData *chunk = [handle availableData];
996+
if (chunk.length == 0) {
997+
return;
998+
}
999+
BOOL shouldSignal = NO;
1000+
@synchronized(strongSession.stderrData) {
1001+
[strongSession.stderrData appendData:chunk];
1002+
NSString *text = XCWStringFromData(strongSession.stderrData);
1003+
if (!recordingStarted && [text rangeOfString:@"Recording started"].location != NSNotFound) {
1004+
recordingStarted = YES;
1005+
shouldSignal = YES;
1006+
}
1007+
}
1008+
if (shouldSignal) {
1009+
dispatch_semaphore_signal(recordingStartedSemaphore);
1010+
}
1011+
} @catch (NSException *exception) {
1012+
}
1013+
};
1014+
1015+
NSError *launchError = nil;
1016+
if (![task launchAndReturnError:&launchError]) {
1017+
XCWCloseRecordingPipes(session);
1018+
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
1019+
if (error != NULL) {
1020+
*error = launchError ?: [self.class errorWithDescription:@"Unable to launch simulator screen recording." code:34];
1021+
}
1022+
return nil;
1023+
}
1024+
1025+
NSDate *startDeadline = [NSDate dateWithTimeIntervalSinceNow:10.0];
1026+
BOOL didStart = NO;
1027+
while ([startDeadline timeIntervalSinceNow] > 0) {
1028+
if (dispatch_semaphore_wait(recordingStartedSemaphore, DISPATCH_TIME_NOW) == 0) {
1029+
didStart = YES;
1030+
break;
1031+
}
1032+
if (!task.running) {
1033+
break;
1034+
}
1035+
usleep(10 * 1000);
1036+
}
1037+
1038+
if (!didStart) {
1039+
XCWStopRecordingTask(session, 2.0);
1040+
XCWCloseRecordingPipes(session);
1041+
[[NSFileManager defaultManager] removeItemAtPath:path error:nil];
1042+
if (error != NULL) {
1043+
NSString *stderrString = XCWStringFromData(XCWDataSnapshot(session.stderrData));
1044+
NSString *stdoutString = XCWStringFromData(XCWDataSnapshot(session.stdoutData));
1045+
NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
1046+
if (details.length == 0) {
1047+
details = @"Simulator screen recording did not start before the timeout.";
1048+
}
1049+
*error = [self.class errorWithDescription:details code:34];
1050+
}
1051+
return nil;
1052+
}
1053+
1054+
@synchronized(sessions) {
1055+
sessions[session.identifier] = session;
1056+
}
1057+
return session.identifier;
1058+
}
1059+
1060+
- (nullable NSData *)stopScreenRecordingWithID:(NSString *)recordingID
1061+
error:(NSError * _Nullable __autoreleasing *)error {
1062+
NSString *trimmedID = XCWTrimmedString(recordingID ?: @"");
1063+
if (trimmedID.length == 0) {
1064+
if (error != NULL) {
1065+
*error = [self.class errorWithDescription:@"Screen recording stop requires a recording ID." code:34];
1066+
}
1067+
return nil;
1068+
}
1069+
1070+
NSMutableDictionary<NSString *, XCWScreenRecordingSession *> *sessions = XCWScreenRecordingSessions();
1071+
XCWScreenRecordingSession *session = nil;
1072+
@synchronized(sessions) {
1073+
session = sessions[trimmedID];
1074+
if (session != nil) {
1075+
[sessions removeObjectForKey:trimmedID];
1076+
}
1077+
}
1078+
1079+
if (session == nil) {
1080+
if (error != NULL) {
1081+
*error = [self.class errorWithDescription:@"No active simulator screen recording matched that ID." code:34];
1082+
}
1083+
return nil;
1084+
}
1085+
1086+
XCWStopRecordingTask(session, 10.0);
1087+
XCWCloseRecordingPipes(session);
1088+
1089+
NSError *readError = nil;
1090+
NSData *data = nil;
1091+
NSDate *fileDeadline = [NSDate dateWithTimeIntervalSinceNow:2.0];
1092+
do {
1093+
data = [NSData dataWithContentsOfFile:session.path options:0 error:&readError];
1094+
if (data.length > 0) {
1095+
break;
1096+
}
1097+
usleep(50 * 1000);
1098+
} while ([fileDeadline timeIntervalSinceNow] > 0);
1099+
[[NSFileManager defaultManager] removeItemAtPath:session.path error:nil];
1100+
if (data.length > 0) {
1101+
return data;
1102+
}
1103+
1104+
if (error != NULL) {
1105+
NSString *stderrString = XCWStringFromData(XCWDataSnapshot(session.stderrData));
1106+
NSString *stdoutString = XCWStringFromData(XCWDataSnapshot(session.stdoutData));
1107+
NSString *details = stderrString.length > 0 ? stderrString : stdoutString;
1108+
if (details.length == 0 && readError.localizedDescription.length > 0) {
1109+
details = readError.localizedDescription;
1110+
} else if (details.length == 0) {
1111+
details = @"Simulator screen recording command produced an empty MP4.";
1112+
}
1113+
*error = [self.class errorWithDescription:details code:34];
1114+
}
1115+
return nil;
1116+
}
1117+
8621118
- (BOOL)eraseSimulatorWithUDID:(NSString *)udid error:(NSError * _Nullable __autoreleasing *)error {
8631119
XCWProcessResult *result = [self.class runSimctl:@[@"erase", udid] error:error];
8641120
if (result == nil) {

cli/native/XCWNativeBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ xcw_native_owned_bytes xcw_native_render_chrome_button_png(const char * _Nonnull
5353
xcw_native_owned_bytes xcw_native_render_screen_mask_png(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
5454
xcw_native_owned_bytes xcw_native_screenshot_png(const char * _Nonnull udid, bool include_bezel, char * _Nullable * _Nullable error_message);
5555
xcw_native_owned_bytes xcw_native_screen_recording_mp4(const char * _Nonnull udid, double duration_seconds, char * _Nullable * _Nullable error_message);
56+
char * _Nullable xcw_native_start_screen_recording(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
57+
xcw_native_owned_bytes xcw_native_stop_screen_recording(const char * _Nonnull recording_id, char * _Nullable * _Nullable error_message);
5658
char * _Nullable xcw_native_recent_logs(const char * _Nonnull udid, double seconds, size_t limit, char * _Nullable * _Nullable error_message);
5759
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);
5860
bool xcw_native_send_touch(const char * _Nonnull udid, double x, double y, const char * _Nonnull phase, char * _Nullable * _Nullable error_message);

0 commit comments

Comments
 (0)