diff --git a/package.json b/package.json index 7a32681976..d2b76e1e14 100644 --- a/package.json +++ b/package.json @@ -311,7 +311,7 @@ "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", - "@limrun/api": "^0.49.3", + "@limrun/api": "^0.59.0", "@nkzw/eslint-plugin": "^2.0.0", "@nkzw/oxlint-config": "^1.3.0", "@stryker-mutator/core": "10.0.0", diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index e549f0e323..bb4db57a97 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -17,6 +17,6 @@ } }, "devDependencies": { - "@limrun/api": "^0.49.3" + "@limrun/api": "^0.59.0" } } diff --git a/packages/provider-limrun/src/app-log-reconnect.test.ts b/packages/provider-limrun/src/app-log-reconnect.test.ts index 70df0b5353..2b3fae9306 100644 --- a/packages/provider-limrun/src/app-log-reconnect.test.ts +++ b/packages/provider-limrun/src/app-log-reconnect.test.ts @@ -95,6 +95,32 @@ test('fails closed when the instance labels do not match the descriptor lease', expect(outcome).toEqual({ status: 'ownership-lost' }); }); +test('reports an owned instance whose status carries no control token as missing', async () => { + vi.mocked(createIosInstanceClient).mockClear(); + const outcome = await reconnectLimrunAppLogReader({ + limrun: { + iosInstances: { + get: vi.fn(async () => ({ + metadata: { labels: { provider: 'limrun', leaseId: 'lease-a' } }, + status: { state: 'ready', apiUrl: 'https://instance' }, + })), + }, + } as never, + descriptor: { + transport: 'limrun-log-poller', + platform: 'ios', + leaseId: 'lease-a', + instanceId: 'instance-a', + ownership: 'created', + appBundleId: 'com.example.app', + outputPath: '/sessions/one/app.log', + }, + dependencies: {} as LimrunRuntimeDependencies, + }); + expect(outcome).toEqual({ status: 'missing' }); + expect(createIosInstanceClient).not.toHaveBeenCalled(); +}); + test('disconnects the Android instance client when tunnel acquisition fails', async () => { androidClient.disconnect.mockClear(); await expect( diff --git a/packages/provider-limrun/src/app-log-reconnect.ts b/packages/provider-limrun/src/app-log-reconnect.ts index f01a86855a..f18c3fe41f 100644 --- a/packages/provider-limrun/src/app-log-reconnect.ts +++ b/packages/provider-limrun/src/app-log-reconnect.ts @@ -79,7 +79,7 @@ async function reconnectIos(options: { ); if (selected.status !== 'active') return selected; return await openIosReader( - { apiUrl: selected.apiUrl, token: selected.instance.status.token }, + { apiUrl: selected.apiUrl, token: selected.token }, options.descriptor, ); } @@ -126,7 +126,7 @@ async function reconnectAndroid(options: { { apiUrl: selected.apiUrl, adbUrl: instance.status.adbWebSocketUrl, - token: instance.status.token, + token: selected.token, }, options.descriptor, options.dependencies, @@ -199,24 +199,25 @@ async function openAndroidReader( type ReconnectableLimrunInstance = Readonly<{ metadata: Readonly<{ labels?: Record }>; - status: Readonly<{ state: string; apiUrl?: string | null }>; + status: Readonly<{ state: string; apiUrl?: string | null; token?: string }>; }>; async function selectOwnedActiveInstance( load: () => Promise, descriptor: LimrunAppLogDescriptor, ): Promise< - | Readonly<{ status: 'active'; instance: Instance; apiUrl: string }> + | Readonly<{ status: 'active'; instance: Instance; apiUrl: string; token: string }> | Extract > { const instance = await load(); if (!hasDescriptorOwnership(instance.metadata.labels, descriptor)) { return { status: 'ownership-lost' }; } - if (instance.status.state === 'terminated' || !instance.status.apiUrl) { + const { apiUrl, token } = instance.status; + if (instance.status.state === 'terminated' || !apiUrl || !token) { return { status: 'missing' }; } - return { status: 'active', instance, apiUrl: instance.status.apiUrl }; + return { status: 'active', instance, apiUrl, token }; } function hasDescriptorOwnership( diff --git a/packages/provider-limrun/src/facts-runtime.ts b/packages/provider-limrun/src/facts-runtime.ts index a68d22c991..8adff5cfd0 100644 --- a/packages/provider-limrun/src/facts-runtime.ts +++ b/packages/provider-limrun/src/facts-runtime.ts @@ -193,7 +193,7 @@ export function limrunAppLogFacts( ...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }), ...limrunNavigationOperationFacts(device), ...limrunKeyboardOperationFacts(device), - ...limrunClipboardOperationFacts(device), + ...limrunClipboardOperationFacts(), ...limrunSystemButtonOperationFacts(device), ...limrunFoldOperationFacts(), ...limrunAppEventOperationFacts(device), @@ -256,7 +256,7 @@ export function limrunAppLogRecoveryFacts( ...limrunInteractionOperationFacts(device, liveSessionUnavailable), ...limrunNavigationOperationFacts(device, liveSessionUnavailable), ...limrunKeyboardOperationFacts(device, liveSessionUnavailable), - ...limrunClipboardOperationFacts(device, liveSessionUnavailable), + ...limrunClipboardOperationFacts(liveSessionUnavailable), ...limrunSystemButtonOperationFacts(device, liveSessionUnavailable), ...limrunFoldOperationFacts(), ...limrunAppEventOperationFacts(device, liveSessionUnavailable), diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 415ae2ca00..46fbb36229 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -55,24 +55,12 @@ test('the Android leg admits back/orientation and gates tv-remote on a real TV t expect(tv.tvRemote).toEqual({ available: true }); }); -// R55: the Android leg reuses the local family's `createAndroidInteractor`, so `cmd clipboard -// get/set text` reaches the device exactly as it does locally; the iOS leg's own clipboard -// methods throw, so both halves stay unavailable there. -test('clipboard follows the same Android-reuse / iOS-refusal split its siblings do', () => { - const android = limrunClipboardOperationFacts(androidMobileDevice); - expect(android.readClipboard).toEqual({ available: true }); - expect(android.writeClipboard).toEqual({ available: true }); - - const ios = limrunClipboardOperationFacts(iosDevice); - const refusal = { - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', - }; - expect(ios.readClipboard).toEqual(refusal); - expect(ios.writeClipboard).toEqual(refusal); +test('a live session reads and writes the clipboard on both legs', () => { + const live = limrunClipboardOperationFacts(); + expect(live.readClipboard).toEqual({ available: true }); + expect(live.writeClipboard).toEqual({ available: true }); - const stale = limrunClipboardOperationFacts(androidMobileDevice, liveSessionUnavailable); + const stale = limrunClipboardOperationFacts(liveSessionUnavailable); expect(stale.readClipboard).toEqual(liveSessionUnavailable); expect(stale.writeClipboard).toEqual(liveSessionUnavailable); }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 1783529b59..f0a0c3e97e 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -121,11 +121,6 @@ const keyboardUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose keyboard actions.', } as const); -const clipboardUnavailableIos = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose clipboard access yet.', -} as const); const settingsReadUnavailableIos = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -255,18 +250,12 @@ export function limrunNavigationOperationFacts( } /** - * `keyboard` (status/dismiss/enter) shares one cell per session: the Android leg rides the same - * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested - * provider keyboard behavior, so it stays unavailable. - */ -/** - * `clipboard` shares the split its siblings have: the Android leg rides - * `session.dependencies.android.createInteractor` — the SAME factory the local Android family - * binds, so `cmd clipboard get/set text` reaches the device exactly as it does locally — while - * the iOS direct session has no pasteboard transport, so both cells stay unavailable there. + * The Android leg rides `session.dependencies.android.createInteractor`, the factory the local + * Android family binds, so `cmd clipboard get/set text` reaches the device as it does locally. + * The iOS direct session reads and writes the pasteboard through Limrun's `simctl pbpaste` and + * `pbcopy`. */ export function limrunClipboardOperationFacts( - device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, ) { if (liveSessionUnavailable) { @@ -274,11 +263,6 @@ export function limrunClipboardOperationFacts( ...clipboardRuntimeOperationFacts({ unsupported: liveSessionUnavailable }), }); } - if (device.platform !== 'android') { - return Object.freeze({ - ...clipboardRuntimeOperationFacts({ unsupported: clipboardUnavailableIos }), - }); - } return Object.freeze({ ...clipboardRuntimeOperationFacts({ unsupported: clipboardUnsupportedProviderMode, @@ -383,6 +367,11 @@ export function limrunSettingsOperationFacts( }); } +/** + * `keyboard` (status/dismiss/enter) shares one cell per session: the Android leg rides the same + * interactor factory `limrunNavigationOperationFacts` above describes; the iOS leg has no tested + * provider keyboard behavior, so it stays unavailable. + */ export function limrunKeyboardOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, diff --git a/packages/provider-limrun/src/ios-interactor-clipboard.test.ts b/packages/provider-limrun/src/ios-interactor-clipboard.test.ts new file mode 100644 index 0000000000..147d2fcc5b --- /dev/null +++ b/packages/provider-limrun/src/ios-interactor-clipboard.test.ts @@ -0,0 +1,42 @@ +import { expect, test, vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; +import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; + +function sessionWithSimctl(result: { code: number; stdout?: string; stderr?: string }) { + const simctl = vi.fn(() => ({ + wait: async () => ({ stdout: '', stderr: '', ...result }), + })); + const session = { + platform: 'ios', + instanceId: 'limrun-clipboard-instance', + client: { simctl }, + } as unknown as LimrunIosSession; + return { interactor: createLimrunIosInteractor(session), simctl }; +} + +test('reads the pasteboard through simctl pbpaste, dropping the trailing newline', async () => { + const { interactor, simctl } = sessionWithSimctl({ code: 0, stdout: 'first\r\nsecond\n' }); + + await expect(interactor.readClipboard!()).resolves.toBe('first\nsecond'); + expect(simctl).toHaveBeenCalledWith(['pbpaste', 'booted'], undefined); +}); + +test('writes the pasteboard by sending the text to simctl pbcopy on stdin', async () => { + const { interactor, simctl } = sessionWithSimctl({ code: 0 }); + + await interactor.writeClipboard!('hello ✓'); + + expect(simctl).toHaveBeenCalledWith(['pbcopy', 'booted'], { stdin: 'hello ✓' }); +}); + +test('a non-zero simctl exit fails with the command, exit code, and stderr', async () => { + const { interactor } = sessionWithSimctl({ code: 1, stderr: 'pbcopy: refused\n' }); + + const failure = await interactor.writeClipboard!('text').catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(AppError); + expect(failure).toMatchObject({ + code: 'COMMAND_FAILED', + details: { command: 'pbcopy', exitCode: 1, stderr: 'pbcopy: refused' }, + }); +}); diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 18edf0baba..de9b69cbd9 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -364,6 +364,29 @@ class LimrunIosInteractor implements Interactor { ]); } + async readClipboard(): Promise { + const result = await this.pasteboard(['pbpaste', 'booted']); + return result.stdout.replaceAll('\r\n', '\n').replace(/\n$/, ''); + } + + async writeClipboard(text: string): Promise { + await this.pasteboard(['pbcopy', 'booted'], text); + } + + private async pasteboard(argv: string[], stdin?: string) { + const result = await this.session.client + .simctl(argv, stdin === undefined ? undefined : { stdin }) + .wait(); + if (result.code !== 0) { + throw new AppError('COMMAND_FAILED', 'Limrun iOS could not access the clipboard.', { + command: argv[0], + exitCode: result.code, + stderr: result.stderr.trim(), + }); + } + return result; + } + async setOrientation(orientation: DeviceRotation): Promise { if (orientation === 'portrait-upside-down') { throw unsupported( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf1452f083..0b79af7294 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,8 +96,8 @@ importers: specifier: ^0.0.4 version: 0.0.4 '@limrun/api': - specifier: ^0.49.3 - version: 0.49.3(supports-color@7.2.0) + specifier: ^0.59.0 + version: 0.59.0(supports-color@7.2.0) '@nkzw/eslint-plugin': specifier: ^2.0.0 version: 2.0.0(eslint@10.9.1(supports-color@7.2.0)) @@ -467,8 +467,8 @@ importers: version: link:../platform-android devDependencies: '@limrun/api': - specifier: ^0.49.3 - version: 0.49.3(supports-color@7.2.0) + specifier: ^0.59.0 + version: 0.59.0(supports-color@7.2.0) packages/provider-webdriver: dependencies: @@ -1105,8 +1105,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@limrun/api@0.49.3': - resolution: {integrity: sha512-gkVhAutGRFnX5TAebXMvWlTHalODe5/KO3AI0pOb5c/xjO41c2GPjpt/pbMTknBxswo7KSI3KzOeK9ZDEuejGQ==} + '@limrun/api@0.59.0': + resolution: {integrity: sha512-CipjJnvfLq8cTYP58vUneFyJlGvPmr+PspBuuGST1/krYi0QoVOpdNXGqOexio1N6ltnAG5GoQcU40ViDEUTbQ==} '@limrun/xdelta3-wasm@0.2.0': resolution: {integrity: sha512-mEohO7CYj4Gdc0v1Gr/AjlBhfbXQnIz6+2wmMhRfwz8hXZrsIC/yqYQhfoDhu1ysqQwBnU5lDDLPUGdUu+8hPQ==} @@ -4697,7 +4697,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@limrun/api@0.49.3(supports-color@7.2.0)': + '@limrun/api@0.59.0(supports-color@7.2.0)': dependencies: '@limrun/xdelta3-wasm': 0.2.0 '@types/har-format': 1.2.16 diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index a0438527a4..00f4118faf 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -69,7 +69,7 @@ agent-device disconnect Limrun Android uses the direct ADB tunnel. Normal Android helper-backed snapshots, installs, and port reverse flow are available, including the usual Android reverse setup for a local Metro server. -Limrun iOS uses the direct Limrun iOS client. It supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, and app installation. `open --launch-args` relaunches the app with those arguments through simctl. It cannot reverse a remote device port to a local host port. For iOS Metro or React DevTools, use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. +Limrun iOS uses the direct Limrun iOS client. It supports app lifecycle commands, snapshots, screenshots, taps, text input, scrolling, the home button, clipboard read and write, and app installation. `open --launch-args` relaunches the app with those arguments through simctl. It cannot reverse a remote device port to a local host port. For iOS Metro or React DevTools, use a publicly reachable HTTPS endpoint or bridge URL instead of a local-only address. Limrun iOS `settings` supports `appearance`, `permission`, `location`, and `clear-app-state`. They use the same arguments as a local simulator, and `clear-app-state` resets the app's data container through Limrun's soft reset. That reset relaunches the app once before agent-device stops it, so the app may run once after its data is cleared. `reset-keychain`, `wifi`, `airplane`, `faceid`, `touchid`, and `text-size` are unsupported, and no iOS setting can be read back.