Skip to content

feat(apple): add watchOS Simulator runtime - #3003

Open
csark0812 wants to merge 1 commit into
callstack:mainfrom
csark0812:chris/agent/watchos-runtime
Open

csark0812 wants to merge 1 commit into
callstack:mainfrom
csark0812:chris/agent/watchos-runtime

Conversation

@csark0812

@csark0812 csark0812 commented Sep 27, 2026 •

Copy link
Copy Markdown

Summary

  • discover installed watchOS Simulator devices as first-class Apple targets
  • add an isolated CoreSimulator HID backend for touch, single-pointer gestures, Digital Crown scrolling, and Crown navigation
  • serve watchOS accessibility snapshots through the existing host AX bridge and screenshots through simctl
  • admit app lifecycle and installation only for watchOS Simulator while keeping physical devices and unsupported operations fail-closed

Verification

  • pnpm format:check
  • pnpm lint
  • pnpm typecheck
  • pnpm build
  • pnpm test:unit — 1,414 files; 11,475 passed; 1 skipped
  • pnpm package:npm release pipeline components: all Apple runner builds and macOS helper passed; Android assets prepared with API 36; pnpm check:package passed
  • live watchOS 27.0 Simulator: discovery, Calculator launch, 29-node accessibility snapshot, semantic ref tap, screenshot, Crown scroll, and Crown navigation

Runtime boundary

The backend checks the selected Simulator's simctl io ... enumerate output for a LegacyHID display and derives its pixel geometry and scale before advertising interaction. Text entry, app switcher, orientation, settings, multi-touch, and physical watchOS devices remain unsupported.

Review in cubic

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

21 issues found across 25 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/platform-apple/src/navigation/runtime.ts">

<violation number="1" location="packages/platform-apple/src/navigation/runtime.ts:70">
P3: `appleAppSwitcherFact` bypasses the canonical device-kind gate for watchOS. An Apple `emulator` record therefore gets a platform-leaf refusal instead of `unsupported-device-kind`, making app-switcher’s refusal metadata inconsistent with `home` and `back`; check the kind before the watchOS exception.</violation>
</file>

<file name="packages/platform-apple/src/runtime.test.ts">

<violation number="1" location="packages/platform-apple/src/runtime.test.ts:197">
P3: The new kind-disjunction branches (`device.kind === 'simulator'`) in `expectAppleCaptureAvailability` and `expectAppleSnapshotAvailability`, and the watchOS-Simulator branches in `expectNavigationAndKeyboardFacts`, are never exercised: `leaves.watchos` and every row in the classification test use a watchOS *simulator*, and no test feeds a watchOS physical device into these helpers. The PR's fail-closed guarantee for physical watchOS devices (capture, snapshot, back, home denied) is therefore unasserted here. Add a watchOS-device leaf row (e.g. `appleDevice({ appleOs: 'watchos', kind: 'device' })`) to the classification and navigation tables.</violation>
</file>

<file name="packages/platform-apple/src/inventory-classification.ts">

<violation number="1" location="packages/platform-apple/src/inventory-classification.ts:3">
P2: Adding `watch` to `APPLE_PRODUCT_TYPE_PATTERN` only changes physical-device discovery: `isAppleProductType` is consumed solely by `isSupportedAppleDevicectlDevice`, while the simctl simulator path keys on the runtime string and never reads productType. A connected Apple Watch (productType `Watch…`) now appears in inventory as `kind: 'device'` / `appleOs: 'watchos'` / `iosPhysicalDeviceBackend: 'coredevice'` / `booted: true` — a selectable-looking target whose every use then hits the `UNSUPPORTED_PLATFORM` sentinel, contradicting the PR boundary that physical watchOS devices stay fail-closed. Either exclude watchOS from the devicectl gate (revert this alternation) or pin the new physical-watch inventory behavior with a test.</violation>

<violation number="2" location="packages/platform-apple/src/inventory-classification.ts:6">
P1: `APPLE_WATCH_PATTERN` classifies a user-named iOS simulator such as `Watch` as watchOS. Make simulator classification rely on the watchOS runtime or device-type marker rather than an unconstrained display name, otherwise that simulator loses normal iOS operations.</violation>
</file>

<file name="docs/adr/0019-request-bound-platform-runtime.md">

<violation number="1" location="docs/adr/0019-request-bound-platform-runtime.md:327">
P1: The advertised watchOS Simulator fact set is not gated by the required HID probe. Probe `simctl io ... enumerate` during fact admission, or keep interaction facts unavailable until the probe succeeds.</violation>

<violation number="2" location="docs/adr/0019-request-bound-platform-runtime.md:328">
P1: Physical watchOS is not currently a fail-closed sentinel across the admitted runtime. Gate gesture, directional-fling, viewport, and scroll facts on simulator kind before documenting this cell as unsupported.</violation>
</file>

<file name="packages/platform-apple/src/interactor.ts">

<violation number="1" location="packages/platform-apple/src/interactor.ts:48">
P2: This branch bypasses the provider transport boundary for watchOS simulators. When `runnerProvider` is supplied, local `simctl` and HID operations can still run on the host instead of failing closed like the other Apple interactor methods; reject or explicitly adapt the provider-backed watchOS interactor before returning it.</violation>
</file>

<file name="apple/watch-helper/WatchControl.m">

<violation number="1" location="apple/watch-helper/WatchControl.m:164">
P1: This source uses `isfinite` without importing `<math.h>`, so the Darwin `-Werror` helper build can fail on an undeclared function. Add the standard math header.</violation>
</file>

<file name="docs/adr/0009-apple-platform-consolidation.md">

<violation number="1" location="docs/adr/0009-apple-platform-consolidation.md:79">
P3: Update the implementation-status date when adding this support. Otherwise the ADR presents September work as shipped in August.</violation>

<violation number="2" location="docs/adr/0009-apple-platform-consolidation.md:86">
P2: This boundary omits operations the runtime advertises: watchOS simulators support app deployment, and `networkDump` is available for every Apple device. Name those operations here or gate their facts; otherwise the fail-closed coverage statement is false.</violation>
</file>

<file name="packages/platform-apple/src/watch/watch-helper-cache.ts">

<violation number="1" location="packages/platform-apple/src/watch/watch-helper-cache.ts:29">
P3: This file duplicates the helper-cache orchestration already implemented in `fold-helper-cache.ts`; extract the shared preparation/error-wrapper logic so cache, timeout, and failure-handling fixes cannot drift between helpers.</violation>
</file>

<file name="packages/platform-apple/src/runtime.ts">

<violation number="1" location="packages/platform-apple/src/runtime.ts:285">
P2: These blanket facts re-admit `ensureReady` and `bootTarget` for physical watchOS devices; binding either calls `ensureAppleReady`, which invokes `applePhysical.ensureConnected` for every `device` kind. Gate both on `device.kind === 'simulator'` for watchOS so the physical-watch sentinel remains fail-closed.</violation>

<violation number="2" location="packages/platform-apple/src/runtime.ts:309">
P3: The comment directly above this spread still claims text entry "shares the exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`)", but this change deliberately diverges for watchOS. Update the comment to state that the watch host backend exposes no text-entry route, matching the new test comment.</violation>
</file>

<file name="packages/platform-apple/src/watch/interactor.ts">

<violation number="1" location="packages/platform-apple/src/watch/interactor.ts:74">
P2: WatchOS screenshot requests silently ignore `pixelDensity`, returning raw simctl dimensions instead of the requested output density. Forward the options through the Apple screenshot normalization flow, or reject unsupported options explicitly.</violation>

<violation number="2" location="packages/platform-apple/src/watch/interactor.ts:109">
P2: `scrollWatch` reads only `options.amount` and silently drops `options.pixels` and `options.durationMs`, so `scroll down --pixels 300` always sends the default ±0.5*360 crown ticks. The shared dispatcher passes both fields through untouched (`src/daemon/scroll-runtime.ts:121`) and `scrollResult` (`scroll-runtime.ts:409`) echoes the requested `pixels` back as honored, so the response claims a distance the crown scroll never approximated. Refuse pixel-based scrolls fail-closed (watch crown has no pixel mapping) instead of substituting the default distance.</violation>

<violation number="3" location="packages/platform-apple/src/watch/interactor.ts:110">
P2: Horizontal watch scrolls are reinterpreted as vertical Crown movement. Reject `left` and `right` because this HID backend exposes only vertical Digital Crown scrolling.</violation>

<violation number="4" location="packages/platform-apple/src/watch/interactor.ts:159">
P2: `watchViewport` runs the `simctl io ... enumerate` probe before `ensureBootedSimulator` has a chance to boot the device: `tap`/`pressPoint`/`longPress`/`focus`/`performGesture` all await `pointArgs` (which calls `watchViewport`) while building the helper argv, and only `runWatchHelper` boots. A shutdown watch simulator therefore fails the first point interaction with the misleading 'does not expose a compatible Simulator HID display' error instead of being booted as every other interaction path intends. Boot the device before enumerating.</violation>

<violation number="5" location="packages/platform-apple/src/watch/interactor.ts:207">
P1: WatchOS HID commands fail for simulators selected from a non-default simulator set because the helper cannot resolve that device in its default set. Pass the simulator-set identity through the helper or reject scoped watchOS targets before advertising interaction.</violation>
</file>

<file name="packages/platform-apple/src/snapshot-route.ts">

<violation number="1" location="packages/platform-apple/src/snapshot-route.ts:265">
P2: Admitting watchOS simulators to `isEligible` sends every bridge-failure path (target-resolution-failed, circuit-disabled, stale-target, capture failure) to `fallback(input)`, which resolves to the watch interactor's `snapshot` and throws `UNSUPPORTED_OPERATION` ('watchOS snapshots are served by the isolated Simulator accessibility bridge.'). Since `opensGenerationCircuit` retires the generation after one non-preparing failure, a single transient bridge failure makes every later capture of that app generation throw this admission-style refusal — while `appleSnapshotFact` advertises `captureSnapshot: available` for watchOS simulators. A mechanism failure is being reported as an unsupported operation; type it as a failure with the bridge failure code (and drop the circuit retry on watchOS, where the fallback can never serve) instead of the misleading refusal.</violation>
</file>

<file name="packages/platform-apple/src/snapshot-source/cache.ts">

<violation number="1" location="packages/platform-apple/src/snapshot-source/cache.ts:117">
P3: Any non-iOS runtime string that is not watchOS silently compiles the bridge against `iphonesimulator` (e.g., a tvOS/xrOS identifier reaching the snapshot source), instead of failing closed. Match the `SimRuntime.watchOS-` identifier prefix rather than a free substring so only actual watchOS runtimes select the watchsimulator SDK.</violation>
</file>

<file name="packages/kernel/src/device.ts">

<violation number="1" location="packages/kernel/src/device.ts:274">
P2: `resolveRunnerPlatformNameForAppleOs` still maps `'watchos'` to `'iOS'` through the default branch, and the new comment replaces the old, accurate justification (watchOS was never produced by discovery) with an asserted invariant that is not enforced here. Discovery now genuinely stamps `appleOs: 'watchos'` (see `inventory-classification.ts` `resolveAppleOs`/`isSupportedAppleRuntime`), so a watchOS record reaching any `resolveRunnerPlatformName` caller (`runner-cache-metadata.ts`, `runner-artifact.ts`, `runner-adoption.ts`, `runner-session.ts`, `readRunnerXcodeVersion`, `write-xcuitest-cache-metadata.ts`) would silently build/probe the iOS runner profile instead of failing closed, contradicting the fail-closed boundary this PR documents. Add an explicit `case 'watchos'` that refuses, so the boundary is enforced at the projection instead of assumed in a comment.</violation>
</file>

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|watch|realitydevice)/i;
const APPLE_IPAD_PATTERN = /ipad/i;
const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i;
const APPLE_WATCH_PATTERN = /\b(apple watch|watchos|watch)\b/i;

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: APPLE_WATCH_PATTERN classifies a user-named iOS simulator such as Watch as watchOS. Make simulator classification rely on the watchOS runtime or device-type marker rather than an unconstrained display name, otherwise that simulator loses normal iOS operations.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/inventory-classification.ts, line 6:

