diff --git a/apple/watch-helper/WatchControl.m b/apple/watch-helper/WatchControl.m new file mode 100644 index 0000000000..d2ed719809 --- /dev/null +++ b/apple/watch-helper/WatchControl.m @@ -0,0 +1,211 @@ +#import +#import +#import +#import +#import +#import +#import +#import + +// Isolated host-side CoreSimulator input helper. The helper loads only the active Xcode's private +// frameworks, resolves every symbol dynamically, and exits after one verified transport send. +// The Indigo protocol and doubled contact shape follow Meta idb's MIT-licensed Simulator HID +// contract; no private framework header is compiled into the package. + +typedef void *(*MouseMessageFn)(CGPoint *, CGPoint *, NSInteger, NSUInteger, CGSize, NSInteger); +typedef void *(*ButtonMessageFn)(int, int, int); +typedef void *(*CrownMessageFn)(double); + +static const size_t kHeaderSize = 0x20; +static const size_t kPayloadSize = 0x90; +static const size_t kDoubledTouchSize = 0x140; +static const NSInteger kDigitizerTarget = 0x32; +static const int kHardwareTarget = 0x33; +static const int kButtonDown = 1; +static const int kButtonUp = 2; +static const int kHomeButtonSource = 0; + +static void fail(NSString *message) { + fprintf(stderr, "%s\n", message.UTF8String); +} + +static BOOL loadFramework(NSArray *candidates) { + for (NSString *path in candidates) { + if (dlopen(path.fileSystemRepresentation, RTLD_LAZY | RTLD_GLOBAL)) return YES; + } + return NO; +} + +static NSString *developerDirectory(void) { + NSString *selected = NSProcessInfo.processInfo.environment[@"DEVELOPER_DIR"]; + if (selected.length > 0) return selected.stringByStandardizingPath; + NSTask *task = [NSTask new]; + task.executableURL = [NSURL fileURLWithPath:@"/usr/bin/xcode-select"]; + task.arguments = @[@"-p"]; + NSPipe *pipe = [NSPipe pipe]; + task.standardOutput = pipe; + task.standardError = [NSPipe pipe]; + if (![task launchAndReturnError:NULL]) return @""; + [task waitUntilExit]; + NSData *data = [pipe.fileHandleForReading readDataToEndOfFile]; + NSString *value = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @""; + return [value stringByTrimmingCharactersInSet:NSCharacterSet.whitespaceAndNewlineCharacterSet]; +} + +static BOOL loadPrivateFrameworks(NSString *developerDir) { + NSString *contents = developerDir.stringByDeletingLastPathComponent; + NSArray *coreSimulator = @[ + [developerDir stringByAppendingPathComponent:@"Library/PrivateFrameworks/CoreSimulator.framework/CoreSimulator"], + @"/Library/Developer/PrivateFrameworks/CoreSimulator.framework/CoreSimulator", + ]; + NSArray *simulatorKit = @[ + [developerDir stringByAppendingPathComponent:@"Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"], + [contents stringByAppendingPathComponent:@"SharedFrameworks/SimulatorKit.framework/SimulatorKit"], + @"/Library/Developer/PrivateFrameworks/SimulatorKit.framework/SimulatorKit", + ]; + return loadFramework(coreSimulator) && loadFramework(simulatorKit); +} + +static id simulatorDevice(NSString *developerDir, NSString *udid, NSError **error) { + Class contextClass = NSClassFromString(@"SimServiceContext"); + SEL shared = NSSelectorFromString(@"sharedServiceContextForDeveloperDir:error:"); + id context = ((id (*)(id, SEL, id, NSError **))objc_msgSend)(contextClass, shared, developerDir, error); + if (!context) return nil; + SEL defaultSet = NSSelectorFromString(@"defaultDeviceSetWithError:"); + id deviceSet = ((id (*)(id, SEL, NSError **))objc_msgSend)(context, defaultSet, error); + if (!deviceSet) return nil; + NSArray *devices = ((id (*)(id, SEL))objc_msgSend)(deviceSet, NSSelectorFromString(@"devices")); + for (id device in devices) { + NSUUID *identifier = ((id (*)(id, SEL))objc_msgSend)(device, NSSelectorFromString(@"UDID")); + if ([identifier.UUIDString caseInsensitiveCompare:udid] == NSOrderedSame) return device; + } + return nil; +} + +static id hidClient(id device, NSError **error) { + Class clientClass = NSClassFromString(@"SimulatorKit.SimDeviceLegacyHIDClient"); + if (!clientClass) return nil; + id allocated = ((id (*)(id, SEL))objc_msgSend)(clientClass, sel_registerName("alloc")); + return ((id (*)(id, SEL, id, NSError **))objc_msgSend)( + allocated, NSSelectorFromString(@"initWithDevice:error:"), device, error); +} + +static BOOL sendMessage(id client, void *message, NSError **error) { + if (!message) return NO; + dispatch_queue_t queue = dispatch_queue_create("agent-device.watch-hid", DISPATCH_QUEUE_SERIAL); + dispatch_semaphore_t finished = dispatch_semaphore_create(0); + __block NSError *completionError = nil; + void (^completion)(NSError *) = ^(NSError *inner) { + completionError = inner; + dispatch_semaphore_signal(finished); + }; + SEL send = NSSelectorFromString(@"sendWithMessage:freeWhenDone:completionQueue:completion:"); + ((void (*)(id, SEL, void *, BOOL, dispatch_queue_t, id))objc_msgSend)( + client, send, message, YES, queue, completion); + if (dispatch_semaphore_wait(finished, dispatch_time(DISPATCH_TIME_NOW, NSEC_PER_SEC)) != 0) { + if (error) *error = [NSError errorWithDomain:@"agent-device.watch-hid" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Simulator HID send timed out"}]; + return NO; + } + if (completionError && error) *error = completionError; + return completionError == nil; +} + +static void *touchMessage(MouseMessageFn builder, double x, double y, int phase) { + CGPoint point = CGPointMake(x, y); + void *base = builder(&point, NULL, kDigitizerTarget, phase, CGSizeMake(1, 1), 0); + if (!base) return NULL; + uint8_t *message = calloc(1, kDoubledTouchSize); + memcpy(message, base, kHeaderSize + kPayloadSize); + free(base); + *(uint32_t *)(message + 0x18) = (uint32_t)kPayloadSize; + message[0x1c] = 2; + *(uint32_t *)(message + 0x20) = 0x0b; + *(uint64_t *)(message + 0x24) = mach_absolute_time(); + *(double *)(message + 0x3c) = x; + *(double *)(message + 0x44) = y; + memcpy(message + kHeaderSize + kPayloadSize, message + kHeaderSize, kPayloadSize); + uint8_t *secondEvent = message + kHeaderSize + kPayloadSize + 0x10; + *(uint32_t *)(secondEvent + 0) = 1; + *(uint32_t *)(secondEvent + 4) = 2; + return message; +} + +static BOOL sendTouch(id client, MouseMessageFn builder, double x, double y, int phase, NSError **error) { + return sendMessage(client, touchMessage(builder, x, y, phase), error); +} + +static BOOL tap(id client, MouseMessageFn builder, double x, double y, NSError **error) { + if (!sendTouch(client, builder, x, y, kButtonDown, error)) return NO; + usleep(60 * 1000); + return sendTouch(client, builder, x, y, kButtonUp, error); +} + +static BOOL swipe(id client, MouseMessageFn builder, double x1, double y1, double x2, double y2, + NSUInteger durationMs, NSError **error) { + NSUInteger frames = MAX(2, MIN(120, durationMs / 16)); + for (NSUInteger index = 0; index <= frames; index++) { + double progress = (double)index / (double)frames; + if (!sendTouch(client, builder, x1 + (x2 - x1) * progress, + y1 + (y2 - y1) * progress, kButtonDown, error)) return NO; + usleep((useconds_t)(durationMs * 1000 / frames)); + } + return sendTouch(client, builder, x2, y2, kButtonUp, error); +} + +static BOOL pressCrown(id client, ButtonMessageFn builder, NSError **error) { + if (!sendMessage(client, builder(kHomeButtonSource, kButtonDown, kHardwareTarget), error)) return NO; + usleep(50 * 1000); + return sendMessage(client, builder(kHomeButtonSource, kButtonUp, kHardwareTarget), error); +} + +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; + return YES; +} + +int main(int argc, const char *argv[]) { + @autoreleasepool { + if (argc < 3) return 2; + NSString *developerDir = developerDirectory(); + if (!loadPrivateFrameworks(developerDir)) { + fail(@"CoreSimulator or SimulatorKit could not be loaded from the selected Xcode"); + return 1; + } + NSError *error = nil; + id device = simulatorDevice(developerDir, @(argv[1]), &error); + id client = device ? hidClient(device, &error) : nil; + if (!client) { + fail(error.localizedDescription ?: @"Watch Simulator HID client is unavailable"); + return 1; + } + MouseMessageFn mouse = (MouseMessageFn)dlsym(RTLD_DEFAULT, "IndigoHIDMessageForMouseNSEvent"); + ButtonMessageFn button = (ButtonMessageFn)dlsym(RTLD_DEFAULT, "IndigoHIDMessageForButton"); + CrownMessageFn crown = (CrownMessageFn)dlsym(RTLD_DEFAULT, "IndigoHIDMessageForDigitalCrownEvent"); + NSString *command = @(argv[2]); + BOOL sent = NO; + if ([command isEqual:@"tap"] && argc == 5 && mouse) { + double x = 0, y = 0; + sent = readDouble(@(argv[3]), 0, 1, &x) && readDouble(@(argv[4]), 0, 1, &y) && + tap(client, mouse, x, y, &error); + } else if ([command isEqual:@"swipe"] && argc == 8 && mouse) { + double x1 = 0, y1 = 0, x2 = 0, y2 = 0, duration = 0; + sent = readDouble(@(argv[3]), 0, 1, &x1) && readDouble(@(argv[4]), 0, 1, &y1) && + readDouble(@(argv[5]), 0, 1, &x2) && readDouble(@(argv[6]), 0, 1, &y2) && + readDouble(@(argv[7]), 50, 5000, &duration) && + swipe(client, mouse, x1, y1, x2, y2, (NSUInteger)duration, &error); + } else if ([command isEqual:@"crown-scroll"] && argc == 4 && crown) { + double delta = 0; + sent = readDouble(@(argv[3]), -10000, 10000, &delta) && + sendMessage(client, crown(delta), &error); + } else if ([command isEqual:@"crown-press"] && argc == 3 && button) { + sent = pressCrown(client, button, &error); + } + if (!sent) fail(error.localizedDescription ?: @"Invalid or unsupported watch HID command"); + return sent ? 0 : 2; + } +} diff --git a/docs/adr/0009-apple-platform-consolidation.md b/docs/adr/0009-apple-platform-consolidation.md index a77ad70c29..b6d8023a6a 100644 --- a/docs/adr/0009-apple-platform-consolidation.md +++ b/docs/adr/0009-apple-platform-consolidation.md @@ -9,6 +9,11 @@ Accepted > `src/platforms/**` location are superseded by the lazy platform-module registry and private > `@agent-device/platform-*` packages. Execution cuts over command-atomically; shared physical > mechanics move only through ADR 0019's legal injected transition or after their last legacy user. +> +> **Amended in 2026-09:** watchOS Simulator is no longer an unsupported sentinel. Discovery, +> application lifecycle, screenshots, accessibility snapshots, single-pointer input, Digital Crown +> scrolling, and Crown navigation are owned by an isolated CoreSimulator backend. Physical watchOS +> devices and operations outside that explicit fact set remain unsupported. ## Context @@ -51,16 +56,17 @@ non-breaking `appleOs` discriminant — the groundwork for this — shipped in # Adding a first-class Apple OS becomes cheap: a leaf module plus a runner-profile row. iOS/iPadOS/tvOS/macOS are mostly relocate-and-rename (the engine never needed to know which Apple OS it drives); visionOS is scoped net-new work (XCUITest supports it — a profile row, a build case, `#if os(visionOS)`, a widened discovery -filter, plus real spatial-input QA); watchOS is an explicit **unsupported sentinel** because XCUITest cannot -drive watchOS UI. macOS stays a distinct AppKit leaf (its helper binary and menubar/desktop surface model are -preserved). The tvOS focus-only interaction contract (no coordinate `tap`) must not be flattened across OSes, -and snapshot fidelity is uneven (the deep-RN AX-server fallback is iOS-simulator-only). The internal +filter, plus real spatial-input QA). watchOS Simulator uses an isolated CoreSimulator backend because XCUITest +cannot drive watchOS UI; its command facts are narrower than the iOS runner and are advertised only after the +selected runtime proves the required HID display. macOS stays a distinct AppKit leaf (its helper binary and +menubar/desktop surface model are preserved). The tvOS focus-only interaction contract (no coordinate `tap`) +must not be flattened across OSes, and snapshot fidelity is uneven. The internal `Platform` collapse of `ios`+`macos` into `apple` was the last, highest-diff step; public leaf output remains a separate compatibility projection. This composes with ADR 0008 (the descriptor's capability facet) and ADR 0003. -Implementation status as of 2026-08: +Implementation status as of 2026-09: - Shipped: the internal `Platform` collapse to `apple`; additive `appleOs` groundwork; the shared Apple engine under `packages/platform-apple/src/core`; macOS leaf files under @@ -68,16 +74,20 @@ Implementation status as of 2026-08: `packages/platform-apple/src/os/tvos`; direct internal imports to the Apple modules; the per-`AppleOS` runtime facts (including `packages/platform-apple/src/gesture-facts.ts`; the former capability projection was retired after - its predicates moved into request-bound facts); the watchOS **unsupported sentinel** (reserved in the `AppleOS` type and interactor-rejected — - XCUITest cannot drive watchOS UI — `isSupportedAppleDeploymentLeaf`, the Apple interactor, and - gesture admission reject it — never produced by discovery); and visionOS profile/build/discovery - 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, and input/navigation only after + a successful LegacyHID display probe on the default simulator set; 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 unit-tested (`packages/platform-apple/src/inventory-classification.ts` and `packages/platform-apple/src/deployment/runtime.test.ts`); app deployment is also admitted for CoreDevice-backed physical devices, while XCTest-backed physical deployment is unsupported and - push remains simulator-only. No public-command coverage is claimed for visionOS or watchOS. This - boundary is recorded here without creating a command-coverage manifest for either leaf. + push remains simulator-only. watchOS simulator app deployment is admitted; `networkDump` remains + available on every Apple leaf. No public-command coverage is claimed for visionOS. watchOS public-command + coverage is limited to the Simulator operations named above; non-default simulator sets, text entry, + app switcher, orientation, settings, multi-touch, physical devices, and other unadvertised operations + fail closed. Crown scrolling is vertical amount-only; pixel-density screenshot normalization is not + supported by the watchOS backend. - Retained compatibility: the public wire still emits `ios`/`macos` leaves through `PUBLIC_PLATFORMS`; internal family ownership must not leak into that projection. - Deferred: net-new visionOS spatial-input QA. diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 7ff3ae3f7a..3f9aaf844d 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -324,9 +324,10 @@ not make a difficult legacy-supported cell disappear. Behavior changes require a Apple coverage uses an exhaustive `AppleOS` fixture table and explicitly exercises iOS simulator and physical backends where they differ, iPadOS, tvOS focus-only/no-coordinate behavior, macOS desktop, -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 gated by a +successful `simctl io enumerate` LegacyHID display probe, and the physical-watchOS unsupported +sentinel. Watch input is unavailable for non-default simulator sets. Every fixture matches exactly one family. A loop over six families +with one generic `platform: 'apple'` device is not leaf coverage. ### 3. Provider ownership is exact and fail-closed diff --git a/package.json b/package.json index 9443057f52..d0a89ab027 100644 --- a/package.json +++ b/package.json @@ -215,6 +215,7 @@ "!apple/macos-helper/**/.build", "apple/snapshot-bridge", "apple/fold-helper", + "apple/watch-helper", "android/snapshot-helper/dist", "!android/snapshot-helper/dist/*.idsig", "!android/snapshot-helper/README.md", diff --git a/packages/kernel/src/device.ts b/packages/kernel/src/device.ts index 5f435f138d..96f60e4286 100644 --- a/packages/kernel/src/device.ts +++ b/packages/kernel/src/device.ts @@ -15,8 +15,8 @@ export { // collapses to the single `apple` platform (ADR-0009 / issue #979). export type ApplePlatform = 'ios' | 'macos'; // Explicit, stored Apple operating system. All six literals are reserved so the -// type is stable as platform support grows, but discovery only ever populates -// the four currently supported ones ('ios' | 'ipados' | 'tvos' | 'macos'). +// type is stable as platform support grows. Local discovery populates iOS, +// iPadOS, tvOS, watchOS, visionOS, and macOS when the corresponding runtime is installed. const APPLE_OS_VALUES = ['ios', 'ipados', 'tvos', 'watchos', 'visionos', 'macos'] as const; export type AppleOS = (typeof APPLE_OS_VALUES)[number]; // Internal device platforms. Apple OSes collapse to a single `apple` platform; the @@ -296,10 +296,12 @@ function resolveRunnerPlatformNameForAppleOs( return 'macOS'; case 'visionos': return 'visionOS'; - // iOS and iPadOS share the single iOS runner profile/SDK. watchOS remains - // reserved in the type but is never produced by discovery; defaulting it to - // iOS keeps any future record on a valid runner profile without introducing - // watchOS support. + case 'watchos': + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'watchOS uses its isolated Simulator backend and has no XCTest runner platform.', + ); + // iOS and iPadOS share the single iOS runner profile/SDK. default: return 'iOS'; } diff --git a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts index eed7d1d34e..9a3a4e5f66 100644 --- a/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts +++ b/packages/platform-apple/src/__tests__/interactor-runner-provider.test.ts @@ -109,6 +109,21 @@ test('the runner/local partition covers the full provider-backed interactor surf assert.deepEqual(Object.keys(interactor).sort(), classified); }); +test('watchOS local HID routing fails closed when a runner provider is injected', () => { + assert.throws( + () => + createAppleInteractor( + { ...IOS_SIMULATOR, id: 'watch-1', name: 'Apple Watch', appleOs: 'watchos' }, + {}, + recordingRunnerProvider([]), + ), + (error: unknown) => + error instanceof AppError && + error.code === 'UNSUPPORTED_OPERATION' && + error.message.includes('unavailable through runner providers'), + ); +}); + test('provider-backed interactor routes runner-command methods through the injected transport', async () => { const calls: RecordedRunnerCall[] = []; const interactor = createAppleInteractor( diff --git a/packages/platform-apple/src/__tests__/watchos-sentinel.test.ts b/packages/platform-apple/src/__tests__/watchos-sentinel.test.ts index 4d94f82dfc..10a432ef8e 100644 --- a/packages/platform-apple/src/__tests__/watchos-sentinel.test.ts +++ b/packages/platform-apple/src/__tests__/watchos-sentinel.test.ts @@ -4,9 +4,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import type { RunnerContext } from '@agent-device/contracts/interactor-types'; import { AppError } from '@agent-device/kernel/errors'; -// watchOS is an explicit unsupported sentinel: XCUITest cannot drive watchOS UI, -// so a `appleOs: 'watchos'` device must be rejected at interactor creation (the -// admission seam) rather than silently falling through to the iOS runner. +// Physical watchOS remains an explicit unsupported sentinel. Simulator watchOS uses +// the isolated CoreSimulator backend rather than silently falling through to XCTest. const watchOsDevice: DeviceInfo = { platform: 'apple', id: 'watch-1', @@ -29,6 +28,17 @@ test('createAppleInteractor rejects a watchOS device as UNSUPPORTED_PLATFORM', ( } }); +test('createAppleInteractor constructs the isolated backend for a watchOS simulator', () => { + const simulator = createAppleInteractor( + { ...watchOsDevice, kind: 'simulator', booted: true }, + {} as RunnerContext, + ); + expect(simulator.tap).toBeTypeOf('function'); + expect(simulator.screenshot).toBeTypeOf('function'); + expect(simulator.home).toBeTypeOf('function'); + expect(simulator.appSwitcher).toBeUndefined(); +}); + test('a non-watchOS appleOs does not trigger the watchOS sentinel', () => { // A tvOS device must pass the watchOS admission guard. Interactor creation // succeeds on an empty runner context, and pinning that success keeps this diff --git a/packages/platform-apple/src/deployment/runtime.test.ts b/packages/platform-apple/src/deployment/runtime.test.ts index e56a876049..da3271603a 100644 --- a/packages/platform-apple/src/deployment/runtime.test.ts +++ b/packages/platform-apple/src/deployment/runtime.test.ts @@ -192,9 +192,9 @@ test.each([ 'unsupported-platform-leaf', ], [ - 'watchOS sentinel', + 'watchOS simulator', appleDevice({ appleOs: 'watchos' }), - false, + true, false, 'unsupported-platform-leaf', ], diff --git a/packages/platform-apple/src/deployment/runtime.ts b/packages/platform-apple/src/deployment/runtime.ts index 22dd945e4a..aa0efd61f8 100644 --- a/packages/platform-apple/src/deployment/runtime.ts +++ b/packages/platform-apple/src/deployment/runtime.ts @@ -247,6 +247,8 @@ async function runAppleTool( } function appleDeployFact(device: DeviceInfo): RuntimeOperationFact { + if (device.appleOs === 'watchos') + return device.kind === 'simulator' ? available : unsupportedLeaf; if (!isSupportedAppleDeploymentLeaf(device)) return unsupportedLeaf; if (device.kind !== 'simulator' && device.kind !== 'device') return unsupportedKind; if (device.kind === 'device' && device.iosPhysicalDeviceBackend === 'xctest') { diff --git a/packages/platform-apple/src/foldable/fold-helper-cache.ts b/packages/platform-apple/src/foldable/fold-helper-cache.ts index fdf15f9da0..ae9ce103d4 100644 --- a/packages/platform-apple/src/foldable/fold-helper-cache.ts +++ b/packages/platform-apple/src/foldable/fold-helper-cache.ts @@ -1,19 +1,11 @@ import path from 'node:path'; import { AppError } from '@agent-device/kernel/errors'; import { execFailureDetails } from '@agent-device/host-kit/command'; -import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; import { findProjectRoot } from '@agent-device/host-kit/version'; -import { runAppleToolCommand } from '../core/tool-provider.ts'; -import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; -import { createNativeBuildDeadline, type NativeBuildDeadline } from '../native-build/deadline.ts'; -import { NativeBuildError } from '../native-build/errors.ts'; -import { createNativeBuildHost, type NativeBuildHost } from '../native-build/host.ts'; -import { readHostToolchainIdentity } from '../native-build/toolchain-identity.ts'; -import { - ensureNativeBuildCacheEntry, - execNativeBuildClang, - fingerprintNativeBuildSource, -} from '../native-build/cache.ts'; +import type { NativeBuildDeadline } from '../native-build/deadline.ts'; +import type { NativeBuildHost } from '../native-build/host.ts'; +import { execNativeBuildClang } from '../native-build/cache.ts'; +import { ensureNativeHelperBinary } from '../native-build/helper-cache.ts'; const FOLD_HELPER_SOURCE_FILENAME = 'Fold.m'; const FOLD_HELPER_BINARY_FILENAME = 'fold-helper'; @@ -25,10 +17,6 @@ const FOLD_HELPER_BUILD_HINT = /** Upper bound on a single fold-helper clang invocation; the same budget the prior per-call build used. */ export const FOLD_HELPER_BUILD_TIMEOUT_MS = 30_000; -/** Ceiling on locating, probing and (if needed) building a cached fold-helper binary. */ -const FOLD_HELPER_PREPARATION_DEADLINE_MS = - COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + FOLD_HELPER_BUILD_TIMEOUT_MS; - /** * The fold helper binary for the host's active toolchain, building and caching it if needed. Shares * the snapshot bridge's content+toolchain-keyed build cache (`native-build/cache.ts`), so a fold @@ -47,45 +35,21 @@ export async function ensureFoldHelperBinary( sourceRoot?: string; }> = {}, ): Promise> { - const host = input.host ?? createFoldHelperCacheHost(); - const deadline = createNativeBuildDeadline(FOLD_HELPER_PREPARATION_DEADLINE_MS, input.signal); - try { - const sourceRoot = input.sourceRoot ?? path.join(findProjectRoot(), 'apple', 'fold-helper'); - const sourceHash = await fingerprintNativeBuildSource( - host, - sourceRoot, - [FOLD_HELPER_SOURCE_FILENAME], - deadline, - ); - const toolchain = await readHostToolchainIdentity(host, deadline); - const cacheRoot = - input.cacheRoot ?? path.join(hostHomeDirectory(), '.agent-device', 'fold-helper'); - return await ensureNativeBuildCacheEntry({ - host, - deadline, - lockDescription: FOLD_HELPER_LOCK_DESCRIPTION, - cacheRoot, - binaryFilename: FOLD_HELPER_BINARY_FILENAME, - keyInputs: { - schemaVersion: FOLD_HELPER_SCHEMA_VERSION, - sourceHash, - toolchain, - // Placeholder paths keep the key independent of the install location and build directory. - compileArgv: buildFoldHelperCompileArgv({ sourceRoot: '', outputPath: '' }), - }, - build: (outputPath) => compileFoldHelper(host, deadline, sourceRoot, outputPath), - }); - } catch (error) { - throw asFoldHelperCacheError(error); - } -} - -function createFoldHelperCacheHost(): NativeBuildHost { - // Routed through the Apple tool-provider scope, not `run`'s default `runCmd`, so a fold test can - // fake every exec this cache makes the same way it fakes the simctl dispatch (#2796). A narrow - // build host, not the full snapshot-bridge host: compilation needs no bridge socket and no - // target-process inspection (#2970). - return createNativeBuildHost(runAppleToolCommand); + return await ensureNativeHelperBinary({ + ...input, + resolveSourceRoot: () => path.join(findProjectRoot(), 'apple', 'fold-helper'), + sourceFilenames: [FOLD_HELPER_SOURCE_FILENAME], + cacheDirectory: 'fold-helper', + schemaVersion: FOLD_HELPER_SCHEMA_VERSION, + lockDescription: FOLD_HELPER_LOCK_DESCRIPTION, + binaryFilename: FOLD_HELPER_BINARY_FILENAME, + buildTimeoutMs: FOLD_HELPER_BUILD_TIMEOUT_MS, + compileArgv: buildFoldHelperCompileArgv, + build: ({ host, deadline, sourceRoot, outputPath }) => + compileFoldHelper(host, deadline, sourceRoot, outputPath), + wrapFailure: (error) => + foldHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error), + }); } /** @@ -137,11 +101,6 @@ async function compileFoldHelper( * details; a cancellation, and any error that is not a native-build failure (including the fold * helper's own `foldHelperBuildFailed`, already in its public shape), passes through unchanged. */ -function asFoldHelperCacheError(error: unknown): unknown { - if (!(error instanceof NativeBuildError) || error.buildFailureKind === 'cancelled') return error; - return foldHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error); -} - function foldHelperBuildFailed(details: Readonly>, cause?: unknown) { return new AppError( 'COMMAND_FAILED', diff --git a/packages/platform-apple/src/gesture-facts.test.ts b/packages/platform-apple/src/gesture-facts.test.ts index 987398271f..badcefaae0 100644 --- a/packages/platform-apple/src/gesture-facts.test.ts +++ b/packages/platform-apple/src/gesture-facts.test.ts @@ -45,7 +45,27 @@ test.each([ ['tvOS simulator', leaves.tvos, true, true, false, false, true, true], ['macOS host', leaves.macos, true, true, false, false, true, true], ['visionOS simulator', leaves.visionos, false, false, false, false, true, true], - ['watchOS sentinel', leaves.watchos, false, false, false, false, false, true], + ['watchOS simulator', leaves.watchos, true, true, false, false, true, true], + [ + 'physical watchOS', + appleDevice({ appleOs: 'watchos', kind: 'device' }), + false, + false, + false, + false, + false, + false, + ], + [ + 'watchOS custom simulator set', + appleDevice({ appleOs: 'watchos', simulatorSetPath: '/tmp/watch-set' }), + false, + false, + false, + false, + false, + false, + ], ])( 'declares the %s gesture and scroll cells', async (_name, device, plan, directionalFling, multiTouch, drag, viewport, scroll) => { @@ -78,10 +98,37 @@ test('carries the retired multi-touch hints verbatim on every Apple leaf that re available: false, hint: expect.stringContaining('source hold, timed movement, and destination hold'), }); - // watchOS was caught by the retired admission's FIRST branch, before the policy that carries - // the per-OS hints ever ran, so it refuses without one. + // The watch backend accepts one contact but still refuses multi-touch explicitly. const watchos = await runtime.inspectFacts(leaves.watchos); - expect(watchos.operations.performGesturePlan).toEqual({ + expect(watchos.operations.performGesturePlan).toEqual({ available: true }); + expect(watchos.operations.performMultiTouchGesturePlan).toEqual({ + available: false, + reason: 'unsupported-platform-leaf', + }); +}); + +test('watchOS interaction facts fail closed when the selected runtime lacks the HID display probe', async () => { + const base = platformRuntimeHostFixture(); + const host = { + ...base, + appleTools: { + ...base.appleTools, + run: async (request: Parameters[0]) => + request.tool === 'simctl' && request.args.includes('enumerate') + ? { stdout: '', stderr: 'LegacyHID unavailable', exitCode: 1 } + : await base.appleTools.run(request), + }, + } as typeof base; + const facts = await createApplePlatformRuntime(host).inspectFacts(leaves.watchos); + expect(facts.operations.tapPoint).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + }); + expect(facts.operations.scrollDirection).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + }); + expect(facts.operations.back).toMatchObject({ available: false, reason: 'unsupported-platform-leaf', }); diff --git a/packages/platform-apple/src/gesture-facts.ts b/packages/platform-apple/src/gesture-facts.ts index 93e651f196..beaa6a9fa9 100644 --- a/packages/platform-apple/src/gesture-facts.ts +++ b/packages/platform-apple/src/gesture-facts.ts @@ -38,35 +38,43 @@ function unsupportedAppleDeviceKind(hint: string) { } /** The gesture and scroll cells one Apple leaf declares, ready to spread into its fact catalog. */ -export function appleGestureAndScrollFacts(device: DeviceInfo) { +export function appleGestureAndScrollFacts(device: DeviceInfo, watchHidAvailable = false) { return { ...gestureRuntimeOperationFacts({ - unsupported: appleGestureFamilyUnavailable(device), - plan: appleGesturePlanFact(device), - directionalFling: appleGesturePlanFact(device), - multiTouch: appleMultiTouchGestureFact(device), - targetAuthoredDrag: appleTargetAuthoredDragFact(device), - viewport: appleGestureViewportFact(device), + unsupported: appleGestureFamilyUnavailable(device, watchHidAvailable), + plan: appleGesturePlanFact(device, watchHidAvailable), + directionalFling: appleGesturePlanFact(device, watchHidAvailable), + multiTouch: appleMultiTouchGestureFact(device, watchHidAvailable), + targetAuthoredDrag: appleTargetAuthoredDragFact(device, watchHidAvailable), + viewport: appleGestureViewportFact(device, watchHidAvailable), }), - ...scrollRuntimeOperationFacts({ scroll: appleScrollFact(device) }), + ...scrollRuntimeOperationFacts({ scroll: appleScrollFact(device, watchHidAvailable) }), }; } /** The reason this leaf refuses gestures it does not name, before any tier is consulted. */ -function appleGestureFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability { - return device.appleOs === 'watchos' || device.appleOs === 'visionos' - ? gestureLeafUnavailable - : gestureKindUnavailable; +function appleGestureFamilyUnavailable( + device: DeviceInfo, + watchHidAvailable: boolean, +): RuntimeOperationUnavailability { + if (device.appleOs === 'watchos' && !watchHidAvailable) return gestureLeafUnavailable; + return device.appleOs === 'visionos' ? gestureLeafUnavailable : gestureKindUnavailable; } -function appleGesturePlanFact(device: DeviceInfo): RuntimeOperationFact { - if (device.appleOs === 'watchos' || device.appleOs === 'visionos') return gestureLeafUnavailable; - return appleTouchKind(device) ? available : gestureKindUnavailable; +function appleGesturePlanFact( + device: DeviceInfo, + watchHidAvailable: boolean, +): RuntimeOperationFact { + if (device.appleOs === 'visionos') return gestureLeafUnavailable; + return appleTouchKind(device, watchHidAvailable) ? available : gestureKindUnavailable; } -function appleMultiTouchGestureFact(device: DeviceInfo): RuntimeOperationFact { +function appleMultiTouchGestureFact( + device: DeviceInfo, + watchHidAvailable: boolean, +): RuntimeOperationFact { if (device.appleOs === 'watchos') return gestureLeafUnavailable; - if (!appleTouchKind(device)) return gestureKindUnavailable; + if (!appleTouchKind(device, watchHidAvailable)) return gestureKindUnavailable; const appleOs = resolveDeviceAppleOs(device); if (appleOs !== 'ios' && appleOs !== 'ipados') { const hint = APPLE_MULTI_TOUCH_UNSUPPORTED_HINTS[appleOs]; @@ -79,8 +87,11 @@ function appleMultiTouchGestureFact(device: DeviceInfo): RuntimeOperationFact { return device.kind === 'simulator' ? available : physicalIosMultiTouchUnavailable; } -function appleTargetAuthoredDragFact(device: DeviceInfo): RuntimeOperationFact { - if (!appleTouchKind(device)) return gestureKindUnavailable; +function appleTargetAuthoredDragFact( + device: DeviceInfo, + watchHidAvailable: boolean, +): RuntimeOperationFact { + if (!appleTouchKind(device, watchHidAvailable)) return gestureKindUnavailable; const supported = device.appleOs === undefined ? device.target !== 'desktop' && device.target !== 'tv' @@ -88,15 +99,20 @@ function appleTargetAuthoredDragFact(device: DeviceInfo): RuntimeOperationFact { return supported ? available : targetAuthoredDragUnavailable; } -function appleGestureViewportFact(device: DeviceInfo): RuntimeOperationFact { - if (device.appleOs === 'watchos') return gestureLeafUnavailable; - return appleTouchKind(device) ? available : gestureKindUnavailable; +function appleGestureViewportFact( + device: DeviceInfo, + watchHidAvailable: boolean, +): RuntimeOperationFact { + return appleTouchKind(device, watchHidAvailable) ? available : gestureKindUnavailable; } -function appleScrollFact(device: DeviceInfo): RuntimeOperationFact { - return appleTouchKind(device) ? available : scrollKindUnavailable; +function appleScrollFact(device: DeviceInfo, watchHidAvailable: boolean): RuntimeOperationFact { + return appleTouchKind(device, watchHidAvailable) ? available : scrollKindUnavailable; } -function appleTouchKind(device: DeviceInfo): boolean { +function appleTouchKind(device: DeviceInfo, watchHidAvailable = false): boolean { + if (device.appleOs === 'watchos') { + return device.kind === 'simulator' && !device.simulatorSetPath && watchHidAvailable; + } return device.kind === 'simulator' || device.kind === 'device'; } diff --git a/packages/platform-apple/src/interactor.ts b/packages/platform-apple/src/interactor.ts index 0d960fea39..f556f5d39a 100644 --- a/packages/platform-apple/src/interactor.ts +++ b/packages/platform-apple/src/interactor.ts @@ -37,21 +37,21 @@ import { import type { AppleRunnerSnapshotResult } from './runner/snapshot-presentation.ts'; import { iosSystemSurfaceDisclosure } from '@agent-device/contracts/ios-system-surface'; import { iosTargetActivationDisclosure } from '@agent-device/contracts/ios-target-activation'; +import { createWatchOsInteractor } from './watch/interactor.ts'; export function createAppleInteractor( device: DeviceInfo, runnerContext: RunnerContext, runnerProvider?: AppleRunnerProvider | AppleRunnerCommandExecutor, ): Interactor { - // watchOS unsupported sentinel: XCUITest cannot drive watchOS UI (no - // XCUIApplication), so a watchOS device has no runner backend. Reject it - // explicitly here rather than letting `appleOs: 'watchos'` silently fall - // through to the iOS runner profile (see resolveRunnerPlatformNameForAppleOs). if (device.appleOs === 'watchos') { - throw new AppError( - 'UNSUPPORTED_PLATFORM', - 'watchOS is not supported: XCUITest cannot drive watchOS UI, so this device has no runner backend.', - ); + if (runnerProvider !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'watchOS interaction uses local Simulator tooling and is unavailable through runner providers.', + ); + } + return createWatchOsInteractor(device, runnerContext); } const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext); const interactor: Interactor = { diff --git a/packages/platform-apple/src/inventory-classification.test.ts b/packages/platform-apple/src/inventory-classification.test.ts index f43bebedbb..0c9d53a587 100644 --- a/packages/platform-apple/src/inventory-classification.test.ts +++ b/packages/platform-apple/src/inventory-classification.test.ts @@ -22,7 +22,7 @@ test('devicectl classification recognizes Apple families without relying on a de ); }); -test('devicectl classification resolves tvOS, iPadOS, and visionOS vocabulary', () => { +test('devicectl classification resolves Apple OS vocabulary and excludes physical Watches', () => { assert.equal( resolveAppleTargetFromDevicectlDevice({ hardwareProperties: { platform: 'tvOS' }, @@ -31,6 +31,22 @@ test('devicectl classification resolves tvOS, iPadOS, and visionOS vocabulary', ); assert.equal(resolveAppleOs('mobile', ['iPad16,3']), 'ipados'); assert.equal(resolveAppleOs('mobile', ['visionOS 2.0']), 'visionos'); + assert.equal(resolveAppleOs('mobile', ['iOS-26-0', 'Watch']), 'ios'); + assert.equal( + resolveAppleOs('mobile', [ + 'iOS-26-0', + 'com.apple.CoreSimulator.SimDeviceType.Apple-Watch-46mm', + ]), + 'watchos', + ); + assert.equal(resolveAppleOs('mobile', ['iOS-26-0', 'Watch6,4']), 'ios'); + assert.equal(isAppleProductType('Watch6,4'), false); + assert.equal( + isSupportedAppleDevicectlDevice({ + hardwareProperties: { platform: 'iOS', productType: 'Watch6,4' }, + }), + false, + ); }); test('devicectl records map to normalized physical Apple devices', () => { diff --git a/packages/platform-apple/src/inventory-classification.ts b/packages/platform-apple/src/inventory-classification.ts index 3e74b49add..82b9606d40 100644 --- a/packages/platform-apple/src/inventory-classification.ts +++ b/packages/platform-apple/src/inventory-classification.ts @@ -3,6 +3,8 @@ import type { AppleOS, DeviceInfo, DeviceTarget } from '@agent-device/kernel/dev const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i; const APPLE_IPAD_PATTERN = /ipad/i; const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i; +// OS identity must come from Xcode/CoreDevice identifiers, never a user-editable device label. +const APPLE_WATCH_PATTERN = /(?:\bwatchos\b|simdevicetype\.apple-watch)/i; const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i; const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i; const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const; @@ -36,6 +38,7 @@ export function isSupportedAppleRuntime(runtime: string): boolean { return ( normalized.includes('ios') || normalized.includes('tvos') || + normalized.includes('watchos') || normalized.includes('xros') || normalized.includes('visionos') ); @@ -58,6 +61,7 @@ export function resolveAppleTargetFromLabel(value: string): DeviceTarget | null export function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS { if (target === 'tv') return 'tvos'; + if (descriptors.some((descriptor) => APPLE_WATCH_PATTERN.test(descriptor))) return 'watchos'; if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos'; if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados'; return 'ios'; @@ -93,6 +97,11 @@ export function resolveAppleTargetFromDevicectlDevice(device: DevicectlAppleDevi export function isSupportedAppleDevicectlDevice(device: DevicectlAppleDevice): boolean { const platform = normalizeAppleDescriptor(device.hardwareProperties?.platform); + // A paired physical Watch is not an interactive target in this runtime. Do not surface it as + // selectable Apple inventory; pairing owns its discovery separately. + if (/^watch\d+,\d+$/i.test(devicectlProductType(device)) || platform.includes('watchos')) { + return false; + } if (platform.includes('ios') || isAppleTvPlatform(platform) || isAppleVisionPlatform(platform)) { return true; } diff --git a/packages/platform-apple/src/native-build/helper-cache.ts b/packages/platform-apple/src/native-build/helper-cache.ts new file mode 100644 index 0000000000..0178b6fa26 --- /dev/null +++ b/packages/platform-apple/src/native-build/helper-cache.ts @@ -0,0 +1,71 @@ +import path from 'node:path'; +import { hostHomeDirectory } from '@agent-device/host-kit/host-file'; +import { runAppleToolCommand } from '../core/tool-provider.ts'; +import { COLD_TOOLCHAIN_PROBE_TIMEOUT_MS } from '../runner/apple-runner-platform.ts'; +import { createNativeBuildDeadline, type NativeBuildDeadline } from './deadline.ts'; +import { NativeBuildError } from './errors.ts'; +import { createNativeBuildHost, type NativeBuildHost } from './host.ts'; +import { readHostToolchainIdentity } from './toolchain-identity.ts'; +import { ensureNativeBuildCacheEntry, fingerprintNativeBuildSource } from './cache.ts'; + +type NativeHelperBuildRequest = Readonly<{ + host: NativeBuildHost; + deadline: NativeBuildDeadline; + sourceRoot: string; + outputPath: string; +}>; + +/** Shared owner for helper source identity, toolchain fingerprinting, timeout, cache, and errors. */ +export async function ensureNativeHelperBinary( + input: Readonly<{ + signal?: AbortSignal; + host?: NativeBuildHost; + cacheRoot?: string; + sourceRoot?: string; + resolveSourceRoot: (host: NativeBuildHost) => string; + sourceFilenames: readonly string[]; + cacheDirectory: string; + schemaVersion: number; + lockDescription: string; + binaryFilename: string; + buildTimeoutMs: number; + compileArgv: (input: Readonly<{ sourceRoot: string; outputPath: string }>) => readonly string[]; + build: (request: NativeHelperBuildRequest) => Promise; + wrapFailure: (error: NativeBuildError) => unknown; + }>, +): Promise> { + const host = input.host ?? createNativeBuildHost(runAppleToolCommand); + const deadline = createNativeBuildDeadline( + COLD_TOOLCHAIN_PROBE_TIMEOUT_MS + input.buildTimeoutMs, + input.signal, + ); + try { + const sourceRoot = input.sourceRoot ?? input.resolveSourceRoot(host); + const sourceHash = await fingerprintNativeBuildSource( + host, + sourceRoot, + input.sourceFilenames, + deadline, + ); + const toolchain = await readHostToolchainIdentity(host, deadline); + const cacheRoot = + input.cacheRoot ?? path.join(hostHomeDirectory(), '.agent-device', input.cacheDirectory); + return await ensureNativeBuildCacheEntry({ + host, + deadline, + lockDescription: input.lockDescription, + cacheRoot, + binaryFilename: input.binaryFilename, + keyInputs: { + schemaVersion: input.schemaVersion, + sourceHash, + toolchain, + compileArgv: input.compileArgv({ sourceRoot: '', outputPath: '' }), + }, + build: (outputPath) => input.build({ host, deadline, sourceRoot, outputPath }), + }); + } catch (error) { + if (!(error instanceof NativeBuildError) || error.buildFailureKind === 'cancelled') throw error; + throw input.wrapFailure(error); + } +} diff --git a/packages/platform-apple/src/navigation/runtime.ts b/packages/platform-apple/src/navigation/runtime.ts index eb0d8af2e9..2f06ccea55 100644 --- a/packages/platform-apple/src/navigation/runtime.ts +++ b/packages/platform-apple/src/navigation/runtime.ts @@ -20,19 +20,21 @@ const backKindUnavailable = Object.freeze({ reason: 'unsupported-device-kind', hint: 'back is supported on Apple simulators and physical devices.', } as const); -/** watchOS has no XCUITest-driveable UI (ADR-0009): no Apple interactor can be constructed for - * it, so every interactor-backed operation below stays unavailable there regardless of what the - * retired per-command capability table said for it — facts are the support authority (ADR 0019), - * not a mirror of a table that never modeled interactor constructibility. */ const backOsUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', } as const); /** No apple-family closure ever gated `back` beyond device kind: every other Apple OS, tvOS * included (the interactor drives the remote's Menu button there), supports it. */ -function appleBackFact(device: DeviceInfo): RuntimeOperationFact { +function appleBackFact(device: DeviceInfo, watchHidAvailable: boolean): RuntimeOperationFact { if (device.kind !== 'simulator' && device.kind !== 'device') return backKindUnavailable; - return resolveDeviceAppleOs(device) === 'watchos' ? backOsUnavailable : available; + const os = resolveDeviceAppleOs(device); + if (os === 'watchos') { + return device.kind === 'simulator' && !device.simulatorSetPath && watchHidAvailable + ? available + : backOsUnavailable; + } + return available; } const homeKindUnavailable = Object.freeze({ @@ -41,8 +43,8 @@ const homeKindUnavailable = Object.freeze({ hint: 'home is supported on Apple simulators and physical devices.', } as const); /** Parity with the retired `supportsAppAndDeviceLifecycle` closure: unavailable on macOS, which - * drives an already-running app with no springboard home; also unavailable on watchOS, whose - * interactor cannot be constructed at all (see {@link backOsUnavailable}). */ + * drives an already-running app with no springboard home. watchOS Simulator maps home to the + * Digital Crown hardware button through the isolated CoreSimulator backend. */ const homeLifecycleUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', @@ -61,10 +63,24 @@ const appSwitcherKindUnavailable = Object.freeze({ function appleSpringboardFact( device: DeviceInfo, kindUnavailable: RuntimeOperationFact, + watchHidAvailable: boolean, ): RuntimeOperationFact { if (device.kind !== 'simulator' && device.kind !== 'device') return kindUnavailable; const os = resolveDeviceAppleOs(device); - return os === 'macos' || os === 'watchos' ? homeLifecycleUnavailable : available; + if (os === 'watchos') { + return device.kind === 'simulator' && !device.simulatorSetPath && watchHidAvailable + ? available + : homeLifecycleUnavailable; + } + return os === 'macos' ? homeLifecycleUnavailable : available; +} + +function appleAppSwitcherFact(device: DeviceInfo): RuntimeOperationFact { + if (device.kind !== 'simulator' && device.kind !== 'device') { + return appSwitcherKindUnavailable; + } + if (resolveDeviceAppleOs(device) === 'watchos') return homeLifecycleUnavailable; + return appleSpringboardFact(device, appSwitcherKindUnavailable, false); } /** @@ -163,13 +179,13 @@ function appleActionButtonFact(device: DeviceInfo): RuntimeOperationFact { * The navigation cells: back, home, app-switcher, action-button, orientation, tv-remote, and * keyboard status/dismiss/enter. */ -export function appleNavigationFacts(device: DeviceInfo) { +export function appleNavigationFacts(device: DeviceInfo, watchHidAvailable = false) { return Object.freeze({ - ...backRuntimeOperationFacts({ back: appleBackFact(device) }), + ...backRuntimeOperationFacts({ back: appleBackFact(device, watchHidAvailable) }), ...systemButtonRuntimeOperationFacts({ unsupported: systemButtonUnavailable, - home: appleSpringboardFact(device, homeKindUnavailable), - appSwitcher: appleSpringboardFact(device, appSwitcherKindUnavailable), + home: appleSpringboardFact(device, homeKindUnavailable, watchHidAvailable), + appSwitcher: appleAppSwitcherFact(device), actionButton: appleActionButtonFact(device), }), ...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }), @@ -188,12 +204,13 @@ export function createAppleNavigationOperations(params: { host: Pick; device: DeviceInfo; signal: AbortSignal; + watchHidAvailable?: boolean; }) { - const { host, device, signal } = params; + const { host, device, signal, watchHidAvailable = false } = params; return bindAdmittedLocalInteractorOperations({ device, signal, resolveInteractor: host.localInteractors.resolve, - facts: appleNavigationFacts(device), + facts: appleNavigationFacts(device, watchHidAvailable), }); } diff --git a/packages/platform-apple/src/open-policy.ts b/packages/platform-apple/src/open-policy.ts index 67f0ebe24a..21e516ba17 100644 --- a/packages/platform-apple/src/open-policy.ts +++ b/packages/platform-apple/src/open-policy.ts @@ -31,6 +31,9 @@ export function resolveRunnerPrewarmPolicy( input: OpenApplicationInput, localIosSimulator: boolean, ): RunnerPrewarmPolicy { + if (device.appleOs === 'watchos') { + return { runnerDemand: 'none', shouldPrewarmRunner: false, awaitPrewarmAfterOpen: false }; + } // Only a Simulator with the host AX bridge has a runner-free observation path, so only it // consults the plan and skips the relaunch wait; every other Apple target keeps its lifecycle. const bridge = localIosSimulator && hasSimulatorBridge(device); diff --git a/packages/platform-apple/src/runtime.fixtures.ts b/packages/platform-apple/src/runtime.fixtures.ts index 11f29d7fcb..e76bdf429d 100644 --- a/packages/platform-apple/src/runtime.fixtures.ts +++ b/packages/platform-apple/src/runtime.fixtures.ts @@ -4,8 +4,24 @@ import type { Interactor } from '@agent-device/contracts/interactor-types'; import { hostFixture } from './logs/runtime.fixtures.ts'; export function platformRuntimeHostFixture(): PlatformRuntimeHost { + const base = hostFixture().host; return { - ...hostFixture().host, + ...base, + appleTools: { + ...base.appleTools, + run: async ( + request: Parameters[0], + signal?: Parameters[1], + ) => + request.tool === 'simctl' && request.args.includes('enumerate') + ? { + stdout: + 'Default width: 396\nDefault height: 484\nPreferred UI Scale: 2\ncom.apple.CoreSimulator.HID.LegacyHID', + stderr: '', + exitCode: 0, + } + : await base.appleTools.run(request, signal), + }, appLogs: { readRecent: async () => ({ path: '/sessions/one/app.log', diff --git a/packages/platform-apple/src/runtime.test.ts b/packages/platform-apple/src/runtime.test.ts index fdee94259d..fbe250e864 100644 --- a/packages/platform-apple/src/runtime.test.ts +++ b/packages/platform-apple/src/runtime.test.ts @@ -16,6 +16,7 @@ import { } from '@agent-device/contracts/session'; import { HOVER_UNAVAILABLE_HINT } from '@agent-device/contracts/touch-runtime'; import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; +import { resolveApplePlatformName } from '@agent-device/kernel/device'; import { createApplePlatformRuntime } from './runtime.ts'; import { platformRuntimeHostFixture } from './runtime.fixtures.ts'; @@ -126,13 +127,13 @@ test.each([ expect(facts.operations.listApps.available).toBe( device.appleOs !== 'watchos' && device.iosPhysicalDeviceBackend !== 'xctest', ); - // R40/R41: touch and text ride the Apple interactor, which exists for the simulator and - // physical device kinds — every leaf in this table is one of those two, so both cells are - // available across it (parity with the retired buckets). + // The watch host backend drives touch but deliberately exposes no text-entry route. expect(facts.operations.focusPoint).toEqual({ available: true }); - expect(facts.operations.typeText).toEqual({ available: true }); + expect(facts.operations.typeText.available).toBe(device.appleOs !== 'watchos'); expect(binding.operations.focusPoint).toBeTypeOf('function'); - expect(binding.operations.typeText).toBeTypeOf('function'); + expect(binding.operations.typeText).toBeTypeOf( + device.appleOs === 'watchos' ? 'undefined' : 'function', + ); for (const operation of ['appLogInspect', 'appLogDoctor', 'appLogStart'] as const) { const fact = facts.operations[operation]; expect(fact.available).toBe(available); @@ -156,10 +157,8 @@ test.each([ hint: 'watchOS recording is not supported.', }); } - expect(facts.operations.ensureReady.available).toBe(device.appleOs !== 'watchos'); - expect(facts.operations.bootTarget.available).toBe( - device.appleOs !== 'macos' && device.appleOs !== 'watchos', - ); + expect(facts.operations.ensureReady.available).toBe(true); + expect(facts.operations.bootTarget.available).toBe(device.appleOs !== 'macos'); expect(facts.operations.bootTargetHeadless.available).toBe(false); expect(facts.operations.setViewport).toEqual({ available: false, @@ -190,14 +189,13 @@ function expectApplePerfAvailability( } /** - * watchOS is admitted by no capture cell: the Apple interactor cannot even be constructed for it, - * so the refusal is a fact rather than a throw from inside the leaf. + * watchOS Simulator screenshots use simctl directly, without an XCTest runner. */ function expectAppleCaptureAvailability( binding: DeviceBinding, 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'); } @@ -206,7 +204,7 @@ function expectAppleSnapshotAvailability( binding: DeviceBinding, device: DeviceInfo, ): void { - const available = device.appleOs !== 'watchos'; + const available = device.appleOs !== 'watchos' || device.kind === 'simulator'; expect(binding.facts.operations.captureSnapshot.available).toBe(available); expect(binding.facts.operations.captureSnapshotWithCustomActions.available).toBe( device.appleOs !== 'macos' && device.appleOs !== 'watchos' && device.kind === 'simulator', @@ -215,10 +213,12 @@ function expectAppleSnapshotAvailability( device.appleOs === 'macos', ); expect(binding.operations.captureSnapshot).toBeTypeOf(available ? 'function' : 'undefined'); - // The live point read needs a driveable Apple UI, so it follows the same watchOS sentinel the - // capture does; every other supported leaf advertises and binds it. - expect(binding.facts.operations.readTextAtPoint.available).toBe(available); - expect(binding.operations.readTextAtPoint).toBeTypeOf(available ? 'function' : 'undefined'); + // The watch bridge captures the tree but exposes no separate live point-text probe. + const pointTextAvailable = available && device.appleOs !== 'watchos'; + expect(binding.facts.operations.readTextAtPoint.available).toBe(pointTextAvailable); + expect(binding.operations.readTextAtPoint).toBeTypeOf( + pointTextAvailable ? 'function' : 'undefined', + ); } test.each(Object.entries(leaves))( @@ -237,6 +237,39 @@ test.each(Object.entries(leaves))( }, ); +test('physical watchOS refuses lifecycle and touch facts before dispatch', async () => { + const device = appleDevice({ appleOs: 'watchos', kind: 'device' }); + const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const facts = await createApplePlatformRuntime(platformRuntimeHostFixture()).inspectFacts(device); + expectAppleCaptureAvailability(binding, device); + expectAppleSnapshotAvailability(binding, device); + expectNavigationAndKeyboardFacts(binding, device); + expect(facts.operations.ensureReady).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + }); + expect(facts.operations.bootTarget).toMatchObject({ + available: false, + reason: 'unsupported-device-kind', + }); + expect(facts.operations.back).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); +}); + +test('watchOS never resolves to an XCTest iOS runner profile', () => { + expect(() => resolveApplePlatformName('mobile', 'watchos')).toThrow('no XCTest runner platform'); +}); + test('hover has no Apple interactor route on macOS, iOS, or tvOS; the touch family reports its typed denial', async () => { for (const device of [leaves.macos, leaves.ios, leaves.tvos]) { const binding = await createApplePlatformRuntime(platformRuntimeHostFixture()).bind({ @@ -355,29 +388,23 @@ function expectOperationAvailability( } /** - * watchOS has no constructible Apple interactor (XCUITest cannot drive its UI, ADR-0009), so every - * interactor-backed operation here stays unavailable there regardless of what else gates it. + * watchOS Simulator maps back/home to the Digital Crown; it has no app switcher or keyboard. */ function expectNavigationAndKeyboardFacts( binding: DeviceBinding, device: DeviceInfo, ): void { - // Every simulator/device leaf supports back except watchOS, tvOS's Menu remote press - // included — no apple-family closure ever gated it beyond device kind and interactor - // constructibility. - expectOperationAvailability(binding, 'back', device.appleOs !== 'watchos'); + expectOperationAvailability(binding, 'back', supportsBackNavigation(binding, device)); // home and app-switcher share one springboard reading (R56): both are unavailable on macOS, // which drives an already-running app with no springboard, and on watchOS. That is parity, not // convenience — the retired `supportsAppAndDeviceLifecycle` closure gated both off the same row. - const springboard = device.appleOs !== 'macos' && device.appleOs !== 'watchos'; - expectOperationAvailability(binding, 'home', springboard); - expectOperationAvailability(binding, 'appSwitcher', springboard); + expectOperationAvailability(binding, 'home', supportsHomeNavigation(binding, device)); + expectOperationAvailability(binding, 'appSwitcher', supportsAppSwitcher(device)); // orientation and keyboard dismiss/enter share mobile-input eligibility: unavailable on tvOS // (focus-only XCUIRemote navigation), macOS (an AppKit desktop host), and watchOS. - const mobileInputEligible = - device.appleOs !== 'tvos' && device.appleOs !== 'macos' && device.appleOs !== 'watchos'; + const mobileInputEligible = supportsAppleMobileInput(device); expectOperationAvailability(binding, 'setOrientation', mobileInputEligible); expectOperationAvailability(binding, 'keyboardDismiss', mobileInputEligible); expectOperationAvailability(binding, 'keyboardEnter', mobileInputEligible); @@ -386,6 +413,39 @@ function expectNavigationAndKeyboardFacts( expectTvRemoteFact(binding, device); } +function supportsBackNavigation( + binding: DeviceBinding, + device: DeviceInfo, +): boolean { + return device.appleOs !== 'watchos' || supportsWatchCrownNavigation(binding, device); +} + +function supportsHomeNavigation( + binding: DeviceBinding, + device: DeviceInfo, +): boolean { + return device.appleOs !== 'macos' && supportsBackNavigation(binding, device); +} + +function supportsWatchCrownNavigation( + binding: DeviceBinding, + device: DeviceInfo, +): boolean { + return ( + device.kind === 'simulator' && + !device.simulatorSetPath && + binding.facts.operations.tapPoint.available + ); +} + +function supportsAppSwitcher(device: DeviceInfo): boolean { + return device.appleOs !== 'macos' && device.appleOs !== 'watchos'; +} + +function supportsAppleMobileInput(device: DeviceInfo): boolean { + return device.appleOs !== 'tvos' && device.appleOs !== 'macos' && device.appleOs !== 'watchos'; +} + /** Apple never had a live keyboard status read: every eligible leaf still refuses status/get with * the retired in-handler hint; ineligible leaves fall through the outer cell instead. */ function expectKeyboardStatusFact( @@ -543,6 +603,10 @@ test('macOS readiness is a no-op while boot remains unavailable', async () => { await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); expect(ensureConnected).not.toHaveBeenCalled(); + expect(binding.facts.operations.bootTarget).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); expect(binding.operations.bootTarget).toBeUndefined(); }); @@ -626,7 +690,13 @@ const LEGACY_APPLE_LIFECYCLE_CELLS = { device: LEGACY_APPLE_DEVICE, }, watchos: { - simulator: LEGACY_UNSUPPORTED, + simulator: { + openTarget: true, + prepareAppleRunner: false, + closeTarget: true, + runtimeHints: false, + portReverse: false, + }, emulator: LEGACY_UNSUPPORTED, device: LEGACY_UNSUPPORTED, }, @@ -720,10 +790,12 @@ function expectLegacyLifecycleFactCell( } } const snapshotAvailable = - facts.device.appleOs !== 'watchos' && + (facts.device.appleOs !== 'watchos' || facts.device.kind === 'simulator') && (facts.device.kind === 'simulator' || facts.device.kind === 'device'); expect(facts.operations.captureSnapshot.available).toBe(snapshotAvailable); - expect(facts.operations.readTextAtPoint.available).toBe(snapshotAvailable); + expect(facts.operations.readTextAtPoint.available).toBe( + snapshotAvailable && facts.device.appleOs !== 'watchos', + ); } // The macOS non-app surface branch calls `captureSurface` directly instead of going through diff --git a/packages/platform-apple/src/runtime.ts b/packages/platform-apple/src/runtime.ts index 3907f76689..97071bbc7c 100644 --- a/packages/platform-apple/src/runtime.ts +++ b/packages/platform-apple/src/runtime.ts @@ -71,6 +71,8 @@ import { appleSystemFacts, createAppleSystemOperations } from './system/runtime. import { appleFoldableFacts, createAppleFoldableOperations } from './foldable/runtime.ts'; import { bindAppleFindTextRuntime, bindAppleSnapshotRuntime } from './runtime-snapshot.ts'; import { createAppleSnapshotRoute } from './snapshot-route.ts'; +import { scopeSimctlArgsForDevice } from './core/simctl.ts'; +import { hasWatchSimulatorHidDisplay } from './watch/hid.ts'; const owner = localRuntimeOwner('apple'); const available = Object.freeze({ available: true } as const); @@ -133,21 +135,11 @@ const elementTextKindUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', } as const); -const watchOpenTargetUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', - hint: 'watchOS open is not supported because XCUITest cannot drive watchOS UI.', -} as const); const watchPrepareUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', hint: 'watchOS runner preparation is not supported because XCUITest cannot drive watchOS UI.', } as const); -const watchCloseTargetUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', - hint: 'watchOS close is not supported because XCUITest cannot drive watchOS UI.', -} as const); const runtimeHintsUnavailable = Object.freeze({ available: false, reason: 'unsupported-device-kind', @@ -181,11 +173,6 @@ const snapshotCustomActionsUnavailable = Object.freeze({ reason: 'unsupported-platform-leaf', hint: 'Re-run without --actions, or target an iOS simulator.', } as const); -const screenshotWatchOsUnavailable = Object.freeze({ - available: false, - reason: 'unsupported-platform-leaf', - hint: 'screenshot is not supported on watchOS because XCUITest cannot drive watchOS UI.', -} as const); const screenshotKindUnavailable = unsupportedAppleDeviceKind( 'screenshot is supported only for Apple simulators and devices.', ); @@ -222,7 +209,9 @@ function appleApplicationLifecycleFacts(device: DeviceInfo) { } function appleOpenTargetFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return watchOpenTargetUnavailable; + if (resolveDeviceAppleOs(device) === 'watchos') { + return device.kind === 'simulator' ? available : appleOpenTargetKindUnavailable; + } return device.kind === 'simulator' || device.kind === 'device' ? available : appleOpenTargetKindUnavailable; @@ -236,7 +225,9 @@ function applePrepareAppleRunnerFact(device: DeviceInfo) { } function appleCloseTargetFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return watchCloseTargetUnavailable; + if (resolveDeviceAppleOs(device) === 'watchos') { + return device.kind === 'simulator' ? available : appleCloseTargetKindUnavailable; + } return device.kind === 'simulator' || device.kind === 'device' ? available : appleCloseTargetKindUnavailable; @@ -268,7 +259,12 @@ function appInventoryFacts(device: DeviceInfo) { return available; } -function appleFocusFact(device: DeviceInfo): RuntimeOperationFact { +function appleFocusFact(device: DeviceInfo, watchHidAvailable = false): RuntimeOperationFact { + if (device.appleOs === 'watchos') { + return device.kind === 'simulator' && !device.simulatorSetPath && watchHidAvailable + ? available + : focusKindUnavailable; + } return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable; } @@ -277,21 +273,10 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR const snapshotRoute = createAppleSnapshotRoute(host); const inspectFacts = async (device: DeviceInfo) => { const logs = await appLogs.inspectFacts(device); + const watchHidAvailable = await inspectWatchHidAvailability(host, device); const deployment = appleAppDeploymentFacts(device); - const leafRecordingFacts = appleScreenRecordingFacts(device); - const hostAvailability = leafRecordingFacts.available - ? await host.screenRecording.apple.availability(device) - : undefined; - const recordingFacts = - leafRecordingFacts.available && hostAvailability?.available === false - ? Object.freeze({ - available: false, - reason: 'unsupported-provider-mode' as const, - hint: hostAvailability.hint, - }) - : leafRecordingFacts; - const readiness = device.appleOs === 'watchos' ? unavailable : available; - const boot = isMacOs(device) || device.appleOs === 'watchos' ? unavailable : available; + const recordingFacts = await resolvedScreenRecordingFacts(host, device); + const { readiness, boot } = appleLifecycleReadinessFacts(device); const apps = appInventoryFacts(device); return Object.freeze({ device: logs.device, @@ -309,20 +294,25 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR findText: appleSnapshotFact(device), }), ...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }), - ...focusRuntimeOperationFacts({ focus: appleFocusFact(device) }), - ...appleGestureAndScrollFacts(device), - // Text entry rides the same interactor authority the point focus does, so it shares the - // exact kind cell (parity with the retired `type` bucket, `{ simulator, device }`). - ...typeTextRuntimeOperationFacts({ type: appleFocusFact(device) }), + ...focusRuntimeOperationFacts({ focus: appleFocusFact(device, watchHidAvailable) }), + ...appleGestureAndScrollFacts(device, watchHidAvailable), + // The watchOS host HID backend has no text-entry route, so this remains unavailable there. + ...typeTextRuntimeOperationFacts({ + type: + device.appleOs === 'watchos' ? unavailable : appleFocusFact(device, watchHidAvailable), + }), ...touchRuntimeOperationFacts({ unsupported: unavailable, - tap: appleFocusFact(device), - longPress: appleFocusFact(device), - fill: appleFocusFact(device), - ...(isIosFamily(device) ? { tapElementSelector: appleFocusFact(device) } : {}), + tap: appleFocusFact(device, watchHidAvailable), + longPress: appleFocusFact(device, watchHidAvailable), + fill: + device.appleOs === 'watchos' ? unavailable : appleFocusFact(device, watchHidAvailable), + ...(isIosFamily(device) && device.appleOs !== 'watchos' + ? { tapElementSelector: appleFocusFact(device, watchHidAvailable) } + : {}), }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }), - ...appleNavigationFacts(device), + ...appleNavigationFacts(device, watchHidAvailable), ...appleFoldableFacts(device), ...appleSystemFacts(device), ...audioProbeRuntimeOperationFacts({ @@ -460,6 +450,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR host, device: request.device, signal: request.scope.signal, + watchHidAvailable: facts.operations.tapPoint.available, }), ...createAppleSystemOperations({ host, @@ -516,6 +507,61 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR }); } +async function resolvedScreenRecordingFacts( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + const leafFact = appleScreenRecordingFacts(device); + if (!leafFact.available) return leafFact; + const hostFact = await host.screenRecording.apple.availability(device); + if (hostFact?.available !== false) return leafFact; + return Object.freeze({ + available: false, + reason: 'unsupported-provider-mode' as const, + hint: hostFact.hint, + }); +} + +function appleLifecycleReadinessFacts(device: DeviceInfo): Readonly<{ + readiness: RuntimeOperationFact; + boot: RuntimeOperationFact; +}> { + const physicalWatchUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-device-kind' as const, + hint: 'watchOS lifecycle is supported on Simulator targets only.', + }); + const watchNeedsSimulator = device.appleOs === 'watchos' && device.kind !== 'simulator'; + return { + readiness: watchNeedsSimulator ? physicalWatchUnavailable : available, + boot: isMacOs(device) + ? unavailable + : watchNeedsSimulator + ? physicalWatchUnavailable + : available, + }; +} + +async function inspectWatchHidAvailability( + host: PlatformRuntimeHost, + device: DeviceInfo, +): Promise { + if (device.appleOs !== 'watchos' || device.kind !== 'simulator' || device.simulatorSetPath) { + return false; + } + try { + const result = await host.appleTools.run({ + tool: 'simctl', + args: scopeSimctlArgsForDevice(device, ['io', device.id, 'enumerate']), + allowFailure: true, + timeoutMs: 1_500, + }); + return hasWatchSimulatorHidDisplay(result); + } catch { + return false; + } +} + function applePerfFacts(device: DeviceInfo): Readonly<{ frames: RuntimeOperationFact; memorySample: RuntimeOperationFact; @@ -548,7 +594,9 @@ function appleElementTextFact(device: DeviceInfo) { } function appleSnapshotFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return snapshotKindUnavailable; + if (resolveDeviceAppleOs(device) === 'watchos') { + return device.kind === 'simulator' ? available : snapshotKindUnavailable; + } return device.kind === 'simulator' || device.kind === 'device' ? available : snapshotKindUnavailable; @@ -560,7 +608,9 @@ function appleSnapshotFact(device: DeviceInfo) { * snapshot, whose desktop surfaces come from a separate host port. */ function appleScreenshotFact(device: DeviceInfo) { - if (resolveDeviceAppleOs(device) === 'watchos') return screenshotWatchOsUnavailable; + if (resolveDeviceAppleOs(device) === 'watchos') { + return device.kind === 'simulator' ? available : screenshotKindUnavailable; + } return device.kind === 'simulator' || device.kind === 'device' ? available : screenshotKindUnavailable; @@ -571,7 +621,10 @@ function appleSnapshotFacts(device: DeviceInfo) { return snapshotRuntimeOperationFacts({ capture, customActions: - capture.available && isIosFamily(device) && device.kind === 'simulator' + capture.available && + isIosFamily(device) && + device.appleOs !== 'watchos' && + device.kind === 'simulator' ? available : snapshotCustomActionsUnavailable, withoutActiveApp: isIosFamily(device) ? snapshotActiveAppRequired : capture, diff --git a/packages/platform-apple/src/simulator-inventory.test.ts b/packages/platform-apple/src/simulator-inventory.test.ts index 17ab04e6bd..25aed39dc6 100644 --- a/packages/platform-apple/src/simulator-inventory.test.ts +++ b/packages/platform-apple/src/simulator-inventory.test.ts @@ -61,9 +61,33 @@ test('simctl parser keeps available supported runtimes and their target semantic booted: false, simulatorSetPath: '/tmp/custom-set', }, + { + platform: 'apple', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + appleOs: 'watchos', + booted: true, + simulatorSetPath: '/tmp/custom-set', + }, ]); }); +test('simulator display names never override the Xcode runtime platform', () => { + const [device] = parseSimctlAppleDevices( + { + devices: { + 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [ + { name: 'Watch', udid: 'ios-watch-name', state: 'Booted', isAvailable: true }, + ], + }, + }, + undefined, + ); + assert.equal(device?.appleOs, 'ios'); +}); + test('simctl parser classifies visionOS from the runtime when the name is not descriptive', () => { const [device] = parseSimctlAppleDevices( { @@ -111,9 +135,9 @@ test('simulator inventory scopes bounded simctl and reports fresh booted observa ]); assert.deepEqual( devices.map((device) => device.id), - ['iphone-1', 'tv-1'], + ['watch-1', 'iphone-1', 'tv-1'], ); - assert.deepEqual(observed, ['iphone-1']); + assert.deepEqual(observed, ['iphone-1', 'watch-1']); }); test('simulator inventory classifies malformed native output as a command failure', async () => { diff --git a/packages/platform-apple/src/snapshot-observability.ts b/packages/platform-apple/src/snapshot-observability.ts index 117218b2d4..1cd1e89464 100644 --- a/packages/platform-apple/src/snapshot-observability.ts +++ b/packages/platform-apple/src/snapshot-observability.ts @@ -44,9 +44,13 @@ const LAUNCH_TRANSITION_WINDOW_MS: ReadonlyMap = new Map([ ['foreground-owner-changed', 1_000], ]); -/** Only iOS Simulators carry the host AX bridge; other Apple simulators observe through XCTest. */ +/** iOS and watchOS Simulators carry the host AX bridge; other Apple simulators use XCTest. */ export function hasSimulatorBridge(device: DeviceInfo): boolean { - return device.platform === 'apple' && device.appleOs === 'ios' && device.kind === 'simulator'; + return ( + device.platform === 'apple' && + (device.appleOs === 'ios' || device.appleOs === 'watchos') && + device.kind === 'simulator' + ); } export function createLaunchObservationProbe( diff --git a/packages/platform-apple/src/snapshot-route.ts b/packages/platform-apple/src/snapshot-route.ts index 0e49378f80..b0b8b5d1ad 100644 --- a/packages/platform-apple/src/snapshot-route.ts +++ b/packages/platform-apple/src/snapshot-route.ts @@ -21,6 +21,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { createSimulatorSnapshotSource, type SimulatorSnapshotSource, + type SnapshotSourceOutcome, type SnapshotSourceFailure, } from './snapshot-source-facade.ts'; import { @@ -111,57 +112,27 @@ export function createAppleSnapshotRoute( shutdown: async () => await source.close(), capture: async (device, input, signal, fallback) => { if (!isEligible(device, input)) return await captureOffRoute(device, input, fallback); - // A system surface (e.g. the web sign-in sheet) presented over the app is invisible to the - // host AX bridge — the app is still the AX primaryApp, so the bridge would serve the occluded - // app tree as if healthy (#2438). The XCTest runner can see and drive the surface, so route - // this capture to it. The runner serves the surface only while it is genuinely foreground and - // otherwise serves the app, so this is correct even while a dismissed host lingers. Anything - // but a proven `absent` takes the runner: an unproven probe must not fall through to a bridge - // capture that would answer confidently from the occluded app tree. - const surfacePresence = await systemSurfacePresent(device, signal); - if (surfacePresence === 'unknown') { - // The probe could not answer. Take the runner rather than a bridge capture that would - // answer confidently from the occluded app tree — but say so: silently losing the bridge - // fast path, with no warning and a comparable identity, would be its own defect. - return await runFallback( - device.id, - input, - fallback, - appLineage(device.id, input), - requestFor(input), - 'system-surface-probe-unavailable', - [unknownGenerationResidue()], - ); - } - if (surfacePresence !== 'absent') { - return await runSurfaceFallback(device, input, fallback, surfacePresence.host.bundleId); - } - let target: SimulatorSnapshotTarget; - try { - target = await resolveTargetForObservation(host, resolveTarget, device, input, signal); - } catch (error) { - rethrowIfResolutionCancelled(signal, error); - emitRouteDiagnostic('target-resolution-failed', device, undefined, error); - return await runFallback( - device.id, - input, - fallback, - appLineage(device.id, input), - requestFor(input), - 'target-resolution-failed', - [unknownGenerationResidue()], - ); - } + const surfaceResult = await routeSystemSurface( + systemSurfacePresent, + device, + input, + signal, + fallback, + ); + if (surfaceResult) return surfaceResult; + + const resolution = await resolveTargetOrFallback( + host, + resolveTarget, + device, + input, + signal, + fallback, + ); + if ('result' in resolution) return resolution.result; + const { target } = resolution; if (isBridgeDisabled(target)) { - emitRouteDiagnostic('circuit-disabled', device, target.generation); - return await runFallback( - device.id, - input, - fallback, - target, - requestFor(input), - 'circuit-disabled', - ); + return await captureWithDisabledBridge(device, target, input, fallback); } const request = requestFor(input); @@ -171,57 +142,231 @@ export function createAppleSnapshotRoute( signal, }); if (outcome.stage === 'failed') { - if (outcome.failure.kind === 'cancelled') { - signal.throwIfAborted(); - throw new AppError('COMMAND_FAILED', 'Simulator AX snapshot acquisition was cancelled.', { - reason: outcome.failure.code, - ...outcome.failure.details, - }); - } - const fallbackIdentity = await resolveFailureFallbackIdentity( - outcome.failure, - target, - device, - input.options!.appBundleId!, - signal, + return await captureAfterSourceFailure({ + host, resolveTarget, - ); - return await fallbackAfterFailure( - input, - fallback, - target, - fallbackIdentity, - request, - outcome.failure, disabledGenerations, - ); - } - try { - return await withDiagnosticTimer( - 'ios.snapshot-source.present', - async () => - await host.snapshot.presentIosAcquisition( - outcome as SnapshotRuntimeAcquiredResult, - input.options, - ), - { producer: 'simulator-ax-bridge' }, - ); - } catch (error) { - return await fallbackAfterFailure( + device, input, + signal, fallback, target, - { lineage: target, residue: [] }, request, - { kind: 'malformed-tree', code: 'presentation-invariant' }, - disabledGenerations, - error, - ); + failure: outcome.failure, + }); } + return await presentSourceAcquisition( + host, + disabledGenerations, + device, + input, + fallback, + target, + request, + outcome, + ); }, }); } +function watchSnapshotBridgeFailure( + device: DeviceInfo, + bridgeFailureCode: string, + details?: Readonly>, + cause?: unknown, +): AppError { + return new AppError( + 'COMMAND_FAILED', + 'watchOS Simulator accessibility bridge failed; no XCTest fallback is available.', + { + ...(details ?? {}), + reason: 'watchos-ax-bridge-failed', + deviceId: device.id, + bridgeFailureCode, + }, + cause, + ); +} + +type CaptureTargetResolution = + | Readonly<{ target: SimulatorSnapshotTarget }> + | Readonly<{ result: SnapshotResult }>; + +async function routeSystemSurface( + systemSurfacePresent: SystemSurfacePresenceProbe, + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + fallback: SnapshotFallback, +): Promise { + // The watch bridge observes its own Simulator surface; the iOS host probe is not applicable. + const presence = + device.appleOs === 'watchos' ? ('absent' as const) : await systemSurfacePresent(device, signal); + if (presence === 'unknown') { + return await runFallback( + device.id, + input, + fallback, + appLineage(device.id, input), + requestFor(input), + 'system-surface-probe-unavailable', + [unknownGenerationResidue()], + ); + } + if (presence !== 'absent') { + return await runSurfaceFallback(device, input, fallback, presence.host.bundleId); + } + return undefined; +} + +async function resolveTargetOrFallback( + host: PlatformRuntimeHost, + resolveTarget: SimulatorSnapshotTargetResolver, + device: DeviceInfo, + input: CaptureSnapshotInput, + signal: AbortSignal, + fallback: SnapshotFallback, +): Promise { + try { + return { + target: await resolveTargetForObservation(host, resolveTarget, device, input, signal), + }; + } catch (error) { + rethrowIfResolutionCancelled(signal, error); + emitRouteDiagnostic('target-resolution-failed', device, undefined, error); + if (device.appleOs === 'watchos') { + throw watchSnapshotBridgeFailure(device, 'target-resolution-failed', undefined, error); + } + return { + result: await runFallback( + device.id, + input, + fallback, + appLineage(device.id, input), + requestFor(input), + 'target-resolution-failed', + [unknownGenerationResidue()], + ), + }; + } +} + +async function captureWithDisabledBridge( + device: DeviceInfo, + target: SimulatorSnapshotTarget, + input: CaptureSnapshotInput, + fallback: SnapshotFallback, +): Promise { + emitRouteDiagnostic('circuit-disabled', device, target.generation); + if (device.appleOs === 'watchos') throw watchSnapshotBridgeFailure(device, 'circuit-disabled'); + return await runFallback( + device.id, + input, + fallback, + target, + requestFor(input), + 'circuit-disabled', + ); +} + +async function captureAfterSourceFailure( + options: Readonly<{ + host: PlatformRuntimeHost; + resolveTarget: SimulatorSnapshotTargetResolver; + disabledGenerations: Set; + device: DeviceInfo; + input: CaptureSnapshotInput; + signal: AbortSignal; + fallback: SnapshotFallback; + target: SimulatorSnapshotTarget; + request: ReturnType; + failure: SnapshotSourceFailure; + }>, +): Promise { + const { failure, signal, device, target, input, fallback, request, disabledGenerations } = + options; + if (failure.kind === 'cancelled') { + signal.throwIfAborted(); + throw new AppError('COMMAND_FAILED', 'Simulator AX snapshot acquisition was cancelled.', { + reason: failure.code, + ...failure.details, + }); + } + if (device.appleOs === 'watchos') throw watchAcquisitionFailure(device, failure); + const identity = await resolveFailureFallbackIdentity( + failure, + target, + device, + input.options!.appBundleId!, + signal, + options.resolveTarget, + ); + return await fallbackAfterFailure( + input, + fallback, + target, + identity, + request, + failure, + disabledGenerations, + ); +} + +function watchAcquisitionFailure(device: DeviceInfo, failure: SnapshotSourceFailure): AppError { + if (failure.kind !== 'preparing') { + return watchSnapshotBridgeFailure(device, failure.code, failure.details); + } + emitRouteDiagnostic(failure.code, device, undefined, undefined, failure.details); + return new AppError( + 'COMMAND_FAILED', + 'watchOS Simulator accessibility bridge preparation is still running; retry the snapshot shortly.', + { + ...(failure.details ?? {}), + reason: 'watchos-ax-bridge-preparing', + deviceId: device.id, + bridgeFailureCode: failure.code, + retryable: true, + }, + ); +} + +async function presentSourceAcquisition( + host: PlatformRuntimeHost, + disabledGenerations: Set, + device: DeviceInfo, + input: CaptureSnapshotInput, + fallback: SnapshotFallback, + target: SimulatorSnapshotTarget, + request: ReturnType, + outcome: SnapshotSourceOutcome, +): Promise { + try { + return await withDiagnosticTimer( + 'ios.snapshot-source.present', + async () => + await host.snapshot.presentIosAcquisition( + outcome as SnapshotRuntimeAcquiredResult, + input.options, + ), + { producer: 'simulator-ax-bridge' }, + ); + } catch (error) { + if (device.appleOs === 'watchos') { + throw watchSnapshotBridgeFailure(device, 'presentation-failed', undefined, error); + } + return await fallbackAfterFailure( + input, + fallback, + target, + { lineage: target, residue: [] }, + request, + { kind: 'malformed-tree', code: 'presentation-invariant' }, + disabledGenerations, + error, + ); + } +} + /** * A discovery still in flight is not a failure while no runner can answer instead. The XCTest * fallback would first wait for a runner start, and #2198 keeps observation off that wait, so the @@ -258,15 +403,29 @@ function rethrowIfResolutionCancelled(signal: AbortSignal, error: unknown): void function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean { return ( - device.platform === 'apple' && - device.appleOs === 'ios' && - device.kind === 'simulator' && - Boolean(input.options?.appBundleId) && - input.options?.customActions !== true && - input.options?.preferredBackend === undefined + isAppleSimulator(device) && + supportsSimulatorSnapshot(device) && + hasAppTarget(input) && + usesDefaultCapture(input) ); } +function isAppleSimulator(device: DeviceInfo): boolean { + return device.platform === 'apple' && device.kind === 'simulator'; +} + +function supportsSimulatorSnapshot(device: DeviceInfo): boolean { + return device.appleOs === 'ios' || device.appleOs === 'watchos'; +} + +function hasAppTarget(input: CaptureSnapshotInput): boolean { + return Boolean(input.options?.appBundleId); +} + +function usesDefaultCapture(input: CaptureSnapshotInput): boolean { + return input.options?.customActions !== true && input.options?.preferredBackend === undefined; +} + async function fallbackAfterFailure( input: CaptureSnapshotInput, fallback: SnapshotFallback, diff --git a/packages/platform-apple/src/snapshot-source/cache.test.ts b/packages/platform-apple/src/snapshot-source/cache.test.ts index ef1ce8536f..435862a2a6 100644 --- a/packages/platform-apple/src/snapshot-source/cache.test.ts +++ b/packages/platform-apple/src/snapshot-source/cache.test.ts @@ -134,6 +134,63 @@ test('the runtime clang build never uses -Werror', () => { assert.ok(!argv.includes('-Werror')); }); +test('watchOS runtime compiles the bridge against watchsimulator', () => { + const argv = buildSnapshotBridgeCompileArgv({ + architecture: 'arm64', + runtime: 'com.apple.CoreSimulator.SimRuntime.watchOS-27-0', + sourceRoot: '/source', + outputPath: '/output', + }); + assert.deepEqual(argv.slice(0, 7), [ + '--sdk', + 'watchsimulator', + 'clang', + '-arch', + 'arm64', + '-mwatchos-simulator-version-min=10.0', + '-fobjc-arc', + ]); +}); + +test('the named iOS-simulator preparation profile compiles against iphonesimulator', () => { + const argv = buildSnapshotBridgeCompileArgv({ + architecture: 'arm64', + runtime: 'ios-simulator', + sourceRoot: '/source', + outputPath: '/output', + }); + assert.deepEqual(argv.slice(0, 6), [ + '--sdk', + 'iphonesimulator', + 'clang', + '-arch', + 'arm64', + '-mios-simulator-version-min=15.0', + ]); +}); + +test('snapshot bridge runtime selection rejects non-iOS and non-watchOS simulator runtimes', () => { + for (const runtime of [ + 'com.apple.CoreSimulator.SimRuntime.tvOS-27-0', + 'com.apple.CoreSimulator.SimRuntime.xrOS-27-0', + 'not-a-watchOS-runtime', + ]) { + assert.throws( + () => + buildSnapshotBridgeCompileArgv({ + architecture: 'arm64', + runtime, + sourceRoot: '/source', + outputPath: '/output', + }), + (error: unknown) => + error instanceof SnapshotSourceError && + error.failureKind === 'unsupported' && + error.failureCode === 'unsupported-simulator-runtime', + ); + } +}); + test('concurrent snapshot bridge preparation publishes one cache entry', async () => { const root = await mkdtempForTest('agent-device-snapshot-source-concurrent-'); const sourceRoot = path.join(root, 'source'); diff --git a/packages/platform-apple/src/snapshot-source/cache.ts b/packages/platform-apple/src/snapshot-source/cache.ts index 86866748b2..774e09c6dd 100644 --- a/packages/platform-apple/src/snapshot-source/cache.ts +++ b/packages/platform-apple/src/snapshot-source/cache.ts @@ -67,6 +67,7 @@ export async function ensureSnapshotBridgeBinary( // Placeholder paths keep the key independent of the install location and build directory. compileArgv: buildSnapshotBridgeCompileArgv({ architecture: toolchain.architecture, + runtime: input.runtime, sourceRoot: '', outputPath: '', }), @@ -77,6 +78,7 @@ export async function ensureSnapshotBridgeBinary( deadline, argv: buildSnapshotBridgeCompileArgv({ architecture: toolchain.architecture, + runtime: input.runtime, sourceRoot, outputPath, }), @@ -111,17 +113,29 @@ export async function ensureSnapshotBridgeBinary( export function buildSnapshotBridgeCompileArgv( input: Readonly<{ architecture: SnapshotSourceToolchainIdentity['architecture']; + runtime?: string; sourceRoot: string; outputPath: string; }>, ): readonly string[] { + const runtime = input.runtime?.toLowerCase(); + const watch = /^com\.apple\.coresimulator\.simruntime\.watchos-/i.test(runtime ?? ''); + const ios = + /^com\.apple\.coresimulator\.simruntime\.ios-/i.test(runtime ?? '') || + /^ios\s/i.test(runtime ?? '') || + runtime === 'ios-simulator'; + if (runtime !== undefined && !watch && !ios) { + throw snapshotSourceError('unsupported', 'unsupported-simulator-runtime', { + runtime: input.runtime, + }); + } return [ '--sdk', - 'iphonesimulator', + watch ? 'watchsimulator' : 'iphonesimulator', 'clang', '-arch', input.architecture, - '-mios-simulator-version-min=15.0', + watch ? '-mwatchos-simulator-version-min=10.0' : '-mios-simulator-version-min=15.0', '-fobjc-arc', '-Wall', '-Wextra', diff --git a/packages/platform-apple/src/watch/hid.ts b/packages/platform-apple/src/watch/hid.ts new file mode 100644 index 0000000000..ba8f45a031 --- /dev/null +++ b/packages/platform-apple/src/watch/hid.ts @@ -0,0 +1,21 @@ +import type { HostCommandResult } from '@agent-device/contracts/platform-runtime-host'; + +/** Accept only a live watch Simulator display that advertises the HID transport we use. */ +export function hasWatchSimulatorHidDisplay( + result: Pick, +): boolean { + const output = `${result.stdout}\n${result.stderr}`; + const width = Number(/Default width:\s*(\d+)/.exec(output)?.[1]); + const height = Number(/Default height:\s*(\d+)/.exec(output)?.[1]); + const scale = Number(/Preferred UI Scale:\s*([\d.]+)/.exec(output)?.[1]); + return ( + result.exitCode === 0 && + output.includes('com.apple.CoreSimulator.HID.LegacyHID') && + Number.isFinite(width) && + Number.isFinite(height) && + Number.isFinite(scale) && + width > 0 && + height > 0 && + scale > 0 + ); +} diff --git a/packages/platform-apple/src/watch/interactor.test.ts b/packages/platform-apple/src/watch/interactor.test.ts new file mode 100644 index 0000000000..8c37c06361 --- /dev/null +++ b/packages/platform-apple/src/watch/interactor.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { RunnerContext } from '@agent-device/contracts/interactor-types'; + +const { runAppleToolCommand, runSimctlForDevice } = vi.hoisted(() => ({ + runAppleToolCommand: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })), + runSimctlForDevice: vi.fn(async () => ({ + stdout: + 'Default width: 396\nDefault height: 484\nPreferred UI Scale: 2\ncom.apple.CoreSimulator.HID.LegacyHID', + stderr: '', + exitCode: 0, + })), +})); + +vi.mock('../core/app-launch.ts', () => ({ + openIosApp: vi.fn(), + closeIosApp: vi.fn(), + openIosDevice: vi.fn(), +})); +vi.mock('../core/screenshot.ts', () => ({ captureSimulatorScreenshotWithRetry: vi.fn() })); +vi.mock('../core/simulator.ts', () => ({ ensureBootedSimulator: vi.fn(async () => undefined) })); +vi.mock('../core/tool-provider.ts', () => ({ runAppleToolCommand })); +vi.mock('../core/simctl.ts', () => ({ runSimctlForDevice })); +vi.mock('./watch-helper-cache.ts', () => ({ + ensureWatchHelperBinary: vi.fn(async () => ({ path: '/tmp/watch-control' })), +})); + +import { createWatchOsInteractor } from './interactor.ts'; + +function watch(overrides: Partial = {}): DeviceInfo { + return { + platform: 'apple', + appleOs: 'watchos', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + booted: true, + ...overrides, + }; +} + +const context = { signal: new AbortController().signal } as RunnerContext; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('watchOS HID interactor rejects a non-default simulator set before host dispatch', () => { + expect(() => + createWatchOsInteractor(watch({ simulatorSetPath: '/tmp/watch-set' }), context), + ).toThrow('default Simulator device set'); +}); + +test('watchOS HID interactor rejects physical devices before host dispatch', () => { + expect(() => createWatchOsInteractor(watch({ kind: 'device' }), context)).toThrow( + 'Simulator targets', + ); + expect(runSimctlForDevice).not.toHaveBeenCalled(); + expect(runAppleToolCommand).not.toHaveBeenCalled(); +}); + +test('watchOS HID interactor refuses a runtime without LegacyHID with a typed reason', async () => { + vi.mocked(runSimctlForDevice).mockResolvedValueOnce({ + stdout: 'Default width: 396\nDefault height: 484\nPreferred UI Scale: 2', + stderr: '', + exitCode: 0, + }); + const interactor = createWatchOsInteractor(watch({ id: 'watch-no-hid' }), context); + + await expect(interactor.tap(99, 121)).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + details: { + reason: 'watchos-simulator-hid-unavailable', + deviceId: 'watch-no-hid', + exitCode: 0, + }, + }); + expect(runAppleToolCommand).not.toHaveBeenCalled(); +}); + +test('watchOS HID tap converts logical display points to normalized LegacyHID coordinates', async () => { + const interactor = createWatchOsInteractor(watch({ id: 'watch-scaled' }), context); + + await interactor.tap(99, 121); + + expect(runAppleToolCommand).toHaveBeenCalledWith( + '/tmp/watch-control', + ['watch-scaled', 'tap', '0.5', '0.5'], + expect.objectContaining({ allowFailure: true }), + ); +}); + +test('watchOS Crown scroll refuses horizontal, pixel, and duration inputs instead of misreporting them', async () => { + const interactor = createWatchOsInteractor(watch(), context); + + await expect(interactor.scroll('left')).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); + await expect(interactor.scroll('right')).rejects.toMatchObject({ code: 'UNSUPPORTED_OPERATION' }); + await expect(interactor.scroll('down', { pixels: 300 })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); + await expect(interactor.scroll('up', { durationMs: 500 })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); + expect(runAppleToolCommand).not.toHaveBeenCalled(); +}); + +test('watchOS Crown scroll sends only supported vertical amount input', async () => { + const interactor = createWatchOsInteractor(watch(), context); + await interactor.scroll('down', { amount: 0.25 }); + expect(runAppleToolCommand).toHaveBeenCalledWith( + '/tmp/watch-control', + ['watch-1', 'crown-scroll', '90'], + expect.objectContaining({ allowFailure: true }), + ); +}); + +test('watchOS screenshot rejects an unsupported density request instead of ignoring it', async () => { + const interactor = createWatchOsInteractor(watch(), context); + await expect(interactor.screenshot('/tmp/watch.png', { pixelDensity: 2 })).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + }); +}); diff --git a/packages/platform-apple/src/watch/interactor.ts b/packages/platform-apple/src/watch/interactor.ts new file mode 100644 index 0000000000..8838709d21 --- /dev/null +++ b/packages/platform-apple/src/watch/interactor.ts @@ -0,0 +1,247 @@ +import { openIosApp, closeIosApp, openIosDevice } from '../core/app-launch.ts'; +import { captureSimulatorScreenshotWithRetry } from '../core/screenshot.ts'; +import { ensureBootedSimulator } from '../core/simulator.ts'; +import { runAppleToolCommand } from '../core/tool-provider.ts'; +import { runSimctlForDevice } from '../core/simctl.ts'; +import { ensureWatchHelperBinary } from './watch-helper-cache.ts'; +import { hasWatchSimulatorHidDisplay } from './hid.ts'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; +import type { ScrollExecutionOptions } from '@agent-device/contracts/scroll-command'; +import type { GesturePlan } from '@agent-device/contracts/gesture-plan-types'; +import { singlePointerPlanEndpoints } from '@agent-device/contracts/gesture-plan'; +import type { ScrollDirection } from '@agent-device/contracts/scroll-gesture'; + +const watchViewportCache = new Map< + string, + Readonly<{ x: 0; y: 0; width: number; height: number }> +>(); + +export function createWatchOsInteractor(device: DeviceInfo, context: RunnerContext): Interactor { + if (device.kind !== 'simulator') { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'watchOS interaction is supported only on Simulator targets.', + ); + } + if (device.simulatorSetPath) { + throw new AppError( + 'UNSUPPORTED_PLATFORM', + 'watchOS interaction supports only the default Simulator device set.', + ); + } + return { + open: (app, options) => + openIosApp(device, app, { + appBundleId: options?.appBundleId, + launchArgs: options?.launchArgs, + terminateRunningApp: options?.terminateRunningApp, + url: options?.url, + }), + openDevice: () => openIosDevice(device), + close: (app) => closeIosApp(device, app), + tap: async (x, y) => + await runWatchHelper(device, context, ['tap', ...(await pointArgs(device, context, x, y))]), + pressPoint: async (point, options) => { + if (options.button !== 'primary') { + throw new AppError('UNSUPPORTED_OPERATION', 'watchOS supports primary touch only.'); + } + if (options.count !== 1 || options.doubleTap || options.jitterPx !== 0) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'watchOS does not support touch series, jitter, or fused double taps.', + ); + } + if (options.holdMs > 0) { + return await runWatchSwipe( + device, + context, + point.x, + point.y, + point.x, + point.y, + options.holdMs, + ); + } + return await runWatchHelper(device, context, [ + 'tap', + ...(await pointArgs(device, context, point.x, point.y)), + ]); + }, + longPress: async (x, y, durationMs = 750) => + await runWatchSwipe(device, context, x, y, x, y, durationMs), + focus: async (x, y) => + await runWatchHelper(device, context, ['tap', ...(await pointArgs(device, context, x, y))]), + type: async () => unsupported('Text entry is not supported by the watchOS HID backend.'), + fill: async () => unsupported('Text entry is not supported by the watchOS HID backend.'), + scroll: async (direction, options) => await scrollWatch(device, context, direction, options), + screenshot: async (outPath, options) => { + if (options?.pixelDensity !== undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'watchOS Simulator screenshots do not support pixel-density normalization.', + ); + } + await ensureBootedSimulator(device); + await captureSimulatorScreenshotWithRetry(device, outPath); + }, + snapshot: async () => + unsupported('watchOS snapshots are served by the isolated Simulator accessibility bridge.'), + gestureViewport: async () => await watchViewport(device, context), + performGesture: async (plan) => await performWatchGesture(device, context, plan), + back: async () => { + await runWatchHelper(device, context, ['crown-press']); + }, + home: async () => { + await runWatchHelper(device, context, ['crown-press']); + }, + setOrientation: async () => unsupported('watchOS has a fixed display orientation.'), + setSetting: async () => unsupported('watchOS settings are not supported by this backend.'), + }; +} + +async function performWatchGesture( + device: DeviceInfo, + context: RunnerContext, + plan: GesturePlan, +): Promise> { + if (plan.topology !== 'single') { + return unsupported('watchOS supports only single-contact gestures.'); + } + const { start, end } = singlePointerPlanEndpoints(plan); + return await runWatchSwipe(device, context, start.x, start.y, end.x, end.y, plan.durationMs); +} + +async function scrollWatch( + device: DeviceInfo, + context: RunnerContext, + direction: ScrollDirection, + options?: ScrollExecutionOptions, +): Promise> { + assertCrownScrollDirection(direction); + assertCrownScrollOptions(options); + const delta = crownScrollDelta(direction, options?.amount); + return await runWatchHelper(device, context, ['crown-scroll', String(delta)]); +} + +function assertCrownScrollDirection(direction: ScrollDirection): void { + if (direction === 'up' || direction === 'down') return; + throw new AppError('UNSUPPORTED_OPERATION', 'watchOS Digital Crown scrolling is vertical only.'); +} + +function assertCrownScrollOptions(options?: ScrollExecutionOptions): void { + if (options?.pixels === undefined && options?.durationMs === undefined) return; + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'watchOS Digital Crown scrolling supports amount only; pixels and duration are not mapped.', + ); +} + +function crownScrollDelta(direction: ScrollDirection, requestedAmount?: number): number { + const amount = requestedAmount ?? 0.5; + return (direction === 'down' ? 1 : -1) * amount * 360; +} + +async function runWatchSwipe( + device: DeviceInfo, + context: RunnerContext, + x1: number, + y1: number, + x2: number, + y2: number, + durationMs: number, +): Promise> { + return await runWatchHelper(device, context, [ + 'swipe', + ...(await pointArgs(device, context, x1, y1)), + ...(await pointArgs(device, context, x2, y2)), + String(Math.max(50, Math.min(5_000, Math.round(durationMs)))), + ]); +} + +async function pointArgs( + device: DeviceInfo, + context: RunnerContext, + x: number, + y: number, +): Promise<[string, string]> { + const viewport = await watchViewport(device, context); + if ( + ![x, y].every(Number.isFinite) || + x < 0 || + y < 0 || + x > viewport.width || + y > viewport.height + ) { + throw new AppError('INVALID_ARGS', 'watchOS point is outside the selected display viewport.', { + point: { x, y }, + viewport, + }); + } + return [String(x / viewport.width), String(y / viewport.height)]; +} + +async function watchViewport( + device: DeviceInfo, + context: RunnerContext, +): Promise> { + await ensureBootedSimulator(device); + const cached = watchViewportCache.get(device.id); + if (cached) return cached; + const result = await runSimctlForDevice(device, ['io', device.id, 'enumerate'], { + signal: context.signal, + allowFailure: true, + timeoutMs: 10_000, + }); + if (!hasWatchSimulatorHidDisplay(result)) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + 'The selected Xcode/watchOS runtime does not expose a compatible Simulator HID display.', + { + reason: 'watchos-simulator-hid-unavailable', + deviceId: device.id, + exitCode: result.exitCode, + }, + ); + } + const output = `${result.stdout}\n${result.stderr}`; + const width = Number(/Default width:\s*(\d+)/.exec(output)?.[1]); + const height = Number(/Default height:\s*(\d+)/.exec(output)?.[1]); + const scale = Number(/Preferred UI Scale:\s*([\d.]+)/.exec(output)?.[1]); + const viewport = Object.freeze({ + x: 0 as const, + y: 0 as const, + width: width / scale, + height: height / scale, + }); + watchViewportCache.set(device.id, viewport); + return viewport; +} + +async function runWatchHelper( + device: DeviceInfo, + context: RunnerContext, + args: string[], +): Promise> { + 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, + timeoutMs: 10_000, + }); + if (result.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'watchOS Simulator control failed.', { + reason: 'watch-helper-command-failed', + exitCode: result.exitCode, + stderr: result.stderr.slice(0, 2_048), + }); + } + return { backend: 'watchos-coresimulator' }; +} + +function unsupported(message: string): never { + throw new AppError('UNSUPPORTED_OPERATION', message); +} diff --git a/packages/platform-apple/src/watch/snapshot-route.test.ts b/packages/platform-apple/src/watch/snapshot-route.test.ts new file mode 100644 index 0000000000..4b0bc0f505 --- /dev/null +++ b/packages/platform-apple/src/watch/snapshot-route.test.ts @@ -0,0 +1,97 @@ +import { expect, test, vi } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { platformRuntimeHostFixture } from '../runtime.fixtures.ts'; +import { simulatorAddressFor } from '../core/simctl.ts'; +import { createAppleSnapshotRoute } from '../snapshot-route.ts'; +import type { SimulatorSnapshotSource } from '../snapshot-source-facade.ts'; + +const watch = { + platform: 'apple', + appleOs: 'watchos', + id: 'watch-1', + name: 'Apple Watch', + kind: 'simulator', + target: 'mobile', + booted: true, +} as const satisfies DeviceInfo; + +const target = { + simulator: simulatorAddressFor(watch), + runtime: 'watchOS 26.0', + pid: 42, + generation: '42:launch-a', + targetId: `${watch.id}:com.example.app`, + processStartTime: 'target-start', +} as const; + +const input = { options: { appBundleId: 'com.example.app' } } as const; + +function sourceReturning( + outcome: Awaited>, +): SimulatorSnapshotSource { + return { acquire: vi.fn(async () => outcome), close: vi.fn(async () => {}) }; +} + +function signal(): AbortSignal { + return new AbortController().signal; +} + +test('watchOS bridge failures preserve their failure code instead of claiming unsupported fallback', async () => { + const source = sourceReturning({ + stage: 'failed', + failure: { + kind: 'transport-failure', + code: 'bridge-disconnected', + details: { reason: 'untrusted-detail', deviceId: 'untrusted-device' }, + }, + }); + const fallback = vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'apple-runner' as const, + nodes: [], + })); + const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), { + source, + resolveTarget: vi.fn(async () => target), + }); + + await expect(route.capture(watch, input, signal(), fallback)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'watchos-ax-bridge-failed', + bridgeFailureCode: 'bridge-disconnected', + }, + }); + expect(fallback).not.toHaveBeenCalled(); +}); + +test('watchOS bridge preparation is reported as a retriable state, not a bridge failure', async () => { + const source = sourceReturning({ + stage: 'failed', + failure: { + kind: 'preparing', + code: 'bridge-preparation-pending', + details: { reason: 'untrusted-detail', retryable: false }, + }, + }); + const fallback = vi.fn(async () => ({ + backend: 'xctest' as const, + producer: 'apple-runner' as const, + nodes: [], + })); + const route = createAppleSnapshotRoute(platformRuntimeHostFixture(), { + source, + resolveTarget: vi.fn(async () => target), + }); + + await expect(route.capture(watch, input, signal(), fallback)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'watchos-ax-bridge-preparing', + deviceId: watch.id, + bridgeFailureCode: 'bridge-preparation-pending', + retryable: true, + }, + }); + expect(fallback).not.toHaveBeenCalled(); +}); diff --git a/packages/platform-apple/src/watch/watch-helper-cache.test.ts b/packages/platform-apple/src/watch/watch-helper-cache.test.ts new file mode 100644 index 0000000000..efed5e548e --- /dev/null +++ b/packages/platform-apple/src/watch/watch-helper-cache.test.ts @@ -0,0 +1,87 @@ +import assert from 'node:assert/strict'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { beforeAll, describe, test } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; +import { mkdtempForTest } from '../__tests__/tmp-dir.ts'; +import { createNativeBuildHost, type NativeBuildHost } from '../native-build/host.ts'; +import { + buildWatchHelperCompileArgv, + ensureWatchHelperBinary, + WATCH_HELPER_BUILD_TIMEOUT_MS, +} from './watch-helper-cache.ts'; + +function fakeHost(onBuild: () => string): NativeBuildHost { + return createNativeBuildHost(async (command, args) => { + if (command === 'xcrun' && args.includes('clang')) { + await writeFile(args.at(-1)!, onBuild()); + return { stdout: '', stderr: '', exitCode: 0 }; + } + const stdout = + command === 'xcodebuild' + ? 'Xcode 27.0\nBuild version 17A1' + : command === 'sw_vers' + ? args.includes('-buildVersion') + ? '25A1' + : '26.0' + : command === 'uname' + ? 'arm64' + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }); +} + +test('watch helper cache builds once and reuses the content-addressed binary', async () => { + const root = await mkdtempForTest('agent-device-watch-helper-cache-'); + const sourceRoot = path.join(root, 'source'); + const cacheRoot = path.join(root, 'cache'); + await (await import('@agent-device/host-kit/host-file')).ensureHostDirectory(sourceRoot); + await writeFile(path.join(sourceRoot, 'WatchControl.m'), 'watch source'); + let builds = 0; + const host = fakeHost(() => `binary-${++builds}`); + try { + const first = await ensureWatchHelperBinary({ host, sourceRoot, cacheRoot }); + const second = await ensureWatchHelperBinary({ host, sourceRoot, cacheRoot }); + assert.equal(first.path, second.path); + assert.equal(builds, 1); + assert.equal(await readFile(first.path, 'utf8'), 'binary-1'); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('watch helper compile argv links only the isolated host dependencies', () => { + assert.deepEqual(buildWatchHelperCompileArgv({ sourceRoot: '/source', outputPath: '/out' }), [ + '--sdk', + 'macosx', + 'clang', + '-fobjc-arc', + '-fblocks', + '-Wall', + '-Wextra', + '-framework', + 'Foundation', + '-framework', + 'CoreGraphics', + '/source/WatchControl.m', + '-o', + '/out', + ]); +}); + +describe.skipIf(process.platform !== 'darwin')('watch helper warning gate', () => { + let compiled: { exitCode: number; stderr: string }; + beforeAll(async () => { + const sourceRoot = path.resolve(import.meta.dirname, '../../../../apple/watch-helper'); + const outputPath = path.join(await mkdtempForTest('watch-helper-werror-'), 'watch-control'); + compiled = await runCmd( + 'xcrun', + [...buildWatchHelperCompileArgv({ sourceRoot, outputPath }), '-Werror'], + { allowFailure: true, timeoutMs: WATCH_HELPER_BUILD_TIMEOUT_MS }, + ); + }, WATCH_HELPER_BUILD_TIMEOUT_MS + 30_000); + + test('the production watch helper compiles clean under -Werror', () => { + assert.equal(compiled.exitCode, 0, compiled.stderr); + }); +}); diff --git a/packages/platform-apple/src/watch/watch-helper-cache.ts b/packages/platform-apple/src/watch/watch-helper-cache.ts new file mode 100644 index 0000000000..f140dceabb --- /dev/null +++ b/packages/platform-apple/src/watch/watch-helper-cache.ts @@ -0,0 +1,98 @@ +import path from 'node:path'; +import { AppError } from '@agent-device/kernel/errors'; +import { execFailureDetails } from '@agent-device/host-kit/command'; +import { findProjectRoot } from '@agent-device/host-kit/version'; +import type { NativeBuildDeadline } from '../native-build/deadline.ts'; +import { type NativeBuildHost } from '../native-build/host.ts'; +import { execNativeBuildClang } from '../native-build/cache.ts'; +import { ensureNativeHelperBinary } from '../native-build/helper-cache.ts'; + +const SOURCE_FILENAME = 'WatchControl.m'; +const BINARY_FILENAME = 'watch-control'; +const SCHEMA_VERSION = 1 as const; +const LOCK_DESCRIPTION = 'watchOS Simulator control helper cache'; +const BUILD_HINT = + 'Select an Xcode that provides CoreSimulator and SimulatorKit using DEVELOPER_DIR.'; +export const WATCH_HELPER_BUILD_TIMEOUT_MS = 30_000; + +export async function ensureWatchHelperBinary( + input: Readonly<{ + signal?: AbortSignal; + host?: NativeBuildHost; + cacheRoot?: string; + sourceRoot?: string; + }> = {}, +): Promise> { + return await ensureNativeHelperBinary({ + ...input, + resolveSourceRoot: resolveWatchHelperSourceRoot, + sourceFilenames: [SOURCE_FILENAME], + cacheDirectory: 'watch-helper', + schemaVersion: SCHEMA_VERSION, + lockDescription: LOCK_DESCRIPTION, + binaryFilename: BINARY_FILENAME, + buildTimeoutMs: WATCH_HELPER_BUILD_TIMEOUT_MS, + compileArgv: buildWatchHelperCompileArgv, + build: ({ host, deadline, sourceRoot, outputPath }) => + compileWatchHelper(host, deadline, sourceRoot, outputPath), + wrapFailure: (error) => + watchHelperBuildFailed({ ...error.buildDetails, cause: error.buildFailureCode }, error), + }); +} + +function resolveWatchHelperSourceRoot(host: NativeBuildHost): string { + const projectRoot = findProjectRoot(); + const checkoutRoot = path.join(projectRoot, 'apple', 'watch-helper'); + if (host.exists(path.join(checkoutRoot, SOURCE_FILENAME))) return checkoutRoot; + const packagedRoot = path.join(projectRoot, 'dist', 'apple', 'watch-helper'); + if (host.exists(path.join(packagedRoot, SOURCE_FILENAME))) return packagedRoot; + throw watchHelperBuildFailed({ reason: 'watch-helper-source-missing', projectRoot }); +} + +export function buildWatchHelperCompileArgv( + input: Readonly<{ sourceRoot: string; outputPath: string }>, +): readonly string[] { + return [ + '--sdk', + 'macosx', + 'clang', + '-fobjc-arc', + '-fblocks', + '-Wall', + '-Wextra', + '-framework', + 'Foundation', + '-framework', + 'CoreGraphics', + path.join(input.sourceRoot, SOURCE_FILENAME), + '-o', + input.outputPath, + ]; +} + +async function compileWatchHelper( + host: NativeBuildHost, + deadline: NativeBuildDeadline, + sourceRoot: string, + outputPath: string, +): Promise { + const result = await execNativeBuildClang({ + host, + deadline, + argv: buildWatchHelperCompileArgv({ sourceRoot, outputPath }), + budgetMs: WATCH_HELPER_BUILD_TIMEOUT_MS, + label: 'watch helper', + }); + if (result.exitCode !== 0 || !host.exists(outputPath)) { + throw watchHelperBuildFailed(execFailureDetails(result)); + } +} + +function watchHelperBuildFailed(details: Readonly>, cause?: unknown) { + return new AppError( + 'COMMAND_FAILED', + 'Unable to build the watchOS Simulator control helper', + { hint: BUILD_HINT, ...details, reason: 'watch-helper-build-failed' }, + cause, + ); +} diff --git a/src/daemon/__tests__/gesture-admission-parity.test.ts b/src/daemon/__tests__/gesture-admission-parity.test.ts index fff9c0cadf..ba53943be3 100644 --- a/src/daemon/__tests__/gesture-admission-parity.test.ts +++ b/src/daemon/__tests__/gesture-admission-parity.test.ts @@ -122,7 +122,6 @@ test('target-authored drag is admitted only where adapters preserve every author device({ platform: 'apple', appleOs: 'tvos', kind: 'simulator', target: 'tv' }), device({ platform: 'apple', appleOs: 'macos', kind: 'device', target: 'desktop' }), device({ platform: 'apple', appleOs: 'visionos', kind: 'simulator' }), - device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }), device({ platform: 'linux', kind: 'device', target: 'desktop' }), device({ platform: 'vega', kind: 'device', target: 'tv' }), device({ platform: 'web', kind: 'device', target: 'desktop' }), @@ -135,6 +134,11 @@ test('target-authored drag is admitted only where adapters preserve every author /source hold, timed movement, and destination hold/, ); } + await expectRefused( + drag, + device({ platform: 'apple', appleOs: 'watchos', kind: 'simulator' }), + /not supported on watchos/, + ); }); test('iOS and iPadOS simulators admit multi-touch while physical devices do not', async () => { @@ -163,7 +167,8 @@ test('TV, spatial, watch, desktop, Linux, and web gesture policy stays explicit' await expectRefused(twoFingerPan, androidTv, /Android TV/, /Android TV has no touch input/); await expectRefused(twoFingerPan, tvOs, /tvOS/); await expectRefused(twoFingerPan, visionOs, /visionOS/); - await expectRefused(oneFingerPan, watchOs, /watchos/); + await expectRefused(oneFingerPan, watchOs, /not supported on watchos/); + await expectRefused(twoFingerPan, watchOs, /not supported on watchos/); await expectAdmitted(oneFingerPan, macOs); await expectRefused(twoFingerPan, macOs, /macOS/); await expectAdmitted(oneFingerPan, linux);