Skip to content

Commit 28b0178

Browse files
authored
Improve Apple Watch chrome rendering and HID button coverage (#35)
* Add Apple Watch chrome and HID support * Improve Apple Watch chrome input * Handle crown control in Android paths * Harden simulator integration CI paths
1 parent 794d8f8 commit 28b0178

29 files changed

Lines changed: 452 additions & 45 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Private simulator behavior is implemented locally in:
7878
- Accessibility bridge: `cli/XCWAccessibilityBridge.*`
7979

8080
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.*`.
81-
Physical chrome button support uses DeviceKit `chrome.json` input geometry for browser hit targets. Volume, action, and mute buttons dispatch through `IndigoHIDMessageForHIDArbitrary` with consumer/telephony HID usage pairs from the device chrome metadata; home, lock, and app-switcher remain on the existing SimulatorKit button paths.
81+
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.
8282
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`.
8383

8484
## Build and Run

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ simdeck type <udid> --file message.txt
149149
simdeck button <udid> lock --duration-ms 1000
150150
simdeck button <udid> volume-up
151151
simdeck button <udid> action --duration-ms 1000
152+
simdeck button <udid> digital-crown
153+
simdeck crown <udid> --delta 50
154+
simdeck button <udid> left-side-button
152155
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
153156
simdeck dismiss-keyboard <udid>
154157
simdeck home <udid>

cli/DFPrivateSimulatorDisplayBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ NS_SWIFT_NAME(PrivateSimulatorDisplayBridge)
8585
usagePage:(nullable NSNumber *)usagePage
8686
usage:(nullable NSNumber *)usage
8787
error:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(sendHardwareButton(named:pressed:usagePage:usage:));
88+
- (BOOL)rotateDigitalCrownByDelta:(double)delta
89+
error:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(rotateDigitalCrown(delta:));
8890

8991
- (BOOL)rotateRight:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(rotateRight());
9092
- (BOOL)rotateLeft:(NSError * _Nullable * _Nullable)error NS_SWIFT_NAME(rotateLeft());

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
typedef IndigoHIDMessage *(*DFIndigoHIDMessageForKeyboardNSEventFn)(NSEvent *event);
3939
typedef IndigoHIDMessage *(*DFIndigoHIDMessageForButtonFn)(uint32_t buttonCode, uint32_t operation, uint32_t target);
4040
typedef IndigoHIDMessage *(*DFIndigoHIDMessageForHIDArbitraryFn)(uint32_t target, uint32_t page, uint32_t usage, uint32_t operation);
41+
typedef IndigoHIDMessage *(*DFIndigoHIDMessageForScrollEventFn)(uint32_t target, double deltaX, double deltaY, double momentumPhase);
4142
typedef IndigoHIDMessage *(*DFIndigoHIDServiceMessageFn)(void);
4243

4344
#pragma pack(push, 4)
@@ -1431,6 +1432,29 @@ static void DFWarmIndigoHIDServices(id hidClient) {
14311432
return message;
14321433
}
14331434

1435+
static IndigoHIDMessage *DFCreateScrollHIDMessage(uint32_t target, double deltaX, double deltaY, NSError **error) {
1436+
DFIndigoHIDMessageForScrollEventFn scrollMessage = (DFIndigoHIDMessageForScrollEventFn)dlsym(RTLD_DEFAULT, "IndigoHIDMessageForScrollEvent");
1437+
if (scrollMessage == NULL) {
1438+
if (error != NULL) {
1439+
*error = DFMakeError(
1440+
DFPrivateSimulatorErrorCodeTouchDispatchFailed,
1441+
@"SimulatorKit did not expose IndigoHIDMessageForScrollEvent."
1442+
);
1443+
}
1444+
return NULL;
1445+
}
1446+
1447+
IndigoHIDMessage *message = scrollMessage(target, deltaX, deltaY, 0);
1448+
if (message == NULL && error != NULL) {
1449+
*error = DFMakeError(
1450+
DFPrivateSimulatorErrorCodeTouchDispatchFailed,
1451+
[NSString stringWithFormat:@"SimulatorKit could not construct scroll HID for delta %.3f.", deltaY]
1452+
);
1453+
}
1454+
1455+
return message;
1456+
}
1457+
14341458
static BOOL DFCallSwiftUnitAngleMeasurementGetterByFunction(id selfObject, void *function, DFUnitAngleMeasurement *measurement) {
14351459
if (selfObject == nil || function == NULL || measurement == NULL) {
14361460
return NO;
@@ -3415,6 +3439,9 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
34153439
@"volume-down": @[ @(DFConsumerControlUsagePage), @234 ],
34163440
@"action": @[ @(0x0b), @45 ],
34173441
@"mute": @[ @(0x0b), @46 ],
3442+
@"digital-crown": @[ @(DFConsumerControlUsagePage), @64 ],
3443+
@"side-button": @[ @(DFConsumerControlUsagePage), @149 ],
3444+
@"left-side-button": @[ @(0xff01), @512 ],
34183445
};
34193446
});
34203447

