Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/provider-limrun/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
}
},
"devDependencies": {
"@limrun/api": "^0.49.3"
"@limrun/api": "^0.59.0"
}
}
26 changes: 26 additions & 0 deletions packages/provider-limrun/src/app-log-reconnect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
13 changes: 7 additions & 6 deletions packages/provider-limrun/src/app-log-reconnect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -199,24 +199,25 @@ async function openAndroidReader(

type ReconnectableLimrunInstance = Readonly<{
metadata: Readonly<{ labels?: Record<string, string> }>;
status: Readonly<{ state: string; apiUrl?: string | null }>;
status: Readonly<{ state: string; apiUrl?: string | null; token?: string }>;
}>;

async function selectOwnedActiveInstance<Instance extends ReconnectableLimrunInstance>(
load: () => Promise<Instance>,
descriptor: LimrunAppLogDescriptor,
): Promise<
| Readonly<{ status: 'active'; instance: Instance; apiUrl: string }>
| Readonly<{ status: 'active'; instance: Instance; apiUrl: string; token: string }>
| Extract<LimrunAppLogReconnectOutcome, { status: 'missing' | 'ownership-lost' }>
> {
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(
Expand Down
4 changes: 2 additions & 2 deletions packages/provider-limrun/src/facts-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ export function limrunAppLogFacts(
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementTextUnavailable }),
...limrunNavigationOperationFacts(device),
...limrunKeyboardOperationFacts(device),
...limrunClipboardOperationFacts(device),
...limrunClipboardOperationFacts(),
...limrunSystemButtonOperationFacts(device),
...limrunFoldOperationFacts(),
...limrunAppEventOperationFacts(device),
Expand Down Expand Up @@ -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),
Expand Down
22 changes: 5 additions & 17 deletions packages/provider-limrun/src/interaction-operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
29 changes: 9 additions & 20 deletions packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -255,30 +250,19 @@ 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
Comment thread
jbroma marked this conversation as resolved.
* 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) {
return Object.freeze({
...clipboardRuntimeOperationFacts({ unsupported: liveSessionUnavailable }),
});
}
if (device.platform !== 'android') {
return Object.freeze({
...clipboardRuntimeOperationFacts({ unsupported: clipboardUnavailableIos }),
});
}
return Object.freeze({
...clipboardRuntimeOperationFacts({
unsupported: clipboardUnsupportedProviderMode,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions packages/provider-limrun/src/ios-interactor-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -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' },
});
});
23 changes: 23 additions & 0 deletions packages/provider-limrun/src/ios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,29 @@ class LimrunIosInteractor implements Interactor {
]);
}

async readClipboard(): Promise<string> {
const result = await this.pasteboard(['pbpaste', 'booted']);
return result.stdout.replaceAll('\r\n', '\n').replace(/\n$/, '');
Comment thread
jbroma marked this conversation as resolved.
}

async writeClipboard(text: string): Promise<void> {
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<void> {
if (orientation === 'portrait-upside-down') {
throw unsupported(
Expand Down
14 changes: 7 additions & 7 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion website/docs/docs/limrun.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading