diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fd138dec7..2165709044 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ none, and Android, HarmonyOS, Vega, Linux, web, tvOS, macOS, and visionOS each state their own refusal. Simulators run no Shortcuts or App Intents, so what a press triggers is verifiable only on a physical iPhone (#2699). +- Changed (all): `home`, `app-switcher` and `action-button` are one system-button family in the + runtime contract. An owner without a button now refuses it with the family's hint (for example + `Android has no key event for this system button.`) rather than a per-button sentence, and a + Limrun or WebDriver session that is no longer active refuses `action-button` with that session's + own reason like every other cell; the command name still leads the `UNSUPPORTED_OPERATION` + message. - Fixed (android): `clipboard read` and `clipboard write` stop reporting success on a build whose clipboard service has no shell command. Android 16 (API 36) answers every `adb shell cmd clipboard …` with the framework default `Binder.handleShellCommand` — `No shell command implementation.` on diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 8a86eebe60..2ce9fa24f5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -64,10 +64,6 @@ "types": "./src/app-state-runtime.ts", "default": "./src/app-state-runtime.ts" }, - "./app-switcher-runtime": { - "types": "./src/app-switcher-runtime.ts", - "default": "./src/app-switcher-runtime.ts" - }, "./apple-runner-request": { "types": "./src/apple-runner-request.ts", "default": "./src/apple-runner-request.ts" @@ -220,10 +216,6 @@ "types": "./src/gesture-runtime.ts", "default": "./src/gesture-runtime.ts" }, - "./home-runtime": { - "types": "./src/home-runtime.ts", - "default": "./src/home-runtime.ts" - }, "./host-diagnostics": { "types": "./src/host-diagnostics.ts", "default": "./src/host-diagnostics.ts" @@ -456,6 +448,10 @@ "types": "./src/startup-recovery-fence.ts", "default": "./src/startup-recovery-fence.ts" }, + "./system-button-runtime": { + "types": "./src/system-button-runtime.ts", + "default": "./src/system-button-runtime.ts" + }, "./tap-keyboard-occlusion": { "types": "./src/tap-keyboard-occlusion.ts", "default": "./src/tap-keyboard-occlusion.ts" diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts deleted file mode 100644 index 8f71459ba3..0000000000 --- a/packages/contracts/src/app-switcher-runtime.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { appSwitcherRuntimeOperationFacts, bindAppSwitcher } from './app-switcher-runtime.ts'; -import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource } from './interactor-operation-binding.ts'; - -const device = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -} as const; - -test('builds the exact app-switcher operation fact catalog', () => { - const appSwitcher = { available: true } as const; - expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ - appSwitcher, - }); -}); - -test('a local binding drives the interactor with the request runner context', async () => { - const appSwitcher = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindAppSwitcher(signal, localInteractorSource({ device, resolveInteractor })); - await operations.appSwitcher({ - options: { appBundleId: 'com.example.app' }, - execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, - }); - - expect(resolveInteractor).toHaveBeenCalledWith(device, { - logPath: '/tmp/daemon.log', - requestId: 'switcher-1', - appBundleId: 'com.example.app', - signal, - }); - expect(appSwitcher).toHaveBeenCalledOnce(); -}); diff --git a/packages/contracts/src/app-switcher-runtime.ts b/packages/contracts/src/app-switcher-runtime.ts deleted file mode 100644 index 0852ab5183..0000000000 --- a/packages/contracts/src/app-switcher-runtime.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; - -/** - * Neutral intent for one app-switcher reveal: no arguments, so only runner metadata travels — - * the same shape `home` carries, because it is the same springboard surface. - */ -export type AppSwitcherInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** The reveal returns nothing; the retired leaf discarded whatever the interactor answered. */ -export type AppSwitcherRuntimeOperations = Readonly<{ - appSwitcher(input: AppSwitcherInput): Promise; -}>; - -export type AppSwitcherRuntimeOperationFacts = Readonly<{ - appSwitcher: RuntimeOperationFact; -}>; - -export function appSwitcherRuntimeOperationFacts( - input: Readonly<{ appSwitcher: RuntimeOperationFact }>, -): AppSwitcherRuntimeOperationFacts { - return Object.freeze({ appSwitcher: input.appSwitcher }); -} - -/** - * Captures one selected owner's interactor authority for the lifetime of a request binding. The - * owner is already chosen by the time a binder is called, so each entry point supplies its own - * resolution and this holds only what both share: the runner context and the reveal itself. - */ -export function bindAppSwitcher( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): AppSwitcherRuntimeOperations { - return Object.freeze({ - appSwitcher: async (input: AppSwitcherInput) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.appSwitcher(); - }, - }); -} diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts deleted file mode 100644 index 3ea5fbb4ce..0000000000 --- a/packages/contracts/src/home-runtime.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { bindHome, homeRuntimeOperationFacts } from './home-runtime.ts'; -import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource } from './interactor-operation-binding.ts'; - -const device = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -} as const; - -test('builds the exact home operation fact catalog', () => { - const home = { available: true } as const; - expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); -}); - -test('a local binding drives the interactor with no arguments', async () => { - const home = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindHome(signal, localInteractorSource({ device, resolveInteractor })); - await operations.home({ - options: { appBundleId: 'com.example.app' }, - execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, - }); - - expect(resolveInteractor).toHaveBeenCalledWith(device, { - logPath: '/tmp/daemon.log', - requestId: 'home-1', - appBundleId: 'com.example.app', - signal, - }); - expect(home).toHaveBeenCalledWith(); -}); diff --git a/packages/contracts/src/home-runtime.ts b/packages/contracts/src/home-runtime.ts deleted file mode 100644 index 006bd2b810..0000000000 --- a/packages/contracts/src/home-runtime.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; - -/** Neutral intent for one home navigation: no arguments, so only runner metadata travels. */ -export type HomeInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** Home returns nothing; the legacy leaf discarded whatever the interactor answered. */ -export type HomeRuntimeOperations = Readonly<{ - home(input: HomeInput): Promise; -}>; - -export type HomeRuntimeOperationFacts = Readonly<{ - home: RuntimeOperationFact; -}>; - -export function homeRuntimeOperationFacts( - input: Readonly<{ home: RuntimeOperationFact }>, -): HomeRuntimeOperationFacts { - return Object.freeze({ home: input.home }); -} - -/** - * Captures one selected owner's interactor authority for the lifetime of a request binding. The - * owner is already chosen by the time a binder is called, so each entry point supplies its own - * resolution and this holds only what both share: the runner context and the navigation itself. - */ -export function bindHome( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): HomeRuntimeOperations { - return Object.freeze({ - home: async (input: HomeInput) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.home(); - }, - }); -} diff --git a/packages/contracts/src/interactor-operation-binding.ts b/packages/contracts/src/interactor-operation-binding.ts index 6d0bf5d6dd..6f17dc4409 100644 --- a/packages/contracts/src/interactor-operation-binding.ts +++ b/packages/contracts/src/interactor-operation-binding.ts @@ -16,6 +16,24 @@ export type LocalInteractorOperationResolver = ( export type ProviderInteractorOperationResolver = (runner: RunnerContext) => Interactor | undefined; +/** + * Optional `Interactor` members (keyboard, hover, the hardware buttons only some owners carry) + * are left undefined by a platform with no such concept. Facts admit an operation only for owners + * whose interactor implements it, so a missing method at bind time is a runtime-contract error, + * not a normal refusal — and never a no-op that reports success. + */ +export function requireInteractorMethod( + method: Method | undefined, + operation: string, +): NonNullable { + if (method) return method as NonNullable; + throw new AppError( + 'COMMAND_FAILED', + `${operation} was admitted but its bound interactor has no implementation.`, + { reason: 'interactor-method-missing' }, + ); +} + /** Resolves the already-selected local owner's interactor for one bound operation. */ export function localInteractorSource( params: Readonly<{ device: DeviceInfo; resolveInteractor: LocalInteractorOperationResolver }>, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 932162f317..ccbf84ed81 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,10 +1,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindAlertLeg } from './alert-runtime.ts'; import { bindAppEvent } from './app-event-runtime.ts'; -import { bindAppSwitcher } from './app-switcher-runtime.ts'; import { bindBack } from './back-runtime.ts'; import { bindClipboardRead, bindClipboardWrite } from './clipboard-runtime.ts'; -import { bindHome } from './home-runtime.ts'; import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts'; import { bindOrientation } from './orientation-runtime.ts'; import { bindSetSetting } from './settings-runtime.ts'; @@ -16,35 +14,9 @@ import { type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { - NoArgumentInteractorOperations, - PlatformRuntimeOperations, -} from './platform-runtime-operations.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; - -/** - * Binds the zero-argument interactor group declared by `NoArgumentInteractorOperations`: one fact - * admits each member, one interactor call performs it, and the only thing that travels is runner - * metadata. Every other catalog row imports a binder from the module that owns that operation's - * contract; this group has no operation-specific mechanism to own, so binding it here is what keeps - * it one row rather than a module, an export subpath, and a facts helper. - */ -function bindNoArgumentInteractorOperations( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): NoArgumentInteractorOperations { - return Object.freeze({ - actionButton: async (input) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.actionButton(); - }, - }); -} +import { bindSystemButton, SYSTEM_BUTTON_LABELS } from './system-button-runtime.ts'; /** * How a facet turns one resolved interactor into its own typed operations. Every catalog member @@ -80,7 +52,11 @@ type InteractorOperationDefinition = Readonly<{ */ export const INTERACTOR_OPERATIONS = [ { operation: 'back', label: 'back', bind: bindBack }, - { operation: 'home', label: 'home', bind: bindHome }, + { + operation: 'home', + label: SYSTEM_BUTTON_LABELS.home, + bind: (signal, resolve) => bindSystemButton('home', signal, resolve), + }, { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, { operation: 'tvRemote', label: 'tv-remote', bind: bindTvRemote }, { @@ -100,11 +76,15 @@ export const INTERACTOR_OPERATIONS = [ }, { operation: 'readClipboard', label: 'clipboard read', bind: bindClipboardRead }, { operation: 'writeClipboard', label: 'clipboard write', bind: bindClipboardWrite }, - { operation: 'appSwitcher', label: 'app-switcher', bind: bindAppSwitcher }, + { + operation: 'appSwitcher', + label: SYSTEM_BUTTON_LABELS.appSwitcher, + bind: (signal, resolve) => bindSystemButton('appSwitcher', signal, resolve), + }, { operation: 'actionButton', - label: 'action-button', - bind: bindNoArgumentInteractorOperations, + label: SYSTEM_BUTTON_LABELS.actionButton, + bind: (signal, resolve) => bindSystemButton('actionButton', signal, resolve), }, { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 8c009b65f7..5d3c315475 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -370,11 +370,12 @@ export type Interactor = { appSwitcher(): Promise; tvRemote(button: TvRemoteButton, durationMs?: number): Promise; /** - * Presses the iPhone Action Button. Required rather than optional for the same reason `tvRemote` - * is: an absent member would let an advertised press resolve as a no-op that reports success. - * Owners without the button throw `UNSUPPORTED_OPERATION`. + * Optional (parity with `keyboardDismiss`): presses the iPhone/iPad Action Button, hardware only + * the Apple owner carries. An owner without the button leaves it undefined; its fact refuses the + * press before binding, and the system-button binder fails closed rather than resolving an + * absent member as a successful no-op. */ - actionButton(): Promise; + actionButton?(): Promise; /** Optional: only Android implements a live status read (see {@link KeyboardStatusResult}). */ keyboardStatus?(): Promise; /** Optional: platforms with no keyboard-dismiss concept leave it undefined. */ diff --git a/packages/contracts/src/keyboard-runtime.ts b/packages/contracts/src/keyboard-runtime.ts index 5903a63974..51f82375d4 100644 --- a/packages/contracts/src/keyboard-runtime.ts +++ b/packages/contracts/src/keyboard-runtime.ts @@ -1,4 +1,3 @@ -import { AppError } from '@agent-device/kernel/errors'; import type { Interactor, KeyboardDismissResult, @@ -75,24 +74,6 @@ export function keyboardRuntimeOperationFacts( }); } -/** - * `Interactor.keyboardStatus`/`keyboardDismiss`/`keyboardEnter` are optional (parity with - * `hover`): a platform with no keyboard concept for that action leaves it undefined. Facts admit - * an operation only for owners whose interactor implements it, so a missing method at bind time - * is a runtime-contract error, not a normal refusal. - */ -function requireKeyboardMethod( - method: Method | undefined, - operation: string, -): NonNullable { - if (method) return method as NonNullable; - throw new AppError( - 'COMMAND_FAILED', - `${operation} was admitted but its bound interactor has no implementation.`, - { reason: 'interactor-method-missing' }, - ); -} - /** * Captures one selected owner's interactor authority for the lifetime of a request binding. The * owner is already chosen by the time a binder is called, so each entry point supplies its own @@ -129,7 +110,11 @@ export function bindKeyboardAction( ): Pick { const action = async (input: KeyboardActionInput) => { const interactor = await resolveKeyboardInteractor(signal, resolveInteractor, input); - const method = requireKeyboardMethod(interactor[key], KEYBOARD_ACTION_LABELS[key]); + // The guard every optional-member binder shares. Loaded on the call rather than at module + // evaluation because this facade's eager closure is held at its merge-base size + // (`eager-closure-budgets`); a static edge would grow it by one module. + const { requireInteractorMethod } = await import('./interactor-operation-binding.ts'); + const method = requireInteractorMethod(interactor[key], KEYBOARD_ACTION_LABELS[key]); return await (method as () => Promise).call(interactor); }; return Object.freeze({ [key]: action }) as Pick; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 33cf5192a3..f3627735c1 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -10,11 +10,7 @@ import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-run import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts'; import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts'; import type { ScreenshotRuntimeOperations } from './screenshot-runtime.ts'; -import type { - SnapshotRuntimeExecution, - SnapshotRuntimeHost, - SnapshotRuntimeOperations, -} from './snapshot-runtime.ts'; +import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts'; import type { SelectorObservationRuntimeOperations } from './selector-observation-runtime.ts'; import type { ViewportRuntimeOperations } from './viewport-runtime.ts'; import type { FocusRuntimeOperations } from './focus-runtime.ts'; @@ -25,12 +21,11 @@ import type { ScrollRuntimeOperations } from './scroll-runtime.ts'; import type { TypeTextRuntimeOperations } from './type-text-runtime.ts'; import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; import type { BackRuntimeOperations } from './back-runtime.ts'; -import type { HomeRuntimeOperations } from './home-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; -import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; +import type { SystemButtonRuntimeOperations } from './system-button-runtime.ts'; import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; import type { SettingsRuntimeOperations } from './settings-runtime.ts'; import type { AlertRuntimeOperations } from './alert-runtime.ts'; @@ -62,31 +57,6 @@ import { import { runtimeUse } from './platform-runtime-use.ts'; import type { AndroidToolHost } from './platform-runtime-host.ts'; -/** - * The intent one zero-argument interactor operation carries: there are no arguments, so only runner - * metadata travels. `home` and `app-switcher` restate this shape in their own modules; the group - * below is the version of it that needs no module of its own. - */ -export type NoArgumentInteractorInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** - * The zero-argument interactor operations, bound as one group by - * `bindNoArgumentInteractorOperations`. - * - * `actionButton` presses the iPhone/iPad Action Button. Its member is required rather than optional - * even though one owner can perform it today, which is how `tvRemote` handles a control only some - * owners have: an optional member would turn a fact that advertises the press without an interactor - * that performs it into a successful-looking no-op. Owners without the hardware declare the refusal - * on the interactor, and the fact is what keeps that throw off every supported path. - */ -export type NoArgumentInteractorOperations = Readonly<{ - actionButton(input: NoArgumentInteractorInput): Promise; -}>; - export type PlatformRuntimeOperations = AppLogRuntimeOperations & AppInventoryRuntimeOperations & AppDeploymentRuntimeOperations & @@ -103,13 +73,11 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & TypeTextRuntimeOperations & ElementTextRuntimeOperations & BackRuntimeOperations & - HomeRuntimeOperations & OrientationRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & - AppSwitcherRuntimeOperations & - NoArgumentInteractorOperations & + SystemButtonRuntimeOperations & AppEventRuntimeOperations & SettingsRuntimeOperations & AlertRuntimeOperations & diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 7034639035..b5745a1e2d 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -44,13 +44,11 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { touch: { available: false, reason: 'unsupported-provider-mode' }, elementText: { available: false, reason: 'unsupported-provider-mode' }, back: { available: false, reason: 'unsupported-provider-mode' }, - home: { available: false, reason: 'unsupported-provider-mode' }, orientation: { available: false, reason: 'unsupported-provider-mode' }, tvRemote: { available: false, reason: 'unsupported-provider-mode' }, keyboard: { available: false, reason: 'unsupported-provider-mode' }, clipboard: { available: false, reason: 'unsupported-provider-mode' }, - appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, - actionButton: { available: false, reason: 'unsupported-provider-mode' }, + systemButton: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, setSetting: { available: false, reason: 'unsupported-provider-mode' }, readAlert: { available: false, reason: 'unsupported-provider-mode' }, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 4d2499d08c..aa57d5ded9 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -21,12 +21,11 @@ import { scrollRuntimeOperationFacts } from './scroll-runtime.ts'; import { typeTextRuntimeOperationFacts } from './type-text-runtime.ts'; import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts'; import { backRuntimeOperationFacts } from './back-runtime.ts'; -import { homeRuntimeOperationFacts } from './home-runtime.ts'; import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; -import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; +import { systemButtonRuntimeOperationFacts } from './system-button-runtime.ts'; import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; import { settingsRuntimeOperationFacts } from './settings-runtime.ts'; import { alertRuntimeOperationFacts } from './alert-runtime.ts'; @@ -58,13 +57,11 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ touch: RuntimeOperationUnavailability; elementText: RuntimeOperationUnavailability; back: RuntimeOperationUnavailability; - home: RuntimeOperationUnavailability; orientation: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; clipboard: RuntimeOperationUnavailability; - appSwitcher: RuntimeOperationUnavailability; - actionButton: RuntimeOperationUnavailability; + systemButton: RuntimeOperationUnavailability; triggerAppEvent: RuntimeOperationUnavailability; setSetting: RuntimeOperationUnavailability; readAlert: RuntimeOperationUnavailability; @@ -114,13 +111,11 @@ const UNAVAILABLE_CELLS = { touch: true, elementText: true, back: true, - home: true, orientation: true, tvRemote: true, keyboard: true, clipboard: true, - appSwitcher: true, - actionButton: true, + systemButton: true, triggerAppEvent: true, setSetting: true, readAlert: true, @@ -235,13 +230,11 @@ export function createUnavailablePlatformRuntimeFacts( }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), - ...homeRuntimeOperationFacts({ home: frozen.home }), ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), - ...appSwitcherRuntimeOperationFacts({ appSwitcher: frozen.appSwitcher }), - actionButton: frozen.actionButton, + ...systemButtonRuntimeOperationFacts({ unsupported: frozen.systemButton }), ...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }), ...settingsRuntimeOperationFacts({ setSetting: frozen.setSetting }), ...alertRuntimeOperationFacts({ diff --git a/packages/contracts/src/system-button-runtime.test.ts b/packages/contracts/src/system-button-runtime.test.ts new file mode 100644 index 0000000000..13468254d1 --- /dev/null +++ b/packages/contracts/src/system-button-runtime.test.ts @@ -0,0 +1,73 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; +import { + SYSTEM_BUTTONS, + bindSystemButton, + systemButtonRuntimeOperationFacts, +} from './system-button-runtime.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; +const available = { available: true } as const; +const unsupported = { available: false, reason: 'unsupported-platform-leaf' } as const; + +test('an omitted button reports the family denial, a named one its own cell', () => { + expect(systemButtonRuntimeOperationFacts({ unsupported, home: available })).toEqual({ + home: available, + appSwitcher: unsupported, + actionButton: unsupported, + }); + expect(systemButtonRuntimeOperationFacts({ unsupported })).toEqual({ + home: unsupported, + appSwitcher: unsupported, + actionButton: unsupported, + }); +}); + +test.each(SYSTEM_BUTTONS)( + 'a local %s binding presses the interactor once with no arguments', + async (button) => { + const press = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ [button]: press }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindSystemButton( + button, + signal, + localInteractorSource({ device, resolveInteractor }), + ); + await operations[button]({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: `${button}-1` }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: `${button}-1`, + appBundleId: 'com.example.app', + signal, + }); + expect(press).toHaveBeenCalledExactlyOnceWith(); + }, +); + +test('an admitted button whose interactor lacks the member fails closed instead of no-op success', async () => { + const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor); + const operations = bindSystemButton( + 'actionButton', + new AbortController().signal, + localInteractorSource({ device, resolveInteractor }), + ); + + await expect(operations.actionButton({})).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'action-button was admitted but its bound interactor has no implementation.', + details: { reason: 'interactor-method-missing' }, + }); +}); diff --git a/packages/contracts/src/system-button-runtime.ts b/packages/contracts/src/system-button-runtime.ts new file mode 100644 index 0000000000..9b17bd4dfd --- /dev/null +++ b/packages/contracts/src/system-button-runtime.ts @@ -0,0 +1,89 @@ +import { requireInteractorMethod } from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * The system buttons: one press each, no arguments, nothing returned. `home` and `appSwitcher` + * reach a springboard or recents surface; `actionButton` presses iPhone/iPad hardware. What + * varies between them is which owners carry the control, and that is the fact table's job, not + * a per-button module's: a button joins this list and its owners state a cell. + */ +export const SYSTEM_BUTTONS = ['home', 'appSwitcher', 'actionButton'] as const; + +export type SystemButton = (typeof SYSTEM_BUTTONS)[number]; + +/** How a provider fail-closed refusal names each button to the caller. */ +export const SYSTEM_BUTTON_LABELS = { + home: 'home', + appSwitcher: 'app-switcher', + actionButton: 'action-button', +} as const satisfies Record; + +/** Neutral intent for one press: no arguments, so only runner metadata travels. */ +export type SystemButtonInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** A press returns nothing; every leaf discarded whatever the interactor answered. */ +export type SystemButtonRuntimeOperations = Readonly<{ + [Button in SystemButton]: (input: SystemButtonInput) => Promise; +}>; + +export type SystemButtonRuntimeOperationFacts = Readonly<{ + [Button in SystemButton]: RuntimeOperationFact; +}>; + +/** + * What an owner declares about the system buttons. No button is one every owner carries, so + * every cell is optional and `unsupported` names the denial an omitted cell reports: an owner + * with no such control states that denial once instead of once per button, and a button added + * to {@link SYSTEM_BUTTONS} is refused by every owner that has not named it. + * + * Omission is a classified denial, never an unclassified cell and never an implied success: the + * type refuses a call that does not carry `unsupported`, so no owner can leave the family blank. + * An owner that carries one button names it — omission means "refuses", never "the same as the + * neighbour". + */ +export type SystemButtonRuntimeOperationFactsInput = Readonly< + { unsupported: RuntimeOperationUnavailability } & { + [Button in SystemButton]?: RuntimeOperationFact; + } +>; + +export function systemButtonRuntimeOperationFacts( + input: SystemButtonRuntimeOperationFactsInput, +): SystemButtonRuntimeOperationFacts { + const facts = {} as Record; + for (const button of SYSTEM_BUTTONS) facts[button] = input[button] ?? input.unsupported; + return Object.freeze(facts); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding and + * presses one button through it. `Interactor` members for hardware only some owners carry are + * optional; facts admit a button only for owners whose interactor implements it, so a missing + * method at press time is a runtime-contract error, not a normal refusal. + */ +export function bindSystemButton