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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,8 @@
## Unreleased

- 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.
- `alert wait 0` on Android reports an alert that is already visible instead of timing out.
- `settings --help` describes each setting on its own line, and its usage folds settings that share a form.
- Carry provider keyboard facts through snapshots. Appium captures, including wait polls and fill pre-captures, add one sequential keyboard visibility probe with a 500 ms timeout. Probe results preserve tap-time docked-tree checks; unavailable probes leave visibility unknown.
7 changes: 7 additions & 0 deletions docs/adr/0019-request-bound-platform-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ typed reason and a hint, at the point the shape is requested. Absence of a whole
operation's member remains an ownership bug and fails closed with its own typed reason; it is never
the mechanism that expresses an unsupported shape.

**A subject of an operation may carry its own fact.** When one operation serves a closed vocabulary
that owners serve unevenly — `setSetting` over the settings catalog — its available fact may name a
fact per subject and an `unlisted` refusal for every other subject. Admission takes the subject the
request names and refuses an unavailable one exactly as it refuses an unavailable operation, before
binding. A subject is the thing the request names (which setting); a shape of the request (a
coordinate form, a permission target) still belongs to the owning leaf mechanic, as above.

`RequestExecutionScope.bindDevice(device, use)` is the trust choke point. It:

1. resolves the exact local or provider runtime owner;
Expand Down
4 changes: 4 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -484,6 +484,10 @@
"types": "./src/settings.ts",
"default": "./src/settings.ts"
},
"./settings-catalog": {
"types": "./src/settings-catalog.ts",
"default": "./src/settings-catalog.ts"
},
"./settings-runtime": {
"types": "./src/settings-runtime.ts",
"default": "./src/settings-runtime.ts"
Expand Down
87 changes: 31 additions & 56 deletions packages/contracts/src/client-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,79 +6,56 @@ import type {
MOBILE_PERMISSION_TARGETS,
PermissionAction,
PermissionMode,
TextSizeCategory,
} from './settings.ts';
SETTINGS,
SettingName,
} from './settings-catalog.ts';

/**
* Every permission the public client can name: the app-scoped subset plus the macOS-only one, both
* from the owning declaration. Type-only on purpose — naming a permission must not pull
* `settings.ts` and its `AppError` dependency onto the client's runtime path.
*/
/** Every permission the public client can name: the app-scoped targets plus the macOS ones. */
export type PermissionTarget =
| (typeof MOBILE_PERMISSION_TARGETS)[number]
| (typeof MACOS_PERMISSION_TARGETS)[number];

type FormState<Form> = Form extends { kind: 'on-off' }
? 'on' | 'off'
: Form extends { kind: 'choice'; states: readonly (infer State)[] }
? State
: never;

type SettingState<Name extends SettingName> = FormState<(typeof SETTINGS)[Name]['forms'][number]>;

/** Settings whose whole input is a state: no app, coordinates, or permission target. */
type StateOnlySetting = {
[Name in SettingName]: (typeof SETTINGS)[Name] extends { app: string } ? never : Name;
}[SettingName];

/** A readable setting omits `state` to read the value the target holds. */
type StateOnlySettingOptions = {
[Name in StateOnlySetting]: DeviceCommandBaseOptions &
((typeof SETTINGS)[Name] extends { readable: true }
? { setting: Name; state?: SettingState<Name> }
: { setting: Name; state: SettingState<Name> });
}[StateOnlySetting];

export type SettingsUpdateOptions =
| StateOnlySettingOptions
| (DeviceCommandBaseOptions & {
setting: 'clear-app-state';
state: 'clear';
app?: string;
})
| (DeviceCommandBaseOptions & {
setting: 'reset-keychain';
state: 'clear';
})
| (DeviceCommandBaseOptions & {
setting: 'wifi' | 'airplane';
state: 'on' | 'off';
})
/**
* On Apple simulators `on`/`off` grants or revokes the app's location permission, so this leg
* takes the same explicit `app` as `permission` and defaults to the session app. On Android the
* toggle writes the global `location_mode` and consumes no app, so naming one there is refused
* rather than dropped; `settingsAppScope` is the declaration.
*/
/** On Apple simulators `on`/`off` is a location-permission change for `app` (or the session app). */
| (DeviceCommandBaseOptions & {
setting: 'location';
state: 'on' | 'off';
app?: string;
})
/**
* `set` moves the device's own location for every target, so naming an app here is a contradiction
* the daemon refuses with `setting_app_not_consumed` rather than a value it silently drops.
*/
| (DeviceCommandBaseOptions & {
setting: 'location';
state: 'set';
latitude: number;
longitude: number;
app?: string;
})
| (DeviceCommandBaseOptions & {
setting: 'animations';
state: 'on' | 'off';
})
| (DeviceCommandBaseOptions & {
setting: 'appearance';
state: 'light' | 'dark' | 'toggle';
})
/**
* One member, two legs: with a `state` it applies that rung, and without one it asks the target
* what it currently holds. The ladder is shared across platforms; an owner that serves neither
* leg refuses on its own runtime fact rather than answering an empty value.
*/
| (DeviceCommandBaseOptions & {
setting: 'text-size';
state?: TextSizeCategory;
})
| (DeviceCommandBaseOptions & {
setting: 'faceid' | 'touchid';
state: 'match' | 'nonmatch' | 'enroll' | 'unenroll';
})
| (DeviceCommandBaseOptions & {
setting: 'fingerprint';
state: 'match' | 'nonmatch';
})
| (DeviceCommandBaseOptions & {
setting: 'permission';
state: PermissionAction;
Expand All @@ -87,12 +64,10 @@ export type SettingsUpdateOptions =
/**
* The app the permission changes, by bundle id, package name, or app name, resolved as `open`
* resolves it: an iOS simulator matches display names and accepts only an installed bundle
* id, Android accepts only an installed package and matches other names against installed
* package names, and a Limrun iOS session resolves only the `settings` alias. One that
* resolves to nothing is refused with `app_not_installed`. Without it the app bound to the
* session is used; with it no app has to be running or open, because `simctl privacy` and
* Android's `pm` need only the id. macOS permissions are host-level TCC grants, so naming an
* app there is refused rather than dropped.
* id, Android accepts only an installed package and refuses a name that resolves to an intent
* with `setting_app_not_package`, and a Limrun iOS session resolves only the `settings`
* alias. Without it the session app is used; no app has to be running or open. macOS
* permissions are host-level TCC grants, so naming an app there is refused.
*/
app?: string;
});
3 changes: 2 additions & 1 deletion packages/contracts/src/interactor-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type { ScrollDirection } from './scroll-gesture.ts';
import type { ScrollExecutionOptions } from './scroll-command.ts';
import type { TvRemoteButton } from './tv-remote.ts';
import type { GesturePlan } from './gesture-plan-types.ts';
import type { ReadableSetting, ReadSettingResult, SettingOptions } from './settings.ts';
import type { ReadSettingResult, SettingOptions } from './settings.ts';
import type { ReadableSetting } from './settings-catalog.ts';
import type { SessionSurface } from './session-surface.ts';
import type { BackendSnapshotResult } from './snapshot-types.ts';
import type { RunnerLogicalLeaseContext } from './runner-lease-context.ts';
Expand Down
31 changes: 9 additions & 22 deletions packages/contracts/src/platform-runtime-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts';
import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts';
import type { SystemButtonRuntimeOperations } from './system-button-runtime.ts';
import type { AppEventRuntimeOperations } from './app-event-runtime.ts';
import type { ReadableSetting } from './settings.ts';
import type { ReadableSetting } from './settings-catalog.ts';
import type { SettingsRuntimeOperations } from './settings-runtime.ts';
import type { AlertRuntimeOperations } from './alert-runtime.ts';
import type { AudioProbeRuntimeOperations } from './audio-probe-runtime.ts';
Expand Down Expand Up @@ -761,20 +761,12 @@ export const clipboardRuntimePlanUses = Object.freeze([
export const settingsRuntimePlanUses = Object.freeze([settingsRuntimeUse, settingReadUse] as const);

/**
* The settings that answer a bare `settings <setting>` with the value the target holds, in the order
* `settings` help lists them. A setting joins the list only when at least one owner can read it back,
* which is what makes the read a second operation rather than a stateless write.
*
* The NAME lives in `settings.ts`, where every settings type and the settings owners already read it
* from; this is its VALUE, because the CLI's eager command-registry closure must evaluate the leg
* rule below and `contracts/settings.ts` is not in that closure — importing it here would add it
* (`scripts/__tests__/eager-closure-budgets.test.ts` ratchets the hub against growth) and importing
* this module from `settings.ts` would close a contracts type cycle the layering scan forbids. The
* two lines after this declaration pin the pair equal in both directions, so no test has to.
* The catalog's readable settings (`readable: true` in `settings-catalog.ts`). Restated as a value
* because the CLI's eager command-registry closure evaluates this module and not the catalog; the
* `Record` key type makes a readable setting missing here, or an extra one, a compile error.
*/
export const READABLE_SETTINGS: readonly ReadableSetting[] = ['text-size'];
const readableSettingsAreTheVocabulary: Record<ReadableSetting, true> = { 'text-size': true };
void readableSettingsAreTheVocabulary;
const READABLE_SETTING_SET: Record<ReadableSetting, true> = { 'text-size': true };
export const READABLE_SETTINGS = Object.keys(READABLE_SETTING_SET) as readonly ReadableSetting[];

/** The leg one `settings` request is on, and the owner fact that leg admits. */
export type SettingsRuntimePlan =
Expand All @@ -787,14 +779,9 @@ const WRITE_SETTINGS_RUNTIME_PLAN: SettingsRuntimePlan = Object.freeze({
});

/**
* The ONE leg rule `settings` has, and the only place its readable-setting list is read. A request
* reads when it names a readable setting and nothing else; anything with a state is a mutation,
* including `settings text-size large`, which names the same word and changes the device. Both
* consumers resolve through here: the command descriptor turns `kind` into the recording and
* ref-frame effect, and the daemon admits `use` and reads the setting named — so the classification
* and the operation admitted cannot disagree. It sits beside the two uses rather than in the settings
* vocabulary because both consumers already evaluate this module, while the CLI's eager command-
* registry closure is ratcheted against growth by `scripts/__tests__/eager-closure-budgets.test.ts`.
* A request reads when it names a readable setting and nothing else; `settings text-size large`
* names the same word and is a mutation. The command descriptor's effects and the daemon's admitted
* use both resolve through here, so they cannot disagree.
*/
export function resolveSettingsRuntimePlan(
positionals: readonly string[] | undefined,
Expand Down
14 changes: 3 additions & 11 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts';
import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts';
import { systemButtonRuntimeOperationFacts } from './system-button-runtime.ts';
import { appEventRuntimeOperationFacts } from './app-event-runtime.ts';
import { settingsRuntimeOperationFacts } from './settings-runtime.ts';
import { alertRuntimeOperationFacts } from './alert-runtime.ts';
import { audioProbeRuntimeOperationFacts } from './audio-probe-runtime.ts';
import { perfRuntimeOperationFacts } from './perf-runtime.ts';
Expand Down Expand Up @@ -64,12 +63,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
clipboard: RuntimeOperationUnavailability;
systemButton: RuntimeOperationUnavailability;
triggerAppEvent: RuntimeOperationUnavailability;
/**
* One cell for both settings halves: an owner that names no settings surface here refuses the
* write and the read alike, and an owner whose two halves genuinely differ (the Apple leaf, whose
* host can set an appearance it cannot read back) states them directly through
* `settingsRuntimeOperationFacts` instead of routing both through this denial.
*/
/** Refuses every setting, read and write alike. */
settings: RuntimeOperationUnavailability;
readAlert: RuntimeOperationUnavailability;
awaitAlert: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -249,10 +243,8 @@ export function createUnavailablePlatformRuntimeFacts(
...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }),
...systemButtonRuntimeOperationFacts({ unsupported: frozen.systemButton }),
...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }),
...settingsRuntimeOperationFacts({
setSetting: frozen.settings,
readSetting: frozen.settings,
}),
setSetting: frozen.settings,
readSetting: frozen.settings,
...alertRuntimeOperationFacts({
read: frozen.readAlert,
wait: frozen.awaitAlert,
Expand Down
26 changes: 25 additions & 1 deletion packages/contracts/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,31 @@ export type RuntimeOperationUnavailability = Readonly<{
hint?: string;
}>;

export type RuntimeOperationFact = Readonly<{ available: true }> | RuntimeOperationUnavailability;
type RuntimeSubjectFact = Readonly<{ available: true }> | RuntimeOperationUnavailability;

/**
* `subjects` names, for an operation an owner serves for only some of its subjects (the settings
* `setSetting` changes), each subject's own fact; `unlisted` answers for every other subject.
* Admission checks the requested subject's fact; a bound runtime keeps only `available`.
*/
export type RuntimeOperationFact =
| Readonly<{
available: true;
subjects?: Readonly<Record<string, RuntimeSubjectFact>>;
unlisted?: RuntimeOperationUnavailability;
}>
| RuntimeOperationUnavailability;

const UNLISTED_SUBJECT = unavailableFact('unsupported-platform-leaf');

export function runtimeSubjectFact(
fact: RuntimeOperationFact,
subject: string,
): RuntimeSubjectFact {
if (!fact.available || fact.subjects === undefined) return fact;
Comment thread
thymikee marked this conversation as resolved.
if (Object.hasOwn(fact.subjects, subject)) return fact.subjects[subject]!;
return fact.unlisted ?? UNLISTED_SUBJECT;
}

/** One frozen unavailable cell; owners list their refusals as calls instead of literals. */
export function unavailableFact(
Expand Down
Loading
Loading