From 28d22579f17df6d8c94d4edc0854924ea811ffee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 21 Oct 2026 00:05:10 +0200 Subject: [PATCH 1/2] feat(hardware-button): press phone volume and power keys once (#3848) --- .../RunnerTests+CommandExecution.swift | 12 ++ .../RunnerTests+Interaction.swift | 25 +++++ .../RunnerTests+Models.swift | 4 +- .../UnitTests/RunnerTests+ModelsTests.swift | 12 +- contracts/fixtures/runner-requests.json | 2 + .../src/__tests__/command-result.test.ts | 1 + .../command-registry/src/command-result.ts | 2 + packages/command-registry/src/registry.ts | 13 +++ packages/contracts/src/client-system.ts | 10 +- packages/contracts/src/device-rotation.ts | 92 +++++++++++++++ packages/contracts/src/facades/client.ts | 1 + packages/contracts/src/facades/device.ts | 5 + ...ctor-operation-catalog-conformance.test.ts | 3 + .../src/interactor-operation-catalog.ts | 15 +++ packages/contracts/src/interactor-types.ts | 17 +++ packages/contracts/src/navigation.ts | 15 ++- .../src/platform-runtime-operations.ts | 10 ++ .../contracts/src/runtime-operation-names.ts | 3 + .../src/system-button-runtime.test.ts | 75 +++++++++++++ .../contracts/src/system-button-runtime.ts | 78 ++++++++++++- .../support/runtime-binding-conformance.ts | 35 ++++++ .../src/core/runtime-operation-effects.ts | 3 + .../__tests__/system-button-runtime.test.ts | 72 ++++++++++++ .../execution/generic-runtime-execution.ts | 9 +- .../src/execution/system-button-runtime.ts | 105 +++++++++++++++++- .../src/__tests__/input-actions.test.ts | 17 +++ .../platform-android/src/input-actions.ts | 25 +++++ packages/platform-android/src/mechanics.ts | 1 + packages/platform-android/src/runtime.test.ts | 48 ++++++++ packages/platform-android/src/runtime.ts | 19 +++- .../__tests__/index-runner-requests.test.ts | 2 + .../interactor-runner-provider.test.ts | 2 + packages/platform-apple/src/interactor.ts | 16 +++ .../platform-apple/src/navigation/runtime.ts | 26 +++++ packages/platform-apple/src/runner-demand.ts | 7 +- .../__tests__/runner-command-traits.test.ts | 2 + .../src/runner/runner-command-traits.ts | 2 + .../src/runner/runner-contract.ts | 2 + packages/platform-apple/src/runtime.test.ts | 51 +++++++++ packages/platform-linux/src/runtime.test.ts | 6 +- packages/platform-web/src/runtime.test.ts | 6 +- .../src/interaction-operations.test.ts | 30 +++++ .../src/interaction-operations.ts | 17 ++- .../provider-webdriver/src/capabilities.ts | 41 ++++++- .../src/platform-runtime.test.ts | 28 +++++ .../src/platform-runtime.ts | 25 ++++- ...bdriver-interactor-hardware-button.test.ts | 76 +++++++++++++ .../src/webdriver-interactor.ts | 41 +++++++ .../src/session-event-action-presentation.ts | 10 ++ .../runner-read-only-registry-parity.test.ts | 2 + src/agent-device-client.ts | 2 + src/client/client-types.ts | 15 +-- src/commands/replay/replay-script.fixtures.ts | 1 + src/commands/system/index.test.ts | 20 ++++ src/commands/system/index.ts | 68 ++++++++++++ src/commands/system/output.ts | 1 + src/core/interactors/android.ts | 4 + .../command-coverage/declarations.ts | 33 ++++++ test/integration/command-coverage/evidence.ts | 10 ++ .../apple-platform-output-guard.test.ts | 1 + .../provider-device-runtime.fixtures.ts | 12 ++ .../provider-ios-runner-transport.test.ts | 92 +++++++++++++-- website/docs/docs/client-api.md | 6 + website/docs/docs/commands.md | 6 + 64 files changed, 1339 insertions(+), 53 deletions(-) create mode 100644 packages/provider-webdriver/src/webdriver-interactor-hardware-button.test.ts diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index d8fd33a985..f8205b0305 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -539,6 +539,18 @@ extension RunnerTests { ) } return Response(ok: true, data: DataPayload(message: "actionButton")) + case .volumeUp, .volumeDown: + guard pressVolumeButton(command.command) else { + return Response( + ok: false, + error: ErrorPayload( + code: "UNSUPPORTED_OPERATION", + message: "\(command.command) requires a physical iPhone or iPad; XCUIDevice exposes no volume press on a simulator", + hint: "hardware-button volume-up/volume-down is supported on physical iOS devices through the runner or Appium; iOS Simulators have no pressable volume key." + ) + ) + } + return Response(ok: true, data: DataPayload(message: command.command.rawValue)) case .keyboardDismiss: let result = dismissKeyboard(app: activeApp) if result.wasVisible && !result.dismissed { diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift index c9e410f220..1998fcd036 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Interaction.swift @@ -141,6 +141,31 @@ extension RunnerTests { #endif } + /// Presses a physical volume key once, returning false when this target cannot express the press. + /// + /// `XCUIDevice.press(_:)` declares `.volumeUp` and `.volumeDown` unavailable to simulator + /// builds (`XCUI_SIMULATOR_UNAVAILABLE` in the XCUITest header), so the press compiles only for + /// a physical-device build; a simulator target refuses rather than pressing a control that + /// does not exist there. Every iPhone and iPad carries a volume key, so no per-model probe is + /// needed the way the Action Button needs `hasHardwareButton(.action)`. + @discardableResult + func pressVolumeButton(_ button: CommandType) -> Bool { +#if os(iOS) && !targetEnvironment(simulator) + switch button { + case .volumeUp: + XCUIDevice.shared.press(.volumeUp) + return true + case .volumeDown: + XCUIDevice.shared.press(.volumeDown) + return true + default: + return false + } +#else + return false +#endif + } + func findElement(app: XCUIApplication, text: String) -> XCUIElement? { let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR identifier CONTAINS[c] %@ OR value CONTAINS[c] %@", text, text, text) let element = app.descendants(matching: .any).matching(predicate).firstMatch diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift index b2b4252d4f..bb3c41658d 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Models.swift @@ -23,6 +23,8 @@ enum CommandType: String, Codable, CaseIterable { case rotate case appSwitcher case actionButton + case volumeUp + case volumeDown case keyboardDismiss case keyboardReturn case alert @@ -292,7 +294,7 @@ extension Command { case .pasteboardWrite: return .deviceState - case .actionButton: + case .actionButton, .volumeUp, .volumeDown: return .presentedSurfaceMutation case .querySelector: diff --git a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift index 5104e61801..59cf06ba41 100644 --- a/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift +++ b/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/UnitTests/RunnerTests+ModelsTests.swift @@ -208,7 +208,9 @@ extension RunnerTests { /// Commands that did not exist at the merge-base, so no classification of its is compared with /// theirs. `appState` arrived with #2929, `pasteboardWrite` with the runner-written simulator /// clipboard. - private static let commandsNewerThanTheMergeBase: Set = [.appState, .pasteboardWrite] + private static let commandsNewerThanTheMergeBase: Set = [ + .appState, .pasteboardWrite, .volumeUp, .volumeDown, + ] /// The commands production never runs through the prepared path's body: `executeOnMain` answers /// these before `executeOnMainPrepared` runs, and `executeDispatched` answers `snapshot` earlier @@ -278,6 +280,14 @@ extension RunnerTests { .actionButton, expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true) ), + ( + .volumeUp, + expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true) + ), + ( + .volumeDown, + expectation(interaction: false, retry: false, launch: .presentedSurface, converts: true, retains: true) + ), (.keyboardDismiss, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)), (.keyboardReturn, expectation(interaction: true, retry: false, launch: .mayLaunch, converts: true, retains: true)), ( diff --git a/contracts/fixtures/runner-requests.json b/contracts/fixtures/runner-requests.json index 1beed4fbd0..f8c44628d5 100644 --- a/contracts/fixtures/runner-requests.json +++ b/contracts/fixtures/runner-requests.json @@ -45,6 +45,8 @@ {"name": "ios-simulator.interactor-set-orientation.rotate", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "rotate", "orientation": "landscape-left", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-snapshot.every-option", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "preferredBackend": "tree", "customActions": true, "depth": 3, "scope": "Go", "raw": true, "commandId": ""}}, {"name": "ios-simulator.interactor-snapshot.observe-only", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "observeOnly": true, "commandId": ""}}, + {"name": "ios-simulator.interactor-volume-down.press", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "volumeDown", "appBundleId": "com.example.app", "commandId": ""}}, + {"name": "ios-simulator.interactor-volume-up.press", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "volumeUp", "appBundleId": "com.example.app", "commandId": ""}}, {"name": "ios-simulator.interactor-write-clipboard.text", "producer": "packages/platform-apple/src/__tests__/index-runner-requests.test.ts", "request": {"command": "pasteboardWrite", "text": "one-time code 246810", "commandId": ""}}, {"name": "ios-simulator.recording-clock-anchor.snapshot", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "snapshot", "appBundleId": "com.example.app", "interactiveOnly": true, "depth": 1}}, {"name": "ios-simulator.recording-start.default", "producer": "src/__tests__/screen-recording-runner-requests.test.ts", "request": {"command": "recordStart", "outPath": "capture.mp4", "appBundleId": "com.example.app"}}, diff --git a/packages/command-registry/src/__tests__/command-result.test.ts b/packages/command-registry/src/__tests__/command-result.test.ts index 2884e64567..6d97d6887f 100644 --- a/packages/command-registry/src/__tests__/command-result.test.ts +++ b/packages/command-registry/src/__tests__/command-result.test.ts @@ -130,6 +130,7 @@ test('CommandResultMap is seeded only from already-existing contract result type | 'orientation' | 'app-switcher' | 'action-button' + | 'hardware-button' | 'fold' | 'clipboard' | 'appstate' diff --git a/packages/command-registry/src/command-result.ts b/packages/command-registry/src/command-result.ts index 56e2805d3c..6e0a9aeb1b 100644 --- a/packages/command-registry/src/command-result.ts +++ b/packages/command-registry/src/command-result.ts @@ -15,6 +15,7 @@ import type { AppSwitcherCommandResult, BackCommandResult, FoldCommandResult, + HardwareButtonCommandResult, HomeCommandResult, OrientationCommandResult, TvRemoteCommandResult, @@ -72,6 +73,7 @@ export interface CommandResultMap { fill: FillCommandResponseData; find: FindCommandResponseData; fold: FoldCommandResult; + 'hardware-button': HardwareButtonCommandResult; home: HomeCommandResult; hover: HoverCommandResponseData; keyboard: KeyboardCommandResult; diff --git a/packages/command-registry/src/registry.ts b/packages/command-registry/src/registry.ts index 79eecf1335..f8a8e4eda5 100644 --- a/packages/command-registry/src/registry.ts +++ b/packages/command-registry/src/registry.ts @@ -58,6 +58,7 @@ import { focusRuntimeUse, gestureRuntimePlanUses, gestureViewportRuntimeUse, + hardwareButtonRuntimePlanUses, homeRuntimeUse, hoverRuntimeUses, keyboardRuntimePlanUses, @@ -1640,6 +1641,18 @@ export const RAW_COMMAND_DESCRIPTORS = [ ...GENERIC_MUTATING_COMMAND_TRAITS, platformExecution: { kind: 'device-runtime', uses: [actionButtonRuntimeUse] }, }, + { + name: 'hardware-button', + ...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}), + catalog: { group: 'public', key: 'hardwareButton' }, + frameworkTier: 'extended', + // The first positional selects the cell (volume-up, volume-down, power), the way `orientation` + // selects between setOrientation and unlockOrientation; admission binds exactly that one fact. + // Like action-button, no post-action observation is declared: the press goes to the system, + // not the session app, and settling would re-capture the app the press leaves alone (#3848). + ...GENERIC_MUTATING_COMMAND_TRAITS, + platformExecution: { kind: 'device-runtime', uses: hardwareButtonRuntimePlanUses }, + }, { name: 'install-from-source', deviceClaimPolicy: 'none', diff --git a/packages/contracts/src/client-system.ts b/packages/contracts/src/client-system.ts index 6b2e209c06..0de68503d9 100644 --- a/packages/contracts/src/client-system.ts +++ b/packages/contracts/src/client-system.ts @@ -5,7 +5,11 @@ import type { BackMode } from './back-mode.ts'; import type { SelectorSnapshotCommandOptions } from './client-capture.ts'; import type { DeviceCommandBaseOptions } from './client-connection.ts'; import type { SettleCommandOptions } from './client-gesture.ts'; -import type { OrientationRequest, SetFoldPoseInput } from './device-rotation.ts'; +import type { + HardwareButtonName, + OrientationRequest, + SetFoldPoseInput, +} from './device-rotation.ts'; import type { TelephonyEventInput } from './telephony-input.ts'; import type { TvRemoteButton } from './tv-remote.ts'; @@ -103,6 +107,10 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & { durationMs?: number; }; +export type HardwareButtonCommandOptions = DeviceCommandBaseOptions & { + button: HardwareButtonName; +}; + export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput; export type KeyboardCommandOptions = DeviceCommandBaseOptions & { diff --git a/packages/contracts/src/device-rotation.ts b/packages/contracts/src/device-rotation.ts index 6107699568..629e21e13c 100644 --- a/packages/contracts/src/device-rotation.ts +++ b/packages/contracts/src/device-rotation.ts @@ -82,6 +82,98 @@ export function parseOrientationRequest(input: string | undefined): OrientationR return input?.trim().toLowerCase() === 'auto' ? 'auto' : parseDeviceRotation(input); } +// ---- Hardware button --------------------------------------------------------------------------- +// The `hardware-button` command's positional vocabulary lives beside the rotation vocabulary +// because every entry that parses one command positional already evaluates this module; a module +// of its own would join the eager closure of the daemon, replay-port, session-journal and CLI +// entries that name a volume key (the eager-closure budgets in +// scripts/__tests__/eager-closure-budgets.ts). The runtime-press vocabulary — the button family, +// labels and binding — stays in system-button-runtime.ts. This module must not import from it, +// type-only included: system-button-runtime.ts reaches this module through the snapshot and +// interactor type graphs, so an edge back would close a contracts type cycle (layering R9/R10). +// The label owner pins the drift with a type-level check instead — see system-button-runtime.ts. + +/** + * The phone hardware keys the `hardware-button` command selects between by positional, the way + * `keyboard ` selects a keyboard-family cell. `home`, `appSwitcher` and + * `actionButton` keep their own public commands, so each button has exactly one public route. + * Each name is a cell of {@link SYSTEM_BUTTONS}; system-button-runtime.ts checks that at + * compile time. The cells stay module-internal: consumers select a button by its public name. + */ +const HARDWARE_BUTTONS = ['volumeUp', 'volumeDown', 'power'] as const; + +export type HardwareButton = (typeof HARDWARE_BUTTONS)[number]; + +/** + * The public positional names of the hardware keys: `volume-up`, `volume-down`, `power`. + * The literals are the same ones SYSTEM_BUTTON_LABELS carries for these buttons; the label + * owner's type-level check and the contracts tests refuse drift between the two lists. + */ +export type HardwareButtonName = 'volume-up' | 'volume-down' | 'power'; + +/** + * The accepted positional values, in command order. `satisfies` turns an out-of-vocabulary + * typo into a build failure; the contracts test pins the list is exhaustive. + */ +export const HARDWARE_BUTTON_NAMES = [ + 'volume-up', + 'volume-down', + 'power', +] as const satisfies readonly HardwareButtonName[]; + +/** The CLI synopsis tail naming the accepted positional names. */ +export const HARDWARE_BUTTON_USAGE = `<${HARDWARE_BUTTON_NAMES.join('|')}>`; + +/** + * The public positional name each parsed button answers with, and the same literals + * SYSTEM_BUTTON_LABELS carries. Readers in eager-closure entries take the label from here + * rather than value-importing system-button-runtime.ts (#3848). The parse direction is + * derived by inverting this table rather than restating it. + */ +export const HARDWARE_BUTTON_NAME_LABELS = { + volumeUp: 'volume-up', + volumeDown: 'volume-down', + power: 'power', +} as const satisfies Record; + +const HARDWARE_BUTTON_NAME_MAP: ReadonlyMap = new Map( + (Object.entries(HARDWARE_BUTTON_NAME_LABELS) as [HardwareButton, HardwareButtonName][]).map( + ([button, name]) => [name, button], + ), +); + +/** + * Parses the `hardware-button` positional. Exact names only — no aliases — and an unknown name + * refuses with `INVALID_ARGS` before any device dispatch. + */ +/** Public entry for parsing one positional is {@link parseHardwareButtonPositionals}. */ +function parseHardwareButton(value: string | undefined): HardwareButton { + const normalized = value?.toLowerCase(); + const button = + normalized === undefined + ? undefined + : HARDWARE_BUTTON_NAME_MAP.get(normalized as HardwareButtonName); + if (button) return button; + throw new AppError( + 'INVALID_ARGS', + `hardware-button button must be one of: ${HARDWARE_BUTTON_NAMES.join(', ')}.`, + ); +} + +/** + * Owns the whole `hardware-button` positional contract: exactly one name, then the parse. The CLI + * reader and the daemon resolver both call this, so one typed answer serves both seams. + */ +export function parseHardwareButtonPositionals(positionals: readonly string[]): HardwareButton { + if (positionals.length !== 1) { + throw new AppError( + 'INVALID_ARGS', + `hardware-button requires exactly one button: ${HARDWARE_BUTTON_NAMES.join(', ')}.`, + ); + } + return parseHardwareButton(positionals[0]); +} + // ---- Hinge pose ------------------------------------------------------------------------------ // Lives beside the rotation vocabulary because every entry that reads one device pose already // evaluates this module; a module of its own would join the eager closure of entries that can diff --git a/packages/contracts/src/facades/client.ts b/packages/contracts/src/facades/client.ts index f10e131d96..9325a37ae9 100644 --- a/packages/contracts/src/facades/client.ts +++ b/packages/contracts/src/facades/client.ts @@ -123,6 +123,7 @@ export type { ClipboardCommandOptions, FoldCommandOptions, DoctorCommandOptions, + HardwareButtonCommandOptions, HomeCommandOptions, KeyboardCommandOptions, OrientationCommandOptions, diff --git a/packages/contracts/src/facades/device.ts b/packages/contracts/src/facades/device.ts index 051aee5e1e..5373fde7fd 100644 --- a/packages/contracts/src/facades/device.ts +++ b/packages/contracts/src/facades/device.ts @@ -46,7 +46,12 @@ export { ORIENTATION_REQUESTS, parseOrientationRequest, parseFoldPose, + HARDWARE_BUTTON_NAMES, + HARDWARE_BUTTON_NAME_LABELS, + HARDWARE_BUTTON_USAGE, + parseHardwareButtonPositionals, } from '../device-rotation.ts'; +export type { HardwareButton, HardwareButtonName } from '../device-rotation.ts'; export { TELEPHONY_ACTIONS, TELEPHONY_CALL_ACTIONS, diff --git a/packages/contracts/src/interactor-operation-catalog-conformance.test.ts b/packages/contracts/src/interactor-operation-catalog-conformance.test.ts index af902a059e..14d38343af 100644 --- a/packages/contracts/src/interactor-operation-catalog-conformance.test.ts +++ b/packages/contracts/src/interactor-operation-catalog-conformance.test.ts @@ -36,6 +36,9 @@ const EXPECTATIONS: { writeClipboard: { method: 'writeClipboard', input: { text: '' } }, appSwitcher: { method: 'appSwitcher', input: {} }, actionButton: { method: 'actionButton', input: {} }, + volumeUp: { method: 'volumeUp', input: {} }, + volumeDown: { method: 'volumeDown', input: {} }, + power: { method: 'power', input: {} }, triggerAppEvent: { method: 'open', input: { eventUrl: 'myapp://x' } }, setSetting: { method: 'setSetting', diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index c07319310b..f77a73f229 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -99,6 +99,21 @@ export const INTERACTOR_OPERATIONS = [ label: SYSTEM_BUTTON_LABELS.actionButton, bind: (signal, resolve) => bindSystemButton('actionButton', signal, resolve), }, + { + operation: 'volumeUp', + label: SYSTEM_BUTTON_LABELS.volumeUp, + bind: (signal, resolve) => bindSystemButton('volumeUp', signal, resolve), + }, + { + operation: 'volumeDown', + label: SYSTEM_BUTTON_LABELS.volumeDown, + bind: (signal, resolve) => bindSystemButton('volumeDown', signal, resolve), + }, + { + operation: 'power', + label: SYSTEM_BUTTON_LABELS.power, + bind: (signal, resolve) => bindSystemButton('power', signal, resolve), + }, { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, { operation: 'readSetting', label: 'settings read', bind: bindReadSetting }, diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index c0a30db5b3..4167053f7e 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -432,6 +432,23 @@ export type Interactor = { * absent member as a successful no-op. */ actionButton?(): Promise; + /** + * Optional (parity with `home`): presses the physical volume-up key once, a control phone + * owners with a volume button carry. An owner without it 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 silent no-op. + */ + volumeUp?(): Promise; + /** + * Optional (parity with `volumeUp`): presses the physical volume-down key once. + */ + volumeDown?(): Promise; + /** + * Optional (parity with `volumeUp`): presses the physical power key once, which toggles the + * screen on Android. Apple exposes no public power/lock button press, so no Apple leaf carries + * it; an owner without it leaves it undefined and its fact refuses before binding. + */ + power?(): 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/navigation.ts b/packages/contracts/src/navigation.ts index fca6600b99..8c2e998c51 100644 --- a/packages/contracts/src/navigation.ts +++ b/packages/contracts/src/navigation.ts @@ -1,5 +1,5 @@ import type { BackMode } from './back-mode.ts'; -import type { DeviceRotation, FoldPose } from './device-rotation.ts'; +import type { DeviceRotation, FoldPose, HardwareButtonName } from './device-rotation.ts'; import type { FoldScreenReport } from './fold-runtime.ts'; import type { SettleObservation } from './interaction.ts'; import type { TvRemoteButton } from './tv-remote.ts'; @@ -89,3 +89,16 @@ export type TvRemoteCommandResult = { durationMs?: number; message: string; }; + +/** + * `hardware-button` — `{ action: 'hardware-button', button, message: 'Pressed hardware button