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
8 changes: 4 additions & 4 deletions packages/command-registry/src/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1646,10 +1646,10 @@ export const RAW_COMMAND_DESCRIPTORS = [
...(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).
// The first positional selects one of the eleven named keys, 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 },
},
Expand Down
50 changes: 45 additions & 5 deletions packages/contracts/src/device-rotation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,40 @@ export function parseOrientationRequest(input: string | undefined): OrientationR
* 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;
const HARDWARE_BUTTONS = [
'volumeUp',
'volumeDown',
'power',
'mute',
'sleep',
'wake',
'mediaPlayPause',
'mediaNext',
'mediaPrevious',
'mediaRewind',
'mediaFastForward',
] 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.
* The public positional names of the hardware keys: the volume/power/mute keys, the explicit
* sleep and wake events, and the five media keys. 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';
export type HardwareButtonName =
| 'volume-up'
| 'volume-down'
| 'power'
| 'mute'
| 'sleep'
| 'wake'
| 'media-play-pause'
| 'media-next'
| 'media-previous'
| 'media-rewind'
| 'media-fast-forward';

/**
* The accepted positional values, in command order. `satisfies` turns an out-of-vocabulary
Expand All @@ -119,6 +143,14 @@ export const HARDWARE_BUTTON_NAMES = [
'volume-up',
'volume-down',
'power',
'mute',
'sleep',
'wake',
'media-play-pause',
'media-next',
'media-previous',
'media-rewind',
'media-fast-forward',
] as const satisfies readonly HardwareButtonName[];

/** The CLI synopsis tail naming the accepted positional names. */
Expand All @@ -134,6 +166,14 @@ export const HARDWARE_BUTTON_NAME_LABELS = {
volumeUp: 'volume-up',
volumeDown: 'volume-down',
power: 'power',
mute: 'mute',
sleep: 'sleep',
wake: 'wake',
mediaPlayPause: 'media-play-pause',
mediaNext: 'media-next',
mediaPrevious: 'media-previous',
mediaRewind: 'media-rewind',
mediaFastForward: 'media-fast-forward',
} as const satisfies Record<HardwareButton, HardwareButtonName>;

