Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cc650f0
feat(hardware-key): press volume, power, and media keys on Android
mikolajadamowicz Oct 15, 2026
7c31017
chore(gates): register hardware-key in the coverage and conformance t…
mikolajadamowicz Oct 15, 2026
c8b336b
refactor(daemon): route hardware-key through the system-button arm
mikolajadamowicz Oct 19, 2026
deb604d
feat(hardware-key): add Android TV channel, guide, and color keys
mikolajadamowicz Oct 19, 2026
4253278
feat(hardware-key): accept a raw Android keycode as android-keycode:<n>
mikolajadamowicz Oct 19, 2026
4a91cdc
fix(hardware-key): derive help from the key table and trim its descri…
mikolajadamowicz Oct 19, 2026
323668f
refactor(hardware-key): model hardware-key as a standalone keyed oper…
mikolajadamowicz Oct 20, 2026
25e51da
feat(hardware-key): press keys on Appium Android through mobile: pres…
mikolajadamowicz Oct 20, 2026
64f2f36
chore(lint): re-pin the registry line cap for the hardware-key descri…
mikolajadamowicz Oct 20, 2026
bfba81d
test(hardware-key): add an Android provider-scenario for named, raw, …
mikolajadamowicz Oct 20, 2026
e267f85
style(hardware-key): apply oxfmt to the hardware-key sources
mikolajadamowicz Oct 20, 2026
0c74983
chore(layering): add the hardware-key subpaths to the contracts expor…
mikolajadamowicz Oct 20, 2026
6fcd962
refactor(contracts): default the hardware-key denial through its fact…
mikolajadamowicz Oct 20, 2026
25710a2
test(webdriver): move the hardware-key interactor tests into their ow…
mikolajadamowicz Oct 20, 2026
3c47df8
fix(hardware-key): fold the help pointer into the Android TV line to …
mikolajadamowicz Oct 20, 2026
a6b5caf
refactor(daemon): read the generic runtime positionals once
mikolajadamowicz Oct 20, 2026
b51bd1b
chore(client): drop the six stale unused-type suppressions
mikolajadamowicz Oct 19, 2026
ff08e4b
refactor(hardware-key): keep the key vocabulary and binder in the tv-…
mikolajadamowicz Oct 20, 2026
5ab777a
fix(daemon): defer the hardware-key runtime to keep the daemon eager …
mikolajadamowicz Oct 20, 2026
32e4b08
fix(hardware-key): name Appium Android beside ADB in help and refusal…
mikolajadamowicz Oct 20, 2026
72d7e99
refactor(hardware-key): drop the system-button family leftovers
mikolajadamowicz Oct 20, 2026
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
4 changes: 3 additions & 1 deletion oxlint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ 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,
// A command must add its descriptor to RAW_COMMAND_DESCRIPTORS, so this cap is re-pinned per
// added command; the statement-neutral split (ADR 0027) is the path to shrinking it.
'packages/command-registry/src/registry.ts': 2191,
'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 @@ -13,6 +13,7 @@ import type {
import type {
AppSwitcherCommandResult,
BackCommandResult,
HardwareKeyCommandResult,
HomeCommandResult,
OrientationCommandResult,
TvRemoteCommandResult,
Expand Down Expand Up @@ -62,6 +63,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
const keyboard: Equal<CommandResult<'keyboard'>, KeyboardCommandResult> = true;
const tvRemote: Equal<CommandResult<'tv-remote'>, TvRemoteCommandResult> = true;
const telephony: Equal<CommandResult<'telephony'>, TelephonyCommandResult> = true;
const hardwareKey: Equal<CommandResult<'hardware-key'>, HardwareKeyCommandResult> = true;
const wait: Equal<CommandResult<'wait'>, WaitCommandResult> = true;
const prepare: Equal<CommandResult<'prepare'>, PrepareCommandResult> = true;
const push: Equal<CommandResult<'push'>, PushCommandResult> = true;
Expand Down Expand Up @@ -93,6 +95,7 @@ test('seeded CommandResult entries resolve to their existing contract result typ
keyboard,
tvRemote,
telephony,
hardwareKey,
wait,
prepare,
push,
Expand Down Expand Up @@ -136,6 +139,7 @@ test('CommandResultMap is seeded only from already-existing contract result type
| 'keyboard'
| 'tv-remote'
| 'telephony'
| 'hardware-key'
| 'wait'
| 'scroll'
| 'prepare'
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,
HardwareKeyCommandResult,
HomeCommandResult,
OrientationCommandResult,
TvRemoteCommandResult,
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface CommandResultMap {
fill: FillCommandResponseData;
find: FindCommandResponseData;
fold: FoldCommandResult;
'hardware-key': HardwareKeyCommandResult;
home: HomeCommandResult;
hover: HoverCommandResponseData;
keyboard: KeyboardCommandResult;
Expand Down
12 changes: 12 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,
hardwareKeyRuntimeUse,
homeRuntimeUse,
hoverRuntimeUses,
keyboardRuntimePlanUses,
Expand Down Expand Up @@ -1494,6 +1495,17 @@ export const RAW_COMMAND_DESCRIPTORS = [
...GENERIC_MUTATING_COMMAND_TRAITS,
platformExecution: { kind: 'device-runtime', uses: [tvRemoteRuntimeUse] },
},
{
name: 'hardware-key',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}),
catalog: { group: 'public', key: 'hardwareKey' },
frameworkTier: 'extended',
// Admission is the owner's standalone `hardwareKey` fact. No post-action observation, as for
// `action-button`: the key goes to the system and the media session rather than the session
// app, and power/sleep leave the display off.
...GENERIC_MUTATING_COMMAND_TRAITS,
platformExecution: { kind: 'device-runtime', uses: [hardwareKeyRuntimeUse] },
},
{
name: 'orientation',
...(ownerFilesEnabled ? { ownerFiles: ['src/commands/system/index.ts'] as const } : {}),
Expand Down
6 changes: 5 additions & 1 deletion packages/contracts/src/client-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { DeviceCommandBaseOptions } from './client-connection.ts';
import type { SettleCommandOptions } from './client-gesture.ts';
import type { DeviceRotation, SetFoldPoseInput } from './device-rotation.ts';
import type { TelephonyEventInput } from './telephony-input.ts';
import type { TvRemoteButton } from './tv-remote.ts';
import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts';

export type WaitCommandTarget =
| {
Expand Down Expand Up @@ -104,6 +104,10 @@ export type TvRemoteCommandOptions = DeviceCommandBaseOptions & {

export type TelephonyCommandOptions = DeviceCommandBaseOptions & TelephonyEventInput;

export type HardwareKeyCommandOptions = DeviceCommandBaseOptions & {
key: HardwareKeyToken;
};

export type KeyboardCommandOptions = DeviceCommandBaseOptions & {
action?: 'status' | 'dismiss' | 'enter' | 'return';
};
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 @@ -120,6 +120,7 @@ export type {
ClipboardCommandOptions,
FoldCommandOptions,
DoctorCommandOptions,
HardwareKeyCommandOptions,
HomeCommandOptions,
KeyboardCommandOptions,
OrientationCommandOptions,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const EXPECTATIONS: {
home: { method: 'home', input: {} },
setOrientation: { method: 'setOrientation', input: { rotation: 'portrait' } },
tvRemote: { method: 'tvRemote', input: { button: 'select' } },
hardwareKey: { method: 'hardwareKey', input: { key: 'volume-up' } },
keyboardStatus: { method: 'keyboardStatus', input: {} },
keyboardDismiss: { method: 'keyboardDismiss', input: {} },
keyboardEnter: { method: 'keyboardEnter', input: {} },
Expand Down
8 changes: 7 additions & 1 deletion packages/contracts/src/interactor-operation-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts';
import { bindOrientation } from './orientation-runtime.ts';
import { bindReadSetting, bindSetSetting } from './settings-runtime.ts';
import { TV_REMOTE_LABEL, bindTvRemote } from './tv-remote-runtime.ts';
import {
TV_REMOTE_LABEL,
bindTvRemote,
HARDWARE_KEY_LABEL,
bindHardwareKey,
} from './tv-remote-runtime.ts';
import {
localInteractorSource,
providerInteractorSource,
Expand Down Expand Up @@ -63,6 +68,7 @@ export const INTERACTOR_OPERATIONS = [
},
{ operation: 'setOrientation', label: 'orientation', bind: bindOrientation },
{ operation: 'tvRemote', label: TV_REMOTE_LABEL, bind: bindTvRemote },
{ operation: 'hardwareKey', label: HARDWARE_KEY_LABEL, bind: bindHardwareKey },
{
operation: 'keyboardStatus',
label: KEYBOARD_ACTION_LABELS.keyboardStatus,
Expand Down
9 changes: 8 additions & 1 deletion packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { DeviceRotation } from './device-rotation.ts';
import type { FillUnconfirmedVerification } from './fill-evidence.ts';
import type { ScrollDirection } from './scroll-gesture.ts';
import type { ScrollExecutionOptions } from './scroll-command.ts';
import type { TvRemoteButton } from './tv-remote.ts';
import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts';
import type { GesturePlan } from './gesture-plan-types.ts';
import type { ReadSettingResult, SettingOptions } from './settings.ts';
import type { ReadableSetting } from './settings-catalog.ts';
Expand Down Expand Up @@ -419,6 +419,13 @@ export type Interactor = {
* no-op.
*/
tvRemote?(button: TvRemoteButton, durationMs?: number): Promise<void>;
/**
* Optional (parity with `tvRemote`): presses one volume, power, or media key, a control only
* owners with a key-event channel carry. An owner without it leaves it undefined; its fact
* refuses the press before binding, and the hardware-key binder fails closed rather than
* resolving an absent member as a silent no-op.
*/
hardwareKey?(key: HardwareKeyToken): Promise<void>;
/**
* 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
Expand Down
9 changes: 8 additions & 1 deletion packages/contracts/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { BackMode } from './back-mode.ts';
import type { DeviceRotation, FoldPose } from './device-rotation.ts';
import type { FoldScreenReport } from './fold-runtime.ts';
import type { SettleObservation } from './interaction.ts';
import type { TvRemoteButton } from './tv-remote.ts';
import type { HardwareKeyToken, TvRemoteButton } from './tv-remote.ts';

/**
* Closed results of the navigation/global action commands. Each mirrors its
Expand Down Expand Up @@ -89,3 +89,10 @@ export type TvRemoteCommandResult = {
durationMs?: number;
message: string;
};

/** `hardware-key` — `{ action: 'hardware-key', key, message }`. */
export type HardwareKeyCommandResult = {
action: 'hardware-key';
key: HardwareKeyToken;
message: string;
};
7 changes: 6 additions & 1 deletion packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import type { BackRuntimeOperations } from './back-runtime.ts';
import type { OrientationRuntimeOperations } from './orientation-runtime.ts';
import type { FoldRuntimeOperations } from './fold-runtime.ts';
import type { TelephonyRuntimeOperations } from './telephony-runtime.ts';
import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts';
import type {
TvRemoteRuntimeOperations,
HardwareKeyRuntimeOperations,
} from './tv-remote-runtime.ts';
import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts';
import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts';
import type { SystemButtonRuntimeOperations } from './system-button-runtime.ts';
Expand Down Expand Up @@ -81,6 +84,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
FoldRuntimeOperations &
TelephonyRuntimeOperations &
TvRemoteRuntimeOperations &
HardwareKeyRuntimeOperations &
KeyboardRuntimeOperations &
ClipboardRuntimeOperations &
SystemButtonRuntimeOperations &
Expand Down Expand Up @@ -117,6 +121,7 @@ export const orientationRuntimeUse = defineUse({ required: ['setOrientation'] })
export const foldRuntimeUse = defineUse({ required: ['setFoldPose'] });
export const telephonyRuntimeUse = defineUse({ required: ['sendTelephonyEvent'] });
export const tvRemoteRuntimeUse = defineUse({ required: ['tvRemote'] });
export const hardwareKeyRuntimeUse = defineUse({ required: ['hardwareKey'] });
export const keyboardStatusUse = defineUse({ required: ['keyboardStatus'] });
export const keyboardDismissUse = defineUse({ required: ['keyboardDismiss'] });
export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = {
back: { available: false, reason: 'unsupported-provider-mode' },
orientation: { available: false, reason: 'unsupported-provider-mode' },
tvRemote: { available: false, reason: 'unsupported-provider-mode' },
hardwareKey: { available: false, reason: 'unsupported-provider-mode' },
keyboard: { available: false, reason: 'unsupported-provider-mode' },
clipboard: { available: false, reason: 'unsupported-provider-mode' },
systemButton: { available: false, reason: 'unsupported-provider-mode' },
Expand Down
8 changes: 7 additions & 1 deletion packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ import { typeTextRuntimeOperationFacts } from './type-text-runtime.ts';
import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts';
import { backRuntimeOperationFacts } from './back-runtime.ts';
import { orientationRuntimeOperationFacts } from './orientation-runtime.ts';
import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts';
import {
tvRemoteRuntimeOperationFacts,
hardwareKeyRuntimeOperationFacts,
} from './tv-remote-runtime.ts';
import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts';
import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts';
import { systemButtonRuntimeOperationFacts } from './system-button-runtime.ts';
Expand Down Expand Up @@ -60,6 +63,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
fold: RuntimeOperationUnavailability;
telephony: RuntimeOperationUnavailability;
tvRemote: RuntimeOperationUnavailability;
hardwareKey: RuntimeOperationUnavailability;
keyboard: RuntimeOperationUnavailability;
clipboard: RuntimeOperationUnavailability;
systemButton: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -117,6 +121,7 @@ const UNAVAILABLE_CELLS = {
fold: true,
telephony: true,
tvRemote: true,
hardwareKey: true,
keyboard: true,
clipboard: true,
systemButton: true,
Expand Down Expand Up @@ -243,6 +248,7 @@ export function createUnavailablePlatformRuntimeFacts(
setFoldPose: frozen.fold,
sendTelephonyEvent: frozen.telephony,
...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }),
...hardwareKeyRuntimeOperationFacts({ hardwareKey: frozen.hardwareKey }),
...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }),
...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }),
...systemButtonRuntimeOperationFacts({ unsupported: frozen.systemButton }),
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const RUNTIME_OPERATION_NAMES = [
'findText',
'focusPoint',
'gestureViewport',
'hardwareKey',
'home',
'hoverPoint',
'hoverRef',
Expand Down
71 changes: 71 additions & 0 deletions packages/contracts/src/tv-remote-hardware-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { HARDWARE_KEYS, parseHardwareKey, toAndroidHardwareKeycode } from './tv-remote.ts';

test('every hardware key maps to its Android KeyEvent keycode', () => {
assert.deepEqual(
Object.fromEntries(HARDWARE_KEYS.map((key) => [key, toAndroidHardwareKeycode(key)])),
{
'volume-up': 24,
'volume-down': 25,
mute: 164,
power: 26,
sleep: 223,
wake: 224,
'media-play-pause': 85,
'media-next': 87,
'media-previous': 88,
'media-rewind': 89,
'media-fast-forward': 90,
'channel-up': 166,
'channel-down': 167,
'last-channel': 229,
guide: 172,
info: 165,
captions: 175,
'tv-input': 178,
'color-red': 183,
'color-green': 184,
'color-yellow': 185,
'color-blue': 186,
},
);
});

test('hardware key names parse case-insensitively', () => {
assert.equal(parseHardwareKey('Volume-Up'), 'volume-up');
assert.equal(parseHardwareKey('MEDIA-PLAY-PAUSE'), 'media-play-pause');
});

test('a raw Android keycode parses to its token and sends that keycode', () => {
assert.equal(parseHardwareKey('android-keycode:300'), 'android-keycode:300');
assert.equal(parseHardwareKey('ANDROID-KEYCODE:1'), 'android-keycode:1');
assert.equal(toAndroidHardwareKeycode('android-keycode:300'), 300);
assert.equal(
toAndroidHardwareKeycode(parseHardwareKey('android-keycode:2147483647')),
2147483647,
);
});

test.each([
undefined,
'',
'volume',
'home',
'toString',
'KEYCODE_VOLUME_UP',
'24',
'android-keycode:',
'android-keycode:0',
'android-keycode:007',
'android-keycode:-1',
'android-keycode:1.5',
'android-keycode:2147483648',
'android-keycode:24 ',
'keycode:24',
])('an unknown hardware key %j is INVALID_ARGS naming every key', (value) => {
assert.throws(() => parseHardwareKey(value), {
code: 'INVALID_ARGS',
message: `hardware-key key must be one of: ${HARDWARE_KEYS.join(', ')}, or android-keycode:<n> with n from 1 to 2147483647.`,
});
});
58 changes: 58 additions & 0 deletions packages/contracts/src/tv-remote-runtime-hardware-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test, vi } from 'vitest';
import type { Interactor } from './interactor-types.ts';
import { localInteractorSource } from './interactor-operation-binding.ts';
import { bindHardwareKey, hardwareKeyRuntimeOperationFacts } from './tv-remote-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('the hardware-key facts carry one cell', () => {
expect(hardwareKeyRuntimeOperationFacts({ hardwareKey: available })).toEqual({
hardwareKey: available,
});
expect(hardwareKeyRuntimeOperationFacts({ hardwareKey: unsupported })).toEqual({
hardwareKey: unsupported,
});
});

test('a local hardware-key binding presses the interactor once with the parsed key', async () => {
const hardwareKey = vi.fn(async () => undefined);
const resolveInteractor = vi.fn(async () => ({ hardwareKey }) as unknown as Interactor);
const signal = new AbortController().signal;

const operations = bindHardwareKey(signal, localInteractorSource({ device, resolveInteractor }));
await operations.hardwareKey({
key: 'media-play-pause',
options: { appBundleId: 'com.example.app' },
execution: { logPath: '/tmp/daemon.log', requestId: 'hardware-key-1' },
});

expect(resolveInteractor).toHaveBeenCalledWith(device, {
logPath: '/tmp/daemon.log',
requestId: 'hardware-key-1',
appBundleId: 'com.example.app',
signal,
});
expect(hardwareKey).toHaveBeenCalledExactlyOnceWith('media-play-pause');
});

test('an admitted hardware key whose interactor lacks the member fails closed', async () => {
const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor);
const operations = bindHardwareKey(
new AbortController().signal,
localInteractorSource({ device, resolveInteractor }),
);

await expect(operations.hardwareKey({ key: 'volume-up' })).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'hardware-key was admitted but its bound interactor has no implementation.',
details: { reason: 'interactor-method-missing' },
});
});
Loading
Loading