@@ -3494,6 +3521,57 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
34943521
return success;
34953522
}
34963523

3524+
- (BOOL)rotateDigitalCrownByDelta:(double)delta
3525+
error:(NSError * _Nullable __autoreleasing *)error {
3526+
if (!isfinite(delta)) {
3527+
if (error != NULL) {
3528+
*error = DFMakeError(
3529+
DFPrivateSimulatorErrorCodeTouchDispatchFailed,
3530+
@"Digital Crown delta must be finite."
3531+
);
3532+
}
3533+
return NO;
3534+
}
3535+
3536+
__block BOOL success = NO;
3537+
__block NSError *dispatchError = nil;
3538+
3539+
dispatch_block_t work = ^{
3540+
if (self->_hidClient == nil) {
3541+
dispatchError = DFMakeError(
3542+
DFPrivateSimulatorErrorCodeTouchDispatchFailed,
3543+
@"SimulatorKit did not provide a headless HID client for Digital Crown rotation."
3544+
);
3545+
return;
3546+
}
3547+
3548+
NSError *messageError = nil;
3549+
IndigoHIDMessage *message = DFCreateScrollHIDMessage(DFIndigoTouchTarget, 0, delta, &messageError);
3550+
if (message == NULL || !DFSendHIDMessage(self->_hidClient, message, YES, &messageError)) {
3551+
dispatchError = messageError;
3552+
return;
3553+
}
3554+
3555+
DFLog(@"Sending Digital Crown rotation delta=%.3f", delta);
3556+
success = YES;
3557+
};
3558+
3559+
if (dispatch_get_specific(DFPrivateSimulatorCallbackQueueKey) != NULL) {
3560+
work();
3561+
} else {
3562+
dispatch_sync(_callbackQueue, work);
3563+
}
3564+
3565+
if (!success && error != NULL) {
3566+
*error = dispatchError ?: DFMakeError(
3567+
DFPrivateSimulatorErrorCodeTouchDispatchFailed,
3568+
@"SimulatorKit rejected Digital Crown rotation."
3569+
);
3570+
}
3571+
3572+
return success;
3573+
}
3574+
34973575
- (BOOL)rotateRight:(NSError * _Nullable __autoreleasing *)error {
34983576
return [self rotateByDegrees:90.0 error:error];
34993577
}

cli/XCWChromeRenderer.m

Lines changed: 63 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ @interface XCWChromeRenderer ()
1212
+ (nullable NSDictionary *)inputNamed:(NSString *)buttonName
1313
chromeInfo:(NSDictionary *)chromeInfo
1414
error:(NSError * _Nullable __autoreleasing *)error;
15+
+ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo;
1516
@end
1617

1718
@implementation XCWChromeRenderer
@@ -665,6 +666,14 @@ + (BOOL)drawInputImagesForChromeInfo:(NSDictionary *)chromeInfo
665666

