Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 4 additions & 8 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
39 changes: 0 additions & 39 deletions packages/contracts/src/app-switcher-runtime.test.ts

This file was deleted.

50 changes: 0 additions & 50 deletions packages/contracts/src/app-switcher-runtime.ts

This file was deleted.

37 changes: 0 additions & 37 deletions packages/contracts/src/home-runtime.test.ts

This file was deleted.

47 changes: 0 additions & 47 deletions packages/contracts/src/home-runtime.ts

This file was deleted.

18 changes: 18 additions & 0 deletions packages/contracts/src/interactor-operation-binding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: Method | undefined,
operation: string,
): NonNullable<Method> {
if (method) return method as NonNullable<Method>;
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 }>,
Expand Down
48 changes: 14 additions & 34 deletions packages/contracts/src/interactor-operation-catalog.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Interactor>,
): 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
Expand Down Expand Up @@ -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 },
{
Expand All @@ -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 },
Expand Down
9 changes: 5 additions & 4 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,11 +370,12 @@ export type Interactor = {
appSwitcher(): Promise<void>;
tvRemote(button: TvRemoteButton, durationMs?: number): Promise<void>;
/**
* 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<void>;
actionButton?(): Promise<void>;
/** Optional: only Android implements a live status read (see {@link KeyboardStatusResult}). */
keyboardStatus?(): Promise<KeyboardStatusResult>;
/** Optional: platforms with no keyboard-dismiss concept leave it undefined. */
Expand Down
25 changes: 5 additions & 20 deletions packages/contracts/src/keyboard-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { AppError } from '@agent-device/kernel/errors';
import type {
Interactor,
KeyboardDismissResult,
Expand Down Expand Up @@ -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: Method | undefined,
operation: string,
): NonNullable<Method> {
if (method) return method as NonNullable<Method>;
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
Expand Down Expand Up @@ -129,7 +110,11 @@ export function bindKeyboardAction<Key extends keyof KeyboardRuntimeOperations>(
): Pick<KeyboardRuntimeOperations, Key> {
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<unknown>).call(interactor);
};
return Object.freeze({ [key]: action }) as Pick<KeyboardRuntimeOperations, Key>;
Expand Down
Loading
Loading