From 26016baabb8b36a15c7a50c9a96e57c32e79f995 Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Tue, 6 Oct 2026 17:03:22 +0200 Subject: [PATCH 1/2] feat(limrun): press the iOS home button on Limrun instances --- .../src/app-log-runtime.test.ts | 13 +++---- .../src/interaction-operations.test.ts | 13 +++---- .../src/interaction-operations.ts | 13 +++---- .../src/ios-interactor-home.test.ts | 35 +++++++++++++++++++ packages/provider-limrun/src/ios.ts | 8 +++++ website/docs/docs/limrun.md | 2 +- 6 files changed, 56 insertions(+), 28 deletions(-) create mode 100644 packages/provider-limrun/src/ios-interactor-home.test.ts diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index 54de2f926c..9736086110 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -379,9 +379,9 @@ test('fails closed for a stale Android identity before exposing facts or binding }); /** - * back/orientation are admitted on both direct-session platforms; home/tv-remote differ by + * back/orientation/home are admitted on both direct-session platforms; tv-remote differs by * platform (the Android leg reuses the local family's interactor factory, the iOS leg refuses - * both explicitly); keyboard status/dismiss/enter reuse that same Android-only interactor. + * it explicitly); keyboard status/dismiss/enter reuse that same Android-only interactor. */ function expectLimrunNavigationAndKeyboardFacts( binding: DeviceBinding, @@ -392,13 +392,8 @@ function expectLimrunNavigationAndKeyboardFacts( expect(binding.operations.back).toBeTypeOf('function'); expect(binding.facts.operations.setOrientation).toEqual({ available: true }); expect(binding.operations.setOrientation).toBeTypeOf('function'); - expect(binding.facts.operations.home.available).toBe(isAndroid); - expect(binding.operations.home).toBeTypeOf(isAndroid ? 'function' : 'undefined'); - if (!isAndroid) { - expect(binding.facts.operations.home).toMatchObject({ - hint: 'Limrun iOS direct sessions do not expose home yet.', - }); - } + expect(binding.facts.operations.home).toEqual({ available: true }); + expect(binding.operations.home).toBeTypeOf('function'); // tv-remote additionally requires a real TV target on the Android leg. expect(binding.facts.operations.tvRemote.available).toBe(false); expect(binding.operations.tvRemote).toBeUndefined(); diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 5873908ba0..415ae2ca00 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -77,9 +77,7 @@ test('clipboard follows the same Android-reuse / iOS-refusal split its siblings expect(stale.writeClipboard).toEqual(liveSessionUnavailable); }); -// R56: the springboard buttons follow the same Android-reuse / iOS-refusal split; a hardware -// button is refused on both legs, and a dead session closes the whole family. -test('home and app-switcher ride the Android interactor and are refused on the iOS leg', () => { +test('home is admitted on both legs, app-switcher only on Android, the action button on neither', () => { const android = limrunSystemButtonOperationFacts(androidMobileDevice); expect(android.home).toEqual({ available: true }); expect(android.appSwitcher).toEqual({ available: true }); @@ -89,11 +87,7 @@ test('home and app-switcher ride the Android interactor and are refused on the i }); const ios = limrunSystemButtonOperationFacts(iosDevice); - expect(ios.home).toEqual({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose home yet.', - }); + expect(ios.home).toEqual({ available: true }); expect(ios.appSwitcher).toEqual({ available: false, reason: 'unsupported-provider-mode', @@ -231,7 +225,8 @@ test('binding omits every operation an unavailable fact refused', () => { expect(operations.back).toBeTypeOf('function'); expect(operations.setOrientation).toBeTypeOf('function'); - expect(operations.home).toBeUndefined(); + expect(operations.home).toBeTypeOf('function'); + expect(operations.appSwitcher).toBeUndefined(); expect(operations.tvRemote).toBeUndefined(); }); diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 6bd898b3dc..5a2f4ccd03 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -99,11 +99,6 @@ function limrunGestureFacts( viewport: available, }); } -const homeUnavailableIos = Object.freeze({ - available: false, - reason: 'unsupported-provider-mode', - hint: 'Limrun iOS direct sessions do not expose home yet.', -} as const); const tvRemoteUnavailableIos = Object.freeze({ available: false, reason: 'unsupported-provider-mode', @@ -316,9 +311,9 @@ export function limrunAlertOperationFacts( } /** - * The system buttons split the way the navigation leaves do: the Android leg rides the local - * family's own interactor factory for `home` and `appSwitcher`, the iOS direct session refuses - * both, and no Limrun session exposes a hardware button on either leg. + * The Android leg rides the local family's own interactor factory for `home` and `appSwitcher`. + * The iOS direct session presses `home` through Limrun's button actions and refuses + * `appSwitcher`. No Limrun session exposes the action button. */ export function limrunSystemButtonOperationFacts( device: DeviceInfo, @@ -336,7 +331,7 @@ export function limrunSystemButtonOperationFacts( } return systemButtonRuntimeOperationFacts({ unsupported: systemButtonUnavailable, - home: homeUnavailableIos, + home: available, appSwitcher: appSwitcherUnavailableIos, }); } diff --git a/packages/provider-limrun/src/ios-interactor-home.test.ts b/packages/provider-limrun/src/ios-interactor-home.test.ts new file mode 100644 index 0000000000..0ca18e0c6d --- /dev/null +++ b/packages/provider-limrun/src/ios-interactor-home.test.ts @@ -0,0 +1,35 @@ +import { expect, test, vi } from 'vitest'; +import { createLimrunIosInteractor, type LimrunIosSession } from './ios.ts'; + +function sessionWithClient() { + const client = { + pressKey: vi.fn(async () => {}), + performActions: vi.fn(async () => ({ results: [] })), + }; + const session = { + platform: 'ios', + instanceId: 'limrun-home-instance', + client, + } as unknown as LimrunIosSession; + return { interactor: createLimrunIosInteractor(session), client }; +} + +test('home presses and releases the hardware home button in one batch', async () => { + const { interactor, client } = sessionWithClient(); + + await interactor.home!(); + + expect(client.performActions).toHaveBeenCalledTimes(1); + expect(client.performActions).toHaveBeenCalledWith([ + { type: 'buttonDown', button: 'home' }, + { type: 'buttonUp', button: 'home' }, + ]); + expect(client.pressKey).not.toHaveBeenCalled(); +}); + +test('a batch the device rejects surfaces as the home failure', async () => { + const { interactor, client } = sessionWithClient(); + client.performActions.mockRejectedValueOnce(new Error('buttonDown: unsupported button')); + + await expect(interactor.home!()).rejects.toThrow('buttonDown: unsupported button'); +}); diff --git a/packages/provider-limrun/src/ios.ts b/packages/provider-limrun/src/ios.ts index 6b3e32e85f..18edf0baba 100644 --- a/packages/provider-limrun/src/ios.ts +++ b/packages/provider-limrun/src/ios.ts @@ -356,6 +356,14 @@ class LimrunIosInteractor implements Interactor { await this.session.client.pressKey('escape'); } + /** One press and release of the hardware home button, sent as a single action batch. */ + async home(): Promise { + await this.session.client.performActions([ + { type: 'buttonDown', button: 'home' }, + { type: 'buttonUp', button: 'home' }, + ]); + } + async setOrientation(orientation: DeviceRotation): Promise { if (orientation === 'portrait-upside-down') { throw unsupported( diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 31ab07470a..a0438527a4 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, 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, 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. From f29d4a8e884d8b3b9490e9bbf8f0723351d7413f Mon Sep 17 00:00:00 2001 From: Jakub Romanczyk Date: Tue, 6 Oct 2026 18:16:25 +0200 Subject: [PATCH 2/2] docs(limrun): keep the system button and keyboard comments on what they describe --- packages/provider-limrun/src/interaction-operations.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 5a2f4ccd03..1783529b59 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -136,7 +136,7 @@ const appSwitcherUnavailableIos = Object.freeze({ reason: 'unsupported-provider-mode', hint: 'Limrun iOS direct sessions do not expose app switcher yet.', } as const); -/** No Limrun session exposes a hardware button; only the springboard buttons split by leg. */ +/** No Limrun session exposes the action button; only `appSwitcher` still splits by leg. */ const systemButtonUnavailable = Object.freeze({ available: false, reason: 'unsupported-provider-mode',