Skip to content
Open
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: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
- Add `settings mobile-data on|off` for adb-backed Android targets. It switches mobile data through `svc data` and reports the `mobileData` state stored in the global `mobile_data` row; multi-SIM read-back is not covered yet. Every other target refuses it before anything runs, and `--app` is refused because the switch is device-wide.
- On iOS, a `fill` that runs under a degraded XCTest snapshot channel now aims at a point, including ref, selector and replay fills. When the runner identifies no text input under that point, it refuses with `NO_TEXT_INPUT_AT_POINT` (`error.details.reason: no_text_input_at_point`, `dispatched: no`) before dispatching its focus tap, and no field changes; when the input lookup itself cannot answer, it refuses with `TEXT_INPUT_PROBE_UNAVAILABLE` (`error.details.reason: text_input_probe_unavailable`). Previously such a fill cleared and typed through the app-level first responder, so it could rewrite a field the caller never named, or report a clear failure after emptying it. A ref or selector fill whose rect centre misses the real field (an outer container, a non-input node) now needs a point inside the input's own rect.
- Opening a macOS app by bundle identifier now waits briefly when the app is already running but not yet registered with LaunchServices — the case when a supervisor or dev script started it directly — and if registration arrives within that bounded window the open attaches to the running copy, so the session drives the app the caller started, with its arguments. A copy still unlisted when the window closes gets the plain `open` as before, which can still duplicate; the wait only bounds the race, it does not close it. Launching an app that is not running costs one extra LaunchServices lookup and one process-table read, plus one bundle-identifier read when app-shaped processes exist.
- Limrun iOS sessions now serve `alert get/wait/accept/dismiss` instead of refusing with `unsupported-provider-mode`. System alerts are read from the same element-tree capture snapshots ride — a blank-label, process-exclusive application root hosting a text-and-buttons sheet — and accepted or dismissed by tapping the button whose English label states the asked-for action; a capture that is alert-shaped but unresolvable (degraded buttons, competing sheets, an unverifiable owner) is reported as undecided and never pressed or counted as dismissed. The one shape no capture fact separates is an app dialog under a blanked root shared by no other process; the label-only acting legs answer it with a refusal, never a guess.
- The tree-based alert backends (Android and Limrun iOS) now share one alert driver and one English button-label vocabulary declared as a per-role preference table: `alert accept/dismiss` classify the same label the same way on both platforms, so Android now recognizes iOS variant grants (`Allow Once`, `Allow All the Time`, `Allow While Using App`, `Allow Notifications`) and `Ask App Not to Track` as acting choices, and a sheet offering two grants (Android 11+'s "While using the app"/"Only this time", iOS's "Allow Once"/"Allow While Using App") resolves by the declared preference order rather than tree order or refusal. A choice refuses only when the capture names no single physical button for the action — the same label at two points — and each backend's own policy answers then (Android's neutral-button/Back fallback, Limrun's typed refusal). `alert not found` and the no-matching-button refusal now carry the same typed `details.reason` on both platforms. `alert get` reports its outcome as a typed `status` (`visible`/`none`/`undecided`) so clients switch on states instead of parsing the message. Every alert budget now bounds the capture and press inside it, with per-transport honesty: Android hands the leg's cancellation to the adb executor, which kills the child in flight; Limrun's client exposes no abort, so its guards answer post-capture and pre-press — a late answer is refused and nothing is pressed, but an already-sent request runs on. An `alert wait` budget above 2147483647 ms is refused as `INVALID_ARGS` rather than misfiring a timer.
- Limrun iOS snapshots no longer fail when the instance reports a numeric or boolean accessibility value (the system photo picker's tabs report `1`/`0`): the provider seam normalizes variant scalars to text, the way the Apple tree does. Limrun nodes now also publish `selected: true` for nodes carrying the `Selected` trait, so snapshot output and `selected:` selectors can tell a picked photo from an unpicked one.
- `settings` refuses a setting the target does not serve before anything changes, with `UNSUPPORTED_OPERATION`, `settings <setting> is not supported on this device`, and the target's `error.details.reason` (such as `unsupported-platform-leaf`). This replaces the macOS `INVALID_ARGS` refusal and the Apple `setting-unsupported-on-leaf` reason, and now also covers settings that used to reach the device first, such as Android `reset-keychain` or Apple `animations`.
- `settings` refuses, before touching the device, a state its setting does not accept (`Invalid <setting> state: …`), a permission action or mode its target does not take (such as `permission deny accessibility`), and a stray extra argument (such as `clear-app-state <app> <extra>`). The CLI and the daemon accept the same state spellings, in any casing or surrounding whitespace.
Expand Down
53 changes: 53 additions & 0 deletions packages/contracts/src/alert-contract.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest';
import { classifyAlertButtonRole, selectRankedAlertButtonByRole } from './alert-contract.ts';

type Button = { label: string; role: ReturnType<typeof classifyAlertButtonRole> };
const button = (label: string): Button => ({ label, role: classifyAlertButtonRole(label) });

describe('selectRankedAlertButtonByRole', () => {
it('resolves distinct grants by the declared preference order', () => {
const buttons = [button("Don't Allow"), button('Allow Once'), button('Allow While Using App')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('Allow While Using App');
expect(selectRankedAlertButtonByRole(buttons, 'dismiss')?.label).toBe("Don't Allow");
});

it('resolves the Android grant pair by the same table', () => {
const buttons = [button('While using the app'), button('Only this time'), button('Deny')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('While using the app');
});

it('refuses on equal-rank duplicates: the capture names no single physical button', () => {
const buttons = [button('OK'), button('OK'), button('Cancel')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('refuses when multiple candidates share a role the vocabulary cannot rank', () => {
// Roles can arrive from a platform id classifier; labels outside the vocabulary then tie.
const buttons: Button[] = [
{ label: 'While on the go', role: 'accept' },
{ label: 'Just this run', role: 'accept' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('refuses a mix of ranked and unranked candidates rather than preferring the known label', () => {
// The unranked one's role came from a platform id classifier, so it is a real grant the
// table cannot order. Letting the one known English label win would press an arbitrary
// grant of an unordered pair.
const buttons: Button[] = [
{ label: 'OK', role: 'accept' },
{ label: 'Unrecognized grant', role: 'accept' },
];
expect(selectRankedAlertButtonByRole(buttons, 'accept')).toBeNull();
});

it('keeps the declared order over mixed English variants of distinct grants', () => {
// The closest positive: both candidates are ranked, so this is a preference, not ambiguity.
const buttons = [button('Allow Once'), button('Allow')];
expect(selectRankedAlertButtonByRole(buttons, 'accept')?.label).toBe('Allow');
});

it('returns null when no button carries the role', () => {
expect(selectRankedAlertButtonByRole([button('Settings')], 'accept')).toBeNull();
});
});
143 changes: 141 additions & 2 deletions packages/contracts/src/alert-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@ export const ALERT_POLL_INTERVAL_MS = 300;
export const DEFAULT_ALERT_TIMEOUT_MS = 10_000;
export const ALERT_ACTION_RETRY_MS = 2_000;

/**
* The largest alert budget the legs accept. The window signal rides `AbortSignal.timeout`, whose
* Node ceiling is 2^31-1 ms: larger values are rejected outright there, and its underlying timer
* overflows to a 1 ms firing when merely exceeded. The daemon refuses a larger `alert wait`
* budget as `INVALID_ARGS`, and a tree driver reads one as the bounded default — never a
* silently shortened window.
*/
export const MAX_ALERT_WINDOW_MS = 2_147_483_647;

/**
* The one alert failure the family retries on: the backend looked and there was no alert *yet*.
* Both Apple backends state it in typed form — the XCTest runner as this `ErrorPayload.code`
* (surfacing as `details.runnerErrorCode`, like `RUNNER_BUSY`, without changing the wire error
* code), the macOS helper as `details.reason`. Retry and the fallback hint key on these, never on
* the message text: a transport, runner or helper failure must never read as an absent alert.
* code), the macOS helper as details.reason — and so do the tree-based acting legs (Android and
* Limrun iOS, through the shared driver), as this `details.reason` paired with `dispatched:
* 'no'`. Retry and the fallback hint key on these, never on the message text: a transport,
* runner or helper failure must never read as an absent alert.
*/
export const ALERT_NOT_FOUND_RUNNER_CODE = 'ALERT_NOT_FOUND';
export const ALERT_NOT_FOUND_REASON = 'alert-not-found';
Expand All @@ -20,6 +31,134 @@ export const ALERT_NOT_FOUND_REASON = 'alert-not-found';
*/
export const ALERT_BUTTON_NOT_FOUND_RUNNER_CODE = 'ALERT_BUTTON_NOT_FOUND';

/**
* The acting leg's window closed after the alert resolved but before the press could be sent.
* Typed so a caller can tell "the sheet was there and we refused to press late" from absence or
* an unresolvable capture — the press was never dispatched.
*/
export const ALERT_WINDOW_EXPIRED_REASON = 'alert-window-expired';

/** What an `alert get` capture resolved to, typed so clients switch on states, not messages. */
export type AlertStatusKind = 'visible' | 'none' | 'undecided';

export type AlertButtonRole = 'accept' | 'dismiss' | 'neutral';

/**
* What one capture from a tree-based alert backend says about alerts. `none` states absence;
* `undecided` states the capture is alert-shaped yet unresolvable (a degraded or shared root,
* competing sheets) and proves nothing — confirming a dismissal may accept `none`, never
* `undecided`. Backends whose capture cannot reach `undecided` report a quiet capture as `none`.
*/
export type AlertCapture<TCandidate> =
| { kind: 'alert'; candidate: TCandidate }
| { kind: 'none' }
| { kind: 'undecided' };

/**
* The one button label vocabulary every tree-based alert backend classifies with, declared as a
* per-role preference table: each entry names one English label (regex only for spelling
* variants of the *same* label), earlier entries outrank later ones, so a sheet offering several
* grants resolves by declared preference ("while using the app" over "only this time") instead
* of tree order. English-only, matching the untranslated device locale the alert lanes are
* validated against; a localized button classifies `neutral`, so acting legs refuse on the
* button-undecided reason rather than guessing at a translation.
*/
const ACCEPT_LABEL_PREFERENCES: readonly RegExp[] = [
/^allow while using (?:the )?app$/i,
/^while using the app$/i,
/^allow all$/i,
/^always allow$/i,
/^allow all the time$/i,
/^allow notifications$/i,
/^allow$/i,
/^ok$/i,
/^yes$/i,
/^continue$/i,
/^save$/i,
/^confirm$/i,
/^turn on$/i,
/^open settings$/i,
// One-time grants last: a sheet offering a lasting grant should not spend the tap here.
/^allow once$/i,
/^only this time$/i,
];
// `don.?t allow` covers the straight, missing, and typographic apostrophe in one alternate.
const DISMISS_LABEL_PREFERENCES: readonly RegExp[] = [
/^cancel$/i,
/^deny$/i,
/^don.?t allow$/i,
/^not now$/i,
/^no$/i,
/^dismiss$/i,
/^close$/i,
/^close app$/i,
/^later$/i,
/^skip$/i,
/^ask app not to track$/i,
];

const LABEL_PREFERENCES: Record<'accept' | 'dismiss', readonly RegExp[]> = {
accept: ACCEPT_LABEL_PREFERENCES,
dismiss: DISMISS_LABEL_PREFERENCES,
};

/** One button's action role under the shared vocabulary; anything unnamed is neutral. */
export function classifyAlertButtonRole(label: string): AlertButtonRole {
const trimmed = label.trim();
if (ACCEPT_LABEL_PREFERENCES.some((pattern) => pattern.test(trimmed))) return 'accept';
if (DISMISS_LABEL_PREFERENCES.some((pattern) => pattern.test(trimmed))) return 'dismiss';
return 'neutral';
}

/**
* A role-named button's place in the declared preference table, or null when its role came from
* something other than the vocabulary (Android id classifiers, localized labels).
*/
function alertButtonPreferenceRank(label: string, role: 'accept' | 'dismiss'): number | null {
const index = LABEL_PREFERENCES[role].findIndex((pattern) => pattern.test(label.trim()));
return index === -1 ? null : index;
}

/**
* The one choice boundary for a shared choice: pick the action's role candidates by the declared
* preference table. A single candidate wins outright. Several resolve only when every one of
* them carries a vocabulary rank and one rank outscores the rest: a mix of ranked and
* id-classified grants cannot be ordered by the table, so it names no single physical button and
* is refused rather than resolved by tree order or by one side's label happening to be known.
*/
export function selectRankedAlertButtonByRole<
TButton extends Readonly<{ role: AlertButtonRole; label: string }>,
>(buttons: readonly TButton[], role: 'accept' | 'dismiss'): TButton | null {
const matching = buttons.filter((button) => button.role === role);
if (matching.length === 1) return matching[0] ?? null;
if (matching.length === 0) return null;
const ranked = matching.map((button) => ({
button,
rank: alertButtonPreferenceRank(button.label, role),
}));
if (ranked.some((entry) => entry.rank === null)) return null;
ranked.sort((a, b) => (a.rank ?? 0) - (b.rank ?? 0));
const [first, second] = ranked;
if (!first || (second && second.rank === first.rank)) return null;
return first.button;
}

/**
* The reason a tree-based acting leg (`alert accept|dismiss`) refuses after seeing an alert: the
* visible buttons name no single choice for the asked-for action. Callers branch on
* `error.details.reason` instead of the prose; `dispatched: 'no'` states the refusal ran before
* any press.
*/
export const ALERT_BUTTON_UNDECIDED_REASON = 'alert_button_undecided';

/**
* The reason a tree-based acting leg refuses before it has a candidate at all: the capture was
* alert-shaped but unresolvable (degraded buttons, competing sheets, an unverifiable owner).
* This is not absence — reporting `alert-not-found` over it would state more than the capture
* supports — and confirming a dismissal must never accept such a capture as proof of absence.
*/
export const ALERT_CAPTURE_UNDECIDED_REASON = 'alert_capture_undecided';

export const ALERT_ACTIONS = ['get', 'accept', 'dismiss', 'wait'] as const;
export type AlertAction = (typeof ALERT_ACTIONS)[number];

Expand Down
10 changes: 7 additions & 3 deletions packages/contracts/src/alert-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ test('each local leg forwards the window and the session target to its own owner
timeoutMs: 37,
appBundleId: 'com.example.app',
surface: 'app',
// The request's cancellation rides along: owners whose transport cannot abort a call (the
// Limrun tree legs) still refuse to consume a capture once the request is canceled.
signal,
};
expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions);
expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions);
Expand All @@ -76,12 +79,13 @@ test('an absent target field never reaches the owner as an explicit undefined',
const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({}));
const resolveInteractor = async () => ({ readAlert }) as unknown as Interactor;
const source = localInteractorSource({ device, resolveInteractor });
const operations = bindAlertLeg('readAlert', new AbortController().signal, source);
const signal = new AbortController().signal;
const operations = bindAlertLeg('readAlert', signal, source);

await operations.readAlert({ surface: 'frontmost-app' });

expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' });
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']);
expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app', signal });
expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface', 'signal']);
});

// Facts admitted the leg, so an interactor without it is an ownership bug the caller must see
Expand Down
8 changes: 6 additions & 2 deletions packages/contracts/src/alert-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,11 +94,15 @@ async function resolveAlertInteractor(
});
}

function alertInteractorOptions(input: AlertRuntimeInput): AlertInteractorOptions {
function alertInteractorOptions(
input: AlertRuntimeInput,
signal: AbortSignal | undefined,
): AlertInteractorOptions {
return {
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
...(input.appBundleId === undefined ? {} : { appBundleId: input.appBundleId }),
...(input.surface === undefined ? {} : { surface: input.surface }),
...(signal ? { signal } : {}),
};
}

Expand All @@ -124,7 +128,7 @@ export function bindAlertLeg<Leg extends AlertLeg>(
// held at its merge-base size (`eager-closure-budgets`); a static edge would grow it.
const { requireInteractorMethod } = await import('./interactor-operation-binding.ts');
const method = requireInteractorMethod(interactor[leg], ALERT_LEG_LABELS[leg]);
return await method.call(interactor, alertInteractorOptions(input));
return await method.call(interactor, alertInteractorOptions(input, signal));
},
}) as Readonly<Record<Leg, (input: AlertRuntimeInput) => Promise<Record<string, unknown>>>>;
}
2 changes: 2 additions & 0 deletions packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,8 @@ export type Interactor = {
*/
export type AlertInteractorOptions = {
timeoutMs?: number;
/** The daemon request's cancellation; owners whose transport can abort thread it into the legs. */
signal?: AbortSignal;
appBundleId?: string;
surface?: SessionSurface;
};
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,34 @@ for (const action of ['accept', 'dismiss']) {
expect(successor?.actions).toEqual([]);
});
}

test('alert wait forwards its timeout budget to the runtime leg', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim';
sessionStore.publish(sessionName, makeSession(sessionName));

await handleSnapshotCommands(sessionName, sessionStore, ['wait', '1500']);

// The ceiling test below proves the refusal; this proves a legal budget crosses the runtime
// seam whole. (The Apple runner leg reads the state as `get` and waits on its own deadline.)
expect(mockRunnerCommand.mock.calls[0]?.[1]).toMatchObject({
command: 'alert',
timeoutMs: 1_500,
});
});

test('alert wait refuses a budget past the window ceiling instead of arming a misfired timer', async () => {
// Above 2^31-1 ms Node's timer overflows: `AbortSignal.timeout` throws outright and a bare
// setTimeout fires after 1 ms. The refusal must be the typed argument error, never either.
const sessionStore = makeSessionStore();
const sessionName = 'ios-sim';
sessionStore.publish(sessionName, makeSession(sessionName));

await expect(
handleSnapshotCommands(sessionName, sessionStore, ['wait', '4294967296']),
).rejects.toThrow(/exceeds the maximum/);
await expect(
handleSnapshotCommands(sessionName, sessionStore, ['wait', '2147483648']),
).rejects.toThrow(/exceeds the maximum/);
expect(mockRunnerCommand).not.toHaveBeenCalled();
});
Loading
Loading