diff --git a/src/platforms/__tests__/app-resolution-cache.test.ts b/src/platforms/__tests__/app-resolution-cache.test.ts new file mode 100644 index 000000000..2e7c27d70 --- /dev/null +++ b/src/platforms/__tests__/app-resolution-cache.test.ts @@ -0,0 +1,55 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { createAppResolutionCache } from '../app-resolution-cache.ts'; + +test('app resolution cache returns values until the expiry boundary', () => { + let nowMs = 1_000; + const cache = createAppResolutionCache({ ttlMs: 50, nowMs: () => nowMs }); + const scope = { platform: 'android', deviceId: 'device-a' } as const; + + assert.equal(cache.set(scope, 'Maps', 'com.example.maps'), 'com.example.maps'); + assert.equal(cache.get(scope, 'maps'), 'com.example.maps'); + + nowMs = 1_049; + assert.equal(cache.get(scope, 'Maps'), 'com.example.maps'); + + nowMs = 1_050; + assert.equal(cache.get(scope, 'Maps'), undefined); + assert.equal(cache.get(scope, 'Maps'), undefined); +}); + +test('app resolution cache clear removes all variants for one device', () => { + const cache = createAppResolutionCache({ nowMs: () => 0 }); + const mobile = { platform: 'android', deviceId: 'device-a', variant: 'mobile' } as const; + const tv = { platform: 'android', deviceId: 'device-a', variant: 'tv' } as const; + const otherDevice = { platform: 'android', deviceId: 'device-b', variant: 'mobile' } as const; + const otherPlatform = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const; + + cache.set(mobile, 'Maps', 'com.example.mobile.maps'); + cache.set(tv, 'Maps', 'com.example.tv.maps'); + cache.set(otherDevice, 'Maps', 'com.example.other.maps'); + cache.set(otherPlatform, 'Maps', 'com.example.ios.maps'); + + cache.clear(mobile); + + assert.equal(cache.get(mobile, 'Maps'), undefined); + assert.equal(cache.get(tv, 'Maps'), undefined); + assert.equal(cache.get(otherDevice, 'Maps'), 'com.example.other.maps'); + assert.equal(cache.get(otherPlatform, 'Maps'), 'com.example.ios.maps'); +}); + +test('app resolution cache invalidates before and after an operation', async () => { + const cache = createAppResolutionCache({ nowMs: () => 0 }); + const scope = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const; + + cache.set(scope, 'Maps', 'com.example.before'); + + const result = await cache.invalidateWhile(scope, async () => { + assert.equal(cache.get(scope, 'Maps'), undefined); + cache.set(scope, 'Maps', 'com.example.during'); + return 'installed'; + }); + + assert.equal(result, 'installed'); + assert.equal(cache.get(scope, 'Maps'), undefined); +}); diff --git a/src/platforms/android/__tests__/index.test.ts b/src/platforms/android/__tests__/index.test.ts index 35427917e..d09f1e8c1 100644 --- a/src/platforms/android/__tests__/index.test.ts +++ b/src/platforms/android/__tests__/index.test.ts @@ -10,6 +10,7 @@ import { getAndroidKeyboardState, inferAndroidAppName, installAndroidApp, + installAndroidInstallablePath, isAmStartError, listAndroidApps, openAndroidApp, @@ -978,6 +979,82 @@ test('resolveAndroidApp does not treat file paths as package names', async () => ); }); +test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => { + await withMockedAdb( + 'agent-device-android-resolve-cache-', + [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'if [ "$1" = "-s" ]; then shift; shift; fi', + 'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then', + ' echo "package:com.example.cachemaps"', + ' exit 0', + 'fi', + 'exit 1', + '', + ].join('\n'), + async ({ argsLogPath, device }) => { + const first = await resolveAndroidApp(device, 'cachemaps'); + const second = await resolveAndroidApp(device, 'cachemaps'); + const exact = await resolveAndroidApp(device, 'com.example.cachemaps'); + + assert.deepEqual(first, { type: 'package', value: 'com.example.cachemaps' }); + assert.deepEqual(second, first); + assert.deepEqual(exact, { type: 'package', value: 'com.example.cachemaps' }); + + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.equal((logged.match(/pm list packages/g) ?? []).length, 1); + }, + ); +}); + +test('installAndroidInstallablePath invalidates cached display-name package matches', async () => { + await withMockedAdb( + 'agent-device-android-install-cache-', + [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'if [ "$1" = "-s" ]; then shift; shift; fi', + 'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then', + ' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then', + ' echo "package:com.example.installedcachemaps"', + ' else', + ' echo "package:com.example.cachemaps"', + ' fi', + ' exit 0', + 'fi', + 'if [ "$1" = "install" ] && [ "$2" = "-r" ]; then', + ' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"', + ' exit 0', + 'fi', + 'exit 1', + '', + ].join('\n'), + async ({ device }) => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-cache-apk-')); + const apkPath = path.join(tmpDir, 'App.apk'); + const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER; + process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = path.join(tmpDir, 'installed.marker'); + try { + await fs.writeFile(apkPath, '', 'utf8'); + const before = await resolveAndroidApp(device, 'cachemaps'); + await installAndroidInstallablePath(device, apkPath); + const after = await resolveAndroidApp(device, 'cachemaps'); + + assert.deepEqual(before, { type: 'package', value: 'com.example.cachemaps' }); + assert.deepEqual(after, { type: 'package', value: 'com.example.installedcachemaps' }); + } finally { + if (previousMarker === undefined) { + delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER; + } else { + process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }, + ); +}); + test('openAndroidApp default launch uses -p package flag', async () => { await withMockedAdb( 'agent-device-android-open-default-', diff --git a/src/platforms/android/app-lifecycle.ts b/src/platforms/android/app-lifecycle.ts index 33460f393..0d8746a71 100644 --- a/src/platforms/android/app-lifecycle.ts +++ b/src/platforms/android/app-lifecycle.ts @@ -5,6 +5,7 @@ import { resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { isDeepLinkTarget } from '../../core/open-target.ts'; +import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { waitForAndroidBoot } from './devices.ts'; import { adbArgs } from './adb.ts'; import { classifyAndroidAppTarget } from './open-target.ts'; @@ -34,16 +35,28 @@ const ANDROID_APPS_DISCOVERY_HINT = const ANDROID_AMBIGUOUS_APP_HINT = 'Run agent-device apps --platform android to see the exact installed package names before retrying open.'; +type AndroidAppResolution = { type: 'intent' | 'package'; value: string }; + +const androidAppResolutionCache = createAppResolutionCache(); + +function androidAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope { + return { platform: 'android', deviceId: device.id, variant: device.target ?? '' }; +} + export async function resolveAndroidApp( device: DeviceInfo, app: string, -): Promise<{ type: 'intent' | 'package'; value: string }> { +): Promise { const trimmed = app.trim(); if (classifyAndroidAppTarget(trimmed) === 'package') return { type: 'package', value: trimmed }; const alias = ALIASES[trimmed.toLowerCase()]; if (alias) return alias; + const cacheScope = androidAppResolutionScope(device); + const cached = androidAppResolutionCache.get(cacheScope, trimmed); + if (cached) return cached; + const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages'])); const packages = result.stdout .split('\n') @@ -54,7 +67,10 @@ export async function resolveAndroidApp( pkg.toLowerCase().includes(trimmed.toLowerCase()), ); if (matches.length === 1) { - return { type: 'package', value: matches[0] }; + return androidAppResolutionCache.set(cacheScope, trimmed, { + type: 'package', + value: matches[0], + }); } if (matches.length > 1) { @@ -560,10 +576,12 @@ export async function installAndroidInstallablePath( device: DeviceInfo, installablePath: string, ): Promise { - if (!device.booted) { - await waitForAndroidBoot(device.id); - } - await installAndroidAppFiles(device, installablePath); + await androidAppResolutionCache.invalidateWhile(androidAppResolutionScope(device), async () => { + if (!device.booted) { + await waitForAndroidBoot(device.id); + } + await installAndroidAppFiles(device, installablePath); + }); } export async function installAndroidInstallablePathAndResolvePackageName( @@ -617,18 +635,23 @@ export async function reinstallAndroidApp( app: string, appPath: string, ): Promise<{ package: string }> { - if (!device.booted) { - await waitForAndroidBoot(device.id); - } - const { package: pkg } = await uninstallAndroidApp(device, app); - const prepared = await prepareAndroidInstallArtifact( - { kind: 'path', path: appPath }, - { resolveIdentity: false }, + return await androidAppResolutionCache.invalidateWhile( + androidAppResolutionScope(device), + async () => { + if (!device.booted) { + await waitForAndroidBoot(device.id); + } + const { package: pkg } = await uninstallAndroidApp(device, app); + const prepared = await prepareAndroidInstallArtifact( + { kind: 'path', path: appPath }, + { resolveIdentity: false }, + ); + try { + await installAndroidInstallablePath(device, prepared.installablePath); + } finally { + await prepared.cleanup(); + } + return { package: pkg }; + }, ); - try { - await installAndroidInstallablePath(device, prepared.installablePath); - return { package: pkg }; - } finally { - await prepared.cleanup(); - } } diff --git a/src/platforms/app-resolution-cache.ts b/src/platforms/app-resolution-cache.ts new file mode 100644 index 000000000..dfb9cd8c7 --- /dev/null +++ b/src/platforms/app-resolution-cache.ts @@ -0,0 +1,80 @@ +const APP_RESOLUTION_CACHE_TTL_MS = 30_000; + +export type AppResolutionCacheScope = { + platform: 'android' | 'ios' | 'macos'; + deviceId: string; + variant?: string; +}; + +type AppResolutionCacheEntry = { + value: T; + expiresAtMs: number; +}; + +type AppResolutionCache = { + get(scope: AppResolutionCacheScope, target: string): T | undefined; + set(scope: AppResolutionCacheScope, target: string, value: T): T; + clear(scope: AppResolutionCacheScope): void; + invalidateWhile( + scope: AppResolutionCacheScope, + operation: () => Promise, + ): Promise; +}; + +export function createAppResolutionCache( + options: { ttlMs?: number; nowMs?: () => number } = {}, +): AppResolutionCache { + const ttlMs = options.ttlMs ?? APP_RESOLUTION_CACHE_TTL_MS; + const nowMs = options.nowMs ?? Date.now; + const entries = new Map>(); + const clearScope = (scope: AppResolutionCacheScope): void => { + const prefix = buildAppResolutionCacheScopePrefix(scope); + for (const key of entries.keys()) { + if (key.startsWith(prefix)) { + entries.delete(key); + } + } + }; + + return { + get(scope, target) { + const key = buildAppResolutionCacheKey(scope, target); + const entry = entries.get(key); + if (!entry) return undefined; + if (entry.expiresAtMs <= nowMs()) { + entries.delete(key); + return undefined; + } + return entry.value; + }, + set(scope, target, value) { + entries.set(buildAppResolutionCacheKey(scope, target), { + value, + expiresAtMs: nowMs() + ttlMs, + }); + return value; + }, + clear(scope) { + clearScope(scope); + }, + async invalidateWhile(scope, operation) { + clearScope(scope); + try { + return await operation(); + } finally { + // A concurrent name lookup can finish after the initial clear and repopulate stale data. + clearScope(scope); + } + }, + }; +} + +function buildAppResolutionCacheKey(scope: AppResolutionCacheScope, target: string): string { + return [scope.platform, scope.deviceId, scope.variant ?? '', target.trim().toLowerCase()].join( + '\0', + ); +} + +function buildAppResolutionCacheScopePrefix(scope: AppResolutionCacheScope): string { + return [scope.platform, scope.deviceId, ''].join('\0'); +} diff --git a/src/platforms/ios/__tests__/index.test.ts b/src/platforms/ios/__tests__/index.test.ts index 331728c0b..959415c07 100644 --- a/src/platforms/ios/__tests__/index.test.ts +++ b/src/platforms/ios/__tests__/index.test.ts @@ -46,6 +46,7 @@ const screenshotStatusBarActual = await vi.importActual< import { closeIosApp, installIosApp, + installIosInstallablePath, listIosApps, openIosApp, parseIosDeviceAppsPayload, @@ -1708,6 +1709,128 @@ test('resolveIosApp resolves app display name on iOS physical devices', async () } }); +test('resolveIosApp caches display-name bundle matches but bypasses exact bundle ids', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-resolve-cache-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const argsLogPath = path.join(tmpDir, 'args.log'); + await fs.writeFile( + xcrunPath, + [ + '#!/bin/sh', + 'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"', + 'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then', + " cat <<'JSON'", + '{"com.example.cachemaps":{"CFBundleDisplayName":"Cache Maps"}}', + 'JSON', + ' exit 0', + 'fi', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + + const previousPath = process.env.PATH; + const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; + + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-cache-1', + name: 'iPhone Cache', + kind: 'simulator', + booted: true, + }; + + try { + const first = await resolveIosApp(device, 'Cache Maps'); + const second = await resolveIosApp(device, 'Cache Maps'); + const exact = await resolveIosApp(device, 'com.example.cachemaps'); + + assert.equal(first, 'com.example.cachemaps'); + assert.equal(second, 'com.example.cachemaps'); + assert.equal(exact, 'com.example.cachemaps'); + + const logged = await fs.readFile(argsLogPath, 'utf8'); + assert.equal((logged.match(/simctl listapps/g) ?? []).length, 1); + } finally { + process.env.PATH = previousPath; + if (previousArgsFile === undefined) { + delete process.env.AGENT_DEVICE_TEST_ARGS_FILE; + } else { + process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +test('installIosInstallablePath invalidates cached display-name bundle matches', async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-install-cache-')); + const xcrunPath = path.join(tmpDir, 'xcrun'); + const appPath = path.join(tmpDir, 'Cache.app'); + const markerPath = path.join(tmpDir, 'installed.marker'); + await fs.writeFile( + xcrunPath, + [ + '#!/bin/sh', + 'if [ "$1" = "simctl" ] && [ "$2" = "listapps" ]; then', + ' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then', + " cat <<'JSON'", + '{"com.example.installedcachemaps":{"CFBundleDisplayName":"Cache Maps"}}', + 'JSON', + ' else', + " cat <<'JSON'", + '{"com.example.cachemaps":{"CFBundleDisplayName":"Cache Maps"}}', + 'JSON', + ' fi', + ' exit 0', + 'fi', + 'if [ "$1" = "simctl" ] && [ "$2" = "install" ]; then', + ' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"', + ' exit 0', + 'fi', + 'exit 1', + '', + ].join('\n'), + 'utf8', + ); + await fs.chmod(xcrunPath, 0o755); + await fs.mkdir(appPath); + + const previousPath = process.env.PATH; + const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER; + process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; + process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = markerPath; + + const device: DeviceInfo = { + platform: 'ios', + id: 'sim-cache-install-1', + name: 'iPhone Cache', + kind: 'simulator', + booted: true, + }; + mockEnsureBootedSimulator.mockResolvedValue(undefined); + + try { + const before = await resolveIosApp(device, 'Cache Maps'); + await installIosInstallablePath(device, appPath); + const after = await resolveIosApp(device, 'Cache Maps'); + + assert.equal(before, 'com.example.cachemaps'); + assert.equal(after, 'com.example.installedcachemaps'); + } finally { + process.env.PATH = previousPath; + if (previousMarker === undefined) { + delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER; + } else { + process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker; + } + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + test('listIosApps applies user-installed filter on simulator', async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-ios-list-sim-')); const xcrunPath = path.join(tmpDir, 'xcrun'); diff --git a/src/platforms/ios/apps.ts b/src/platforms/ios/apps.ts index 370dd8e0e..11d7b8ab2 100644 --- a/src/platforms/ios/apps.ts +++ b/src/platforms/ios/apps.ts @@ -14,6 +14,7 @@ import { type PermissionSettingOptions, } from '../permission-utils.ts'; import { parseAppearanceAction } from '../appearance.ts'; +import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { IOS_APP_LAUNCH_TIMEOUT_MS, IOS_DEVICECTL_TIMEOUT_MS } from './config.ts'; import { @@ -52,9 +53,15 @@ export { const ALIASES: Record = { settings: 'com.apple.Preferences', }; + +const iosAppResolutionCache = createAppResolutionCache(); let cachedSimctlPrivacyServices: Set | null = null; let cachedSimctlPrivacyServicesCacheKey: string | undefined; +function iosAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope { + return { platform: 'ios', deviceId: device.id, variant: device.kind }; +} + function simctlArgs(device: DeviceInfo, args: string[]): string[] { return buildSimctlArgsForDevice(device, args); } @@ -85,12 +92,17 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise entry.name.toLowerCase() === trimmed.toLowerCase()); - if (matches.length === 1) return matches[0].bundleId; + if (matches.length === 1) + return iosAppResolutionCache.set(cacheScope, trimmed, matches[0].bundleId); if (matches.length > 1) { throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); } @@ -203,49 +215,51 @@ export async function uninstallIosApp( device: DeviceInfo, app: string, ): Promise<{ bundleId: string }> { - const bundleId = await resolveIosApp(device, app); - if (device.kind !== 'simulator') { - const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId]; - const result = await runCmd('xcrun', args, { + return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { + const bundleId = await resolveIosApp(device, app); + if (device.kind !== 'simulator') { + const args = ['devicectl', 'device', 'uninstall', 'app', '--device', device.id, bundleId]; + const result = await runCmd('xcrun', args, { + allowFailure: true, + timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, + }); + if (result.exitCode !== 0) { + const stdout = String(result.stdout ?? ''); + const stderr = String(result.stderr ?? ''); + const output = `${stdout}\n${stderr}`.toLowerCase(); + if (!isMissingAppErrorOutput(output)) { + throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { + cmd: 'xcrun', + args, + exitCode: result.exitCode, + stdout, + stderr, + deviceId: device.id, + hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, + }); + } + } + return { bundleId }; + } + + await ensureBootedSimulator(device); + + const result = await runSimctl(device, ['uninstall', device.id, bundleId], { allowFailure: true, - timeoutMs: IOS_DEVICECTL_TIMEOUT_MS, }); if (result.exitCode !== 0) { - const stdout = String(result.stdout ?? ''); - const stderr = String(result.stderr ?? ''); - const output = `${stdout}\n${stderr}`.toLowerCase(); + const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `Failed to uninstall iOS app ${bundleId}`, { - cmd: 'xcrun', - args, + throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { + stdout: result.stdout, + stderr: result.stderr, exitCode: result.exitCode, - stdout, - stderr, - deviceId: device.id, - hint: resolveIosDevicectlHint(stdout, stderr) ?? IOS_DEVICECTL_DEFAULT_HINT, }); } } - return { bundleId }; - } - - await ensureBootedSimulator(device); - const result = await runSimctl(device, ['uninstall', device.id, bundleId], { - allowFailure: true, + return { bundleId }; }); - if (result.exitCode !== 0) { - const output = `${result.stdout}\n${result.stderr}`.toLowerCase(); - if (!isMissingAppErrorOutput(output)) { - throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, { - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.exitCode, - }); - } - } - - return { bundleId }; } export async function installIosApp( @@ -279,25 +293,29 @@ export async function reinstallIosApp( app: string, appPath: string, ): Promise<{ bundleId: string }> { - const { bundleId } = await uninstallIosApp(device, app); - await installIosApp(device, appPath, { appIdentifierHint: app }); - return { bundleId }; + return await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { + const { bundleId } = await uninstallIosApp(device, app); + await installIosApp(device, appPath, { appIdentifierHint: app }); + return { bundleId }; + }); } export async function installIosInstallablePath( device: DeviceInfo, installablePath: string, ): Promise { - if (device.kind !== 'simulator') { - await runIosDevicectl(['device', 'install', 'app', '--device', device.id, installablePath], { - action: 'install iOS app', - deviceId: device.id, - }); - return; - } + await iosAppResolutionCache.invalidateWhile(iosAppResolutionScope(device), async () => { + if (device.kind !== 'simulator') { + await runIosDevicectl(['device', 'install', 'app', '--device', device.id, installablePath], { + action: 'install iOS app', + deviceId: device.id, + }); + return; + } - await ensureBootedSimulator(device); - await runSimctl(device, ['install', device.id, installablePath]); + await ensureBootedSimulator(device); + await runSimctl(device, ['install', device.id, installablePath]); + }); } export async function readIosClipboardText(device: DeviceInfo): Promise { diff --git a/src/platforms/ios/macos-apps.ts b/src/platforms/ios/macos-apps.ts index b17e47b58..329a36831 100644 --- a/src/platforms/ios/macos-apps.ts +++ b/src/platforms/ios/macos-apps.ts @@ -6,6 +6,7 @@ import type { DeviceInfo } from '../../utils/device.ts'; import { AppError } from '../../utils/errors.ts'; import { runCmd } from '../../utils/exec.ts'; import { parseAppearanceAction } from '../appearance.ts'; +import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts'; import { filterAppleAppsByBundlePrefix } from './app-filter.ts'; import { quitMacOsApp } from './macos-helper.ts'; import { readInfoPlistString } from './plist.ts'; @@ -17,6 +18,14 @@ const MACOS_ALIASES: Record = { const MACOS_BUNDLE_ID_PATTERN = /^[a-z0-9-]+(?:\.[a-z0-9-]+)+$/; +// macOS currently has no install/uninstall flow; add cache invalidation if that changes. +const MACOS_APP_RESOLUTION_CACHE_SCOPE = { + platform: 'macos', + deviceId: 'host', + variant: 'all', +} satisfies AppResolutionCacheScope; +const macOsAppResolutionCache = createAppResolutionCache(); + function isMacOsBundleId(value: string): boolean { return MACOS_BUNDLE_ID_PATTERN.test(value); } @@ -60,9 +69,18 @@ export async function resolveMacOsApp(app: string): Promise { return trimmed; } + const cached = macOsAppResolutionCache.get(MACOS_APP_RESOLUTION_CACHE_SCOPE, trimmed); + if (cached) return cached; + const apps = await listMacApps('all'); const matches = apps.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase()); - if (matches.length === 1) return matches[0].bundleId; + if (matches.length === 1) { + return macOsAppResolutionCache.set( + MACOS_APP_RESOLUTION_CACHE_SCOPE, + trimmed, + matches[0].bundleId, + ); + } if (matches.length > 1) { throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches }); }