diff --git a/docs/adr/0022-daemon-platform-runtime-coupling.md b/docs/adr/0022-daemon-platform-runtime-coupling.md index f21b8bd1c6..5c9856db8e 100644 --- a/docs/adr/0022-daemon-platform-runtime-coupling.md +++ b/docs/adr/0022-daemon-platform-runtime-coupling.md @@ -51,9 +51,11 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. (`daemon-runtime.ts` → `platform-runtime-apple-runner-owner.ts`, `platform-runtime-resource-cleanup.ts`, and the dynamic `platform-runtime-operation-host.ts` import). - - **#2334** — open-target planning separated from platform mechanics - (`session-open-prepare.ts`, `session-selector-dispatch.ts` → - `platform-runtime-open-target.ts`), blocked by #2332. + - **Open-target planning** — surface/relaunch policy and session identity consume a neutral + root surface. Session identity and lifecycle opens share the application tools' + `resolveOpenTarget` result; the daemon supplies no platform callback. Android package + lookup and post-open foreground inference live in `packages/platform-android`, while + Apple target probes stay behind the existing Apple application tools. - **#2273/#2274** (existing) — `direct-ios-selector.ts` → `queryAppleRuntimeSelector` is the selector seam those issues own; coordination was posted there rather than opening a second selector producer. @@ -82,7 +84,7 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. 5. **Per-audit-area decisions.** Apple session observation: consume the neutral `AppleSessionObservation` contract. Runtime lifecycle participation: deepen through the existing lifecycle phases, no generic hook bag (#2333). Open-target planning: separate plan/result from - platform mechanics, one construction path preserved (#2334). Session state/store authority: + platform mechanics through the existing application-tool resolution. Session state/store authority: keep the current access shape, ratchet the handler-owned slice (R75). Route depth: record the re-traced routes; collapse only the proven pass-through hops (none undertaken in this change). @@ -117,5 +119,5 @@ or owning issue. #2278 audited all four concerns at `27a97ee619`. `scripts/layering/check.ts` (both observed red against planted violations before acceptance). - R7 `session-state-ownership` and the R10 merge-base ratchet for the owning-module slice. - R65 for the concrete-platform-import ban this audit builds on. -- Child issues #2333, #2334 (and #2273/#2274 for the selector seam) for the remaining category-3 +- Child issue #2333 (and #2273/#2274 for the selector seam) for the remaining category-3 implementation work. diff --git a/packages/platform-android/src/__tests__/app-deployment-resolution.test.ts b/packages/platform-android/src/__tests__/app-deployment-resolution.test.ts new file mode 100644 index 0000000000..a83f1d9601 --- /dev/null +++ b/packages/platform-android/src/__tests__/app-deployment-resolution.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from 'vitest'; +import { resolveAndroidPackageForOpen } from '../app-deployment-resolution.ts'; +import { withFakeAdb } from './test-utils/fake-adb.ts'; + +test.each([ + ['com.example.app', 'com.example.app'], + ['settings', undefined], + ['myapp://login', undefined], + ['https://example.com', undefined], + [undefined, undefined], +])('open package resolution avoids a device probe for %s', async (target, expected) => { + await withFakeAdb( + () => new Error('unexpected device probe'), + async ({ calls, device }) => { + expect(await resolveAndroidPackageForOpen(device, target)).toBe(expected); + expect(calls).toEqual([]); + }, + ); +}); + +test('open package resolution adopts a unique installed package match', async () => { + await withFakeAdb( + () => 'package:com.example.calendar\npackage:com.example.mail', + async ({ calls, device }) => { + expect(await resolveAndroidPackageForOpen(device, 'calendar')).toBe('com.example.calendar'); + expect(calls).toEqual([['shell', 'pm', 'list', 'packages']]); + }, + ); +}); + +test.each([ + ['missing', 'package:com.example.app'], + ['ambiguous', 'package:com.example.ambiguous.one\npackage:com.example.ambiguous.two'], + ['unavailable', new Error('device unavailable')], +] as const)('open package resolution leaves %s identity inconclusive', async (target, response) => { + await withFakeAdb( + () => response, + async ({ device }) => { + expect(await resolveAndroidPackageForOpen(device, target)).toBeUndefined(); + }, + ); +}); diff --git a/packages/platform-android/src/__tests__/window-state.test.ts b/packages/platform-android/src/__tests__/window-state.test.ts index 82907b6730..bdb0e543ce 100644 --- a/packages/platform-android/src/__tests__/window-state.test.ts +++ b/packages/platform-android/src/__tests__/window-state.test.ts @@ -4,6 +4,7 @@ import { withFakeAdb } from './test-utils/fake-adb.ts'; import { createAndroidWindowDumpReader, getAndroidAppState, + inferAndroidPackageAfterOpen, getAndroidBlockingDialogObservation, resetAndroidWindowDumpFocusMemoForTests, type AndroidBlockingDialogObservation, @@ -40,6 +41,63 @@ beforeEach(() => { resetAndroidWindowDumpFocusMemoForTests(); }); +test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => { + await withFakeAdb( + () => 'mCurrentFocus=Window{a1b2c3 u0 host.exp.exponent/.experience.ExperienceActivity}', + async ({ calls, device }) => { + assert.equal( + await inferAndroidPackageAfterOpen(device, 'exp://127.0.0.1:8082', undefined), + 'host.exp.exponent', + ); + assert.deepEqual( + calls.map((args) => args.join(' ')), + [dumpsysWindowWindows], + ); + }, + ); +}); + +test('post-open inference preserves an existing package without observing another foreground app', async () => { + await withFakeAdb( + () => NORMAL_FOCUS_DUMP, + async ({ calls, device }) => { + assert.equal( + await inferAndroidPackageAfterOpen(device, 'myapp://login', 'com.example.current'), + 'com.example.current', + ); + assert.deepEqual(calls, []); + }, + ); +}); + +test.each([undefined, 'com.example.app'])( + 'post-open inference does not probe for a non-URL target: %s', + async (target) => { + await withFakeAdb( + () => NORMAL_FOCUS_DUMP, + async ({ calls, device }) => { + assert.equal(await inferAndroidPackageAfterOpen(device, target, undefined), undefined); + assert.deepEqual(calls, []); + }, + ); + }, +); + +test.each(['', new Error('device unavailable')])( + 'post-open inference leaves absent or failed foreground evidence inconclusive: %s', + async (response) => { + await withFakeAdb( + () => response, + async ({ device }) => { + assert.equal( + await inferAndroidPackageAfterOpen(device, 'myapp://login', undefined), + undefined, + ); + }, + ); + }, +); + test('a focused-window dump answers the blocking-dialog question without a second dumpsys variant', async () => { const { calls, observation } = await withFakeAdb( (args) => (args.join(' ') === dumpsysWindowWindows ? NORMAL_FOCUS_DUMP : ''), diff --git a/packages/platform-android/src/app-deployment-resolution.ts b/packages/platform-android/src/app-deployment-resolution.ts index 9345717ed3..bd7c440d46 100644 --- a/packages/platform-android/src/app-deployment-resolution.ts +++ b/packages/platform-android/src/app-deployment-resolution.ts @@ -1,3 +1,4 @@ +import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { @@ -78,6 +79,20 @@ export async function resolveAndroidApp( }); } +export async function resolveAndroidPackageForOpen( + device: DeviceInfo, + openTarget: string | undefined, +): Promise { + if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) + return undefined; + try { + const resolved = await resolveAndroidApp(device, openTarget); + return resolved.type === 'package' ? resolved.value : undefined; + } catch { + return undefined; + } +} + /** Produces a readable display label when an Android provider reports only a package id. */ export function inferAndroidAppName(packageName: string): string { const ignoredTokens = new Set([ diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index adad0225ec..bee09cc903 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -54,6 +54,7 @@ export { androidAppsDiscoveryHint, inferAndroidAppName, resolveAndroidApp, + resolveAndroidPackageForOpen, withAndroidAppResolutionCacheInvalidated, type AndroidAppResolution, } from './app-deployment-resolution.ts'; @@ -327,6 +328,7 @@ export { export { createAndroidWindowDumpReader, getAndroidAppState, + inferAndroidPackageAfterOpen, getAndroidBlockingDialogObservation, resetAndroidWindowDumpFocusMemoForTests, type AndroidBlockingDialogObservation, diff --git a/packages/platform-android/src/window-state.ts b/packages/platform-android/src/window-state.ts index f85b60caac..b70b4ab225 100644 --- a/packages/platform-android/src/window-state.ts +++ b/packages/platform-android/src/window-state.ts @@ -1,3 +1,4 @@ +import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { runAndroidAdb } from './adb.ts'; @@ -135,6 +136,23 @@ export async function getAndroidAppState( return {}; } +export async function inferAndroidPackageAfterOpen( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId: string | undefined, +): Promise { + if (currentAppBundleId) return currentAppBundleId; + if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) { + return currentAppBundleId; + } + try { + const foreground = await getAndroidAppState(device); + return foreground.package?.trim() || currentAppBundleId; + } catch { + return currentAppBundleId; + } +} + /** * A dump that shows the focused window has answered the blocking-dialog question, whether or not * the answer is a dialog. The remaining variants exist for devices whose earlier one says nothing diff --git a/scripts/layering/daemon-platform-runtime-inventory.test.ts b/scripts/layering/daemon-platform-runtime-inventory.test.ts index bf6d4c8489..9a7fb6afd5 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.test.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.test.ts @@ -30,6 +30,22 @@ test('R76 accepts a classified edge with the exact recorded symbols', () => { assert.deepEqual(edgeViolations(sources, 'src/daemon/device-ready.ts'), []); }); +test('R76 rejects Android package mechanics alongside the selector target policy', () => { + const file = 'src/daemon/handlers/session-selector-dispatch.ts'; + const sources = { + 'src/platform-runtime-open-target.ts': + 'export function resolveAndroidPackageForOpen() {}\n' + + 'export function resolveSessionAppBundleIdForTarget() {}\n', + [file]: + "import { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts';\n" + + 'void [resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget];\n', + }; + const found = edgeViolations(sources, file); + assert.equal(found.length, 1); + assert.equal(found[0]!.rule, DAEMON_PLATFORM_RUNTIME_RULE); + assert.match(found[0]!.message, /classified symbols drifted/); +}); + for (const [file, target, symbol] of [ ['request-recording-health', 'apple-resources', 'inspectAppleRunnerSession'], ['session-device-resolution', 'apple-resources', 'inspectAppleRunnerSession'], diff --git a/scripts/layering/daemon-platform-runtime-inventory.ts b/scripts/layering/daemon-platform-runtime-inventory.ts index 29760e4098..b7865cc7a3 100644 --- a/scripts/layering/daemon-platform-runtime-inventory.ts +++ b/scripts/layering/daemon-platform-runtime-inventory.ts @@ -167,22 +167,21 @@ export const DAEMON_PLATFORM_RUNTIME_EDGES: readonly DaemonPlatformRuntimeEdge[] { file: 'src/daemon/handlers/session-selector-dispatch.ts', target: 'src/platform-runtime-open-target.ts', - symbols: ['resolveAndroidPackageForOpen', 'resolveSessionAppBundleIdForTarget'], - classification: 'leaked-platform-mechanics', + symbols: ['resolveSessionAppBundleIdForTarget'], + classification: 'daemon-policy-essential', rationale: - 'selector dispatch consumes the mixed open-target module; Android package resolution ' + - 'is platform mechanics that should sit behind the Android owning seam.', - deepenedBy: '#2334', + 'selector dispatch derives session identity through the same neutral open-target ' + + 'resolution used by lifecycle operations; application tools own the selected-family ' + + 'observations, with Android package lookup behind its package mechanics seam.', }, { file: 'src/daemon/session-lifecycle/internal/session-open-prepare.ts', target: 'src/platform-runtime-open-target.ts', symbols: ['resolveRequestedOpenSurface', 'validateOpenRelaunchTarget'], - classification: 'leaked-platform-mechanics', + classification: 'daemon-policy-essential', rationale: - 'open-prepare policy consumes the mixed open-target module; the neutral open ' + - 'plan/result should be separated from the platform mechanics that share the file.', - deepenedBy: '#2334', + 'open preparation consumes surface and relaunch policy only; device observations ' + + 'are owned by the application tools rather than reconstructed in daemon planning.', }, ] as const; diff --git a/src/__tests__/platform-runtime-android-application-tools.test.ts b/src/__tests__/platform-runtime-android-application-tools.test.ts index 89329763aa..d951e3172d 100644 --- a/src/__tests__/platform-runtime-android-application-tools.test.ts +++ b/src/__tests__/platform-runtime-android-application-tools.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidApplicationTools } from '../platform-runtime-android-application-tools.ts'; +import * as androidMechanics from '../platform-runtime-android-mechanics.ts'; const activateAndroidTestIme = vi.hoisted(() => vi.fn()); const restoreAndroidTestIme = vi.hoisted(() => vi.fn()); @@ -9,6 +10,7 @@ const restoreAndroidTestIme = vi.hoisted(() => vi.fn()); vi.mock('@agent-device/platform-android/mechanics', () => ({ activateAndroidTestIme, restoreAndroidTestIme, + inferAndroidPackageAfterOpen: async () => 'com.example.foreground', listAndroidAdbSerialsQuick: async () => [], restoreOrphanedAndroidTestImeOnDaemonStartup: async () => undefined, })); @@ -27,6 +29,42 @@ const settled = { helperPackageName: 'pkg', }; +describe('android application tools: optional opened package inference', () => { + afterEach(() => vi.restoreAllMocks()); + + test.each([ + ['a targetless fresh open', undefined, undefined], + ['a targetless open with an existing identity', undefined, 'com.example.app'], + ['a deep link with an existing identity', 'example://home', 'com.example.app'], + ])('%s needs no Android mechanics', async (_name, target, currentAppBundleId) => { + const load = vi + .spyOn(androidMechanics, 'loadAndroidMechanics') + .mockRejectedValue(new Error('Android mechanics unavailable')); + + await expect( + createAndroidApplicationTools().inferOpenedAppBundleId(device, target, currentAppBundleId), + ).resolves.toBe(currentAppBundleId); + expect(load).not.toHaveBeenCalled(); + }); + + test('a deep link leaves its package identity unset if Android mechanics cannot load', async () => { + const load = vi + .spyOn(androidMechanics, 'loadAndroidMechanics') + .mockRejectedValue(new Error('Android mechanics unavailable')); + + await expect( + createAndroidApplicationTools().inferOpenedAppBundleId(device, 'example://home', undefined), + ).resolves.toBeUndefined(); + expect(load).toHaveBeenCalledOnce(); + }); + + test('a deep link adopts the inferred foreground package when mechanics are available', async () => { + await expect( + createAndroidApplicationTools().inferOpenedAppBundleId(device, 'example://home', undefined), + ).resolves.toBe('com.example.foreground'); + }); +}); + describe('android application tools: test IME activation policy', () => { // Test IME is default-on for emulators, so an unobtainable helper must not fail the open. test('an unobtainable helper warns and leaves the open successful', async () => { diff --git a/src/__tests__/platform-runtime-open-target.test.ts b/src/__tests__/platform-runtime-open-target.test.ts new file mode 100644 index 0000000000..9faf40b18d --- /dev/null +++ b/src/__tests__/platform-runtime-open-target.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vitest'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { resolveSessionAppBundleIdForTarget } from '../platform-runtime-open-target.ts'; +import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; + +test('session target planning resolves an Android package without a platform callback', async () => { + await expect( + resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, 'com.example.app', undefined), + ).resolves.toBe('com.example.app'); +}); + +test('session target planning preserves Android app context across a deep link', async () => { + await expect( + resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, 'myapp://login', 'com.example.app'), + ).resolves.toBe('com.example.app'); +}); + +test.each([undefined, 'settings'])( + 'session target planning does not retain an Android package for a non-app target: %s', + async (target) => { + await expect( + resolveSessionAppBundleIdForTarget(ANDROID_EMULATOR, target, 'com.example.previous'), + ).resolves.toBeUndefined(); + }, +); + +const harmonyDevice: DeviceInfo = { + platform: 'harmonyos', + id: '127.0.0.1:5555', + name: 'HarmonyOS Emulator', + kind: 'emulator', + booted: true, +}; + +test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => { + await expect( + resolveSessionAppBundleIdForTarget(harmonyDevice, 'com.example.application', undefined), + ).resolves.toBe('com.example.application'); +}); + +test.each(['myapp://login', 'https://example.com', 'Demo App'])( + 'HarmonyOS retains the existing app across non-bundle targets: %s', + async (openTarget) => { + await expect( + resolveSessionAppBundleIdForTarget(harmonyDevice, openTarget, 'com.example.application'), + ).resolves.toBe('com.example.application'); + }, +); diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index 5ad1583ae0..b1c0560ca3 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -22,11 +22,11 @@ vi.mock('../../../platform-runtime-runtime-hints.ts', async (importOriginal) => await importOriginal(); return { ...actual, applyRuntimeHintValues: vi.fn(async () => {}) }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn() }; -}); vi.mock('@agent-device/platform-android/mechanics', () => ({ + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + inferAndroidPackageAfterOpen: vi.fn( + async (_device, _target, currentAppBundleId) => currentAppBundleId, + ), activateAndroidTestIme: vi.fn(async () => ({ activated: false })), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}), @@ -40,8 +40,10 @@ vi.mock('@agent-device/host-kit/process', async (importOriginal) => import { resolveTargetDevice } from '@agent-device/device-selection/dispatch-resolve'; import { ensureDeviceReady } from '../../device-ready.ts'; import { applyRuntimeHintValues } from '../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts'; -import { activateAndroidTestIme } from '@agent-device/platform-android/mechanics'; +import { + activateAndroidTestIme, + resolveAndroidPackageForOpen, +} from '@agent-device/platform-android/mechanics'; import { discoverReadyAndroidEmulators, dispatchApplicationLifecycleEffect, diff --git a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts index c416d5e2a3..97c98f6ae8 100644 --- a/src/daemon/handlers/__tests__/session-relaunch-close.test.ts +++ b/src/daemon/handlers/__tests__/session-relaunch-close.test.ts @@ -46,8 +46,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); +vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); diff --git a/src/daemon/handlers/__tests__/session-test-harness.ts b/src/daemon/handlers/__tests__/session-test-harness.ts index 31fc7522dd..e85490028a 100644 --- a/src/daemon/handlers/__tests__/session-test-harness.ts +++ b/src/daemon/handlers/__tests__/session-test-harness.ts @@ -68,14 +68,11 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); return { ...actual, + resolveAndroidPackageForOpen: vi.fn(async () => undefined), activateAndroidTestIme: vi.fn(async () => ({ activated: false })), restoreAndroidTestIme: vi.fn(async () => ({ restored: false, reason: 'no-record' })), }; @@ -113,7 +110,7 @@ import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId, } from '@agent-device/platform-apple/app-resolution'; -import { resolveAndroidPackageForOpen } from '../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { runCmd } from '@agent-device/host-kit/command'; import { dispatchApplicationLifecycleEffect } from '../../__tests__/application-lifecycle-runtime-fixture.ts'; diff --git a/src/daemon/handlers/session-selector-dispatch.ts b/src/daemon/handlers/session-selector-dispatch.ts index c876d377dd..bec0f91e98 100644 --- a/src/daemon/handlers/session-selector-dispatch.ts +++ b/src/daemon/handlers/session-selector-dispatch.ts @@ -14,10 +14,7 @@ import { resolveBoundAppEventRuntime } from '../app-event-runtime.ts'; import { resolveBoundKeyboardRuntime } from '../keyboard-runtime.ts'; import { resolveRefFrameEffect } from '../daemon-command-registry.ts'; import { expireRefFrame } from '../ref-frame.ts'; -import { - resolveAndroidPackageForOpen, - resolveSessionAppBundleIdForTarget, -} from '../../platform-runtime-open-target.ts'; +import { resolveSessionAppBundleIdForTarget } from '../../platform-runtime-open-target.ts'; import type { BindDeviceRuntime, InspectDeviceRuntimeFacts } from '../request-runtime-binding.ts'; import type { DaemonCommandContext } from '../context.ts'; import type { DeviceReadyOptions } from '../device-ready.ts'; @@ -228,7 +225,6 @@ export async function handleAppEventCommand( session.device, eventUrl, session.appBundleId, - resolveAndroidPackageForOpen, )) ?? session.appBundleId) : session.appBundleId; return { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts index dad7ddb26d..199e523d95 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-execution-runtime.test.ts @@ -41,14 +41,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => await importOriginal(); return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.demo') }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })) }; + return { + ...actual, + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + activateAndroidTestIme: vi.fn(async () => ({ activated: false })), + }; }); vi.mock('@agent-device/host-kit/process', async (importOriginal) => { const actual = await importOriginal(); @@ -64,7 +63,7 @@ import { applyRuntimeHintValues, clearRuntimeHintValues, } from '../../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { makeAndroidEmulator, diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts index d92d1895ae..41f6a9a54d 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-runtime.test.ts @@ -53,14 +53,13 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => await importOriginal(); return { ...actual, resolveIosApp: vi.fn(async () => 'com.example.demo') }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); - return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; -}); vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, activateAndroidTestIme: vi.fn(async () => ({ activated: false })) }; + return { + ...actual, + resolveAndroidPackageForOpen: vi.fn(async () => undefined), + activateAndroidTestIme: vi.fn(async () => ({ activated: false })), + }; }); vi.mock('@agent-device/host-kit/process', async (importOriginal) => { const actual = await importOriginal(); @@ -73,7 +72,7 @@ import { mockInspectDeviceRuntimeFacts, } from '../../../handlers/__tests__/session-command-harness.ts'; import { applyRuntimeHintValues } from '../../../../platform-runtime-runtime-hints.ts'; -import { resolveAndroidPackageForOpen } from '../../../../platform-runtime-open-target.ts'; +import { resolveAndroidPackageForOpen } from '@agent-device/platform-android/mechanics'; import { dispatchApplicationLifecycleEffect } from '../../../__tests__/application-lifecycle-runtime-fixture.ts'; import { lifecycleRuntimeFacts } from '../../../__tests__/application-lifecycle-runtime-harness.ts'; import { diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts deleted file mode 100644 index 1cc81c4664..0000000000 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-target.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { beforeEach, expect, test, vi } from 'vitest'; -import type { DeviceInfo } from '@agent-device/kernel/device'; -import { getAndroidAppState } from '@agent-device/platform-android/mechanics'; -import { - inferAndroidPackageAfterOpen, - resolveSessionAppBundleIdForTarget, -} from '../../../../platform-runtime-open-target.ts'; - -vi.mock('@agent-device/platform-android/mechanics', () => ({ - getAndroidAppState: vi.fn(), -})); - -const mockGetAndroidAppState = vi.mocked(getAndroidAppState); -const androidDevice: DeviceInfo = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel Emulator', - kind: 'emulator', - booted: true, -}; -const harmonyDevice: DeviceInfo = { - platform: 'harmonyos', - id: '127.0.0.1:5555', - name: 'HarmonyOS Emulator', - kind: 'emulator', - booted: true, -}; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -test('inferAndroidPackageAfterOpen reads foreground package for Android URL opens', async () => { - mockGetAndroidAppState.mockResolvedValue({ - package: 'host.exp.exponent', - activity: 'host.exp.exponent.experience.ExperienceActivity', - }); - - await expect( - inferAndroidPackageAfterOpen(androidDevice, 'exp://127.0.0.1:8082', undefined), - ).resolves.toBe('host.exp.exponent'); -}); - -test('HarmonyOS adopts an explicit bundle-id target for app-scoped commands', async () => { - const resolveAndroidPackageForOpen = vi.fn(async () => undefined); - - await expect( - resolveSessionAppBundleIdForTarget( - harmonyDevice, - 'com.example.application', - undefined, - resolveAndroidPackageForOpen, - ), - ).resolves.toBe('com.example.application'); - expect(resolveAndroidPackageForOpen).not.toHaveBeenCalled(); -}); - -test.each(['myapp://login', 'https://example.com', 'Demo App'])( - 'HarmonyOS retains the existing app across non-bundle targets: %s', - async (openTarget) => { - await expect( - resolveSessionAppBundleIdForTarget( - harmonyDevice, - openTarget, - 'com.example.application', - vi.fn(async () => undefined), - ), - ).resolves.toBe('com.example.application'); - }, -); diff --git a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts index 8b296734d4..85286b85c3 100644 --- a/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts +++ b/src/daemon/session-lifecycle/internal/__tests__/session-open-url-prewarm.test.ts @@ -38,9 +38,8 @@ vi.mock('@agent-device/platform-apple/app-resolution', async (importOriginal) => resolveIosSimulatorDeepLinkBundleId: vi.fn(async () => undefined), }; }); -vi.mock('../../../../platform-runtime-open-target.ts', async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock('@agent-device/platform-android/mechanics', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) }; }); diff --git a/src/platform-runtime-android-application-tools.ts b/src/platform-runtime-android-application-tools.ts index e77c27e382..a96ab73b11 100644 --- a/src/platform-runtime-android-application-tools.ts +++ b/src/platform-runtime-android-application-tools.ts @@ -1,3 +1,4 @@ +import { isDeepLinkTarget } from '@agent-device/contracts/command'; import type { AndroidApplicationTools, OpenTargetResolution, @@ -13,9 +14,6 @@ import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; * See the same loaders in `platform-runtime-apple-application-tools.ts` for why a port never * opens its own `import(...)` (#2314). */ -let openTargetModule: Promise | undefined; -const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts')); - let runtimeHintsModule: Promise | undefined; const loadRuntimeHints = () => (runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts')); @@ -25,8 +23,13 @@ export function createAndroidApplicationTools(): AndroidApplicationTools { return Object.freeze({ resolveOpenTarget: async (device, input) => await resolveAndroidOpenTarget(device, input), inferOpenedAppBundleId: async (device, target, currentAppBundleId) => { - const { inferAndroidPackageAfterOpen } = await loadOpenTarget(); - return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId); + if (currentAppBundleId || !target) return currentAppBundleId; + try { + const { inferAndroidPackageAfterOpen } = await loadAndroidMechanics(); + return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId); + } catch { + return currentAppBundleId; + } }, resetFramePerfStats: async (device, appBundleId) => { const { resetAndroidFramePerfStats } = await loadAndroidMechanics(); @@ -96,15 +99,11 @@ async function resolveAndroidOpenTarget( device: DeviceInfo, input: OpenTargetResolutionInput, ): Promise { - const { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } = - await loadOpenTarget(); + const { resolveAndroidPackageForOpen } = await loadAndroidMechanics(); return { - appBundleId: await resolveSessionAppBundleIdForTarget( - device, - input.target, - input.currentAppBundleId, - resolveAndroidPackageForOpen, - ), + appBundleId: + (await resolveAndroidPackageForOpen(device, input.target)) ?? + (input.target && isDeepLinkTarget(input.target) ? input.currentAppBundleId : undefined), appName: input.target, }; } diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 321e287871..a6b5f00355 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -1,3 +1,8 @@ +import { + isDeepLinkTarget, + isWebUrl, + resolveIosDeviceDeepLinkBundleId, +} from '@agent-device/contracts/command'; import type { AppleApplicationTools, AppleRunnerSessionPrewarmOptions, @@ -6,7 +11,12 @@ import type { OpenTargetResolutionInput, PrepareAppleRunnerInput, } from '@agent-device/contracts/application-lifecycle-runtime'; -import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; +import { + isIosFamily, + isMacOs, + isApplePlatform, + type DeviceInfo, +} from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; /** @@ -31,9 +41,6 @@ let runtimeHintsModule: Promise (runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts')); -let openTargetModule: Promise | undefined; -const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts')); - let appResolutionModule: | Promise | undefined; @@ -141,20 +148,59 @@ async function resolveAppleOpenTarget( ); } const macOsSurface = await resolveMacOsSurface(device, input.surface); - const { resolveSessionAppBundleIdForTarget } = await loadOpenTarget(); return { appBundleId: macOsSurface.appBundleId ?? - (await resolveSessionAppBundleIdForTarget( - device, - input.target, - input.currentAppBundleId, - async () => undefined, - )), + (await resolveIosBundleIdForOpen(device, input.target, input.currentAppBundleId)), appName: macOsSurface.appName ?? input.target, }; } +async function resolveIosBundleIdForOpen( + device: DeviceInfo, + openTarget: string | undefined, + currentAppBundleId?: string, +): Promise { + if (!isApplePlatform(device.platform) || !openTarget) return undefined; + if (isDeepLinkTarget(openTarget)) { + if (isMacOs(device)) return undefined; + if (device.kind === 'device') { + return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); + } + if (!isWebUrl(openTarget)) { + return ( + currentAppBundleId ?? (await tryResolveIosSimulatorDeepLinkBundleId(device, openTarget)) + ); + } + return undefined; + } + return await tryResolveIosAppBundleId(device, openTarget); +} + +async function tryResolveIosSimulatorDeepLinkBundleId( + device: DeviceInfo, + openTarget: string, +): Promise { + try { + const { resolveIosSimulatorDeepLinkBundleId } = await loadAppResolution(); + return await resolveIosSimulatorDeepLinkBundleId(device, openTarget); + } catch { + return undefined; + } +} + +async function tryResolveIosAppBundleId( + device: DeviceInfo, + openTarget: string, +): Promise { + try { + const { resolveIosApp } = await loadAppResolution(); + return await resolveIosApp(device, openTarget); + } catch { + return undefined; + } +} + async function resolveAppleForegroundTarget( device: DeviceInfo, ): Promise { diff --git a/src/platform-runtime-open-target.ts b/src/platform-runtime-open-target.ts index 9a5f2e3deb..64029c16c3 100644 --- a/src/platform-runtime-open-target.ts +++ b/src/platform-runtime-open-target.ts @@ -1,18 +1,26 @@ -import { - isDeepLinkTarget, - isWebUrl, - resolveIosDeviceDeepLinkBundleId, -} from '@agent-device/contracts/command'; +import { isDeepLinkTarget } from '@agent-device/contracts/command'; import { parseSessionSurface, type SessionSurface } from '@agent-device/contracts/session'; import { isMacOs, isApplePlatform, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; +let appleApplicationsModule: + | Promise + | undefined; +const loadAppleApplications = () => + (appleApplicationsModule ??= import('./platform-runtime-apple-application-tools.ts')); + +let androidApplicationsModule: + | Promise + | undefined; +const loadAndroidApplications = () => + (androidApplicationsModule ??= import('./platform-runtime-android-application-tools.ts')); + const LINUX_SUPPORTED_SURFACES = new Set(['app', 'desktop', 'frontmost-app']); /** - * Platform-owned surface classification for open. Daemon handlers retain only the public - * error-response construction and session-policy choice of an existing surface. + * Open surface policy. Daemon handlers retain response construction and the choice to reuse + * an existing session surface; application tools own device observations. */ export function resolveRequestedOpenSurface(params: { device: DeviceInfo; @@ -99,92 +107,6 @@ export async function validateOpenRelaunchTarget(params: { return undefined; } -async function resolveIosBundleIdForOpen( - device: DeviceInfo, - openTarget: string | undefined, - currentAppBundleId?: string, -): Promise { - if (!isApplePlatform(device.platform) || !openTarget) return undefined; - if (isDeepLinkTarget(openTarget)) { - if (isMacOs(device)) return undefined; - if (device.kind === 'device') { - return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); - } - if (!isWebUrl(openTarget)) { - return ( - currentAppBundleId ?? (await tryResolveIosSimulatorDeepLinkBundleId(device, openTarget)) - ); - } - return undefined; - } - return await tryResolveIosAppBundleId(device, openTarget); -} - -async function tryResolveIosSimulatorDeepLinkBundleId( - device: DeviceInfo, - openTarget: string, -): Promise { - try { - const { resolveIosSimulatorDeepLinkBundleId } = - await import('@agent-device/platform-apple/app-resolution'); - return await resolveIosSimulatorDeepLinkBundleId(device, openTarget); - } catch { - return undefined; - } -} - -async function tryResolveIosAppBundleId( - device: DeviceInfo, - openTarget: string, -): Promise { - try { - const { resolveIosApp } = await import('@agent-device/platform-apple/app-resolution'); - return await resolveIosApp(device, openTarget); - } catch { - return undefined; - } -} - -export async function resolveAndroidPackageForOpen( - device: DeviceInfo, - openTarget: string | undefined, -): Promise { - if (device.platform !== 'android' || !openTarget || isDeepLinkTarget(openTarget)) - return undefined; - try { - const { resolveAndroidApp } = await loadAndroidMechanics(); - const resolved = await resolveAndroidApp(device, openTarget); - return resolved.type === 'package' ? resolved.value : undefined; - } catch { - return undefined; - } -} - -export async function inferAndroidPackageAfterOpen( - device: DeviceInfo, - openTarget: string | undefined, - currentAppBundleId: string | undefined, -): Promise { - if (currentAppBundleId) return currentAppBundleId; - if (device.platform !== 'android' || !openTarget || !isDeepLinkTarget(openTarget)) { - return currentAppBundleId; - } - try { - const { getAndroidAppState } = await loadAndroidMechanics(); - const foreground = await getAndroidAppState(device); - return foreground.package?.trim() || currentAppBundleId; - } catch { - return currentAppBundleId; - } -} - -function shouldPreserveAndroidPackageContext( - device: DeviceInfo, - openTarget: string | undefined, -): boolean { - return device.platform === 'android' && Boolean(openTarget && isDeepLinkTarget(openTarget)); -} - /** Harmony's local target contract accepts an explicit dotted package without an adb lookup. */ function bundleIdFromOpenTarget(openTarget: string | undefined): string | undefined { const trimmed = openTarget?.trim(); @@ -196,17 +118,18 @@ export async function resolveSessionAppBundleIdForTarget( device: DeviceInfo, openTarget: string | undefined, currentAppBundleId: string | undefined, - resolveAndroidPackageForOpenFn: ( - device: DeviceInfo, - openTarget: string | undefined, - ) => Promise, ): Promise { if (device.platform === 'harmonyos') { return bundleIdFromOpenTarget(openTarget) ?? currentAppBundleId; } - return ( - (await resolveIosBundleIdForOpen(device, openTarget, currentAppBundleId)) ?? - (await resolveAndroidPackageForOpenFn(device, openTarget)) ?? - (shouldPreserveAndroidPackageContext(device, openTarget) ? currentAppBundleId : undefined) - ); + const input = { target: openTarget, currentAppBundleId, surface: 'app' as const }; + if (isApplePlatform(device.platform)) { + const { createAppleApplicationTools } = await loadAppleApplications(); + return (await createAppleApplicationTools().resolveOpenTarget(device, input)).appBundleId; + } + if (device.platform === 'android') { + const { createAndroidApplicationTools } = await loadAndroidApplications(); + return (await createAndroidApplicationTools().resolveOpenTarget(device, input)).appBundleId; + } + return undefined; }