<comment>`APPLE_WATCH_PATTERN` classifies a user-named iOS simulator such as `Watch` as watchOS. Make simulator classification rely on the watchOS runtime or device-type marker rather than an unconstrained display name, otherwise that simulator loses normal iOS operations.</comment>

<file context>
@@ -1,8 +1,9 @@
+const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|watch|realitydevice)/i;
 const APPLE_IPAD_PATTERN = /ipad/i;
 const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i;
+const APPLE_WATCH_PATTERN = /\b(apple watch|watchos|watch)\b/i;
 const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i;
 const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i;
</file context>
Fix with cubic

fixture matches exactly one family. A loop over six families with one generic `platform: 'apple'`
device is not leaf coverage.
visionOS deferred or supported cells, the watchOS Simulator CoreSimulator/host-AX fact set, and the
physical-watchOS unsupported sentinel. Every fixture matches exactly one family. A loop over six families

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Physical watchOS is not currently a fail-closed sentinel across the admitted runtime. Gate gesture, directional-fling, viewport, and scroll facts on simulator kind before documenting this cell as unsupported.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/adr/0019-request-bound-platform-runtime.md, line 328:

<comment>Physical watchOS is not currently a fail-closed sentinel across the admitted runtime. Gate gesture, directional-fling, viewport, and scroll facts on simulator kind before documenting this cell as unsupported.</comment>

<file context>
@@ -324,9 +324,9 @@ not make a difficult legacy-supported cell disappear. Behavior changes require a
-fixture matches exactly one family. A loop over six families with one generic `platform: 'apple'`
-device is not leaf coverage.
+visionOS deferred or supported cells, the watchOS Simulator CoreSimulator/host-AX fact set, and the
+physical-watchOS unsupported sentinel. Every fixture matches exactly one family. A loop over six families
+with one generic `platform: 'apple'` device is not leaf coverage.
 
</file context>
Fix with cubic

visionOS deferred or supported cells, and the watchOS unsupported/discovery-absence sentinel. Every
fixture matches exactly one family. A loop over six families with one generic `platform: 'apple'`
device is not leaf coverage.
visionOS deferred or supported cells, the watchOS Simulator CoreSimulator/host-AX fact set, and the

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The advertised watchOS Simulator fact set is not gated by the required HID probe. Probe simctl io ... enumerate during fact admission, or keep interaction facts unavailable until the probe succeeds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/adr/0019-request-bound-platform-runtime.md, line 327:

<comment>The advertised watchOS Simulator fact set is not gated by the required HID probe. Probe `simctl io ... enumerate` during fact admission, or keep interaction facts unavailable until the probe succeeds.</comment>

<file context>
@@ -324,9 +324,9 @@ not make a difficult legacy-supported cell disappear. Behavior changes require a
-visionOS deferred or supported cells, and the watchOS unsupported/discovery-absence sentinel. Every
-fixture matches exactly one family. A loop over six families with one generic `platform: 'apple'`
-device is not leaf coverage.
+visionOS deferred or supported cells, the watchOS Simulator CoreSimulator/host-AX fact set, and the
+physical-watchOS unsupported sentinel. Every fixture matches exactly one family. A loop over six families
+with one generic `platform: 'apple'` device is not leaf coverage.
</file context>
Fix with cubic

