Skip to content

Commit 0c8227e

Browse files
authored
refactor(runtime): let platform runtimes list apps and read app state directly (#2295)
* refactor(runtime): let platform runtimes list apps and read app state directly The root host carried two adapters, appInventory and appState, that only forwarded a platform call back into that platform's own package. Each platform runtime now performs its own listApps and appState call through a lazy import inside its package, keeping the deferred load, the AbortSignal threading, and the package/bundleId -> id rename. PlatformRuntimeHost loses both keys, so Android, Apple and Harmony fixtures no longer stub the two platforms they do not own. Android is the one platform runtime whose package now reaches adb directly. The adb host that adb mechanics require is bound by a module side effect that only the root can perform, so the Android runtime-module registration binds it before the module loads. loadAndroidMechanics keeps its own binding import for the root host ports that reach mechanics without binding a runtime; neither binder subsumes the other. Android appstate now runs one foreground-focus loop instead of two. The host shaped readAndroidAppState/AndroidAppStateHost pair is gone: limrun's adapter already closes over its own adb executor, so it calls the executor variant directly, and that variant took the per-attempt abort check the host variant had. AppStateRuntimeCommand and AppStateRuntimeCommandResult described the deleted host port and go with it. Tests: the new ordering test in src/platform-runtime-android-adb-binding.test.ts was seen red by deleting the binding import from that registration (order came back ["android-runtime", "adb-host"]); the composed-gateway listApps test in the same file was seen red by reverting the Android runtime's inlined listApps to a host.appInventory lookup (TypeError reading 'android'); the new abort test in packages/platform-android/src/app-state.test.ts was seen red by removing both signal?.throwIfAborted() calls from readAndroidFocusWithExecutor (the second dumpsys was issued and the call resolved). All green after. * chore(gates): drop the retired app-inventory/app-state host allowances The two PLATFORM_RUNTIME_HOST_FILES rows point at host files this change deletes, and the ./platform-runtime-app-state-host.ts composition allowance has no importer left. * refactor(runtime): construct the Android runtime module with its adb host binding The Android runtime now calls adb from inside its package for listApps and appState, which needs the process-wide adb host port bound. That dependency was hidden in a registry wrapper doing a side-effect import, with a paragraph explaining why it and loadAndroidMechanics did not subsume each other and an import-order test pinning the ordering. The package now declares the dependency: createAndroidRuntimeModule({ bindAdbHost }) awaits the binding before the runtime loads, and the composition root supplies the one binding implementation (evaluating its adb host module). The wrapper, the paragraph and the import-order test are gone; the routed listApps test stays and a routed appState test joins it.
1 parent ba6c818 commit 0c8227e

33 files changed

Lines changed: 286 additions & 516 deletions

packages/contracts/src/app-inventory-runtime.ts

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,3 @@ export type ListAppsInput = Readonly<{
1414
export type AppInventoryRuntimeOperations = Readonly<{
1515
listApps(input: ListAppsInput): Promise<readonly InstalledAppInfo[]>;
1616
}>;
17-
18-
export type AppInventoryRuntimeHost = Readonly<{
19-
apple: Readonly<{
20-
listApps(
21-
device: DeviceInfo,
22-
filter: AppsFilter,
23-
signal: AbortSignal,
24-
): Promise<readonly InstalledAppInfo[]>;
25-
}>;
26-
android: Readonly<{
27-
listApps(
28-
device: DeviceInfo,
29-
filter: AppsFilter,
30-
signal: AbortSignal,
31-
): Promise<readonly InstalledAppInfo[]>;
32-
}>;
33-
harmonyos: Readonly<{
34-
listApps(
35-
device: DeviceInfo,
36-
filter: AppsFilter,
37-
signal: AbortSignal,
38-
): Promise<readonly InstalledAppInfo[]>;
39-
}>;
40-
}>;
Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,9 @@
1-
import type { DeviceInfo } from '@agent-device/kernel/device';
2-
31
/** Neutral foreground identity returned by a selected platform/provider runtime. */
42
export type AppStateRuntimeResult = Readonly<{
53
package?: string;
64
activity?: string;
75
}>;
86

9-
export type AppStateRuntimeCommand = Readonly<{
10-
args: readonly string[];
11-
allowFailure?: boolean;
12-
timeoutMs?: number;
13-
}>;
14-
15-
export type AppStateRuntimeCommandResult = Readonly<{
16-
stdout: string;
17-
}>;
18-
197
export type AppStateRuntimeOperations = Readonly<{
208
appState(): Promise<AppStateRuntimeResult>;
219
}>;
22-
23-
export type AppStateRuntimeHost = Readonly<{
24-
android: Readonly<{
25-
run(
26-
device: DeviceInfo,
27-
command: AppStateRuntimeCommand,
28-
signal: AbortSignal,
29-
): Promise<AppStateRuntimeCommandResult>;
30-
}>;
31-
harmonyos: Readonly<{
32-
run(
33-
device: DeviceInfo,
34-
command: AppStateRuntimeCommand,
35-
signal: AbortSignal,
36-
): Promise<AppStateRuntimeCommandResult>;
37-
}>;
38-
}>;

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

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import type { AppLogRuntimeHost, AppLogRuntimeOperations } from './app-log-runtime.ts';
2-
import type {
3-
AppInventoryRuntimeHost,
4-
AppInventoryRuntimeOperations,
5-
} from './app-inventory-runtime.ts';
2+
import type { AppInventoryRuntimeOperations } from './app-inventory-runtime.ts';
63
import type {
74
AndroidAppDeploymentExecutor,
85
AppDeploymentRuntimeOperations,
96
AppleAppDeploymentExecutor,
107
} from './app-deployment-runtime.ts';
11-
import type { AppStateRuntimeHost, AppStateRuntimeOperations } from './app-state-runtime.ts';
8+
import type { AppStateRuntimeOperations } from './app-state-runtime.ts';
129
import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-runtime.ts';
1310
import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts';
1411
import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts';
@@ -721,8 +718,6 @@ export const keyboardRuntimePlanUses = Object.freeze([
721718
export type PlatformRuntimeHost = AppLogRuntimeHost &
722719
NetworkRuntimeHost &
723720
Readonly<{
724-
appInventory: AppInventoryRuntimeHost;
725-
appState: AppStateRuntimeHost;
726721
/** Focused native ports; deployment semantics remain in the owning family packages. */
727722
appleDeployment: AppleAppDeploymentExecutor;
728723
androidDeployment: AndroidAppDeploymentExecutor;

packages/platform-android/src/app-state.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { expect, test } from 'vitest';
2-
import { parseAndroidForegroundApp } from './app-state.ts';
2+
import { parseAndroidForegroundApp, readAndroidAppStateWithExecutor } from './app-state.ts';
33

44
test('parses Android window and activity foreground markers', () => {
55
expect(
@@ -28,3 +28,18 @@ test('scans repeated uncontrolled focus text without regular-expression backtrac
2828
parseAndroidForegroundApp(`ResumedActivity:${'ResumedActivity:a'.repeat(20_000)}`),
2929
).toBeNull();
3030
});
31+
32+
test('stops between dumpsys attempts once the request is aborted', async () => {
33+
const controller = new AbortController();
34+
const issued: string[][] = [];
35+
const run = async (args: string[]) => {
36+
issued.push(args);
37+
controller.abort(new Error('request canceled'));
38+
return { exitCode: 0, stdout: 'mCurrentFocus=Window{1 u0 StatusBar}', stderr: '' };
39+
};
40+
41+
await expect(readAndroidAppStateWithExecutor(run, controller.signal)).rejects.toThrow(
42+
'request canceled',
43+
);
44+
expect(issued).toEqual([['shell', 'dumpsys', 'window', 'windows']]);
45+
});

packages/platform-android/src/app-state.ts

Lines changed: 7 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,6 @@
1-
import type {
2-
AppStateRuntimeCommand,
3-
AppStateRuntimeCommandResult,
4-
AppStateRuntimeResult,
5-
} from '@agent-device/contracts/app-state-runtime';
6-
import type { DeviceInfo } from '@agent-device/kernel/device';
1+
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
72
import { parseAndroidFocusSegment } from './app-parsers.ts';
83

9-
export type AndroidAppStateHost = Readonly<{
10-
run(
11-
device: DeviceInfo,
12-
command: AppStateRuntimeCommand,
13-
signal: AbortSignal,
14-
): Promise<AppStateRuntimeCommandResult>;
15-
}>;
16-
174
const FOCUS_COMMANDS = [
185
['shell', 'dumpsys', 'window', 'windows'],
196
['shell', 'dumpsys', 'window'],
@@ -29,60 +16,35 @@ export type AndroidCommandExecutor = (
2916

3017
export async function readAndroidAppStateWithExecutor(
3118
run: AndroidCommandExecutor,
19+
signal?: AbortSignal,
3220
): Promise<AppStateRuntimeResult> {
33-
const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS);
21+
const windowFocus = await readAndroidFocusWithExecutor(run, FOCUS_COMMANDS, signal);
3422
if (windowFocus) return windowFocus;
3523

36-
const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS);
24+
const activityFocus = await readAndroidFocusWithExecutor(run, ACTIVITY_COMMANDS, signal);
3725
if (activityFocus) return activityFocus;
3826
return {};
3927
}
4028

4129
async function readAndroidFocusWithExecutor(
4230
run: AndroidCommandExecutor,
4331
commands: readonly (readonly string[])[],
32+
signal?: AbortSignal,
4433
): Promise<AppStateRuntimeResult | null> {
4534
for (const args of commands) {
35+
signal?.throwIfAborted();
4636
const result = await run([...args], { allowFailure: true });
37+
signal?.throwIfAborted();
4738
const parsed = parseAndroidForegroundApp(result.stdout ?? '');
4839
if (parsed) return parsed;
4940
}
5041
return null;
5142
}
5243

53-
export async function readAndroidAppState(
54-
host: AndroidAppStateHost,
55-
device: DeviceInfo,
56-
signal: AbortSignal,
57-
): Promise<AppStateRuntimeResult> {
58-
const windowFocus = await readAndroidFocus(host, device, FOCUS_COMMANDS, signal);
59-
if (windowFocus) return windowFocus;
60-
61-
const activityFocus = await readAndroidFocus(host, device, ACTIVITY_COMMANDS, signal);
62-
if (activityFocus) return activityFocus;
63-
return {};
64-
}
65-
6644
export function parseAndroidForegroundApp(text: string): AppStateRuntimeResult | null {
6745
return parseAndroidFocusSegment(text, (segment) => parseAndroidComponentFromSegment(segment));
6846
}
6947

70-
async function readAndroidFocus(
71-
host: AndroidAppStateHost,
72-
device: DeviceInfo,
73-
commands: readonly (readonly string[])[],
74-
signal: AbortSignal,
75-
): Promise<AppStateRuntimeResult | null> {
76-
for (const args of commands) {
77-
signal.throwIfAborted();
78-
const result = await host.run(device, { args, allowFailure: true }, signal);
79-
signal.throwIfAborted();
80-
const parsed = parseAndroidForegroundApp(result.stdout);
81-
if (parsed) return parsed;
82-
}
83-
return null;
84-
}
85-
8648
function parseAndroidComponentFromSegment(segment: string): AppStateRuntimeResult | null {
8749
const match = segment.match(/\b([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+)\/([A-Za-z0-9_.$]+)/);
8850
return match?.[1] && match[2] ? { package: match[1], activity: match[2] } : null;

packages/platform-android/src/index.ts

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,11 @@
1-
import type {
2-
AppStateRuntimeHost,
3-
AppStateRuntimeResult,
4-
} from '@agent-device/contracts/app-state-runtime';
1+
import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime';
52
import type {
63
InventoryPlatformModule,
74
PlatformModuleMetadata,
85
} from '@agent-device/contracts/platform-module';
96
import type { PlatformRuntimeModule } from '@agent-device/contracts/platform-runtime-operations';
107
import type { DeviceShutdownRuntimeDependencies } from '@agent-device/contracts/device-shutdown-runtime';
11-
import type { DeviceInfo } from '@agent-device/kernel/device';
128
import type { AndroidInventoryConfig } from './inventory-config.ts';
13-
import type { AndroidAppStateHost } from './app-state.ts';
149
import type {
1510
AndroidObservationAdapter,
1611
AndroidObservationHost,
@@ -21,7 +16,6 @@ const metadata = Object.freeze({
2116
} satisfies PlatformModuleMetadata);
2217

2318
export type { AndroidInventoryConfig } from './inventory-config.ts';
24-
export type { AndroidAppStateHost } from './app-state.ts';
2519

2620
/** Package-owned Android observation policy, loaded only when a daemon request needs it. */
2721
export function createAndroidObservationAdapter(
@@ -46,29 +40,35 @@ export function createAndroidObservationAdapter(
4640
});
4741
}
4842

49-
export async function readAndroidAppState(
50-
host: AndroidAppStateHost | AppStateRuntimeHost['android'],
51-
device: DeviceInfo,
52-
signal: AbortSignal,
53-
): Promise<AppStateRuntimeResult> {
54-
const { readAndroidAppState: read } = await import('./app-state.ts');
55-
return await read(host, device, signal);
56-
}
57-
5843
export async function readAndroidAppStateWithExecutor(
5944
run: import('./app-state.ts').AndroidCommandExecutor,
60-
): Promise<import('@agent-device/contracts/app-state-runtime').AppStateRuntimeResult> {
45+
signal?: AbortSignal,
46+
): Promise<AppStateRuntimeResult> {
6147
const { readAndroidAppStateWithExecutor: read } = await import('./app-state.ts');
62-
return await read(run);
48+
return await read(run, signal);
6349
}
6450

65-
export const runtimeModule = Object.freeze({
66-
...metadata,
67-
loadRuntime: async (host) => {
68-
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
69-
return createAndroidPlatformRuntime(host);
70-
},
71-
} satisfies PlatformRuntimeModule);
51+
/** What the composition root supplies before this package's runtime can reach a device. */
52+
export type AndroidRuntimeModuleDependencies = Readonly<{
53+
/**
54+
* Binds the process-wide adb host port (`bindAndroidAdbHost`) the runtime's mechanics run
55+
* through. Awaited before the runtime loads, so no caller has to import anything first.
56+
*/
57+
bindAdbHost(): Promise<void>;
58+
}>;
59+
60+
export function createAndroidRuntimeModule(
61+
dependencies: AndroidRuntimeModuleDependencies,
62+
): PlatformRuntimeModule {
63+
return Object.freeze({
64+
...metadata,
65+
loadRuntime: async (host) => {
66+
await dependencies.bindAdbHost();
67+
const { createAndroidPlatformRuntime } = await import('./runtime.ts');
68+
return createAndroidPlatformRuntime(host);
69+
},
70+
} satisfies PlatformRuntimeModule);
71+
}
7272

7373
export function createAndroidInventoryModule(
7474
config: AndroidInventoryConfig,

packages/platform-android/src/mechanics.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ export async function listAndroidAppsWithAdb(
7272
export {
7373
closeAndroidApp,
7474
isAmStartError,
75-
listAndroidApps,
7675
openAndroidApp,
7776
openAndroidDevice,
7877
parseAndroidLaunchComponent,

packages/platform-android/src/network/runtime.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -150,11 +150,6 @@ function host(options: {
150150
readProcessMarker: async () => options.marker,
151151
},
152152
networkTransports: { resolve: async () => ({ mode: 'local' }) },
153-
appInventory: {
154-
apple: { listApps: async () => [] },
155-
android: { listApps: async () => [] },
156-
harmonyos: { listApps: async () => [] },
157-
},
158153
};
159154
}
160155

@@ -192,16 +187,7 @@ function unusedAppLogHost(): Omit<
192187
terminate: async () => 'already-missing',
193188
},
194189
processTransports: { resolve: async () => ({ mode: 'local' }) },
195-
appInventory: {
196-
apple: { listApps: async () => [] },
197-
android: { listApps: async () => [] },
198-
harmonyos: { listApps: async () => [] },
199-
},
200190
clock: { now: () => 1, sleep: async () => {} },
201-
appState: {
202-
android: { run: async () => ({ stdout: '' }) },
203-
harmonyos: { run: async () => ({ stdout: '' }) },
204-
},
205191
deviceReadiness: {
206192
applePhysical: { ensureConnected: async () => {} },
207193
appleAutomation: {

packages/platform-android/src/runtime-facade.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,36 @@ vi.mock('./logs/runtime.ts', async (loadOriginal) => {
77
return await loadOriginal();
88
});
99

10-
import { runtimeModule } from './index.ts';
10+
import { createAndroidRuntimeModule } from './index.ts';
1111

1212
test('defers Android app-log mechanics until runtime load', async () => {
13+
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost: async () => {} });
1314
expect(mechanics.evaluations).toBe(0);
1415
expect(runtimeModule.family).toBe('android');
1516
await runtimeModule.loadRuntime({} as never);
1617
expect(mechanics.evaluations).toBe(1);
1718
});
19+
20+
test('binds the adb host it was constructed with before the runtime loads', async () => {
21+
const order: string[] = [];
22+
const bindAdbHost = vi.fn(async () => {
23+
order.push('bind-adb-host');
24+
});
25+
const runtimeModule = createAndroidRuntimeModule({ bindAdbHost });
26+
27+
expect(bindAdbHost).not.toHaveBeenCalled();
28+
await runtimeModule.loadRuntime({} as never);
29+
order.push('runtime-loaded');
30+
31+
expect(order).toEqual(['bind-adb-host', 'runtime-loaded']);
32+
});
33+
34+
test('a binding that fails keeps the runtime unloaded', async () => {
35+
const runtimeModule = createAndroidRuntimeModule({
36+
bindAdbHost: async () => {
37+
throw new Error('adb host unavailable');
38+
},
39+
});
40+
41+
await expect(runtimeModule.loadRuntime({} as never)).rejects.toThrow('adb host unavailable');
42+
});

packages/platform-android/src/runtime.fixtures.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,6 @@ const audioProbeHost: PlatformRuntimeHost['audioProbe'] = {
3939
ownedProcesses: { replace: () => {}, clear: () => {} },
4040
};
4141

42-
export const emptyAppInventory = {
43-
apple: { listApps: async () => [] },
44-
android: { listApps: async () => [] },
45-
harmonyos: { listApps: async () => [] },
46-
};
47-
48-
const emptyAppState = {
49-
android: { run: async () => ({ stdout: '' }) },
50-
harmonyos: { run: async () => ({ stdout: '' }) },
51-
};
52-
5342
function localAndroidScreenRecording() {
5443
return {
5544
mode: 'local' as const,
@@ -72,7 +61,6 @@ export function androidRuntimeHost(overrides: Record<string, unknown> = {}): Pla
7261
return {
7362
androidTools: { probeClipboardShellSupport: async () => 'supported' as const },
7463
processTransports: { resolve: async () => ({ mode: 'local' as const }) },
75-
appInventory: emptyAppInventory,
7664
localInteractors: { resolve: async () => ({}) },
7765
audioProbe: audioProbeHost,
7866
screenRecording: { android: { resolve: async () => localAndroidScreenRecording() } },
@@ -89,7 +77,6 @@ export function androidNavigationHost(
8977
probeClipboardShellSupport,
9078
runAdb: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
9179
},
92-
appState: emptyAppState,
9380
deviceReadiness: { android: { ensureReady: async (selected: DeviceInfo) => selected } },
9481
});
9582
}

0 commit comments

Comments
 (0)