Skip to content

Commit 56e3238

Browse files
committed
refactor: migrate focus to the request-bound device runtime
Wave 5's first unit (#1739, ADR 0019). `focus x y` and `find <q> focus` now reach the device through one admitted, request-bound `focusPoint` operation instead of the `handleFocusCommand` interactor leaf and its dispatch-table arm. - New `FocusRuntimeOperations` contract with local and provider interactor binders, mirroring the screenshot/element-text seam rather than inventing a second way for one operation class to reach its mechanics. - Exact-owner facts replace the capability bucket: apple simulator/device, android emulator/device/unknown, harmonyos emulator/device, linux device, web device, vega none, providers wherever their interactor is reachable. That is the retired bucket's cell table, restated as facts. - `focus` leaves BASE_COMMAND_CAPABILITY_MATRIX and both hand-maintained overlays (HARMONYOS_SUPPORTED_COMMANDS, WEB_INTERACTION_COMMANDS). - R40 is the new parametrized cutover row; `focusPoint` has exactly one owner. - The `x y` positional parse moves to utils and is shared with the still-legacy touch siblings, so a migrated command cannot drift from them. `find` stays legacy: this unit owns its focus leg only, its `type` leg still dispatches, and R35 waits on the Wave 5 `type` unit.
1 parent 06d27de commit 56e3238

35 files changed

Lines changed: 751 additions & 61 deletions

