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
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -292,7 +294,7 @@ extension Command {
case .pasteboardWrite:
return .deviceState

case .actionButton:
case .actionButton, .volumeUp, .volumeDown:
return .presentedSurfaceMutation

case .querySelector:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommandType> = [.appState, .pasteboardWrite]
private static let commandsNewerThanTheMergeBase: Set<CommandType> = [
.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
Expand Down Expand Up @@ -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)),
(
Expand Down
2 changes: 2 additions & 0 deletions contracts/fixtures/runner-requests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<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": "<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": "<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": "<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": "<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": "<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"}},
Expand Down
2 changes: 1 addition & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ const LEGACY_FILE_LINE_CAPS = {
'src/daemon-client/__tests__/daemon-client.test.ts': 1340,
'src/mcp/__tests__/command-tools.test.ts': 1029,
'test/integration/provider-scenarios/android-lifecycle.test.ts': 1227,
'packages/command-registry/src/registry.ts': 2179,
'packages/command-registry/src/registry.ts': 2192,
'packages/host-kit/src/internal/exec.ts': 1032,
'packages/react-native/src/metro/client-metro.ts': 1123,
'src/cli/commands/connection-runtime.ts': 1019,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions packages/command-registry/src/command-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
AppSwitcherCommandResult,
BackCommandResult,
FoldCommandResult,
HardwareButtonCommandResult,
HomeCommandResult,
OrientationCommandResult,
TvRemoteCommandResult,
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface CommandResultMap {
fill: FillCommandResponseData;
find: FindCommandResponseData;
fold: FoldCommandResult;
'hardware-button': HardwareButtonCommandResult;
home: HomeCommandResult;
hover: HoverCommandResponseData;
keyboard: KeyboardCommandResult;
Expand Down
13 changes: 13 additions & 0 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import {
focusRuntimeUse,
gestureRuntimePlanUses,
gestureViewportRuntimeUse,
hardwareButtonRuntimePlanUses,
homeRuntimeUse,
hoverRuntimeUses,
keyboardRuntimePlanUses,
Expand Down Expand Up @@ -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',
Expand Down
10 changes: 9 additions & 1 deletion packages/contracts/src/client-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 & {
Expand Down
92 changes: 92 additions & 0 deletions packages/contracts/src/device-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <status|dismiss|enter>` 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<HardwareButton, HardwareButtonName>;

const HARDWARE_BUTTON_NAME_MAP: ReadonlyMap<HardwareButtonName, HardwareButton> = 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
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/facades/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export type {
ClipboardCommandOptions,
FoldCommandOptions,
DoctorCommandOptions,
HardwareButtonCommandOptions,
HomeCommandOptions,
KeyboardCommandOptions,
OrientationCommandOptions,
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/facades/device.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 15 additions & 0 deletions packages/contracts/src/interactor-operation-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
17 changes: 17 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,23 @@ export type Interactor = {
* absent member as a successful no-op.
*/
actionButton?(): Promise<void>;
/**
* 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<void>;
/**
* Optional (parity with `volumeUp`): presses the physical volume-down key once.
*/
volumeDown?(): Promise<void>;
/**
* 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<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
15 changes: 14 additions & 1 deletion packages/contracts/src/navigation.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -89,3 +89,16 @@ export type TvRemoteCommandResult = {
durationMs?: number;
message: string;
};

/**
* `hardware-button` — `{ action: 'hardware-button', button, message: 'Pressed hardware button <button>' }`.
*
* The button is the public name (`volume-up`, `volume-down`, `power`). The result reports that the
* press was dispatched; it carries no volume level or screen state, because the command observes
* neither (#3848).
*/
export type HardwareButtonCommandResult = {
action: 'hardware-button';
button: HardwareButtonName;
message: string;
};
Loading
Loading