Skip to content

Commit f7f4a1b

Browse files
committed
Add Apple Watch chrome and HID support
1 parent 25e6c8f commit f7f4a1b

9 files changed

Lines changed: 92 additions & 34 deletions

File tree

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.
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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ simdeck type <udid> --file message.txt
147147
simdeck button <udid> lock --duration-ms 1000
148148
simdeck button <udid> volume-up
149149
simdeck button <udid> action --duration-ms 1000
150+
simdeck button <udid> digital-crown
151+
simdeck button <udid> left-side-button
150152
simdeck batch <udid> --step "tap --label Continue" --step "type 'hello'" --step "wait-for --label hello"
151153
simdeck dismiss-keyboard <udid>
152154
simdeck home <udid>

cli/DFPrivateSimulatorDisplayBridge.m

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3415,6 +3415,9 @@ - (BOOL)sendHardwareButtonNamed:(NSString *)buttonName
34153415
@"volume-down": @[ @(DFConsumerControlUsagePage), @234 ],
34163416
@"action": @[ @(0x0b), @45 ],
34173417
@"mute": @[ @(0x0b), @46 ],
3418+
@"digital-crown": @[ @(DFConsumerControlUsagePage), @64 ],
3419+
@"side-button": @[ @(DFConsumerControlUsagePage), @149 ],
3420+
@"left-side-button": @[ @(0xff01), @512 ],
34183421
};
34193422
});
34203423

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 {

client/src/features/viewport/DeviceChrome.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -197,11 +197,13 @@ export function DeviceChrome({
197197

198198
const CHROME_BUTTON_WIRE_NAMES: Record<string, string> = {
199199
action: "action",
200+
"digital-crown": "digital-crown",
200201
home: "home",
202+
"left-side-button": "left-side-button",
201203
lock: "power",
202204
mute: "mute",
203205
power: "power",
204-
"side-button": "power",
206+
"side-button": "side-button",
205207
"volume-down": "volume-down",
206208
"volume-up": "volume-up",
207209
};
@@ -299,16 +301,20 @@ function ChromeButtonHitTarget({
299301
left: `${(button.x / totalWidth) * 100}%`,
300302
top: `${(button.y / totalHeight) * 100}%`,
301303
width: `${(button.width / totalWidth) * 100}%`,
302-
"--button-rest-x": `${(rolloverDelta.x / Math.max(button.width, 1)) * 100}%`,
303-
"--button-rest-y": `${(rolloverDelta.y / Math.max(button.height, 1)) * 100}%`,
304-
"--button-hover-x": `${((rolloverDelta.x * 2) / Math.max(button.width, 1)) * 100}%`,
305-
"--button-hover-y": `${((rolloverDelta.y * 2) / Math.max(button.height, 1)) * 100}%`,
304+
"--button-rest-x": "0%",
305+
"--button-rest-y": "0%",
306+
"--button-hover-x": `${(rolloverDelta.x / Math.max(button.width, 1)) * 100}%`,
307+
"--button-hover-y": `${(rolloverDelta.y / Math.max(button.height, 1)) * 100}%`,
308+
"--button-pressed-x": `${((-rolloverDelta.x) / Math.max(button.width, 1)) * 100}%`,
309+
"--button-pressed-y": `${((-rolloverDelta.y) / Math.max(button.height, 1)) * 100}%`,
306310
} as CSSProperties &
307311
Record<
308312
| "--button-rest-x"
309313
| "--button-rest-y"
310314
| "--button-hover-x"
311-
| "--button-hover-y",
315+
| "--button-hover-y"
316+
| "--button-pressed-x"
317+
| "--button-pressed-y",
312318
string
313319
>;
314320

client/src/styles/components.css

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1569,7 +1569,11 @@
15691569

15701570
.device-chrome-button:active,
15711571
.device-chrome-button.is-pressed {
1572-
transform: translate3d(var(--button-rest-x, 0), var(--button-rest-y, 0), 0);
1572+
transform: translate3d(
1573+
var(--button-pressed-x, var(--button-rest-x, 0)),
1574+
var(--button-pressed-y, var(--button-rest-y, 0)),
1575+
0
1576+
);
15731577
}
15741578

15751579
.pan-enabled .device-bezel,

docs/api/rest.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,8 +348,8 @@ Content-Type: application/json
348348

349349
Supported button names match the CLI and chrome controls: `home`, `lock`,
350350
`power`, `side-button`, `volume-up`, `volume-down`, `action`, `mute`,
351-
`app-switcher`, `siri`, and `apple-pay`. `durationMs` defaults to `0` and is
352-
used for press-and-hold interactions.
351+
`digital-crown`, `left-side-button`, `app-switcher`, `siri`, and `apple-pay`.
352+
`durationMs` defaults to `0` and is used for press-and-hold interactions.
353353

354354
For live chrome interactions, send explicit button edges instead of a completed
355355
press:

docs/cli/commands.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ simdeck type <udid> --file message.txt
232232
simdeck button <udid> lock --duration-ms 1000
233233
simdeck button <udid> volume-up
234234
simdeck button <udid> action --duration-ms 1000
235+
simdeck button <udid> digital-crown
236+
simdeck button <udid> left-side-button
235237
simdeck dismiss-keyboard <udid>
236238
simdeck home <udid>
237239
simdeck app-switcher <udid>

skills/simdeck/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ simdeck button <UDID> volume-up
136136
simdeck button <UDID> volume-down
137137
simdeck button <UDID> action --duration-ms 1000
138138
simdeck button <UDID> mute
139+
simdeck button <UDID> digital-crown
140+
simdeck button <UDID> left-side-button
139141
simdeck button <UDID> siri
140142
simdeck button <UDID> apple-pay
141143
simdeck home <UDID>

0 commit comments

Comments
 (0)