const HARDWARE_BUTTON_NAME_MAP: ReadonlyMap<HardwareButtonName, HardwareButton> = new Map(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ const EXPECTATIONS: {
volumeUp: { method: 'volumeUp', input: {} },
volumeDown: { method: 'volumeDown', input: {} },
power: { method: 'power', input: {} },
mute: { method: 'mute', input: {} },
sleep: { method: 'sleep', input: {} },
wake: { method: 'wake', input: {} },
mediaPlayPause: { method: 'mediaPlayPause', input: {} },
mediaNext: { method: 'mediaNext', input: {} },
mediaPrevious: { method: 'mediaPrevious', input: {} },
mediaRewind: { method: 'mediaRewind', input: {} },
mediaFastForward: { method: 'mediaFastForward', input: {} },
triggerAppEvent: { method: 'open', input: { eventUrl: 'myapp://x' } },
setSetting: {
method: 'setSetting',
Expand Down
40 changes: 40 additions & 0 deletions packages/contracts/src/interactor-operation-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,46 @@ export const INTERACTOR_OPERATIONS = [
label: SYSTEM_BUTTON_LABELS.power,
bind: (signal, resolve) => bindSystemButton('power', signal, resolve),
},
{
operation: 'mute',
label: SYSTEM_BUTTON_LABELS.mute,
bind: (signal, resolve) => bindSystemButton('mute', signal, resolve),
},
{
operation: 'sleep',
label: SYSTEM_BUTTON_LABELS.sleep,
bind: (signal, resolve) => bindSystemButton('sleep', signal, resolve),
},
{
operation: 'wake',
label: SYSTEM_BUTTON_LABELS.wake,
bind: (signal, resolve) => bindSystemButton('wake', signal, resolve),
},
{
operation: 'mediaPlayPause',
label: SYSTEM_BUTTON_LABELS.mediaPlayPause,
bind: (signal, resolve) => bindSystemButton('mediaPlayPause', signal, resolve),
},
{
operation: 'mediaNext',
label: SYSTEM_BUTTON_LABELS.mediaNext,
bind: (signal, resolve) => bindSystemButton('mediaNext', signal, resolve),
},
{
operation: 'mediaPrevious',
label: SYSTEM_BUTTON_LABELS.mediaPrevious,
bind: (signal, resolve) => bindSystemButton('mediaPrevious', signal, resolve),
},
{
operation: 'mediaRewind',
label: SYSTEM_BUTTON_LABELS.mediaRewind,
bind: (signal, resolve) => bindSystemButton('mediaRewind', signal, resolve),
},
{
operation: 'mediaFastForward',
label: SYSTEM_BUTTON_LABELS.mediaFastForward,
bind: (signal, resolve) => bindSystemButton('mediaFastForward', 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
28 changes: 28 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,34 @@ export type Interactor = {
* it; an owner without it leaves it undefined and its fact refuses before binding.
*/
power?(): Promise<void>;
/**
* Optional (parity with `volumeUp`): presses the media mute key once. Android-only today:
* Apple exposes no public mute press, so no Apple leaf carries it.
*/
mute?(): Promise<void>;
/**
* Optional (parity with `volumeUp`): sends the explicit Android sleep event (KEYCODE_SLEEP),
* which turns the screen off without toggling the way `power` does.
*/
sleep?(): Promise<void>;
/**
* Optional (parity with `volumeUp`): sends the explicit Android wake event (KEYCODE_WAKEUP),
* which turns the screen on without toggling the way `power` does.
*/
wake?(): Promise<void>;
/**
* Optional (parity with `volumeUp`): presses the media play/pause key once. The press goes to
* whatever media session the OS routes it to; it does not claim playback started.
*/
mediaPlayPause?(): Promise<void>;
/** Optional (parity with `mediaPlayPause`): presses the media next key once. */
mediaNext?(): Promise<void>;
/** Optional (parity with `mediaPlayPause`): presses the media previous key once. */
mediaPrevious?(): Promise<void>;
/** Optional (parity with `mediaPlayPause`): presses the media rewind key once. */
mediaRewind?(): Promise<void>;
/** Optional (parity with `mediaPlayPause`): presses the media fast-forward key once. */
mediaFastForward?(): 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
6 changes: 3 additions & 3 deletions packages/contracts/src/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,9 @@ export type TvRemoteCommandResult = {
/**
* `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).
* The button is the public name (`volume-up`, `volume-down`, `power`, `mute`, `sleep`, `wake`, or
* one of the five media names). The result reports that the press was dispatched; it carries no
* volume level, mute state, or screen state, because the command observes none of them (#3848).
*/
export type HardwareButtonCommandResult = {
action: 'hardware-button';
Expand Down
18 changes: 17 additions & 1 deletion packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,30 @@ export const keyboardEnterUse = defineUse({ required: ['keyboardEnter'] });
export const appSwitcherRuntimeUse = defineUse({ required: ['appSwitcher'] });
export const actionButtonRuntimeUse = defineUse({ required: ['actionButton'] });
// `hardware-button`'s cells (ADR 0019 §9): the daemon admits and binds exactly the one the parsed
// positional names, never all three (#3848).
// positional names, never all of them (#3848).
export const volumeUpRuntimeUse = defineUse({ required: ['volumeUp'] });
export const volumeDownRuntimeUse = defineUse({ required: ['volumeDown'] });
export const powerRuntimeUse = defineUse({ required: ['power'] });
export const muteRuntimeUse = defineUse({ required: ['mute'] });
export const sleepRuntimeUse = defineUse({ required: ['sleep'] });
export const wakeRuntimeUse = defineUse({ required: ['wake'] });
export const mediaPlayPauseRuntimeUse = defineUse({ required: ['mediaPlayPause'] });
export const mediaNextRuntimeUse = defineUse({ required: ['mediaNext'] });
export const mediaPreviousRuntimeUse = defineUse({ required: ['mediaPrevious'] });
export const mediaRewindRuntimeUse = defineUse({ required: ['mediaRewind'] });
export const mediaFastForwardRuntimeUse = defineUse({ required: ['mediaFastForward'] });
export const hardwareButtonRuntimePlanUses = Object.freeze([
volumeUpRuntimeUse,
volumeDownRuntimeUse,
powerRuntimeUse,
muteRuntimeUse,
sleepRuntimeUse,
wakeRuntimeUse,
mediaPlayPauseRuntimeUse,
mediaNextRuntimeUse,
mediaPreviousRuntimeUse,
mediaRewindRuntimeUse,
mediaFastForwardRuntimeUse,
] as const);
export const appEventRuntimeUse = defineUse({ required: ['triggerAppEvent'] });
export const settingsRuntimeUse = defineUse({ required: ['setSetting'] });
Expand Down
8 changes: 8 additions & 0 deletions packages/contracts/src/runtime-operation-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const RUNTIME_OPERATION_NAMES = [
'listApps',
'longPressPoint',
'materializeAppSource',
'mediaFastForward',
'mediaNext',
'mediaPlayPause',
'mediaPrevious',
'mediaRewind',
'mute',
'networkDump',
'openApplication',
'perfFrames',
Expand Down Expand Up @@ -83,6 +89,7 @@ export const RUNTIME_OPERATION_NAMES = [
'setSetting',
'setViewport',
'shutdownTarget',
'sleep',
'tapElementSelector',
'tapPoint',
'tapRef',
Expand All @@ -93,6 +100,7 @@ export const RUNTIME_OPERATION_NAMES = [
'unlockOrientation',
'volumeDown',
'volumeUp',
'wake',
'writeClipboard',
] as const;

Expand Down
70 changes: 46 additions & 24 deletions packages/contracts/src/system-button-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { HARDWARE_BUTTON_NAMES, parseHardwareButtonPositionals } from './device-
import {
SYSTEM_BUTTONS,
SYSTEM_BUTTON_LABELS,
androidHardwareButtonAvailability,
androidHardwareButtonFacts,
bindSystemButton,
systemButtonRuntimeOperationFacts,
} from './system-button-runtime.ts';
Expand All @@ -21,22 +21,27 @@ 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', () => {
// Every family cell must be named by an owner or take the denial: this full-object equality
// enumerates all of them, so a new cell that changes the family builder's shape fails here.
expect(systemButtonRuntimeOperationFacts({ unsupported, home: available })).toEqual({
home: available,
appSwitcher: unsupported,
actionButton: unsupported,
volumeUp: unsupported,
volumeDown: unsupported,
power: unsupported,
mute: unsupported,
sleep: unsupported,
wake: unsupported,
mediaPlayPause: unsupported,
mediaNext: unsupported,
mediaPrevious: unsupported,
mediaRewind: unsupported,
mediaFastForward: unsupported,
});
expect(systemButtonRuntimeOperationFacts({ unsupported })).toEqual({
home: unsupported,
appSwitcher: unsupported,
actionButton: unsupported,
volumeUp: unsupported,
volumeDown: unsupported,
power: unsupported,
});
const allDenied = systemButtonRuntimeOperationFacts({ unsupported });
expect(Object.keys(allDenied).sort()).toEqual([...SYSTEM_BUTTONS].sort());
for (const button of SYSTEM_BUTTONS) expect(allDenied[button]).toEqual(unsupported);
});

test.each(SYSTEM_BUTTONS)(
Expand Down Expand Up @@ -91,16 +96,17 @@ test('the hardware-button vocabulary is exhaustive over the family cells and par
),
).toEqual([...HARDWARE_BUTTON_NAMES]);
expect(
[...HARDWARE_BUTTON_NAMES].map((name) => parseHardwareButtonPositionals([name])).sort(),
).toEqual(['power', 'volumeDown', 'volumeUp']);
new Set([...HARDWARE_BUTTON_NAMES].map((name) => parseHardwareButtonPositionals([name]))).size,
).toBe(HARDWARE_BUTTON_NAMES.length);
expect(HARDWARE_BUTTON_NAMES.length).toBe(11);
expect(parseHardwareButtonPositionals(['volume-up'])).toBe('volumeUp');
expect(parseHardwareButtonPositionals(['Volume-Down'])).toBe('volumeDown');
expect(parseHardwareButtonPositionals(['power'])).toBe('power');
for (const name of ['', 'vol', 'mute', 'home', undefined] as const) {
expect(parseHardwareButtonPositionals(['media-fast-forward'])).toBe('mediaFastForward');
for (const name of ['', 'vol', 'media', 'media-pause', 'home', undefined] as const) {
expect(() => parseHardwareButtonPositionals([name as string])).toThrowError(
expect.objectContaining({
code: 'INVALID_ARGS',
message: 'hardware-button button must be one of: volume-up, volume-down, power.',
message: `hardware-button button must be one of: ${HARDWARE_BUTTON_NAMES.join(', ')}.`,
}),
);
}
Expand All @@ -114,31 +120,47 @@ test('the hardware-button positional helper enforces arity and the exact-name pa
expect(() => parseHardwareButtonPositionals(positionals)).toThrowError(
expect.objectContaining({
code: 'INVALID_ARGS',
message: 'hardware-button requires exactly one button: volume-up, volume-down, power.',
message: `hardware-button requires exactly one button: ${HARDWARE_BUTTON_NAMES.join(', ')}.`,
}),
);
}
expect(() => parseHardwareButtonPositionals(['mute'])).toThrowError(
expect(() => parseHardwareButtonPositionals(['media'])).toThrowError(
expect.objectContaining({ code: 'INVALID_ARGS' }),
);
// Every extended name parses to its own cell, the way the original three do.
for (const [name, cell] of [
['mute', 'mute'],
['sleep', 'sleep'],
['wake', 'wake'],
['media-play-pause', 'mediaPlayPause'],
['media-next', 'mediaNext'],
['media-previous', 'mediaPrevious'],
['media-rewind', 'mediaRewind'],
['media-fast-forward', 'mediaFastForward'],
] as const) {
expect(parseHardwareButtonPositionals([name])).toBe(cell);
}
});

// The Android hardware-button admission rule both Android owners share: a phone or tablet
// answers available, a TV target refuses toward tv-remote, and the synthetic simulator row has
// no device behind it (#3848).
// answers available for every declared cell, a TV target refuses toward tv-remote, and the
// synthetic simulator row has no device behind it (#3848).
test('the shared Android hardware-button rule admits phones and refuses TV and simulator', () => {
expect(androidHardwareButtonAvailability({ kind: 'emulator' })).toEqual({ available: true });
expect(androidHardwareButtonAvailability({ kind: 'mobile', target: 'phone' })).toEqual({
available: true,
});
expect(androidHardwareButtonAvailability({ kind: 'mobile', target: 'tv' })).toEqual(
const cells = HARDWARE_BUTTON_NAMES.map((name) => parseHardwareButtonPositionals([name]));
const phone = androidHardwareButtonFacts({ kind: 'mobile', target: 'phone' });
for (const cell of cells) {
expect(phone[cell]).toEqual({ available: true });
}
const tv = androidHardwareButtonFacts({ kind: 'mobile', target: 'tv' });
expect(tv.power).toEqual(
expect.objectContaining({
available: false,
reason: 'unsupported-device-kind',
hint: 'hardware-button is not supported on Android TV targets; use tv-remote.',
}),
);
expect(androidHardwareButtonAvailability({ kind: 'simulator' })).toEqual(
const synthetic = androidHardwareButtonFacts({ kind: 'simulator' });
expect(synthetic.mute).toEqual(
expect.objectContaining({
available: false,
reason: 'unsupported-device-kind',
Expand Down
Loading
Loading