packages/contracts/src/facades/platform.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ export {
221221
selectorTextCaptureRuntimePlanUses,
222222
snapshotRuntimePlanUses,
223223
waitSelectorCaptureRuntimePlanUses,
224+
focusRuntimeUse,
224225
viewportRuntimeUse,
225226
} from '../platform-runtime-operations.ts';
226227
export type {
@@ -288,6 +289,18 @@ export type {
288289
SnapshotRuntimeOperationFacts,
289290
SnapshotResult,
290291
} from '../snapshot-runtime.ts';
292+
export {
293+
bindLocalFocusInteractor,
294+
bindProviderFocusInteractor,
295+
focusRuntimeOperationFacts,
296+
} from '../focus-runtime.ts';
297+
export type {
298+
FocusPointInput,
299+
FocusRuntimeOperationFacts,
300+
FocusRuntimeOperations,
301+
LocalFocusInteractorResolver,
302+
ProviderFocusInteractorResolver,
303+
} from '../focus-runtime.ts';
291304
export { viewportRuntimeOperationFacts } from '../viewport-runtime.ts';
292305
export type {
293306
SetViewportInput,
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { DeviceInfo } from '@agent-device/kernel/device';
2+
import { AppError } from '@agent-device/kernel/errors';
3+
import type { Point } from '@agent-device/kernel/snapshot';
4+
import type { Interactor, RunnerContext } from './interactor-types.ts';
5+
import type { RuntimeOperationFact } from './platform-runtime.ts';
6+
import type { SessionSurface } from './session-surface.ts';
7+
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
8+
9+
/**
10+
* Neutral intent for one point-addressed focus. The point is already resolved — from the
11+
* positionals a caller parsed, or from the node a selector matched — so the operation names no
12+
* command, request, session, or CLI flag.
13+
*/
14+
export type FocusPointInput = Readonly<{
15+
point: Point;
16+
options?: Readonly<{ appBundleId?: string; surface?: SessionSurface }>;
17+
/** Same runner metadata a capture needs; reuses that type rather than restating it. */
18+
execution?: SnapshotRuntimeExecution;
19+
}>;
20+
21+
/**
22+
* Focus returns nothing. The legacy leaf discarded whatever the interactor answered and reported
23+
* only the point it addressed, so a result type here would be a surface the command never had.
24+
*/
25+
export type FocusRuntimeOperations = Readonly<{
26+
focusPoint(input: FocusPointInput): Promise<void>;
27+
}>;
28+
29+
export type FocusRuntimeOperationFacts = Readonly<{
30+
focusPoint: RuntimeOperationFact;
31+
}>;
32+
33+
export function focusRuntimeOperationFacts(
34+
input: Readonly<{ focus: RuntimeOperationFact }>,
35+
): FocusRuntimeOperationFacts {
36+
return Object.freeze({ focusPoint: input.focus });
37+
}
38+
39+
/**
40+
* Captures one selected owner's interactor authority for the lifetime of a request binding. The
41+
* owner is already chosen by the time a binder is called, so each entry point supplies its own
42+
* resolution and this holds only what both share: the runner context and the focus itself.
43+
*/
44+
function bindFocusPoint(
45+
signal: AbortSignal,
46+
resolveInteractor: (runner: RunnerContext) => Promise<Interactor>,
47+
): FocusRuntimeOperations {
48+
return Object.freeze({
49+
focusPoint: async (input: FocusPointInput) => {
50+
signal.throwIfAborted();
51+
const interactor = await resolveInteractor({
52+
...input.execution,
53+
appBundleId: input.options?.appBundleId,
54+
signal,
55+
});
56+
await interactor.focus(input.point.x, input.point.y);
57+
},
58+
});
59+
}
60+
61+
export type LocalFocusInteractorResolver = (
62+
device: DeviceInfo,
63+
runner: RunnerContext,
64+
) => Promise<Interactor>;
65+
66+
export function bindLocalFocusInteractor(
67+
params: Readonly<{
68+
device: DeviceInfo;
69+
signal: AbortSignal;
70+
resolveInteractor: LocalFocusInteractorResolver;
71+
}>,
72+
): FocusRuntimeOperations {
73+
return bindFocusPoint(
74+
params.signal,
75+
async (runner) => await params.resolveInteractor(params.device, runner),
76+
);
77+
}
78+
79+
export type ProviderFocusInteractorResolver = (runner: RunnerContext) => Interactor | undefined;
80+
81+
/** Provider bindings fail closed when their exact owner no longer exposes its interactor. */
82+
export function bindProviderFocusInteractor(
83+
params: Readonly<{
84+
device: DeviceInfo;
85+
signal: AbortSignal;
86+
resolveInteractor: ProviderFocusInteractorResolver;
87+
}>,
88+
): FocusRuntimeOperations {
89+
return bindFocusPoint(params.signal, async (runner) => {
90+
const interactor = params.resolveInteractor(runner);
91+
if (interactor) return interactor;
92+
throw new AppError(
93+
'UNSUPPORTED_OPERATION',
94+
'Provider-owned focus operation has no bound provider interactor.',
95+
{ reason: 'provider-runtime-interactor-missing', deviceId: params.device.id },
96+
);
97+
});
98+
}

packages/contracts/src/platform-runtime-operations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { ScreenshotRuntimeOperations } from './screenshot-runtime.ts';
1616
import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts';
1717
import type { SelectorObservationRuntimeOperations } from './selector-observation-runtime.ts';
1818
import type { ViewportRuntimeOperations } from './viewport-runtime.ts';
19+
import type { FocusRuntimeOperations } from './focus-runtime.ts';
1920
import type { ElementTextRuntimeOperations } from './element-text-runtime.ts';
2021
import type {
2122
DeviceReadinessRuntimeHost,
@@ -50,6 +51,7 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations &
5051
SnapshotRuntimeOperations &
5152
SelectorObservationRuntimeOperations &
5253
ViewportRuntimeOperations &
54+
FocusRuntimeOperations &
5355
ElementTextRuntimeOperations &
5456
DeviceReadinessRuntimeOperations &
5557
DeviceShutdownRuntimeOperations &
@@ -70,6 +72,7 @@ export const bootTargetHeadlessUse = defineUse({
7072
export const appsRuntimeUse = defineUse({ required: ['ensureReady', 'listApps'] });
7173
export const captureSnapshotUse = defineUse({ required: ['captureSnapshot'] });
7274
export const viewportRuntimeUse = defineUse({ required: ['setViewport'] });
75+
export const focusRuntimeUse = defineUse({ required: ['focusPoint'] });
7376
const captureSnapshotWithCustomActionsUse = defineUse({
7477
required: ['captureSnapshot', 'captureSnapshotWithCustomActions'],
7578
});

packages/contracts/src/platform-runtime-unavailable.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ test('generic unavailable binding preserves exact provider ownership and mode',
3131
network: { available: false, reason: 'owner-capability-missing' },
3232
screenshot: { available: false, reason: 'unsupported-device-kind' },
3333
viewport: { available: false, reason: 'unsupported-platform-leaf' },
34+
focus: { available: false, reason: 'unsupported-provider-mode' },
3435
elementText: { available: false, reason: 'unsupported-provider-mode' },
3536
lifecycle,
3637
});
@@ -41,6 +42,11 @@ test('generic unavailable binding preserves exact provider ownership and mode',
4142
available: false,
4243
reason: 'unsupported-platform-leaf',
4344
});
45+
// Interaction is owner-stated too: it never inherits the transport gap's reason.
46+
assert.deepEqual(binding.facts.operations.focusPoint, {
47+
available: false,
48+
reason: 'unsupported-provider-mode',
49+
});
4450
// Both capture cells are owner-stated, so neither inherits the network gap's reason.
4551
assert.deepEqual(binding.facts.operations.captureScreenshot, {
4652
available: false,

packages/contracts/src/platform-runtime-unavailable.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { screenshotRuntimeOperationFacts } from './screenshot-runtime.ts';
1414
import { snapshotRuntimeOperationFacts } from './snapshot-runtime.ts';
1515
import { selectorObservationRuntimeOperationFacts } from './selector-observation-runtime.ts';
1616
import { viewportRuntimeOperationFacts } from './viewport-runtime.ts';
17+
import { focusRuntimeOperationFacts } from './focus-runtime.ts';
1718
import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts';
1819

1920
/**
@@ -30,6 +31,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
3031
screenshot: RuntimeOperationUnavailability;
3132
snapshot?: RuntimeOperationUnavailability;
3233
viewport: RuntimeOperationUnavailability;
34+
focus: RuntimeOperationUnavailability;
3335
elementText: RuntimeOperationUnavailability;
3436
readiness?: RuntimeOperationUnavailability;
3537
shutdown?: RuntimeOperationUnavailability;
@@ -74,6 +76,7 @@ export function createUnavailablePlatformRuntimeFacts(
7476
screenshot,
7577
snapshot,
7678
viewport,
79+
focus,
7780
elementText,
7881
readiness,
7982
shutdown,
@@ -114,6 +117,7 @@ export function createUnavailablePlatformRuntimeFacts(
114117
findSelector: snapshot,
115118
}),
116119
...viewportRuntimeOperationFacts({ setViewport: viewport }),
120+
...focusRuntimeOperationFacts({ focus }),
117121
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementText }),
118122
ensureReady: readiness,
119123
bootTarget: readiness,
@@ -142,6 +146,9 @@ function freezeUnavailableFacts(
142146
screenshot: Object.freeze({ ...unavailable.screenshot }),
143147
snapshot: orNetwork(unavailable.snapshot),
144148
viewport: Object.freeze({ ...unavailable.viewport }),
149+
// Interaction cells are stated by their owner: a family that can drive touch says so for its
150+
// exact kinds, and one that cannot must say why rather than inherit a transport gap.
151+
focus: Object.freeze({ ...unavailable.focus }),
145152
readiness: orNetwork(unavailable.readiness),
146153
shutdown: orNetwork(unavailable.shutdown),
147154
elementText: Object.freeze({ ...unavailable.elementText }),

packages/platform-android/src/runtime.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import type {
99
import {
1010
applicationLifecycleOperationFacts,
1111
availableApplicationLifecycleOperations,
12+
bindLocalFocusInteractor,
1213
bindLocalScreenshotInteractor,
1314
bindElementTextRuntime,
1415
bindLocalSnapshotInteractor,
1516
elementTextRuntimeOperationFacts,
17+
focusRuntimeOperationFacts,
1618
localRuntimeOwner,
1719
screenshotRuntimeOperationFacts,
1820
selectorObservationRuntimeOperationFacts,
@@ -37,6 +39,15 @@ const elementTextKindUnavailable = Object.freeze({
3739
available: false,
3840
reason: 'unsupported-device-kind',
3941
} as const);
42+
/**
43+
* Parity with the retired `focus` capability bucket (`{ emulator, device, unknown }`): every
44+
* Android kind drives touch through adb except the synthetic `simulator` row, which has no device.
45+
*/
46+
const focusKindUnavailable = Object.freeze({
47+
available: false,
48+
reason: 'unsupported-device-kind',
49+
hint: 'focus is supported on Android emulators and physical devices.',
50+
} as const);
4051
const headlessUnavailable = Object.freeze({
4152
available: false,
4253
reason: 'unsupported-device-kind',
@@ -160,6 +171,9 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
160171
findSelector: snapshotKindUnavailable,
161172
}),
162173
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
174+
...focusRuntimeOperationFacts({
175+
focus: device.kind === 'simulator' ? focusKindUnavailable : available,
176+
}),
163177
// uiautomator reads text at a point through the same adb path the snapshot uses, so the
164178
// synthetic `simulator` row is the only Android kind without a live read.
165179
...elementTextRuntimeOperationFacts({
@@ -225,6 +239,13 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
225239
resolveInteractor: host.localInteractors.resolve,
226240
})
227241
: {}),
242+
...(facts.operations.focusPoint.available
243+
? bindLocalFocusInteractor({
244+
device: request.device,
245+
signal: request.scope.signal,
246+
resolveInteractor: host.localInteractors.resolve,
247+
})
248+
: {}),
228249
...(facts.operations.readTextAtPoint.available
229250
? bindElementTextRuntime({
230251
device: request.device,

packages/platform-apple/src/runtime.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import type {
99
import {
1010
applicationLifecycleOperationFacts,
1111
availableApplicationLifecycleOperations,
12+
bindLocalFocusInteractor,
1213
bindLocalScreenshotInteractor,
1314
bindElementTextRuntime,
1415
elementTextRuntimeOperationFacts,
16+
focusRuntimeOperationFacts,
1517
localRuntimeOwner,
1618
screenshotRuntimeOperationFacts,
1719
selectorObservationRuntimeOperationFacts,
@@ -53,6 +55,16 @@ const viewportUnavailable = Object.freeze({
5355
reason: 'unsupported-platform-leaf',
5456
hint: 'viewport resizes web targets only (--platform web). Apple screen geometry is fixed by the selected simulator or device type — open a different simulator to test another screen size.',
5557
} as const);
58+
/**
59+
* Focus drives touch through the Apple interactor, which exists for the simulator and physical
60+
* device kinds only. Parity with the retired `focus` capability bucket
61+
* (`{ simulator: true, device: true }`), stated as one fact instead of an admission table.
62+
*/
63+
const focusKindUnavailable = Object.freeze({
64+
available: false,
65+
reason: 'unsupported-device-kind',
66+
hint: 'focus is supported on Apple simulators and physical devices.',
67+
} as const);
5668
const appStateUnavailable = Object.freeze({
5769
available: false,
5870
reason: 'unsupported-platform-leaf',
@@ -213,6 +225,10 @@ function appInventoryFacts(device: DeviceInfo) {
213225
return available;
214226
}
215227

228+
function appleFocusFact(device: DeviceInfo): RuntimeOperationFact {
229+
return device.kind === 'simulator' || device.kind === 'device' ? available : focusKindUnavailable;
230+
}
231+
216232
export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
217233
const appLogs = createAppleAppLogRuntime(host);
218234
const inspectFacts = async (device: DeviceInfo) => {
@@ -250,6 +266,7 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
250266
findSelector: appleFindSelectorFact(device),
251267
}),
252268
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
269+
...focusRuntimeOperationFacts({ focus: appleFocusFact(device) }),
253270
...elementTextRuntimeOperationFacts({ readTextAtPoint: appleElementTextFact(device) }),
254271
ensureReady: readiness,
255272
bootTarget: boot,
@@ -302,6 +319,13 @@ export function createApplePlatformRuntime(host: PlatformRuntimeHost): PlatformR
302319
resolveInteractor: host.localInteractors.resolve,
303320
}),
304321
),
322+
...whenAdmitted(facts.operations.focusPoint, () =>
323+
bindLocalFocusInteractor({
324+
device: request.device,
325+
signal: request.scope.signal,
326+
resolveInteractor: host.localInteractors.resolve,
327+
}),
328+
),
305329
...whenAdmitted(facts.operations.readTextAtPoint, () =>
306330
bindElementTextRuntime({
307331
device: request.device,

packages/platform-harmonyos/src/runtime.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,16 @@ import type {
33
PlatformRuntimeHost,
44
PlatformRuntimeOperations,
55
PlatformRuntimeOwner,
6+
RuntimeOperationFact,
67
} from '@agent-device/contracts/platform';
78
import {
89
applicationLifecycleOperationFacts,
910
availableApplicationLifecycleOperations,
11+
bindLocalFocusInteractor,
1012
bindLocalScreenshotInteractor,
1113
bindLocalSnapshotInteractor,
1214
elementTextRuntimeOperationFacts,
15+
focusRuntimeOperationFacts,
1316
localRuntimeOwner,
1417
screenshotRuntimeOperationFacts,
1518
selectorObservationRuntimeOperationFacts,
@@ -35,6 +38,15 @@ const elementTextUnavailable = Object.freeze({
3538
reason: 'unsupported-platform-leaf',
3639
hint: 'HarmonyOS reads element text from the captured tree only.',
3740
} as const);
41+
/**
42+
* Parity with the retired `focus` capability bucket, which the HarmonyOS overlay in
43+
* `core/capabilities.ts` filled as `{ emulator, device }` — the two kinds hdc can drive.
44+
*/
45+
const focusKindUnavailable = Object.freeze({
46+
available: false,
47+
reason: 'unsupported-device-kind',
48+
hint: 'focus is supported on HarmonyOS emulators and physical devices.',
49+
} as const);
3850
const available = Object.freeze({ available: true } as const);
3951
const unavailable = Object.freeze({
4052
available: false,
@@ -116,6 +128,10 @@ function harmonyCloseTargetFact(device: DeviceInfo) {
116128
: closeTargetKindUnavailable;
117129
}
118130

131+
function harmonyFocusFact(device: DeviceInfo): RuntimeOperationFact {
132+
return device.kind === 'emulator' || device.kind === 'device' ? available : focusKindUnavailable;
133+
}
134+
119135
export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): PlatformRuntimeOwner {
120136
const appLogs = createHarmonyAppLogRuntime(host);
121137
const inspectFacts = async (device: Parameters<typeof appLogs.inspectFacts>[0]) => {
@@ -155,6 +171,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
155171
findSelector: snapshotKindUnavailable,
156172
}),
157173
...viewportRuntimeOperationFacts({ setViewport: viewportUnavailable }),
174+
...focusRuntimeOperationFacts({ focus: harmonyFocusFact(device) }),
158175
// HarmonyOS has no point-read tool: `get` answers from the captured tree, which is what
159176
// the legacy dispatch already did after its Apple-runner attempt failed.
160177
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }),
@@ -219,6 +236,13 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
219236
resolveInteractor: host.localInteractors.resolve,
220237
})
221238
: {}),
239+
...(facts.operations.focusPoint.available
240+
? bindLocalFocusInteractor({
241+
device: request.device,
242+
signal: request.scope.signal,
243+
resolveInteractor: host.localInteractors.resolve,
244+
})
245+
: {}),
222246
listApps: async (input: { device: DeviceInfo; filter: 'all' | 'user-installed' }) =>
223247
await host.appInventory.harmonyos.listApps(
224248
input.device,

0 commit comments

Comments
 (0)