666667
+ (CGRect)fullFrameForChromeInfo:(NSDictionary *)chromeInfo
667668
chromeSize:(CGSize)chromeSize {
669+
NSEdgeInsets padding = [self devicePaddingForChromeInfo:chromeInfo];
670+
if (padding.top != 0.0 || padding.left != 0.0 || padding.bottom != 0.0 || padding.right != 0.0) {
671+
return CGRectMake(-padding.left,
672+
-padding.top,
673+
chromeSize.width + padding.left + padding.right,
674+
chromeSize.height + padding.top + padding.bottom);
675+
}
676+
668677
CGRect bounds = CGRectMake(0.0, 0.0, chromeSize.width, chromeSize.height);
669678
NSDictionary *json = chromeInfo[@"json"];
670679
NSString *chromePath = chromeInfo[@"chromePath"];
@@ -712,55 +721,85 @@ + (CGRect)inputFrameForInput:(NSDictionary *)input
712721
inSize:(CGSize)size
713722
offsetName:(NSString *)offsetName {
714723
NSDictionary *offsets = [input[@"offsets"] isKindOfClass:[NSDictionary class]] ? input[@"offsets"] : @{};
715-
NSDictionary *requestedOffset = [offsets[offsetName] isKindOfClass:[NSDictionary class]] ? offsets[offsetName] : nil;
716724
NSDictionary *normalOffset = [offsets[@"normal"] isKindOfClass:[NSDictionary class]] ? offsets[@"normal"] : nil;
717725
NSDictionary *rolloverOffset = [offsets[@"rollover"] isKindOfClass:[NSDictionary class]] ? offsets[@"rollover"] : nil;
718-
NSDictionary *offset = requestedOffset ?: rolloverOffset ?: normalOffset ?: @{};
719-
CGFloat offsetX = [self numberValue:offset[@"x"]];
720-
CGFloat offsetY = [self numberValue:offset[@"y"]];
726+
NSDictionary *requestedOffset = [offsets[offsetName] isKindOfClass:[NSDictionary class]] ? offsets[offsetName] : nil;
727+
NSDictionary *primaryOffset = normalOffset ?: rolloverOffset ?: requestedOffset ?: @{};
728+
NSDictionary *secondaryOffset = rolloverOffset ?: normalOffset ?: requestedOffset ?: @{};
729+
CGFloat normalX = [self numberValue:primaryOffset[@"x"]];
730+
CGFloat normalY = [self numberValue:primaryOffset[@"y"]];
731+
CGFloat rolloverX = [self numberValue:secondaryOffset[@"x"]];
732+
CGFloat rolloverY = [self numberValue:secondaryOffset[@"y"]];
733+
CGFloat restX = normalX;
734+
CGFloat restY = normalY;
721735
NSString *anchor = [input[@"anchor"] isKindOfClass:[NSString class]] ? input[@"anchor"] : @"";
722736
NSString *align = [input[@"align"] isKindOfClass:[NSString class]] ? input[@"align"] : @"";
723737

724-
CGFloat x = offsetX - (assetSize.width / 2.0);
725-
CGFloat y = offsetY;
738+
if ([offsetName isEqualToString:@"rollover"]) {
739+
restX = rolloverX;
740+
restY = rolloverY;
741+
} else if ([anchor isEqualToString:@"left"]) {
742+
restX = rolloverX;
743+
restY = rolloverY;
744+
} else if ([anchor isEqualToString:@"right"] ||
745+
[anchor isEqualToString:@"top"] ||
746+
[anchor isEqualToString:@"bottom"]) {
747+
restX = (2.0 * normalX) - rolloverX;
748+
restY = (2.0 * normalY) - rolloverY;
749+
}
750+
751+
CGFloat x = restX - (assetSize.width / 2.0);
752+
CGFloat y = restY;
726753
if ([anchor isEqualToString:@"left"]) {
727-
x = offsetX - (assetSize.width / 2.0);
754+
x = restX - (assetSize.width / 2.0);
728755
} else if ([anchor isEqualToString:@"right"]) {
729-
x = size.width + offsetX - (assetSize.width / 2.0);
756+
x = size.width + restX;
757+
y = restY;
730758
} else if ([anchor isEqualToString:@"top"]) {
731-
y = offsetY;
759+
if ([align isEqualToString:@"trailing"]) {
760+
x = size.width + restX - assetSize.width;
761+
} else {
762+
x = restX;
763+
}
764+
y = restY - assetSize.height;
732765
} else if ([anchor isEqualToString:@"bottom"]) {
733-
y = size.height + offsetY;
766+
if ([align isEqualToString:@"trailing"]) {
767+
x = size.width + restX - assetSize.width;
768+
} else {
769+
x = restX;
770+
}
771+
y = size.height + restY;
734772
}
735773

736774
if ([anchor isEqualToString:@"left"] || [anchor isEqualToString:@"right"]) {
737775
if ([align isEqualToString:@"center"]) {
738-
y = (size.height - assetSize.height) / 2.0 + offsetY;
776+
y = (size.height - assetSize.height) / 2.0 + restY;
739777
} else if ([align isEqualToString:@"trailing"]) {
740-
y = size.height - assetSize.height + offsetY;
778+
y = size.height - assetSize.height + restY;
741779
}
742780
} else if ([anchor isEqualToString:@"top"] || [anchor isEqualToString:@"bottom"]) {
743-
CGFloat baseX = 0.0;
744781
if ([align isEqualToString:@"center"]) {
745-
baseX = size.width / 2.0;
746-
} else if ([align isEqualToString:@"trailing"]) {
747-
baseX = size.width;
748-
}
749-
x = baseX + offsetX - (assetSize.width / 2.0);
750-
if ([align isEqualToString:@"center"]) {
751-
x = (size.width / 2.0) + offsetX - (assetSize.width / 2.0);
752-
} else if ([align isEqualToString:@"trailing"]) {
753-
x = size.width + offsetX - (assetSize.width / 2.0);
782+
x = (size.width / 2.0) + restX - (assetSize.width / 2.0);
754783
}
755784
} else if ([align isEqualToString:@"center"]) {
756-
x = (size.width - assetSize.width) / 2.0 + offsetX;
785+
x = (size.width - assetSize.width) / 2.0 + restX;
757786
} else if ([align isEqualToString:@"trailing"]) {
758-
x = size.width - assetSize.width + offsetX;
787+
x = size.width - assetSize.width + restX;
759788
}
760789

761790
return CGRectMake(x, y, assetSize.width, assetSize.height);
762791
}
763792

793+
+ (NSEdgeInsets)devicePaddingForChromeInfo:(NSDictionary *)chromeInfo {
794+
NSDictionary *json = chromeInfo[@"json"];
795+
NSDictionary *images = [json[@"images"] isKindOfClass:[NSDictionary class]] ? json[@"images"] : @{};
796+
NSDictionary *padding = [images[@"devicePadding"] isKindOfClass:[NSDictionary class]] ? images[@"devicePadding"] : @{};
797+
return NSEdgeInsetsMake([self numberValue:padding[@"top"]],
798+
[self numberValue:padding[@"left"]],
799+
[self numberValue:padding[@"bottom"]],
800+
[self numberValue:padding[@"right"]]);
801+
}
802+
764803
+ (NSArray<NSDictionary<NSString *, id> *> *)buttonProfilesForChromeInfo:(NSDictionary *)chromeInfo
765804
chromeSize:(CGSize)chromeSize
766805
chromeOffset:(CGPoint)chromeOffset {

cli/XCWPrivateSimulatorSession.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ typedef void (^XCWPrivateSimulatorEncodedFrameHandler)(NSData *sampleData,
6666
usagePage:(nullable NSNumber *)usagePage
6767
usage:(nullable NSNumber *)usage
6868
error:(NSError * _Nullable * _Nullable)error;
69+
- (BOOL)rotateDigitalCrownByDelta:(double)delta
70+
error:(NSError * _Nullable * _Nullable)error;
6971
- (BOOL)openAppSwitcher:(NSError * _Nullable * _Nullable)error;
7072
- (BOOL)rotateRight:(NSError * _Nullable * _Nullable)error;
7173
- (BOOL)rotateLeft:(NSError * _Nullable * _Nullable)error;

cli/XCWPrivateSimulatorSession.m

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,11 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
361361
error:error];
362362
}
363363

364+
- (BOOL)rotateDigitalCrownByDelta:(double)delta
365+
error:(NSError * _Nullable __autoreleasing *)error {
366+
return [_displayBridge rotateDigitalCrownByDelta:delta error:error];
367+
}
368+
364369
- (BOOL)openAppSwitcher:(NSError * _Nullable __autoreleasing *)error {
365370
return [_displayBridge openAppSwitcher:error];
366371
}

cli/native/XCWNativeBridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ bool xcw_native_press_home(const char * _Nonnull udid, char * _Nullable * _Nulla
5353
bool xcw_native_open_app_switcher(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
5454
bool xcw_native_press_button(const char * _Nonnull udid, const char * _Nonnull button_name, uint32_t duration_ms, char * _Nullable * _Nullable error_message);
5555
bool xcw_native_send_button(const char * _Nonnull udid, const char * _Nonnull button_name, bool pressed, bool has_usage, uint32_t usage_page, uint32_t usage, char * _Nullable * _Nullable error_message);
56+
bool xcw_native_rotate_crown(const char * _Nonnull udid, double delta, char * _Nullable * _Nullable error_message);
5657
bool xcw_native_rotate_right(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
5758
bool xcw_native_rotate_left(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
5859
bool xcw_native_erase_simulator(const char * _Nonnull udid, char * _Nullable * _Nullable error_message);
@@ -84,6 +85,7 @@ bool xcw_native_session_send_key(void * _Nonnull handle, uint16_t key_code, uint
8485
bool xcw_native_session_press_home(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
8586
bool xcw_native_session_press_button(void * _Nonnull handle, const char * _Nonnull button_name, uint32_t duration_ms, char * _Nullable * _Nullable error_message);
8687
bool xcw_native_session_send_button(void * _Nonnull handle, const char * _Nonnull button_name, bool pressed, bool has_usage, uint32_t usage_page, uint32_t usage, char * _Nullable * _Nullable error_message);
88+
bool xcw_native_session_rotate_crown(void * _Nonnull handle, double delta, char * _Nullable * _Nullable error_message);
8789
bool xcw_native_session_open_app_switcher(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
8890
bool xcw_native_session_rotate_right(void * _Nonnull handle, char * _Nullable * _Nullable error_message);
8991
bool xcw_native_session_rotate_left(void * _Nonnull handle, char * _Nullable * _Nullable error_message);

cli/native/XCWNativeBridge.m

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -761,6 +761,22 @@ bool xcw_native_send_button(const char *udid, const char *button_name, bool pres
761761
}
762762
}
763763

764+
bool xcw_native_rotate_crown(const char *udid, double delta, char **error_message) {
765+
@autoreleasepool {
766+
DFPrivateSimulatorDisplayBridge *bridge = XCWInputBridgeForUDID(udid, error_message);
767+
if (bridge == nil) {
768+
return false;
769+
}
770+
NSError *error = nil;
771+
BOOL ok = [bridge rotateDigitalCrownByDelta:delta error:&error];
772+
[bridge disconnect];
773+
if (!ok) {
774+
XCWSetErrorMessage(error_message, error);
775+
}
776+
return ok;
777+
}
778+
}
779+
764780
bool xcw_native_rotate_right(const char *udid, char **error_message) {
765781
@autoreleasepool {
766782
DFPrivateSimulatorDisplayBridge *bridge = XCWInputBridgeForUDID(udid, error_message);
@@ -1032,6 +1048,17 @@ bool xcw_native_session_send_button(void *handle, const char *button_name, bool
10321048
}
10331049
}
10341050

1051+
bool xcw_native_session_rotate_crown(void *handle, double delta, char **error_message) {
1052+
@autoreleasepool {
1053+
NSError *error = nil;
1054+
BOOL ok = [XCWNativeSessionFromHandle(handle) rotateDigitalCrownByDelta:delta error:&error];
1055+
if (!ok) {
1056+
XCWSetErrorMessage(error_message, error);
1057+
}
1058+
return ok;
1059+
}
1060+
}
1061+
10351062
bool xcw_native_session_open_app_switcher(void *handle, char **error_message) {
10361063
@autoreleasepool {
10371064
NSError *error = nil;

cli/native/XCWNativeSession.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ NS_ASSUME_NONNULL_BEGIN
4444
usagePage:(nullable NSNumber *)usagePage
4545
usage:(nullable NSNumber *)usage
4646
error:(NSError * _Nullable * _Nullable)error;
47+
- (BOOL)rotateDigitalCrownByDelta:(double)delta
48+
error:(NSError * _Nullable * _Nullable)error;
4749
- (BOOL)openAppSwitcher:(NSError * _Nullable * _Nullable)error;
4850
- (BOOL)rotateRight:(NSError * _Nullable * _Nullable)error;
4951
- (BOOL)rotateLeft:(NSError * _Nullable * _Nullable)error;

0 commit comments

Comments
 (0)