static BOOL readDouble(NSString *value, double minimum, double maximum, double *output) {
NSScanner *scanner = [NSScanner scannerWithString:value];
double number = 0;
if (![scanner scanDouble:&number] || !scanner.isAtEnd || !isfinite(number) ||

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This source uses isfinite without importing <math.h>, so the Darwin -Werror helper build can fail on an undeclared function. Add the standard math header.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apple/watch-helper/WatchControl.m, line 164:

<comment>This source uses `isfinite` without importing `<math.h>`, so the Darwin `-Werror` helper build can fail on an undeclared function. Add the standard math header.</comment>

<file context>
@@ -0,0 +1,210 @@
+static BOOL readDouble(NSString *value, double minimum, double maximum, double *output) {
+  NSScanner *scanner = [NSScanner scannerWithString:value];
+  double number = 0;
+  if (![scanner scanDouble:&number] || !scanner.isAtEnd || !isfinite(number) ||
+      number < minimum || number > maximum) return NO;
+  *output = number;
</file context>
Fix with cubic

context.signal?.throwIfAborted();
await ensureBootedSimulator(device);
const helper = await ensureWatchHelperBinary({ signal: context.signal });
const result = await runAppleToolCommand(helper.path, [device.id, ...args], {

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: WatchOS HID commands fail for simulators selected from a non-default simulator set because the helper cannot resolve that device in its default set. Pass the simulator-set identity through the helper or reject scoped watchOS targets before advertising interaction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/watch/interactor.ts, line 207:

<comment>WatchOS HID commands fail for simulators selected from a non-default simulator set because the helper cannot resolve that device in its default set. Pass the simulator-set identity through the helper or reject scoped watchOS targets before advertising interaction.</comment>

<file context>
@@ -0,0 +1,224 @@
+  context.signal?.throwIfAborted();
+  await ensureBootedSimulator(device);
+  const helper = await ensureWatchHelperBinary({ signal: context.signal });
+  const result = await runAppleToolCommand(helper.path, [device.id, ...args], {
+    signal: context.signal,
+    allowFailure: true,
</file context>
Fix with cubic

device: DeviceInfo,
): void {
const available = device.appleOs !== 'watchos';
const available = device.appleOs !== 'watchos' || device.kind === 'simulator';

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new kind-disjunction branches (device.kind === 'simulator') in expectAppleCaptureAvailability and expectAppleSnapshotAvailability, and the watchOS-Simulator branches in expectNavigationAndKeyboardFacts, are never exercised: leaves.watchos and every row in the classification test use a watchOS simulator, and no test feeds a watchOS physical device into these helpers. The PR's fail-closed guarantee for physical watchOS devices (capture, snapshot, back, home denied) is therefore unasserted here. Add a watchOS-device leaf row (e.g. appleDevice({ appleOs: 'watchos', kind: 'device' })) to the classification and navigation tables.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/runtime.test.ts, line 197:

<comment>The new kind-disjunction branches (`device.kind === 'simulator'`) in `expectAppleCaptureAvailability` and `expectAppleSnapshotAvailability`, and the watchOS-Simulator branches in `expectNavigationAndKeyboardFacts`, are never exercised: `leaves.watchos` and every row in the classification test use a watchOS *simulator*, and no test feeds a watchOS physical device into these helpers. The PR's fail-closed guarantee for physical watchOS devices (capture, snapshot, back, home denied) is therefore unasserted here. Add a watchOS-device leaf row (e.g. `appleDevice({ appleOs: 'watchos', kind: 'device' })`) to the classification and navigation tables.</comment>

<file context>
@@ -190,14 +188,13 @@ function expectApplePerfAvailability(
   device: DeviceInfo,
 ): void {
-  const available = device.appleOs !== 'watchos';
+  const available = device.appleOs !== 'watchos' || device.kind === 'simulator';
   expect(binding.facts.operations.captureScreenshot.available).toBe(available);
   expect(binding.operations.captureScreenshot).toBeTypeOf(available ? 'function' : 'undefined');
</file context>
Fix with cubic

plus simulator-deployment evidence.
its predicates moved into request-bound facts); watchOS Simulator discovery and the isolated
CoreSimulator runtime for lifecycle, screenshots, host AX snapshots, touch, single-pointer gestures,
Digital Crown scrolling, and Crown navigation; the physical-watchOS unsupported sentinel; and visionOS

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Update the implementation-status date when adding this support. Otherwise the ADR presents September work as shipped in August.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At docs/adr/0009-apple-platform-consolidation.md, line 79:

<comment>Update the implementation-status date when adding this support. Otherwise the ADR presents September work as shipped in August.</comment>

<file context>
@@ -68,16 +74,17 @@ Implementation status as of 2026-08:
-  plus simulator-deployment evidence.
+  its predicates moved into request-bound facts); watchOS Simulator discovery and the isolated
+  CoreSimulator runtime for lifecycle, screenshots, host AX snapshots, touch, single-pointer gestures,
+  Digital Crown scrolling, and Crown navigation; the physical-watchOS unsupported sentinel; and visionOS
+  profile/build/discovery plus simulator-deployment evidence.
 - Decision-only support boundary: visionOS discovery and simulator deployment are supported and
</file context>
Fix with cubic

export const WATCH_HELPER_BUILD_TIMEOUT_MS = 30_000;
const PREPARATION_DEADLINE_MS = COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + WATCH_HELPER_BUILD_TIMEOUT_MS;

export async function ensureWatchHelperBinary(

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This file duplicates the helper-cache orchestration already implemented in fold-helper-cache.ts; extract the shared preparation/error-wrapper logic so cache, timeout, and failure-handling fixes cannot drift between helpers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/watch/watch-helper-cache.ts, line 29:

<comment>This file duplicates the helper-cache orchestration already implemented in `fold-helper-cache.ts`; extract the shared preparation/error-wrapper logic so cache, timeout, and failure-handling fixes cannot drift between helpers.</comment>

<file context>
@@ -0,0 +1,131 @@
+export const WATCH_HELPER_BUILD_TIMEOUT_MS = 30_000;
+const PREPARATION_DEADLINE_MS = COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + WATCH_HELPER_BUILD_TIMEOUT_MS;
+
+export async function ensureWatchHelperBinary(
+  input: Readonly<{
+    signal?: AbortSignal;
</file context>
Fix with cubic

outputPath: string;
}>,
): readonly string[] {
const watch = input.runtime?.toLowerCase().includes('watchos') === true;

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Any non-iOS runtime string that is not watchOS silently compiles the bridge against iphonesimulator (e.g., a tvOS/xrOS identifier reaching the snapshot source), instead of failing closed. Match the SimRuntime.watchOS- identifier prefix rather than a free substring so only actual watchOS runtimes select the watchsimulator SDK.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/snapshot-source/cache.ts, line 117:

<comment>Any non-iOS runtime string that is not watchOS silently compiles the bridge against `iphonesimulator` (e.g., a tvOS/xrOS identifier reaching the snapshot source), instead of failing closed. Match the `SimRuntime.watchOS-` identifier prefix rather than a free substring so only actual watchOS runtimes select the watchsimulator SDK.</comment>

<file context>
@@ -107,17 +109,19 @@ export async function ensureSnapshotBridgeBinary(
     outputPath: string;
   }>,
 ): readonly string[] {
+  const watch = input.runtime?.toLowerCase().includes('watchos') === true;
   return [
     '--sdk',
</file context>
Suggested change
const watch = input.runtime?.toLowerCase().includes('watchos') === true;
const watch = /SimRuntime\.watchOS/i.test(input.runtime ?? '');
Fix with cubic

// exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`).
...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }),
...typeTextRuntimeOperationFacts({
type: device.appleOs === 'watchos' ? unavailable : appleFocusFact(device),

@cubic-dev-ai cubic-dev-ai Bot Sep 27, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The comment directly above this spread still claims text entry "shares the exact kind cell (parity with the retired type bucket, { simulator, device })", but this change deliberately diverges for watchOS. Update the comment to state that the watch host backend exposes no text-entry route, matching the new test comment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/platform-apple/src/runtime.ts, line 309:

<comment>The comment directly above this spread still claims text entry "shares the exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`)", but this change deliberately diverges for watchOS. Update the comment to state that the watch host backend exposes no text-entry route, matching the new test comment.</comment>

<file context>
@@ -313,13 +305,17 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
         // exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`).
-        ...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }),
+        ...typeTextRuntimeOperationFacts({
+          type: device.appleOs === 'watchos' ? unavailable : appleFocusFact(device),
+        }),
         ...touchRuntimeOperationFacts({
</file context>
Fix with cubic

@thymikee

Copy link
Copy Markdown
Member

This adds real value (a watchOS Simulator runtime) but at 3995110 it isn't ready to merge, mostly because the physical-watch boundary and the classification it depends on aren't actually enforced.

watch-helper-cache.ts:29 (ensureWatchHelperBinary, createWatchHelperCacheHost, compileWatchHelper, watchHelperBuildFailed) copies fold-helper-cache.ts line for line, down to the fake host in the test file. A fix to one helper's cache or timeout handling will drift from the other. Can this become one parameterized host-helper cache next to native-build-cache.ts, taking {sourceFilename, binaryFilename, cacheDir, lockDescription, hint, reason, argv}, with the fold and watch helpers rebased onto it and one shared fake-host fixture?

WatchControl.m:79's simulatorDevice() resolves the UDID only through defaultDeviceSetWithError:, but discovery stamps simulatorSetPath on watch simulators and runWatchHelper only passes device.id. With --ios-simulator-device-set, every tap, press, longpress, gesture, scroll, back and home on a watch simulator will fail with COMMAND_FAILED ('Watch Simulator HID client is unavailable') while facts still say the device is available. Every host-side call that addresses a simulator should resolve it through simulatorAddressFor(device); can the resolved set path be passed to the helper and opened with the set-path SimServiceContext API, with a test asserting the helper argv carries the set?

inventory-classification.ts:6's APPLE_WATCH_PATTERN (/\b(apple watch|watchos|watch)\b/i) is tested against every descriptor, including device.name for simulators and the xctrace/devicectl name labels for physical devices. An iOS simulator or iPhone named 'Watch QA' gets classified as appleOs: 'watchos', losing typing, fill, app switcher and XCTest routing (or hitting the UNSUPPORTED_PLATFORM sentinel for a physical device), which regresses existing iOS targets. Can watchOS classification key only on identifiers the OS stamps — the simctl runtime key (SimRuntime.watchOS-) or deviceTypeIdentifier (SimDeviceType.Apple-Watch-), and the devicectl platform or productType — never the user-chosen name? An inventory row for an iOS simulator named 'Watch' would catch a regression here.

The watch alternation in APPLE_PRODUCT_TYPE_PATTERN (inventory-classification.ts:3) makes devicectl list a paired physical Apple Watch as kind: 'device', appleOs: 'watchos'. Since the watchos leaf refusals were removed from appleGesturePlanFact and appleGestureViewportFact (gesture-facts.ts:60), and runtime.ts:299-300 makes ensureReady/bootTarget available for every watchOS kind, a physical watch now passes admission for gesture, scroll and readiness and only fails inside createAppleInteractor's UNSUPPORTED_PLATFORM throw — contradicting the PR's stated physical-watch boundary, and untested because the fact tables only carry a watch simulator row. Should every watchOS fact require kind === 'simulator' (or should physical watches never reach inventory at all)? The simplest fix is reverting the productType alternation; otherwise every fact needs to route through one appleWatchSimulator predicate, with a watchOS kind: 'device' row added to the gesture, runtime and navigation fact tables.

watch/interactor.ts:122's scrollWatch reads only options.amount, so scroll down --pixels 300 sends the default Crown delta while scrollResult (daemon/scroll-runtime.ts:423) still echoes pixels: 300 as honored, and scroll left/scroll right map to a vertical Crown delta while the response still reports direction: 'left'/'right'; durationMs is dropped too. The response claims a distance and direction the device didn't perform, so agents and replays act on false movement. Can pixels, horizontal directions and durationMs be refused with a typed UNSUPPORTED_OPERATION, or declared unavailable in the watch scroll facts? This also needs watch/interactor.test.ts, since the one-to-one test topology rule requires it anyway, asserting the refusal codes.

snapshot-route.ts:265 now admits watchOS simulators as isEligible, but every failure path and off-route case ends in fallback(), and the watch interactor's snapshot() always throws UNSUPPORTED_OPERATION. opensGenerationCircuit retires the app generation after one non-preparing failure, so after one transient bridge failure, every later snapshot of that generation throws an admission-style refusal while captureSnapshot is still advertised as available — a mechanism failure reported as unsupported. For watchOS, can the bridge failure surface as a typed failure carrying its bridgeFailureCode, skip the generation circuit since there's no fallback to open onto, and refuse preferredBackend through facts instead?

Not blocking: the only test of the new backend (watchos-sentinel.test.ts:351) just checks methods are toBeTypeOf('function') with no watch/interactor.test.ts mirror, watchViewport probes simctl io enumerate before ensureBootedSimulator boots the simulator (and its cache Map is never invalidated), and the docs/ADR/CHANGELOG/comment updates (ADR 0009, commands.md:69,230, screenshot-crop-target.ts:95, device.ts:274's missing explicit watchos case) are all worth doing but can be taken or left for a follow-up.

Is the size of this change proportionate to what it needs to do? The production diff is +641/−60 lines under the usual threshold, but it still touches kernel, discovery, facts and snapshot with roughly a dozen scattered appleOs === 'watchos' ? (kind === 'simulator' ? … : …) : … branches in runtime.ts, navigation/runtime.ts, gesture-facts.ts and deployment/runtime.ts; would one declared watchOS-simulator fact profile, owned by the platform-apple runtime, collapse those branches into a single owning type, alongside dropping the productType watch alternation, keying watchOS on runtime/device-type identifiers only, and sharing one host-helper cache?

I did not run this on a device, so I can't confirm the HID message layout in WatchControl.m or the host AX bridge behavior on watchsimulator, the darwin -Werror compile of WatchControl.m (so the missing <math.h> for isfinite is unconfirmed), that current Xcode devicectl actually lists paired Apple Watches with a productType starting Watch… (f4 depends on this), or how often the watch bridge hits non-preparing failures in practice (relevant to f6). On a watchOS Simulator at this PR's head, merge-readiness needs the raw output of devices --platform apple showing the watch as appleOs: watchos (and an iOS simulator named 'Watch' staying ios), open <watch bundle>, snapshot with diagnostics showing the host bridge built with watchsimulator, click @ref returning backend watchos-coresimulator, scroll down plus the refusal codes for scroll left and scroll down --pixels 300, back/home via the Crown, screenshot, and a repeat of tap and scroll with --ios-simulator-device-set pointing at a non-default set containing the watch.

CI shows one check reported for this cross-repo PR, and it's green; no failing job overlaps the diff, but the full unit suite, typecheck and the darwin -Werror helper gate aren't visible here, so this green result doesn't cover those lanes.

The path to merge is: address the shared-cache duplication, the device-set-aware helper addressing, identifier-only watchOS classification, the physical-watch fact gap, honest scroll-input refusals, and typed bridge failures for watchOS snapshots, then attach the live watchOS Simulator transcript described above.

@thymikee

Copy link
Copy Markdown
Member

@csark0812 can you send some demos of how this works?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants