diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ab58f835c..ffc292f9a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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 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 `). 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. diff --git a/docs/adr/0019-request-bound-platform-runtime.md b/docs/adr/0019-request-bound-platform-runtime.md index 6c800aaa6c..f6074b484c 100644 --- a/docs/adr/0019-request-bound-platform-runtime.md +++ b/docs/adr/0019-request-bound-platform-runtime.md @@ -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; diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d9c30ff5be..781ac21c07 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -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" diff --git a/packages/contracts/src/client-settings.ts b/packages/contracts/src/client-settings.ts index afe9043484..a3e399b4e4 100644 --- a/packages/contracts/src/client-settings.ts +++ b/packages/contracts/src/client-settings.ts @@ -6,47 +6,49 @@ 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 extends { kind: 'on-off' } + ? 'on' | 'off' + : Form extends { kind: 'choice'; states: readonly (infer State)[] } + ? State + : never; + +type SettingState = 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 } + : { setting: Name; state: SettingState }); +}[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'; @@ -54,31 +56,6 @@ export type SettingsUpdateOptions = 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; @@ -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; }); diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 270864341b..c4b6381db4 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -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'; diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 55a2a13b35..3d11d8e2bf 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -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'; @@ -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 ` 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 = { 'text-size': true }; -void readableSettingsAreTheVocabulary; +const READABLE_SETTING_SET: Record = { '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 = @@ -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, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index b4c3ccdeaf..d44c9a3620 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -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'; @@ -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; @@ -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, diff --git a/packages/contracts/src/platform-runtime.ts b/packages/contracts/src/platform-runtime.ts index 2b6ec0d7aa..c46fd84b92 100644 --- a/packages/contracts/src/platform-runtime.ts +++ b/packages/contracts/src/platform-runtime.ts @@ -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>; + 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; + 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( diff --git a/packages/contracts/src/settings-catalog.ts b/packages/contracts/src/settings-catalog.ts new file mode 100644 index 0000000000..cbf4521c61 --- /dev/null +++ b/packages/contracts/src/settings-catalog.ts @@ -0,0 +1,183 @@ +/** + * Every `settings` setting, declared once: its accepted forms, where its app comes from, whether + * naming an app can mean anything on each target family, and its help. Usage, parsing, app scope, + * and help are derived from this table. Which target serves a setting is not declared here: each + * runtime owner states that in its own settings facts, because owners that share a device shape + * (a local iOS simulator and a Limrun iOS session) serve different settings. + * + * This module has no runtime imports, so the public client types can name it. + */ + +export const PERMISSION_ACTIONS = ['grant', 'deny', 'reset'] as const; +export const PERMISSION_MODES = ['full', 'limited'] as const; + +/** The app-scoped targets, the only ones `parsePermissionTarget` accepts. */ +export const MOBILE_PERMISSION_TARGETS = [ + 'all', + 'camera', + 'microphone', + 'photos', + 'contacts', + 'contacts-limited', + 'notifications', + 'calendar', + 'location', + 'location-always', + 'media-library', + 'motion', + 'reminders', + 'siri', +] as const; + +export const MACOS_PERMISSION_TARGETS = [ + 'accessibility', + 'screen-recording', + 'input-monitoring', +] as const; + +export const TEXT_SIZE_CATEGORIES = [ + 'extra-small', + 'small', + 'medium', + 'large', + 'extra-large', + 'extra-extra-large', + 'extra-extra-extra-large', + 'accessibility-medium', + 'accessibility-large', + 'accessibility-extra-large', + 'accessibility-extra-extra-large', + 'accessibility-extra-extra-extra-large', +] as const; + +export const APPEARANCE_ACTIONS = ['light', 'dark', 'toggle'] as const; + +export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]; +export type PermissionMode = (typeof PERMISSION_MODES)[number]; +export type MobilePermissionTarget = (typeof MOBILE_PERMISSION_TARGETS)[number]; +export type TextSizeCategory = (typeof TEXT_SIZE_CATEGORIES)[number]; +export type AppearanceAction = (typeof APPEARANCE_ACTIONS)[number]; + +/** + * `app-scoped`: the change lands on a bundle id or package. `device-level`: the same command + * changes the whole device, so a named app is refused. `unknown`: not settled here; the owner's + * own facts answer for a setting it does not serve. + */ +export type SettingsAppScope = 'app-scoped' | 'device-level' | 'unknown'; + +/** The macOS host, the rest of the Apple family, and every other target (Android, HarmonyOS, …). */ +export type SettingsTargetFamily = 'apple' | 'mobile' | 'macos-host'; + +type FamilyAppScope = Readonly>>; + +export type SettingForm = Readonly< + | { kind: 'on-off'; appScope?: FamilyAppScope } + | { kind: 'choice'; states: readonly string[] } + | { kind: 'coordinates'; appScope?: FamilyAppScope } + | { + kind: 'permission'; + actions: readonly PermissionAction[]; + targets: readonly string[]; + modes?: readonly PermissionMode[]; + } + | { kind: 'app-id' } +>; + +export type SettingDefinition = Readonly<{ + forms: readonly SettingForm[]; + /** A bare `settings ` reads the value the target holds. */ + readable?: true; + /** `input`: a named app rides the request input. `positional`: it is the setting's own argument. */ + app?: 'input' | 'positional'; + /** A write is refused before dispatch when neither the request nor the session names an app. */ + appRequired?: true; + /** Applies to every form; a form's own `appScope` overrides it. */ + appScope?: FamilyAppScope; + help: string; +}>; + +export const SETTINGS = { + wifi: { + forms: [{ kind: 'on-off' }], + help: 'Android switches Wi-Fi with svc wifi; an Apple simulator overrides only its status bar.', + }, + airplane: { + forms: [{ kind: 'on-off' }], + help: 'Android applies it through the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing anything. An Apple simulator overrides only its status bar.', + }, + location: { + forms: [ + { kind: 'on-off', appScope: { apple: 'app-scoped', mobile: 'device-level' } }, + { kind: 'coordinates', appScope: { apple: 'device-level', mobile: 'device-level' } }, + ], + app: 'input', + help: "on|off writes Android's device-wide location_mode, and on an Apple simulator grants or revokes the app's location permission. set moves the device's own coordinates on Apple simulators and Android emulators.", + }, + animations: { + forms: [{ kind: 'on-off' }], + help: 'Android sets its window, transition, and animator duration scales to 1 or 0.', + }, + appearance: { + forms: [{ kind: 'choice', states: APPEARANCE_ACTIONS }], + help: 'Switches to light or dark mode, or toggles it, on Android, Apple simulators, and the macOS host.', + }, + 'text-size': { + forms: [{ kind: 'choice', states: TEXT_SIZE_CATEGORIES }], + readable: true, + help: 'Reads the preferred text size without a category and applies one with it, on iPhone and iPad simulators (simctl content size) and Android (system font_scale). Android has no ladder of its own: a read names the nearest rung and reports the multiplier as platformValue. A running app adopts a new size at its next configuration change, so relaunch it.', + }, + faceid: { + forms: [{ kind: 'choice', states: ['match', 'nonmatch', 'enroll', 'unenroll'] }], + help: 'Simulates Face ID on iPhone and iPad simulators.', + }, + touchid: { + forms: [{ kind: 'choice', states: ['match', 'nonmatch', 'enroll', 'unenroll'] }], + help: 'Simulates Touch ID on iPhone and iPad simulators.', + }, + fingerprint: { + forms: [{ kind: 'choice', states: ['match', 'nonmatch'] }], + help: 'Simulates a fingerprint touch on Android emulators, and on devices that expose cmd fingerprint.', + }, + 'clear-app-state': { + forms: [{ kind: 'app-id' }], + app: 'positional', + appRequired: true, + appScope: { apple: 'app-scoped', mobile: 'app-scoped' }, + help: 'Clears the data of the positional app, the --app app, or the session app, and refuses two different apps. It leaves the keychain alone. An app with no data container, such as a simulator system app, refuses with app-no-data-container.', + }, + 'reset-keychain': { + forms: [{ kind: 'choice', states: ['clear'] }], + help: 'Resets the whole iOS simulator keychain, for every app on it: simctl has no per-app reset. A fresh-install reset needs this and clear-app-state; relaunch the app afterwards to see it signed out.', + }, + permission: { + forms: [ + { + kind: 'permission', + actions: PERMISSION_ACTIONS, + targets: MOBILE_PERMISSION_TARGETS, + modes: PERMISSION_MODES, + }, + { kind: 'permission', actions: ['grant', 'reset'], targets: MACOS_PERMISSION_TARGETS }, + ], + app: 'input', + appScope: { apple: 'app-scoped', mobile: 'app-scoped', 'macos-host': 'device-level' }, + help: 'Changes a permission of the --app app or the session app. An iOS simulator resolves --app to an installed bundle id or display name, Android to an installed package, and a Limrun iOS session only through the settings alias. On Android, deny|reset of a held permission kills a running app; the response reports priorGrantState and warns, and open --relaunch restores it. Android needs a resolvable foreground user and changes nothing without one. On the macOS host, grant|reset change the host TCC grants for the three desktop targets and take no --app.', + }, +} as const satisfies Record; + +export type SettingName = keyof typeof SETTINGS; + +export const SETTING_NAMES = Object.keys(SETTINGS) as readonly SettingName[]; + +/** The settings whose definition is `readable`. */ +export type ReadableSetting = { + [Name in SettingName]: (typeof SETTINGS)[Name] extends { readable: true } ? Name : never; +}[SettingName]; + +/** The definition of a setting name in any casing or padding, or `undefined` outside the catalog. */ +export function settingDefinition(name: string | undefined): SettingDefinition | undefined { + const normalized = name?.trim().toLowerCase(); + return normalized !== undefined && Object.hasOwn(SETTINGS, normalized) + ? SETTINGS[normalized as SettingName] + : undefined; +} diff --git a/packages/contracts/src/settings-runtime.test.ts b/packages/contracts/src/settings-runtime.test.ts index d93c58bcbd..1b1b07f3e9 100644 --- a/packages/contracts/src/settings-runtime.test.ts +++ b/packages/contracts/src/settings-runtime.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest'; +import { runtimeSubjectFact } from './platform-runtime.ts'; import { bindSetSetting, settingsRuntimeOperationFacts } from './settings-runtime.ts'; import type { Interactor } from './interactor-types.ts'; import { localInteractorSource } from './interactor-operation-binding.ts'; @@ -12,16 +13,40 @@ const device = { booted: true, } as const; -test('builds the exact settings operation fact catalog', () => { - const setSetting = { available: true } as const; - const readSetting = { - available: false, - reason: 'unsupported-platform-leaf', - } as const; - expect(settingsRuntimeOperationFacts({ setSetting, readSetting })).toEqual({ - setSetting, - readSetting, +const available = { available: true } as const; +const unsupported = { + available: false, + reason: 'unsupported-platform-leaf', + hint: 'not here', +} as const; +const handheldOnly = { available: false, reason: 'unsupported-device-kind' } as const; + +test('a setting the owner does not list answers with its unsupported refusal', () => { + const facts = settingsRuntimeOperationFacts({ + target: available, + unsupported, + write: { wifi: available, 'text-size': handheldOnly }, + }); + expect(runtimeSubjectFact(facts.setSetting, 'wifi')).toEqual(available); + expect(runtimeSubjectFact(facts.setSetting, 'text-size')).toEqual(handheldOnly); + expect(runtimeSubjectFact(facts.setSetting, 'airplane')).toEqual(unsupported); + expect(facts.readSetting).toEqual(unsupported); +}); + +test('an unavailable target refuses every setting with its own fact', () => { + const target = { available: false, reason: 'unsupported-device-kind', hint: 'kind' } as const; + const facts = settingsRuntimeOperationFacts({ target, unsupported, write: { wifi: available } }); + expect(facts).toEqual({ setSetting: target, readSetting: target }); +}); + +test('an operation none of whose settings is available answers with the first refusal', () => { + const facts = settingsRuntimeOperationFacts({ + target: available, + unsupported, + write: { appearance: available }, + read: { 'text-size': handheldOnly }, }); + expect(facts.readSetting).toEqual(handheldOnly); }); // The daemon has already parsed the CLI form, resolved the target app, and typed the coordinates diff --git a/packages/contracts/src/settings-runtime.ts b/packages/contracts/src/settings-runtime.ts index 76bbf2b909..6dd1103ff7 100644 --- a/packages/contracts/src/settings-runtime.ts +++ b/packages/contracts/src/settings-runtime.ts @@ -1,6 +1,7 @@ import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { ReadableSetting, ReadSettingResult, SettingOptions } from './settings.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; +import type { ReadSettingResult, SettingOptions } from './settings.ts'; +import type { ReadableSetting, SettingName } from './settings-catalog.ts'; import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; /** @@ -46,23 +47,44 @@ export type SettingsRuntimeOperationFacts = Readonly<{ }>; /** - * Builds the exhaustive owner claims for the two settings operations. Both cells are stated, never - * implied: a write and a read genuinely diverge for real owners — the macOS host sets an appearance - * it has no ladder to read back, and the HarmonyOS leaf clears app state it cannot read — so an - * owner that leaves one out is not compiled, not guessed. + * Builds an owner's settings facts from the settings it serves. `target` gates every setting on the + * device in hand; each listed setting may narrow it further, and a setting the owner does not list + * answers with `unsupported`. An operation is available while any of its settings is; otherwise it + * answers with its first listed setting's refusal, or `unsupported` when it lists none. */ export function settingsRuntimeOperationFacts( input: Readonly<{ - setSetting: RuntimeOperationFact; - readSetting: RuntimeOperationFact; + target: RuntimeOperationFact; + unsupported: RuntimeOperationUnavailability; + write: Readonly>>; + read?: Readonly>>; }>, ): SettingsRuntimeOperationFacts { return Object.freeze({ - setSetting: input.setSetting, - readSetting: input.readSetting, + setSetting: settingSubjectsFact(input, input.write), + readSetting: settingSubjectsFact(input, input.read ?? {}), }); } +function settingSubjectsFact( + owner: Readonly<{ target: RuntimeOperationFact; unsupported: RuntimeOperationUnavailability }>, + served: Readonly>>, +): RuntimeOperationFact { + if (!owner.target.available) return owner.target; + const listed = Object.values(served).filter((fact) => fact !== undefined); + if (!listed.some((fact) => fact.available)) return listed[0] ?? owner.unsupported; + const subjects = Object.fromEntries( + Object.entries(served).map(([name, fact]) => [name, fact.available ? AVAILABLE : fact]), + ); + return Object.freeze({ + available: true, + subjects: Object.freeze(subjects), + unlisted: owner.unsupported, + }); +} + +const AVAILABLE = Object.freeze({ available: true } as const); + /** * Captures one selected owner's interactor authority for the lifetime of a request binding. The * owner is already chosen by the time a binder is called, so each entry point supplies its own diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 66add302a5..36fe6f3b99 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,110 +1,32 @@ -import { describe, expect, expectTypeOf, test } from 'vitest'; -import { READABLE_SETTINGS } from './platform-runtime-operations.ts'; -import type { SettingsUpdateOptions } from './client-settings.ts'; +import { describe, expect, test } from 'vitest'; import { - APPEARANCE_ACTIONS, describeSettingRead, describeSettingWrite, - getUnsupportedMacOsSettingMessage, - isMacOsSettingSupported, - MACOS_PERMISSION_TARGETS, - MOBILE_PERMISSION_TARGETS, parseAppearanceAction, parsePermissionAction, parsePermissionTarget, + parseSettingsMutation, parseSettingState, parseTextSizeCategory, - PERMISSION_ACTIONS, - PERMISSION_MODES, readTextSizeCategory, settingsAppNotConsumedRefusal, settingsAppScope, + settingsHelpLines, SETTINGS_APP_NOT_CONSUMED_REASON, SETTINGS_INVALID_ARGS_MESSAGE, - SETTINGS_MACOS_PERMISSION_USAGE, SETTINGS_USAGE_OVERRIDE, - TEXT_SIZE_CATEGORIES, textSizeSettingPayload, - type AppearanceAction, - type PermissionAction, - type PermissionTarget, - type ReadableSetting, - type TextSizeCategory, } from './settings.ts'; +import { + MOBILE_PERMISSION_TARGETS, + SETTING_NAMES, + TEXT_SIZE_CATEGORIES, +} from './settings-catalog.ts'; -// Fixed expected data on purpose (#2614): this file is the witness that a shared permission -// declaration neither widened nor narrowed what any settings surface already accepted, and that it -// kept the accepted names in the order `settings` help has always listed them. -// The one deliberate widening is `all`, first in the list: the Maestro setPermissions merge -// needs it to travel as one `settings permission` call while each backend resolves it. -const MOBILE_TARGETS = [ - 'all', - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', -] as const; - -const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitoring'] as const; - -// Fixed expected data on purpose: the ladder the `settings text-size` surface publishes, in the -// order help lists it. A rung added, dropped, or reordered fails the usage pins below. -const TEXT_SIZE_LADDER = [ - 'extra-small', - 'small', - 'medium', - 'large', - 'extra-large', - 'extra-extra-large', - 'extra-extra-extra-large', - 'accessibility-medium', - 'accessibility-large', - 'accessibility-extra-large', - 'accessibility-extra-extra-large', - 'accessibility-extra-extra-extra-large', -] as const; - -const SETTINGS_FORMS = [ - ' ', - 'location set ', - 'animations ', - 'appearance ', - `text-size [${TEXT_SIZE_LADDER.join('|')}]`, - 'faceid ', - 'touchid ', - 'fingerprint ', - 'clear-app-state [app-id]', - 'reset-keychain clear', - `permission <${MOBILE_TARGETS.join('|')}> [full|limited]`, - 'permission ', -] as const; - -// The whole normalization the parsers promise, written out rather than sampled. const NORMALIZATIONS = [ (name: string) => name, (name: string) => name.toUpperCase(), - (name: string) => name.charAt(0).toUpperCase() + name.slice(1), (name: string) => ` ${name} `, - (name: string) => `\t${name}\n`, -] as const; - -const REJECTED_TARGETS = [ - ...MACOS_ONLY_TARGETS, - 'bluetooth', - 'camera-x', - 'camera limited', - '', - ' ', - undefined, ] as const; function expectInvalidArgs(run: () => unknown, message: string): void { @@ -116,96 +38,146 @@ function expectInvalidArgs(run: () => unknown, message: string): void { ); } -describe('the declared permission vocabulary', () => { - test('holds the names each surface already accepted, in help order', () => { - expect([...MOBILE_PERMISSION_TARGETS]).toEqual([...MOBILE_TARGETS]); - expect([...MACOS_PERMISSION_TARGETS]).toEqual([...MACOS_ONLY_TARGETS]); - expect([...PERMISSION_ACTIONS]).toEqual(['grant', 'deny', 'reset']); - expect([...PERMISSION_MODES]).toEqual(['full', 'limited']); +describe('settings usage', () => { + test('folds settings that share a form into one line', () => { + expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual([ + 'settings ', + 'settings location set ', + 'settings appearance ', + `settings text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`, + 'settings ', + 'settings fingerprint ', + 'settings clear-app-state [app-id]', + 'settings reset-keychain clear', + `settings permission <${MOBILE_PERMISSION_TARGETS.join('|')}> [full|limited]`, + 'settings permission ', + ]); + }); + + test('the invalid-args message lists the same forms, with the last one as an alternative', () => { + expect(SETTINGS_INVALID_ARGS_MESSAGE).toMatch( + /^settings requires , .*, or permission $/, + ); + }); + + test('help has one line per setting', () => { + expect(settingsHelpLines().map((line) => line.split(':')[0])).toEqual(SETTING_NAMES); }); }); -describe('settings usage and error strings', () => { - test('help lists every settings form in its documented order', () => { - expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual( - SETTINGS_FORMS.map((form) => `settings ${form}`), +describe('parseSettingsMutation', () => { + test('accepts every on/off spelling and normalizes names and states', () => { + expect(parseSettingsMutation(['WiFi', 'ON'])).toEqual({ setting: 'wifi', state: 'on' }); + expect(parseSettingsMutation(['airplane', '0'])).toEqual({ setting: 'airplane', state: 'off' }); + expect(parseSettingsMutation(['wifi', ' true '])).toEqual({ setting: 'wifi', state: 'on' }); + }); + + test('refuses a state outside a choice with the accepted states', () => { + expectInvalidArgs( + () => parseSettingsMutation(['appearance', 'bright']), + 'Invalid appearance state: bright. Use light|dark|toggle.', + ); + expectInvalidArgs( + () => parseSettingsMutation(['wifi']), + 'settings wifi requires a state: on|off.', ); }); - test('the invalid-args message lists the same forms, with the last one as an alternative', () => { - expect(SETTINGS_INVALID_ARGS_MESSAGE).toBe( - `settings requires ${SETTINGS_FORMS.slice(0, -1).join(', ')}, or ${SETTINGS_FORMS.at(-1)}`, + test('refuses an unknown setting and extra positionals with the usage', () => { + expectInvalidArgs( + () => parseSettingsMutation(['bluetooth', 'on']), + SETTINGS_INVALID_ARGS_MESSAGE, + ); + expectInvalidArgs( + () => parseSettingsMutation(['reset-keychain', 'clear', 'extra']), + SETTINGS_INVALID_ARGS_MESSAGE, ); }); - test('the macOS permission form keeps the actions it serves and the names it accepts', () => { - expect(SETTINGS_MACOS_PERMISSION_USAGE).toBe( - 'permission ', + test('location set carries its coordinates as written', () => { + expect(parseSettingsMutation(['location', 'set', '37.33', '-122.03'])).toEqual({ + setting: 'location', + state: 'set', + latitude: '37.33', + longitude: '-122.03', + }); + expectInvalidArgs( + () => parseSettingsMutation(['location', 'set', '37.33']), + 'settings requires', ); }); - test('the macOS guidance names the permission form it supports', () => { - expect(getUnsupportedMacOsSettingMessage('wifi')).toBe( - 'Unsupported macOS setting: wifi. macOS supports only settings appearance ' + - 'and settings permission . ' + - 'wifi|airplane|location|animations|text-size remain unsupported on macOS.', + test('permission takes a mobile or macOS target and an optional mode', () => { + expect(parseSettingsMutation(['permission', 'grant', 'Photos', 'LIMITED'])).toEqual({ + setting: 'permission', + state: 'grant', + permission: 'photos', + mode: 'limited', + }); + expect(parseSettingsMutation(['permission', 'reset', 'accessibility'])).toMatchObject({ + permission: 'accessibility', + }); + expectInvalidArgs( + () => parseSettingsMutation(['permission', 'grant', 'bluetooth']), + 'settings permission requires a target:', + ); + expectInvalidArgs( + () => parseSettingsMutation(['permission', 'grant', 'photos', 'some']), + 'settings permission mode must be full|limited.', ); }); - test('only appearance and permission are supported macOS settings', () => { - expect(isMacOsSettingSupported(' Permission ')).toBe(true); - expect(isMacOsSettingSupported('appearance')).toBe(true); - expect(isMacOsSettingSupported('wifi')).toBe(false); - expect(isMacOsSettingSupported('text-size')).toBe(false); + test('a permission target takes only the actions and modes of its own form', () => { + expectInvalidArgs( + () => parseSettingsMutation(['permission', 'deny', 'accessibility']), + 'settings permission deny does not apply to accessibility. Use grant|reset.', + ); + expectInvalidArgs( + () => parseSettingsMutation(['permission', 'grant', 'accessibility', 'full']), + 'settings permission accessibility takes no mode.', + ); }); -}); -describe('text-size vocabulary', () => { - test('the ladder holds the rungs iOS exposes, in size order', () => { - expect([...TEXT_SIZE_CATEGORIES]).toEqual([...TEXT_SIZE_LADDER]); - expectTypeOf().toEqualTypeOf<(typeof TEXT_SIZE_LADDER)[number]>(); + test('clear-app-state reads its app with or without the clear word', () => { + expect(parseSettingsMutation(['clear-app-state', 'com.Example.App'])).toEqual({ + setting: 'clear-app-state', + state: 'clear', + app: 'com.Example.App', + }); + expect(parseSettingsMutation(['clear-app-state', 'clear', 'com.example.app'])).toMatchObject({ + app: 'com.example.app', + }); + expect(parseSettingsMutation(['clear-app-state'])).toEqual({ + setting: 'clear-app-state', + state: 'clear', + }); + expectInvalidArgs( + () => parseSettingsMutation(['clear-app-state', 'com.example.app', 'typo']), + SETTINGS_INVALID_ARGS_MESSAGE, + ); }); +}); - test('every rung parses under each normalization', () => { - for (const category of TEXT_SIZE_LADDER) { +describe('text-size', () => { + test('every rung parses under each normalization; an off-ladder value is refused', () => { + for (const category of TEXT_SIZE_CATEGORIES) { for (const normalize of NORMALIZATIONS) { expect(parseTextSizeCategory(normalize(category))).toBe(category); - expect(readTextSizeCategory(normalize(category))).toBe(category); } } - }); - - test('parseTextSizeCategory refuses an off-ladder value with the whole ladder', () => { - // `simctl ui content_size ` answers "Invalid argument" and exits 0, so refusing - // here is the only thing that keeps a mistyped rung from being reported as applied. - for (const value of ['gigantic', '', ' ', 'larger', 'extra_large']) { - expectInvalidArgs( - () => parseTextSizeCategory(value), - `Invalid text size: ${value}. Use ${TEXT_SIZE_LADDER.join('|')}.`, - ); - } + expectInvalidArgs(() => parseTextSizeCategory('gigantic'), 'Invalid text-size state: gigantic'); expect(readTextSizeCategory('gigantic')).toBeUndefined(); - expect(readTextSizeCategory(undefined)).toBeUndefined(); }); - test('the read payload names the setting that answered, beside the platform value', () => { + test('the read payload is frozen and each leg answers with its sentence', () => { const payload = textSizeSettingPayload('accessibility-large', '1.75'); expect(payload).toEqual({ setting: 'text-size', category: 'accessibility-large', platformValue: '1.75', }); - // Owners build the payload through this builder; a frozen result is what stops one from - // widening the payload the response is composed from. expect(Object.isFrozen(payload)).toBe(true); - }); - - test('each leg answers with the sentence its setting owns', () => { - // The response text is a claim about the setting, so it is composed here rather than at the call - // site that happens to run the request. - expect(describeSettingRead(textSizeSettingPayload('large', 'Small'))).toBe( - 'Text size is large', - ); + expect(describeSettingRead(payload)).toBe('Text size is accessibility-large'); expect(describeSettingWrite('text-size', 'large', undefined)).toBe('Text size set to large'); expect(describeSettingWrite('clear-app-state', 'clear', 'com.example.app')).toBe( 'Cleared user data for com.example.app', @@ -214,181 +186,55 @@ describe('text-size vocabulary', () => { }); }); -test('the readable-setting vocabulary is the names the read leg answers', () => { - // The type is the vocabulary; `READABLE_SETTINGS` beside it is the value the CLI hub evaluates. - // Both directions are pinned where the value is declared, so this is the one direction that has to - // be read: a name that joins the type without joining the value is a compile error there. - const readable: readonly ReadableSetting[] = READABLE_SETTINGS; - expect(readable).toEqual(['text-size']); -}); - -describe('parsePermissionTarget', () => { - test('accepts every mobile target under each normalization', () => { - for (const target of MOBILE_TARGETS) { - for (const normalize of NORMALIZATIONS) { - expect(parsePermissionTarget(normalize(target))).toBe(target); - } - } - }); - - test('refuses every name outside the mobile vocabulary, including the macOS-only ones', () => { - for (const target of REJECTED_TARGETS) { - expectInvalidArgs( - () => parsePermissionTarget(target), - `permission setting requires a target: ${MOBILE_TARGETS.join('|')}`, - ); - } - }); -}); - -describe('parsePermissionAction', () => { - test('accepts each action under each normalization', () => { - for (const action of ['grant', 'deny', 'reset']) { - for (const normalize of NORMALIZATIONS) { - expect(parsePermissionAction(normalize(action))).toBe(action); - } - } - }); - - test('refuses an action outside the vocabulary with the accepted list', () => { - for (const action of ['allow', 'revoke', '', ' ', 'deny-me']) { - expectInvalidArgs( - () => parsePermissionAction(action), - `Invalid permission action: ${action}. Use grant|deny|reset.`, - ); - } - }); -}); - -// The shared declaration must not move either exported type: these pins are what the public -// client and the platform owners compile against today. -describe('permission vocabulary types', () => { - test('the contract vocabulary stays the mobile subset', () => { - expectTypeOf().toEqualTypeOf<(typeof MOBILE_TARGETS)[number]>(); - expectTypeOf().toEqualTypeOf<'grant' | 'deny' | 'reset'>(); - }); - - test('the mobile vocabulary does not grow the macOS-only names', () => { - expectTypeOf<'accessibility'>().not.toMatchTypeOf(); - expectTypeOf<'screen-recording'>().not.toMatchTypeOf(); - expectTypeOf<'input-monitoring'>().not.toMatchTypeOf(); - }); -}); - -// Fixed expected data on purpose: these are the acceptance sets the Apple and Android settings -// owners each enforced in their own copy of the same parser before the copies were deleted. The -// two parsers agreed byte for byte, including the refusal wording, so one declaration now carries -// both — and a widened or narrowed set here fails here rather than diverging one platform at a -// time, which is the only way the copies could have drifted unnoticed. -const APPEARANCE_SET = ['light', 'dark', 'toggle'] as const; - -describe('parseAppearanceAction', () => { - test('holds the appearances every settings surface already accepted, in help order', () => { - expect([...APPEARANCE_ACTIONS]).toEqual([...APPEARANCE_SET]); +describe('owner parsers', () => { + test('parsePermissionTarget accepts the mobile targets only', () => { + expect(parsePermissionTarget(' Camera ')).toBe('camera'); + expectInvalidArgs( + () => parsePermissionTarget('accessibility'), + `permission setting requires a target: ${MOBILE_PERMISSION_TARGETS.join('|')}`, + ); }); - test('accepts each appearance under each normalization', () => { - for (const action of APPEARANCE_SET) { - for (const normalize of NORMALIZATIONS) { - expect(parseAppearanceAction(normalize(action))).toBe(action); - } - } + test('parsePermissionAction and parseAppearanceAction name the accepted list', () => { + expect(parsePermissionAction('DENY')).toBe('deny'); + expectInvalidArgs(() => parsePermissionAction('allow'), 'Use grant|deny|reset.'); + expect(parseAppearanceAction('Dark')).toBe('dark'); + expectInvalidArgs(() => parseAppearanceAction('bright'), 'Use light|dark|toggle.'); }); - test('refuses an appearance outside the vocabulary with the accepted list', () => { - for (const state of ['bright', '', ' ', 'light-dark']) { - expectInvalidArgs( - () => parseAppearanceAction(state), - `Invalid appearance state: ${state}. Use ${[...APPEARANCE_SET].join('|')}.`, - ); - } - }); -}); - -describe('parseSettingState', () => { - test('maps every on and off spelling in any casing', () => { - for (const state of ['on', 'ON', 'true', 'TRUE', '1']) { + test('parseSettingState maps on/off spellings in any casing or padding', () => { + for (const state of ['on', 'true', 'TRUE', '1', ' on ']) { expect(parseSettingState(state)).toBe(true); } - for (const state of ['off', 'OFF', 'false', 'FALSE', '0']) { + for (const state of ['off', 'false', 'FALSE', '0']) expect(parseSettingState(state)).toBe(false); - } - }); - - test('refuses a state that spells neither, and keeps padding unaccepted', () => { - // Padding is not one of these spellings: unlike an appearance name, a state reaches `settings - // put` as an argument, where a stray space is a caller bug rather than a casing variant. - for (const state of ['maybe', '2', '', ' on ']) { + for (const state of ['maybe', '']) { expectInvalidArgs(() => parseSettingState(state), `Invalid setting state: ${state}`); } }); }); -describe('appearance vocabulary types', () => { - test('the vocabulary stays the appearance state the published client type carries', () => { - // The CLI's membership set, both platform parsers, and the published client union are all built - // from this one declaration now. The union itself stays spelled out in `client-settings.ts` so - // the shipped .d.ts keeps its literal shape; this pin is what stops the two drifting. - expectTypeOf().toEqualTypeOf< - Extract['state'] - >(); - }); -}); - describe('settingsAppScope', () => { - test('a permission is app-scoped on every mobile target and host-level on macOS', () => { - expect(settingsAppScope('apple', 'permission', 'grant')).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'permission', 'deny')).toBe('app-scoped'); - expect(settingsAppScope('macos-host', 'permission', 'grant')).toBe('device-level'); - }); - - test('clear-app-state is app-scoped wherever it is served', () => { - expect(settingsAppScope('apple', 'clear-app-state', 'clear')).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'clear-app-state', 'clear')).toBe('app-scoped'); - expect(settingsAppScope('macos-host', 'clear-app-state', 'clear')).toBe('unknown'); - }); - - test('an on/off location is app-scoped only where it maps to a privacy grant', () => { - expect(settingsAppScope('apple', 'location', 'on')).toBe('app-scoped'); - expect(settingsAppScope('apple', 'location', 'off')).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'location', 'on')).toBe('device-level'); - expect(settingsAppScope('mobile', 'location', 'off')).toBe('device-level'); - }); - - test('every state parseSettingState accepts classifies like its on/off spelling', () => { - // The owners toggle on `parseSettingState`, which also accepts true/1/false/0. A spelling the - // parser takes must settle the app scope the same way, or `location 1 --app X` silently drops X. - for (const state of ['on', 'true', 'TRUE', '1']) { - expect(parseSettingState(state)).toBe(true); - expect(settingsAppScope('apple', 'location', state)).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'location', state)).toBe('device-level'); - } - for (const state of ['off', 'false', 'FALSE', '0']) { - expect(parseSettingState(state)).toBe(false); - expect(settingsAppScope('apple', 'location', state)).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'location', state)).toBe('device-level'); - } - // The table trims before consulting the grammar, so a padded spelling still classifies. - expect(settingsAppScope('apple', 'location', ' on ')).toBe('app-scoped'); - }); - - test('a location set moves the device itself for everyone', () => { - expect(settingsAppScope('apple', 'location', 'set')).toBe('device-level'); - expect(settingsAppScope('mobile', 'location', 'set')).toBe('device-level'); - }); - - test('a combination the table does not settle stays unknown, not a verdict', () => { - expect(settingsAppScope('macos-host', 'location', 'on')).toBe('unknown'); - expect(settingsAppScope('mobile', 'wifi', 'on')).toBe('unknown'); - expect(settingsAppScope('apple', 'animations', 'on')).toBe('unknown'); - expect(settingsAppScope('mobile', 'location', 'sideways')).toBe('unknown'); - // A mutation with no state is not a write the surface admits, so the table names no scope for it. - expect(settingsAppScope('apple', 'location', undefined)).toBe('unknown'); - }); - - test('the scope keys on the setting and state vocabulary, not its padding or case', () => { - expect(settingsAppScope('apple', ' PERMISSION ', 'GRANT')).toBe('app-scoped'); - expect(settingsAppScope('mobile', 'Location', ' On ')).toBe('device-level'); + test.each([ + ['apple', 'permission', 'grant', 'app-scoped'], + ['mobile', 'permission', 'deny', 'app-scoped'], + ['macos-host', 'permission', 'grant', 'device-level'], + ['apple', 'clear-app-state', 'clear', 'app-scoped'], + ['mobile', 'clear-app-state', 'clear', 'app-scoped'], + ['macos-host', 'clear-app-state', 'clear', 'unknown'], + ['apple', 'location', 'on', 'app-scoped'], + ['apple', 'location', '0', 'app-scoped'], + ['mobile', 'location', 'TRUE', 'device-level'], + ['apple', 'location', ' on ', 'app-scoped'], + ['apple', 'location', 'set', 'device-level'], + ['macos-host', 'location', 'set', 'unknown'], + ['macos-host', 'location', 'on', 'unknown'], + ['mobile', 'location', 'sideways', 'unknown'], + ['apple', 'location', undefined, 'unknown'], + ['mobile', 'wifi', 'on', 'unknown'], + ['apple', ' PERMISSION ', 'GRANT', 'app-scoped'], + ] as const)('%s %s %s is %s', (family, setting, state, scope) => { + expect(settingsAppScope(family, setting, state)).toBe(scope); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 072f1609cd..ab472b4108 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1,99 +1,22 @@ import { AppError, appNotInstalledDetails, discloseDispatch } from '@agent-device/kernel/errors'; - -/** - * The `settings permission` vocabulary, declared once. These collections are what the parsers - * below, the public client permission types, the CLI's membership sets, and the permission-name - * fragments of `settings` help and its invalid-args message are built from, and their order is - * the order `settings` help lists the names in. - * - * Acceptance is not support: each backend keeps its own target mapping and its own support check, - * so a name accepted here never promises that the selected platform serves it. - */ -export const PERMISSION_ACTIONS = ['grant', 'deny', 'reset'] as const; -export const PERMISSION_MODES = ['full', 'limited'] as const; - -/** The app-scoped targets, the only ones `parsePermissionTarget` accepts. */ -export const MOBILE_PERMISSION_TARGETS = [ - 'all', - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', -] as const; - -/** The desktop targets the CLI and the public client accept; `parsePermissionTarget` refuses them. */ -export const MACOS_PERMISSION_TARGETS = [ - 'accessibility', - 'screen-recording', - 'input-monitoring', -] as const; - -export type PermissionAction = (typeof PERMISSION_ACTIONS)[number]; -export type PermissionMode = (typeof PERMISSION_MODES)[number]; -export type MobilePermissionTarget = (typeof MOBILE_PERMISSION_TARGETS)[number]; -/** - * The app-scoped vocabulary `parsePermissionTarget` returns. The public client's `PermissionTarget` - * in `client-settings.ts` is wider: it also names the macOS targets. - */ -export type PermissionTarget = MobilePermissionTarget; - -/** - * The preferred-text-size ladder `settings text-size` accepts, in the order `settings` help lists - * it. Apple serves it natively — `simctl ui content_size` reads and writes exactly these - * names — and Android serves it through its `system font_scale` multiplier, so this ladder is the - * cross-platform vocabulary and each owner maps it in its own native terms. Acceptance is not - * support: an owner that cannot serve the ladder refuses at admission, on its own fact. - */ -export const TEXT_SIZE_CATEGORIES = [ - 'extra-small', - 'small', - 'medium', - 'large', - 'extra-large', - 'extra-extra-large', - 'extra-extra-extra-large', - 'accessibility-medium', - 'accessibility-large', - 'accessibility-extra-large', - 'accessibility-extra-extra-large', - 'accessibility-extra-extra-extra-large', -] as const; - -export type TextSizeCategory = (typeof TEXT_SIZE_CATEGORIES)[number]; - -/** - * The appearances `settings appearance` accepts, in the order `settings` help lists them. Apple - * applies them natively; Android maps them onto `ui_night_mode`. Acceptance is not support. - */ -export const APPEARANCE_ACTIONS = ['light', 'dark', 'toggle'] as const; - -export type AppearanceAction = (typeof APPEARANCE_ACTIONS)[number]; - -/** - * The state spellings `settings ` accepts. Unlike the appearance and permission - * vocabularies these are aliases onto a boolean rather than a name the device echoes back, so - * membership — not {@link findVocabularyName} — decides, and padding stays rejected: an - * `adb shell settings put` argument with a stray space in it is a caller bug, not a spelling. - */ -const SETTING_STATE_ON = ['on', 'true', '1']; -const SETTING_STATE_OFF = ['off', 'false', '0']; - -/** - * The settings that answer a bare `settings ` with the value the target holds. This is the - * vocabulary every settings type and every settings owner reads it from; the matching value is - * `READABLE_SETTINGS` in `platform-runtime-operations.ts`, where the CLI hub can evaluate it, and - * that module pins the two equal in both directions at compile time. - */ -export type ReadableSetting = 'text-size'; +import { + APPEARANCE_ACTIONS, + MOBILE_PERMISSION_TARGETS, + PERMISSION_ACTIONS, + SETTINGS, + settingDefinition, + SETTING_NAMES, + TEXT_SIZE_CATEGORIES, + type AppearanceAction, + type MobilePermissionTarget, + type PermissionAction, + type SettingDefinition, + type SettingForm, + type SettingName, + type SettingsAppScope, + type SettingsTargetFamily, + type TextSizeCategory, +} from './settings-catalog.ts'; /** * What `text-size` answers with. The ladder is shared across platforms, so a read names the @@ -178,71 +101,22 @@ export type SimctlSettingRequest = { }; /** - * Whether naming an app for one mutation can mean anything on one target. - * - * `app-scoped` is the shape the public `app` field exists for: the change lands on that bundle id - * or package (`simctl privacy`, Android's `pm`). `device-level` is a mutation the same command - * serves for the whole device: Android's on/off `location` writes the global `location_mode`, and - * the macOS host's permissions are TCC grants to the host process, so neither can consume an app. - * `unknown` covers every combination this table does not settle — a setting with no app shape and no - * device-wide write of its own (`wifi` on the macOS host) among them — and is deliberately not a - * verdict: support belongs to the owner's runtime fact, which answers a setting it does not serve - * (`permission` on HarmonyOS) with its own refusal rather than with a complaint about an argument it - * never read. - */ -export type SettingsAppScope = 'app-scoped' | 'device-level' | 'unknown'; - -/** - * What kind of target a `settings` mutation runs on, as far as consuming an app is concerned: - * the Apple family, the macOS host, or the non-Apple targets that share the mobile app shape - * (Android, HarmonyOS, Vega, Linux, web). The daemon derives it from a device with the kernel's - * own predicates; being app-scoped here is a claim about the shape of the change, never about - * support — an owner that serves no such setting answers with its own refusal. - */ -export type SettingsTargetFamily = 'apple' | 'mobile' | 'macos-host'; - -/** - * The one table deciding whether an app named on a `settings` request is consumed. It is keyed on - * the target family, a fact the daemon derives from the device before binding a runtime, and it - * stays honest by settling only the combinations whose owner behavior is already fixed elsewhere: - * `clear-app-state` is app-scoped wherever it is served (`isMacOsSettingSupported` keeps macOS out - * of that claim), `permission` is app-scoped on every non-Apple mobile target and on Apple and - * host-level on macOS, and on/off `location` is app-scoped only on Apple, where it maps to a - * privacy grant — while `location set` moves the device's own location for everyone. + * Whether naming an app on one mutation can mean anything on one target family, read from the + * matched form's `appScope`, then the setting's own. */ export function settingsAppScope( family: SettingsTargetFamily, setting: string, state: string | undefined, ): SettingsAppScope { - const normalizedSetting = setting.trim().toLowerCase(); - if (normalizedSetting === 'clear-app-state') { - return family === 'macos-host' ? 'unknown' : 'app-scoped'; - } - if (normalizedSetting === 'permission') { - return family === 'macos-host' ? 'device-level' : 'app-scoped'; - } - if (normalizedSetting === 'location') return locationAppScope(family, state); - return 'unknown'; -} - -/** - * One on/off `location` is a privacy grant only on Apple, where it maps to `simctl privacy`; on - * the other mobile targets it writes the global `location_mode`, and `set` moves the device's own - * location for everyone. A state this ladder does not name settles nothing, and the macOS host, - * whose location surface `isMacOsSettingSupported` keeps out of the settings vocabulary, is no - * verdict either. - */ -function locationAppScope( - family: SettingsTargetFamily, - state: string | undefined, -): SettingsAppScope { - const normalizedState = state?.trim().toLowerCase(); - if (normalizedState === 'set') return 'device-level'; - if (readSettingState(normalizedState) === undefined) return 'unknown'; - if (family === 'apple') return 'app-scoped'; - if (family === 'mobile') return 'device-level'; - return 'unknown'; + const definition = settingDefinition(setting); + if (definition === undefined) return 'unknown'; + const form = + definition.forms.length === 1 + ? definition.forms[0] + : definition.forms.find((candidate) => formAcceptsState(candidate, state)); + const scope = (form && 'appScope' in form ? form.appScope : undefined) ?? definition.appScope; + return scope?.[family] ?? 'unknown'; } /** @@ -318,68 +192,197 @@ export async function resolveSettingsApp( } } -const SETTINGS_WIFI_USAGE = ' '; -const SETTINGS_LOCATION_SET_USAGE = 'location set '; -const SETTINGS_ANIMATIONS_USAGE = 'animations '; -const SETTINGS_APPEARANCE_USAGE = `appearance <${APPEARANCE_ACTIONS.join('|')}>`; -const SETTINGS_FACEID_USAGE = 'faceid '; -const SETTINGS_TOUCHID_USAGE = 'touchid '; -const SETTINGS_FINGERPRINT_USAGE = 'fingerprint '; -const SETTINGS_CLEAR_APP_STATE_USAGE = 'clear-app-state [app-id]'; -const SETTINGS_RESET_KEYCHAIN_USAGE = 'reset-keychain clear'; +/** The positionals after `settings` one form accepts, as help prints them. */ +function formUsage(form: SettingForm, readable: boolean): string { + switch (form.kind) { + case 'on-off': + return ''; + case 'choice': { + const states = form.states.join('|'); + if (readable) return `[${states}]`; + return form.states.length === 1 ? states : `<${states}>`; + } + case 'coordinates': + return 'set '; + case 'permission': { + const modes = form.modes ? ` [${form.modes.join('|')}]` : ''; + return `<${form.actions.join('|')}> <${form.targets.join('|')}>${modes}`; + } + case 'app-id': + return '[app-id]'; + } +} + +/** Every settings form, with settings that share a form folded into one ` ` line. */ +function settingsUsageForms(): string[] { + const namesByForm = new Map(); + for (const name of SETTING_NAMES) { + const definition: SettingDefinition = SETTINGS[name]; + for (const form of definition.forms) { + const usage = formUsage(form, definition.readable === true); + namesByForm.set(usage, [...(namesByForm.get(usage) ?? []), name]); + } + } + return [...namesByForm].map(([usage, names]) => + names.length === 1 ? `${names[0]} ${usage}` : `<${names.join('|')}> ${usage}`, + ); +} + +export const SETTINGS_USAGE_OVERRIDE = settingsUsageForms() + .map((form) => `settings ${form}`) + .join(' | '); + +export const SETTINGS_INVALID_ARGS_MESSAGE = `settings requires ${settingsUsageForms() + .join(', ') + .replace(/, (?!.*, )/, ', or ')}`; + +/** One line per setting: its name and its help. */ +export function settingsHelpLines(): string[] { + return SETTING_NAMES.map((name) => `${name}: ${SETTINGS[name].help}`); +} + /** - * The bracket is what makes `settings text-size` a read: the ladder is the write form, and the bare - * setting asks the owner for the category it currently holds. + * One validated mutation. Names and states are normalized; the app, mode, and coordinates are kept + * as written, because the app is an id and coordinates are typed where the caller needs them. */ -const SETTINGS_TEXT_SIZE_USAGE = `text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`; -const SETTINGS_PERMISSION_USAGE = `permission <${PERMISSION_ACTIONS.join('|')}> <${MOBILE_PERMISSION_TARGETS.join('|')}> [${PERMISSION_MODES.join('|')}]`; +export type SettingsMutation = Readonly<{ + setting: SettingName; + state: string; + app?: string; + permission?: string; + mode?: string; + latitude?: string; + longitude?: string; +}>; + /** - * The macOS permission form. Its action list is the subset the macOS owner serves (`deny` is - * refused there), so it stays a literal while the accepted names come from the vocabulary. + * Parses `settings …` against the catalog. A lone readable setting is the read + * leg, which `resolveSettingsRuntimePlan` decides before this runs. */ -export const SETTINGS_MACOS_PERMISSION_USAGE = `permission <${MACOS_PERMISSION_TARGETS.join('|')}>`; -const SETTINGS_MACOS_SUPPORTED_MESSAGE = `macOS supports only settings ${SETTINGS_APPEARANCE_USAGE} and settings ${SETTINGS_MACOS_PERMISSION_USAGE}. wifi|airplane|location|animations|text-size remain unsupported on macOS.`; - -export const SETTINGS_USAGE_OVERRIDE = [ - `settings ${SETTINGS_WIFI_USAGE}`, - `settings ${SETTINGS_LOCATION_SET_USAGE}`, - `settings ${SETTINGS_ANIMATIONS_USAGE}`, - `settings ${SETTINGS_APPEARANCE_USAGE}`, - `settings ${SETTINGS_TEXT_SIZE_USAGE}`, - `settings ${SETTINGS_FACEID_USAGE}`, - `settings ${SETTINGS_TOUCHID_USAGE}`, - `settings ${SETTINGS_FINGERPRINT_USAGE}`, - `settings ${SETTINGS_CLEAR_APP_STATE_USAGE}`, - `settings ${SETTINGS_RESET_KEYCHAIN_USAGE}`, - `settings ${SETTINGS_PERMISSION_USAGE}`, - `settings ${SETTINGS_MACOS_PERMISSION_USAGE}`, -].join(' | '); - -export const SETTINGS_INVALID_ARGS_MESSAGE = `settings requires ${SETTINGS_WIFI_USAGE}, ${SETTINGS_LOCATION_SET_USAGE}, ${SETTINGS_ANIMATIONS_USAGE}, ${SETTINGS_APPEARANCE_USAGE}, ${SETTINGS_TEXT_SIZE_USAGE}, ${SETTINGS_FACEID_USAGE}, ${SETTINGS_TOUCHID_USAGE}, ${SETTINGS_FINGERPRINT_USAGE}, ${SETTINGS_CLEAR_APP_STATE_USAGE}, ${SETTINGS_RESET_KEYCHAIN_USAGE}, ${SETTINGS_PERMISSION_USAGE}, or ${SETTINGS_MACOS_PERMISSION_USAGE}`; - -export function isMacOsSettingSupported(setting: string): boolean { - const normalized = setting.trim().toLowerCase(); - return normalized === 'appearance' || normalized === 'permission'; +export function parseSettingsMutation(positionals: readonly string[]): SettingsMutation { + const definition = settingDefinition(positionals[0]); + if (definition === undefined) throw invalidSettingsArgs(); + const setting = normalizeName(positionals[0]) as SettingName; + const state = normalizeName(positionals[1]); + if (definition.forms.some((form) => form.kind === 'app-id')) { + const explicitClear = state === 'clear'; + if (positionals.length > (explicitClear ? 3 : 2)) throw invalidSettingsArgs(); + const app = explicitClear ? positionals[2] : positionals[1]; + return { setting, state: 'clear', ...(app === undefined ? {} : { app }) }; + } + const form = definition.forms.find((candidate) => formAcceptsState(candidate, state)); + if (form === undefined || state === undefined) + throw invalidSettingState(setting, definition, state); + return parseFormPositionals(setting, state, form, definition, positionals); +} + +function parseFormPositionals( + setting: SettingName, + state: string, + form: SettingForm, + definition: SettingDefinition, + positionals: readonly string[], +): SettingsMutation { + switch (form.kind) { + case 'coordinates': + if (positionals.length !== 4) throw invalidSettingsArgs(); + return { setting, state, latitude: positionals[2], longitude: positionals[3] }; + case 'permission': + return { setting, state, ...parsePermissionPositionals(definition, state, positionals) }; + case 'on-off': + if (positionals.length !== 2) throw invalidSettingsArgs(); + return { setting, state: readSettingState(state) ? 'on' : 'off' }; + default: + if (positionals.length !== 2) throw invalidSettingsArgs(); + return { setting, state }; + } } -export function getUnsupportedMacOsSettingMessage(setting: string): string { - return `Unsupported macOS setting: ${setting}. ${SETTINGS_MACOS_SUPPORTED_MESSAGE}`; +/** The target picks its form; the action and mode must then belong to that same form. */ +function parsePermissionPositionals( + definition: SettingDefinition, + action: string, + positionals: readonly string[], +): { permission: string; mode?: string } { + const forms = definition.forms.filter( + (form): form is Extract => form.kind === 'permission', + ); + const targets = forms.flatMap((form) => form.targets); + const permission = findVocabularyName(targets, positionals[2]); + const form = forms.find((candidate) => candidate.targets.includes(permission ?? '')); + if (permission === undefined || form === undefined) { + throw new AppError( + 'INVALID_ARGS', + `settings permission requires a target: ${targets.join('|')}`, + ); + } + if (findVocabularyName(form.actions, action) === undefined) { + throw new AppError( + 'INVALID_ARGS', + `settings permission ${action} does not apply to ${permission}. Use ${form.actions.join('|')}.`, + ); + } + if (positionals.length === 3) return { permission }; + const mode = findVocabularyName(form.modes ?? [], positionals[3]); + if (mode === undefined || positionals.length > 4) { + throw new AppError( + 'INVALID_ARGS', + form.modes === undefined + ? `settings permission ${permission} takes no mode.` + : `settings permission mode must be ${form.modes.join('|')}.`, + ); + } + return { permission, mode }; +} + +function invalidSettingsArgs(): AppError { + return new AppError('INVALID_ARGS', SETTINGS_INVALID_ARGS_MESSAGE); +} + +function invalidSettingState( + setting: SettingName, + definition: SettingDefinition, + state: string | undefined, +): AppError { + const accepted = definition.forms + .map((form) => (form.kind === 'choice' ? form.states.join('|') : formUsage(form, false))) + .join(' | ') + .replace('', 'on|off'); + return new AppError( + 'INVALID_ARGS', + state === undefined + ? `settings ${setting} requires a state: ${accepted}.` + : `Invalid ${setting} state: ${state}. Use ${accepted}.`, + ); +} + +function formAcceptsState(form: SettingForm, state: string | undefined): boolean { + switch (form.kind) { + case 'on-off': + return readSettingState(normalizeName(state)) !== undefined; + case 'choice': + return findVocabularyName(form.states, state) !== undefined; + case 'coordinates': + return normalizeName(state) === 'set'; + case 'permission': + return findVocabularyName(form.actions, state) !== undefined; + case 'app-id': + return true; + } +} + +function normalizeName(value: string | undefined): string | undefined { + return value?.trim().toLowerCase(); } /** * The category a caller asked for. `simctl` exits 0 with `Invalid argument` for a category it does - * not know, so a write must never delegate validation to the tool: every surface parses here - * first, and the refusal names the whole ladder. + * not know, so a write never hands validation to the tool. */ export function parseTextSizeCategory(value: string | undefined): TextSizeCategory { const parsed = readTextSizeCategory(value); if (parsed !== undefined) return parsed; - throw new AppError('INVALID_ARGS', invalidTextSizeMessage(value)); -} - -/** The refusal every surface that rejects an off-ladder category answers with. */ -export function invalidTextSizeMessage(value: string | undefined): string { - return `Invalid text size: ${value ?? ''}. Use ${TEXT_SIZE_CATEGORIES.join('|')}.`; + throw invalidSettingState('text-size', SETTINGS['text-size'], value); } /** The category an owner reads back, or `undefined` for a value the ladder does not name. */ @@ -387,50 +390,11 @@ export function readTextSizeCategory(value: string | undefined): TextSizeCategor return findVocabularyName(TEXT_SIZE_CATEGORIES, value); } -/** - * What a setting declares to refuse a leaf it was never verified on: the sentence, the next command - * to run, and the reason both legs of that setting answer with. The shape is declared here so every - * setting's refusal is checked against it at its declaration, and the Apple leaf guard can name it - * without inventing a second vocabulary. - */ -export type AppleSettingLeafRefusal = Readonly<{ - message: string; - hint: string; - reason: 'setting-unsupported-on-leaf'; -}>; - -/** - * The one refusal an Apple target gives for a text-size request its leaf does not serve, shared by - * the three surfaces that can answer it: the daemon (before it binds, so a request that never touched - * a device also never expires the session ref frame), the Apple runtime's read fact, and the Apple - * owner's own guard. The Apple write fact is one claim for the whole simulator family and - * `simctl ui content_size` is narrower than that, which is why a per-setting refusal has to - * exist at all; a per-setting runtime fact is where it belongs once the fact model carries one, and - * until then this is the single declaration rather than three copies of the prose. - */ -export const APPLE_TEXT_SIZE_LEAF_REFUSAL = Object.freeze({ - message: 'Reading or setting a text size is supported on iOS and iPadOS simulators.', - hint: 'Run `xcrun simctl ui content_size` on a booted iPhone or iPad simulator.', - reason: 'setting-unsupported-on-leaf', -} as const) satisfies AppleSettingLeafRefusal; - -/** - * Simulator biometrics are driven through BiometricKit_Sim notifications, which only the iPhone and - * iPad simulator runtimes observe; a post on an Apple TV or Vision Pro simulator exits 0 and changes - * nothing, so the leaf is refused before anything is posted. - */ -export const APPLE_BIOMETRIC_LEAF_REFUSAL = Object.freeze({ - message: 'Face ID and Touch ID simulation is supported on iOS and iPadOS simulators.', - hint: 'Select a booted iPhone or iPad simulator, then run `settings faceid ` or `settings touchid `.', - reason: 'setting-unsupported-on-leaf', -} as const) satisfies AppleSettingLeafRefusal; - -/** The one membership rule every settings-vocabulary parser shares: a name matches itself, any casing. */ function findVocabularyName( names: TNames, value: string | undefined, ): TNames[number] | undefined { - const normalized = value?.trim().toLowerCase(); + const normalized = normalizeName(value); return normalized === undefined ? undefined : names.find((name) => name === normalized); } @@ -443,7 +407,7 @@ export function parsePermissionAction(action: string): PermissionAction { ); } -export function parsePermissionTarget(value: string | undefined): PermissionTarget { +export function parsePermissionTarget(value: string | undefined): MobilePermissionTarget { const parsed = findVocabularyName(MOBILE_PERMISSION_TARGETS, value); if (parsed !== undefined) return parsed; throw new AppError( @@ -453,9 +417,8 @@ export function parsePermissionTarget(value: string | undefined): PermissionTarg } /** - * The appearance a caller asked for. Every surface that sets one parses here first: `simctl` and - * `adb` both answer a bad token with a tool-specific error or a silent no-op, so the refusal has to - * name the accepted set before a device is touched. + * `simctl` and `adb` answer a bad appearance with a tool-specific error or a silent no-op, so an + * owner parses here before a device is touched. */ export function parseAppearanceAction(state: string): AppearanceAction { const parsed = findVocabularyName(APPEARANCE_ACTIONS, state); @@ -466,20 +429,14 @@ export function parseAppearanceAction(state: string): AppearanceAction { ); } -/** - * The boolean a `settings ` positional spells, or `undefined` when it spells none. - * This is the grammar `parseSettingState` refuses on and the app-scope table classifies with, so a - * state the owners accept can never settle differently about an app than it settles about the toggle. - */ function readSettingState(state: string | undefined): boolean | undefined { - if (state === undefined) return undefined; - const normalized = state.toLowerCase(); - if (SETTING_STATE_ON.includes(normalized)) return true; - if (SETTING_STATE_OFF.includes(normalized)) return false; + const normalized = normalizeName(state); + if (normalized === 'on' || normalized === 'true' || normalized === '1') return true; + if (normalized === 'off' || normalized === 'false' || normalized === '0') return false; return undefined; } -/** The boolean a `settings ` positional spells, in any casing. */ +/** The boolean a `settings ` positional spells, in any casing or padding. */ export function parseSettingState(state: string): boolean { const parsed = readSettingState(state); if (parsed !== undefined) return parsed; diff --git a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts index ecedbc69cf..cdb04ceffb 100644 --- a/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts +++ b/packages/daemon/src/__tests__/support/snapshot-runtime-fixture.ts @@ -156,32 +156,78 @@ export function snapshotRuntimeFixture(requestId?: string): Readonly<{ return { inspectFacts, bindDevice, platformServices: daemonPlatformServicesFixture() }; } -/** A physical Apple device that is not the macOS host: the one settings refusal these suites use. */ -function appleHostOrSimulatorOnly(device: DeviceInfo): boolean { - return device.platform === 'apple' && device.kind === 'device' && !isMacOs(device); -} - -/** - * The narrower read refusal, stated through the same predicate the Apple owner and the Apple runtime - * fact use: only an iPhone/iPad simulator has a content size `simctl ui` reports. - */ -function appleSettingsReadRefused(device: DeviceInfo): boolean { - if (!isApplePlatform(device.platform)) return true; - return !isHandheldAppleSimulator(device); -} - const settingsUnavailable = { available: false, reason: 'unsupported-platform-leaf', hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', } as const; -const settingsReadUnavailable = { +const settingUnavailable = { available: false, reason: 'unsupported-platform-leaf', - hint: 'Reading a setting back is supported on iPhone and iPad simulators, where `simctl ui` reports the value the device holds.', + hint: 'This fixture target does not serve the setting.', } as const; +/** A condensed model of the owners' settings facts; each owner pins its own in its runtime test. */ +function settingsFacts(device: DeviceInfo) { + const available = { available: true } as const; + const unsupported = settingUnavailable; + switch (device.platform) { + case 'android': + return settingsRuntimeOperationFacts({ + target: available, + unsupported, + write: { + wifi: available, + airplane: available, + location: available, + animations: available, + appearance: available, + 'text-size': available, + fingerprint: available, + 'clear-app-state': available, + permission: available, + }, + read: { 'text-size': available }, + }); + case 'harmonyos': + return settingsRuntimeOperationFacts({ + target: available, + unsupported, + write: { 'clear-app-state': available }, + }); + case 'apple': + break; + default: + return settingsRuntimeOperationFacts({ target: settingUnavailable, unsupported, write: {} }); + } + if (isMacOs(device)) { + return settingsRuntimeOperationFacts({ + target: available, + unsupported, + write: { appearance: available, permission: available }, + }); + } + const handheld = isHandheldAppleSimulator(device) ? available : settingUnavailable; + return settingsRuntimeOperationFacts({ + target: device.kind === 'device' ? settingsUnavailable : available, + unsupported, + write: { + wifi: available, + airplane: available, + location: available, + appearance: available, + 'clear-app-state': available, + 'reset-keychain': available, + permission: available, + 'text-size': handheld, + faceid: handheld, + touchid: handheld, + }, + read: { 'text-size': handheld }, + }); +} + const alertUnavailable = { available: false, reason: 'unsupported-platform-leaf', @@ -220,14 +266,7 @@ async function snapshotFacts(device: DeviceInfo): Promise[]; + /** The subject every required operation must serve, such as the setting `settings` names. */ + subject?: string; unavailableResponse?: UnavailableRuntimeResponse; readiness?: boolean; }>; @@ -71,7 +74,11 @@ export async function admitRuntimeOperations( ): Promise { const facts = await requireFactsInspection(request.inspectFacts)(request.device); for (const operation of request.required) { - const fact = facts.operations[operation]; + const operationFact = facts.operations[operation]; + const fact = + request.subject === undefined + ? operationFact + : runtimeSubjectFact(operationFact, request.subject); if (fact.available) continue; const response = request.unavailableResponse ? request.unavailableResponse(fact) diff --git a/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts b/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts index 747af24a4f..4646fa5f5e 100644 --- a/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts +++ b/packages/daemon/src/execution/handlers/__tests__/snapshot-settings-handler.test.ts @@ -104,7 +104,7 @@ test('settings rejects unsupported iOS physical devices', async () => { expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/settings is not supported/i); + expect(response.error.message).toBe('settings wifi is not supported on this device'); } }); @@ -227,35 +227,32 @@ test('settings text-size reads the category the owner holds without mutating any expect(fixtureSettingsMutations).toHaveLength(0); }); -test('settings text-size refuses the macOS host on both legs with the same code', async () => { +test('settings on the macOS host refuses a setting it does not serve, read and write alike', async () => { const sessionStore = makeSessionStore(); - const sessionName = 'macos-text-size-read'; - sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); - - const read = await handleSnapshotCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - const write = await handleSnapshotCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'settings', { positionals: ['text-size', 'large'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); + const sessionName = 'macos-settings'; + const session = makeSession(sessionName, macOsDevice); + activateCompleteRefFrame(session); + sessionStore.publish(sessionName, session); - for (const response of [read, write]) { + for (const positionals of [['text-size'], ['text-size', 'large'], ['wifi', 'on']]) { + const response = await handleSnapshotCommands({ + createCommandSurface: fakeCreateCommandSurface, + platformServices: daemonPlatformServicesFixture(), + req: snapshotRequest(sessionName, 'settings', { positionals }), + sessionName, + logPath: '/tmp/daemon.log', + sessionStore, + }); expect(response?.ok).toBe(false); if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/Unsupported macOS setting: text-size/i); + expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); + expect(response.error.message).toBe( + `settings ${positionals[0]} is not supported on this device`, + ); + expect(response.error.details?.reason).toBe('unsupported-platform-leaf'); } } + expect(refFrameState(session)).toBe('active'); expect(fixtureSettingsReads).toHaveLength(0); expect(fixtureSettingsMutations).toHaveLength(0); }); @@ -312,12 +309,10 @@ test('settings text-size refuses an Apple leaf with no content size before it ex expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('UNSUPPORTED_OPERATION'); - expect(response.error.message).toMatch(/iOS and iPadOS simulators/i); + expect(response.error.message).toBe('settings text-size is not supported on this device'); } } - // The refusal is the point of checking before admission: the write leg expires the frame the - // moment it binds, so a request that never reached a device would otherwise have taken down a - // frame no mutation invalidated. + // Admission refuses before the write leg binds, so the frame survives. expect(refFrameState(session)).toBe('active'); expect(fixtureSettingsReads).toHaveLength(0); expect(fixtureSettingsMutations).toHaveLength(0); @@ -340,7 +335,7 @@ test('settings text-size refuses an off-ladder category with the whole ladder', expect(response?.ok).toBe(false); if (response && !response.ok) { expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/Invalid text size: gigantic/); + expect(response.error.message).toMatch(/Invalid text-size state: gigantic/); expect(response.error.message).toMatch(/extra-small\|small\|medium\|large/); expect(response.error.message).toMatch(/accessibility-extra-extra-extra-large/); } @@ -372,36 +367,6 @@ test('settings usage hint documents canonical faceid states', async () => { } }); -test('settings on macOS rejects wifi before dispatch with explicit subset guidance', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'macos-settings-wifi'; - sessionStore.publish(sessionName, makeSession(sessionName, macOsDevice)); - - const response = await handleSnapshotCommands({ - createCommandSurface: fakeCreateCommandSurface, - platformServices: daemonPlatformServicesFixture(), - req: snapshotRequest(sessionName, 'settings', { positionals: ['wifi', 'on'] }), - sessionName, - logPath: '/tmp/daemon.log', - sessionStore, - }); - - expect(response).toBeTruthy(); - expect(response?.ok).toBe(false); - expect(fixtureSettingsMutations).toHaveLength(0); - if (response && !response.ok) { - expect(response.error.code).toBe('INVALID_ARGS'); - expect(response.error.message).toMatch(/Unsupported macOS setting: wifi/i); - expect(response.error.message).toMatch(/appearance /); - expect(response.error.message).toMatch( - /permission /, - ); - expect(response.error.message).toMatch( - /wifi\|airplane\|location\|animations\|text-size remain unsupported on macOS/i, - ); - } -}); - // #3179: an app-scoped change can name an app the session never opened. The app rides the request // input; the owner receives it as the resolved app id whether or not a session is bound. test('settings permission grant targets an explicit app with no session app bound', async () => { @@ -587,7 +552,7 @@ test('settings iOS location 1 targets an explicit app over the session app', asy expect(response?.ok).toBe(true); expect(fixtureSettingsMutations.at(-1)).toMatchObject({ setting: 'location', - state: '1', + state: 'on', appBundleId: 'com.example.app', }); }); diff --git a/packages/daemon/src/execution/handlers/snapshot-settings.ts b/packages/daemon/src/execution/handlers/snapshot-settings.ts index 2b238b5258..c173ed599e 100644 --- a/packages/daemon/src/execution/handlers/snapshot-settings.ts +++ b/packages/daemon/src/execution/handlers/snapshot-settings.ts @@ -1,25 +1,19 @@ import type { DaemonPlatformServices } from '../../core/platform-services.ts'; +import { isApplePlatform, isMacOs } from '@agent-device/kernel/device'; import { - isApplePlatform, - isHandheldAppleSimulator, - isMacOs, - resolveDeviceAppleOs, -} from '@agent-device/kernel/device'; -import { - APPLE_TEXT_SIZE_LEAF_REFUSAL, describeSettingRead, describeSettingWrite, - getUnsupportedMacOsSettingMessage, - isMacOsSettingSupported, - invalidTextSizeMessage, - readTextSizeCategory, + parseSettingsMutation, settingsAppNotConsumedRefusal, settingsAppScope, - SETTINGS_INVALID_ARGS_MESSAGE, - type ReadableSetting, - type SettingsTargetFamily, + type SettingsMutation, type SettingOptions, } from '@agent-device/contracts/settings'; +import { + settingDefinition, + type ReadableSetting, + type SettingsTargetFamily, +} from '@agent-device/contracts/settings-catalog'; import type { SetSettingInput } from '@agent-device/contracts/settings-runtime'; import { resolveSettingsRuntimePlan, @@ -35,7 +29,7 @@ import { recordSessionAction } from '../../core/session-action-recorder.ts'; import { expireRefFrame } from '../../core/ref-frame.ts'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { readLocationCoordinate } from '@agent-device/kernel/location-coordinates'; -import { sessionAppRequiredDetails } from '@agent-device/kernel/errors'; +import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors'; import { successText, withSuccessText } from '@agent-device/kernel/success-text'; import type { @@ -50,25 +44,10 @@ import { type DaemonResponse, } from '@agent-device/kernel/contracts'; -type ParsedSettingsArgs = { - setting: string; - state: string; - appBundleId?: string; - permissionTarget?: string; - permissionMode?: string; - latitude?: string; - longitude?: string; -}; - -/** - * Which half of the settings surface a request asks for. A bare `settings ` names a - * setting the command vocabulary declares readable and runs the owner's read leg; everything else - * is the mutation it has always been. The legs are separate because an owner admits one without the - * other — the macOS host sets an appearance it has no ladder to read back. - */ +/** A bare readable setting is the read leg; an owner may admit one leg and not the other. */ export type ParsedSettingsRequest = | Readonly<{ leg: 'read'; setting: ReadableSetting }> - | Readonly<{ leg: 'write'; args: ParsedSettingsArgs }>; + | Readonly<{ leg: 'write'; args: SettingsMutation }>; type HandleSettingsCommandParams = { req: DaemonRequest; @@ -85,78 +64,25 @@ type HandleSettingsCommandParams = { export function parseSettingsArgs( req: DaemonRequest, ): { ok: true; parsed: ParsedSettingsRequest } | DaemonFailureResponse { - const setting = req.positionals?.[0]?.toLowerCase(); - const state = req.positionals?.[1]?.toLowerCase(); - const permissionTarget = req.positionals?.[2]?.toLowerCase(); - if (setting === 'clear-app-state') { - const appBundleId = state === 'clear' ? req.positionals?.[2] : req.positionals?.[1]; - return { - ok: true, - parsed: { - leg: 'write', - args: { - setting, - state: 'clear', - appBundleId, - }, - }, - }; - } - const plan = resolveSettingsRuntimePlan(req.positionals); + const positionals = req.positionals ?? []; + const plan = resolveSettingsRuntimePlan(positionals); if (plan.kind === 'read') { return { ok: true, parsed: { leg: 'read', setting: plan.setting } }; } - if ( - !setting || - !state || - (setting === 'permission' && !permissionTarget) || - (setting === 'location' && - state === 'set' && - (!req.positionals?.[2] || !req.positionals?.[3])) || - (setting === 'reset-keychain' && req.positionals?.[2] !== undefined) - ) { - return errorResponse('INVALID_ARGS', SETTINGS_INVALID_ARGS_MESSAGE); - } - // A text size that is not on the ladder is refused here, ahead of admission and of any device - // call: `simctl` answers an unknown category with exit 0, so a write that reached the tool with - // it would have reported success while changing nothing. The refusal carries the whole ladder. - let validatedState = state; - if (setting === 'text-size') { - const category = readTextSizeCategory(state); - if (category === undefined) { - return errorResponse('INVALID_ARGS', invalidTextSizeMessage(state)); - } - validatedState = category; + try { + return { ok: true, parsed: { leg: 'write', args: parseSettingsMutation(positionals) } }; + } catch (error) { + if (!(error instanceof AppError)) throw error; + return errorResponse(error.code, error.message); } - return { - ok: true, - parsed: { - leg: 'write', - args: { - setting, - state: validatedState, - permissionTarget, - permissionMode: req.positionals?.[3], - latitude: req.positionals?.[2], - longitude: req.positionals?.[3], - }, - }, - }; } -/** - * The owner-facing options for one mutation. `permission` and `location set` are the two settings - * whose payload is not just `(setting, state)`; everything else sends none. Coordinate typing - * happens here rather than in the owner: `readLocationCoordinate` is input validation, and it - * throws the same `INVALID_ARGS` the retired dispatcher threw from the same point in the - * sequence — after admission, after the frame expiry and the diagnostic, immediately before the - * device call. - */ -function buildSettingOptions(parsed: ParsedSettingsArgs): SettingOptions | undefined { - if (parsed.setting === 'permission') { - return { permissionTarget: parsed.permissionTarget, permissionMode: parsed.permissionMode }; +/** Coordinates are typed here, after admission and frame expiry, right before the device call. */ +function buildSettingOptions(parsed: SettingsMutation): SettingOptions | undefined { + if (parsed.permission !== undefined) { + return { permissionTarget: parsed.permission, permissionMode: parsed.mode }; } - if (parsed.setting === 'location' && parsed.state === 'set') { + if (parsed.latitude !== undefined) { return { latitude: readLocationCoordinate(parsed.latitude, 'latitude'), longitude: readLocationCoordinate(parsed.longitude, 'longitude'), @@ -165,42 +91,25 @@ function buildSettingOptions(parsed: ParsedSettingsArgs): SettingOptions | undef return undefined; } -/** The `settings_apply` payload the retired dispatcher emitted, kept byte-for-byte. */ function settingsDiagnosticData( - parsed: ParsedSettingsArgs, + parsed: SettingsMutation, appBundleId: string | undefined, platform: string, ): Record { - const { setting, state } = parsed; - if (setting === 'clear-app-state') { - return { setting: 'clear-app-state', state: 'clear', appBundleId, platform }; - } - if (setting === 'location' && state === 'set') { - return { setting, state, latitude: parsed.latitude, longitude: parsed.longitude, platform }; - } - if (setting === 'permission') { - return { - setting, - state, - permissionTarget: parsed.permissionTarget, - permissionMode: parsed.permissionMode, - platform, - }; - } - return { setting, state, appBundleId, platform }; -} - -function readResultMessage(result: Record): string | undefined { - return typeof result.message === 'string' && result.message.length > 0 - ? result.message - : undefined; + return { + setting: parsed.setting, + state: parsed.state, + ...(parsed.permission === undefined + ? {} + : { permissionTarget: parsed.permission, permissionMode: parsed.mode }), + ...(parsed.latitude === undefined + ? { appBundleId } + : { latitude: parsed.latitude, longitude: parsed.longitude }), + platform, + }; } -/** - * The ONE place a bound `settings` executes (R58). The owner answers with its own payload or - * nothing; either way the response carries the requested `setting`/`state` and a message the - * owner may override, exactly as the retired leaf composed it. - */ +/** The owner answers with its own payload or nothing; it may override the message. */ async function executeSetSetting( runtime: BoundDeviceRuntime, input: SetSettingInput, @@ -209,7 +118,12 @@ async function executeSetSetting( const { setting, state } = input; const result = await runtime.operations.setSetting(input); return result && typeof result === 'object' - ? withSuccessText({ setting, state, ...result }, readResultMessage(result) ?? fallbackMessage) + ? withSuccessText( + { setting, state, ...result }, + typeof result.message === 'string' && result.message.length > 0 + ? result.message + : fallbackMessage, + ) : { setting, state, ...successText(fallbackMessage) }; } @@ -220,12 +134,6 @@ export async function handleSettingsCommand( return await executeSettingsWrite(params, params.parsed.args); } -/** - * The ONE place `settings ` reads a device (ADR 0019 §9). It admits the owner's read fact - * rather than its write fact, so a leaf that can change a value it cannot report — or the reverse — - * refuses with its own cell instead of running the wrong leg. Nothing here expires the session ref - * frame or mutates anything: the read is the reason that leg exists. - */ async function executeSettingsRead( params: HandleSettingsCommandParams, setting: ReadableSetting, @@ -233,10 +141,9 @@ async function executeSettingsRead( const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice, platformServices } = params; let session = ref ? sessionStore.requireCurrent(ref) : undefined; - const refusal = settingsRequestRefusal(device, setting); - if (refusal !== undefined) return refusal; const admission = await admitRuntimeUse({ command: `settings ${setting}`, + subject: setting, device, use: settingReadUse, inspectFacts, @@ -273,18 +180,17 @@ async function executeSettingsRead( async function executeSettingsWrite( params: HandleSettingsCommandParams, - parsed: ParsedSettingsArgs, + parsed: SettingsMutation, ): Promise { const { req, logPath, sessionStore, ref, device, inspectFacts, bindDevice, platformServices } = params; let session = ref ? sessionStore.requireCurrent(ref) : undefined; const { setting, state } = parsed; - const refusal = settingsRequestRefusal(device, setting); - if (refusal !== undefined) return refusal; const appRefusal = settingsAppRefusal(device, req, setting, state); if (appRefusal !== undefined) return appRefusal; const admission = await admitRuntimeUse({ - command: 'settings', + command: `settings ${setting}`, + subject: setting, device, use: settingsRuntimeUse, inspectFacts, @@ -297,10 +203,7 @@ async function executeSettingsWrite( const appBundleId = settingsWriteAppId(req, parsed, session, device); const writeRefusal = settingsWriteRefusal(parsed, appBundleId); if (writeRefusal !== undefined) return writeRefusal; - // ADR 0014 side-effect seam: a settings mutation changes device state; expire the frame before - // the bound call (settings is always classified may-invalidate). It runs here, ahead of the - // diagnostic and the coordinate typing, because that is where the retired daemon route expired - // it — a request that later fails on a bad coordinate expired the frame then and expires it now. + // ADR 0014: an admitted mutation expires the frame before the bound call; a refused one never does. if (session) expireRefFrame(session); emitDiagnostic({ level: 'debug', @@ -320,49 +223,7 @@ async function executeSettingsWrite( return { ok: true, data }; } -/** - * The refusals both legs make before a device is touched, so one target answers a read and a write of - * the same setting with the same code. They key on the requested setting, which an operation fact - * cannot express: macOS serves `settings` and still refuses `wifi`, and the Apple ladder needs a - * narrower leaf than the simulator-family write fact admits. Both are daemon-side stops ahead of - * admission — the mutation leg expires the session ref frame as soon as it is admitted, and a request - * this surface refuses must not expire it. - */ -function settingsRequestRefusal( - device: SessionState['device'], - setting: string, -): DaemonResponse | undefined { - if (isMacOs(device) && !isMacOsSettingSupported(setting)) { - return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); - } - // The Apple write fact is one claim covering every simulator, while the content-size ladder is - // confined to iPhone and iPad simulators. The refusal has to land before admission, because a - // settings mutation expires the session ref frame before the bound call, and a request that never - // reached a device must not take a live frame down with it. - if ( - setting === 'text-size' && - isApplePlatform(device.platform) && - !isHandheldAppleSimulator(device) - ) { - return errorResponse( - 'UNSUPPORTED_OPERATION', - APPLE_TEXT_SIZE_LEAF_REFUSAL.message, - { - deviceKind: device.kind, - appleOs: resolveDeviceAppleOs(device), - reason: APPLE_TEXT_SIZE_LEAF_REFUSAL.reason, - }, - { hint: APPLE_TEXT_SIZE_LEAF_REFUSAL.hint }, - ); - } - return undefined; -} - -/** - * The app a request names outside its positionals: the `app` of the request input, which the CLI - * writes from `--app` and the client and MCP surfaces write from their `app` option. Read as a - * non-empty string or nothing, because an empty `--app` was never a request to grant anything. - */ +/** The request input's `app` (CLI `--app`, client and MCP `app`); an empty one names nothing. */ function settingsRequestApp(req: DaemonRequest): string | undefined { const app = req.input?.app; if (typeof app !== 'string') return undefined; @@ -370,21 +231,12 @@ function settingsRequestApp(req: DaemonRequest): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -/** - * The device fact the app-scope table is keyed on, answered with the kernel's own predicates so the - * legacy Apple leaf platforms settle exactly where the predicates do. - */ function settingsTargetFamily(device: SessionState['device']): SettingsTargetFamily { if (isMacOs(device)) return 'macos-host'; return isApplePlatform(device.platform) ? 'apple' : 'mobile'; } -/** - * The refusal for naming an app on a mutation whose target consumes none: Android's on/off `location` - * writes the global `location_mode`, and a macOS permission is a TCC grant to the host process. It - * runs before admission because a request this surface refuses must not bind a runtime, and it stays - * silent for `unknown`, where the owner's own fact answers the setting rather than the app. - */ +/** Refuses an app named on a device-level mutation, before anything is bound. */ function settingsAppRefusal( device: SessionState['device'], req: DaemonRequest, @@ -401,15 +253,12 @@ function settingsAppRefusal( } /** - * The app a mutation targets: the explicit positional wins, then the request's explicit `app`, then - * the Maestro adapter's daemon-internal `settingsAppBundleId`, which aims one request at another app - * than the session carries, and last the app the session is bound to. The request's `app` is only - * consumed where the scope table says the mutation can carry one; elsewhere it was already refused or - * belongs to an owner that never reads it. + * The positional app, then the request's `app` where the mutation is app-scoped, then Maestro's + * internal `settingsAppBundleId`, then the session app. */ function settingsWriteAppId( req: DaemonRequest, - parsed: ParsedSettingsArgs, + parsed: SettingsMutation, session: SessionState | undefined, device: SessionState['device'], ): string | undefined { @@ -417,29 +266,25 @@ function settingsWriteAppId( settingsAppScope(settingsTargetFamily(device), parsed.setting, parsed.state) === 'app-scoped' ? settingsRequestApp(req) : undefined; - return ( - parsed.appBundleId ?? explicitApp ?? req.internal?.settingsAppBundleId ?? session?.appBundleId - ); + return parsed.app ?? explicitApp ?? req.internal?.settingsAppBundleId ?? session?.appBundleId; } -/** The refusal a mutation adds on top of the shared one: an app the session may not carry. */ function settingsWriteRefusal( - parsed: ParsedSettingsArgs, + parsed: SettingsMutation, appBundleId: string | undefined, ): DaemonResponse | undefined { - if (parsed.setting === 'clear-app-state' && !appBundleId) { + if (settingDefinition(parsed.setting)?.appRequired && !appBundleId) { return errorResponse( 'INVALID_ARGS', - 'settings clear-app-state requires an app id when no app is bound to the session', + `settings ${parsed.setting} requires an app id when no app is bound to the session`, sessionAppRequiredDetails(), ); } return undefined; } -/** The owner-facing input for one mutation: the named setting and state, plus only the extras it carries. */ function settingsWriteInput( - parsed: ParsedSettingsArgs, + parsed: SettingsMutation, appBundleId: string | undefined, context: Parameters[0], ): SetSettingInput { diff --git a/packages/host-kit/src/internal/retry.test.ts b/packages/host-kit/src/internal/retry.test.ts index c5cfdfb2f3..194311e1c8 100644 --- a/packages/host-kit/src/internal/retry.test.ts +++ b/packages/host-kit/src/internal/retry.test.ts @@ -1,8 +1,8 @@ import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import { AppError } from '@agent-device/kernel/errors'; -import { retryWithPolicy } from './retry.ts'; +import { AppError, isRequestCanceledError } from '@agent-device/kernel/errors'; +import { Deadline, pollUntilSettled, retryWithPolicy } from './retry.ts'; import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from './diagnostics.ts'; import { mkdtempForTestSync } from './tmp-dir.fixtures.ts'; @@ -127,3 +127,83 @@ test('retryWithPolicy publishes retry diagnostics events', async () => { process.env.HOME = previousHome; } }); + +test('pollUntilSettled answers on the first sample that settles', async () => { + const values = ['pending', 'pending', 'done']; + let probes = 0; + const outcome = await pollUntilSettled({ + probe: async () => { + probes += 1; + return values.shift() ?? 'unexpected'; + }, + settled: (value) => value === 'done', + deadline: Deadline.fromTimeoutMs(1_000), + intervalMs: 1, + }); + assert.deepEqual(outcome, { settled: true, value: 'done' }); + assert.equal(probes, 3); +}); + +test('pollUntilSettled hands the previous sample to a quiet-pair check', async () => { + const values = [1, 2, 2]; + const outcome = await pollUntilSettled({ + probe: async () => values.shift() ?? 2, + settled: (value, previous) => value === previous, + deadline: Deadline.fromTimeoutMs(1_000), + intervalMs: 1, + }); + assert.deepEqual(outcome, { settled: true, value: 2 }); +}); + +test('pollUntilSettled takes its final sample at the deadline and returns it unsettled', async () => { + vi.useFakeTimers(); + try { + const sampledAt: number[] = []; + const startedAt = Date.now(); + const outcome = pollUntilSettled({ + probe: async () => { + sampledAt.push(Date.now() - startedAt); + return 'pending'; + }, + settled: () => false, + deadline: Deadline.fromTimeoutMs(250), + intervalMs: 100, + }); + await vi.runAllTimersAsync(); + assert.deepEqual(await outcome, { settled: false, value: 'pending' }); + assert.deepEqual(sampledAt, [0, 100, 200, 250]); + } finally { + vi.useRealTimers(); + } +}); + +test('pollUntilSettled samples once under a spent deadline', async () => { + const outcome = await pollUntilSettled({ + probe: async () => 'pending', + settled: () => false, + deadline: Deadline.fromTimeoutMs(0), + intervalMs: 100, + }); + assert.deepEqual(outcome, { settled: false, value: 'pending' }); +}); + +test('pollUntilSettled stops sampling once the request is canceled', async () => { + const controller = new AbortController(); + let samples = 0; + const outcome = pollUntilSettled({ + probe: async () => { + samples += 1; + controller.abort(); + return 'pending'; + }, + settled: () => false, + deadline: Deadline.fromTimeoutMs(10_000), + intervalMs: 5_000, + signal: controller.signal, + }); + await assert.rejects(outcome, (error: unknown) => { + assert.ok(isRequestCanceledError(error)); + return true; + }); + assert.equal(samples, 1); +}); diff --git a/packages/host-kit/src/internal/retry.ts b/packages/host-kit/src/internal/retry.ts index 8f143c5370..c636d985b8 100644 --- a/packages/host-kit/src/internal/retry.ts +++ b/packages/host-kit/src/internal/retry.ts @@ -190,6 +190,32 @@ export async function retryWithPolicy( throw new AppError('COMMAND_FAILED', 'retry failed'); } +/** + * Samples `probe` until `settled` accepts a value or `deadline` expires, and returns the last value + * either way so the caller owns its failure. The first sample is always taken, and the last sleep is + * clamped to the deadline, so a final sample lands at the deadline rather than an interval past it. + * An aborted `signal` throws the request-canceled error before the next sample. + */ +export async function pollUntilSettled(params: { + probe: () => Promise; + settled: (value: T, previous: T | undefined) => boolean; + deadline: DeadlineClock; + intervalMs: number; + signal?: AbortSignal; +}): Promise> { + const { probe, settled, deadline, intervalMs, signal } = params; + let previous: T | undefined; + for (;;) { + if (signal?.aborted) throw createRequestCanceledError(); + const value = await probe(); + if (settled(value, previous)) return { settled: true, value }; + const remainingMs = deadline.remainingMs(); + if (remainingMs <= 0) return { settled: false, value }; + previous = value; + await sleep(Math.min(intervalMs, remainingMs), signal); + } +} + function waitForRetryDelay( ms: number, signal: AbortSignal | undefined, diff --git a/packages/host-kit/src/retry.ts b/packages/host-kit/src/retry.ts index 6f5496352a..bfc36cfe26 100644 --- a/packages/host-kit/src/retry.ts +++ b/packages/host-kit/src/retry.ts @@ -2,6 +2,7 @@ export { Deadline, type DeadlineClock, isEnvTruthy, + pollUntilSettled, retryWithPolicy, sleep, } from './internal/retry.ts'; diff --git a/packages/platform-android/src/__tests__/alert.test.ts b/packages/platform-android/src/__tests__/alert.test.ts index 6ecc565feb..eb6a5aa95c 100644 --- a/packages/platform-android/src/__tests__/alert.test.ts +++ b/packages/platform-android/src/__tests__/alert.test.ts @@ -14,8 +14,6 @@ const runAndroidShell = vi.fn( await runAndroidAdb(device, deviceShellArgv('adb', 'shell', words)), ); vi.mock('../adb.ts', () => ({ runAndroidAdb, runAndroidShell })); -// The dismissal re-check polls at the contract interval; the clock is the assertion, not the wait. -vi.mock('@agent-device/host-kit/retry', () => ({ sleep: async () => {} })); const { handleAndroidAlert } = await import('../alert.ts'); @@ -25,6 +23,19 @@ const dialog = [ button(2, 'Cancel', 'android:id/button2', { x: 210, y: 612 }), ]; +/** Runs an alert action whose polls sleep between captures, without waiting the sleeps out. */ +async function underFakeTimers(run: () => Promise): Promise { + vi.useFakeTimers(); + try { + const outcome = run(); + outcome.catch(() => {}); + await vi.runAllTimersAsync(); + return await outcome; + } finally { + vi.useRealTimers(); + } +} + /** The dialog is in the tree until the button press lands, then gone — the real timeline. */ function dialogUntilPressed(nodes = dialog) { return async () => (runAndroidAdb.mock.calls.length === 0 ? nodes : []); @@ -97,9 +108,12 @@ test('dismiss returns only after the dialog has left the tree', async () => { // Pre-press lookup, then two captures that still show the closing dialog, then the app. const captures = [dialog, dialog, dialog, []]; let reads = 0; - const result = await handleAndroidAlert(device, 'dismiss', { - captureNodes: async () => captures[Math.min(reads++, captures.length - 1)] ?? [], - }); + const result = await underFakeTimers( + async () => + await handleAndroidAlert(device, 'dismiss', { + captureNodes: async () => captures[Math.min(reads++, captures.length - 1)] ?? [], + }), + ); assert.equal(result.kind, 'alertHandled'); assert.equal(reads, 4); @@ -126,28 +140,24 @@ test('a different alert replacing the pressed one counts as dismissed', async () test('dismiss fails when the dialog is still visible after the action budget', async () => { runAndroidAdb.mockClear(); - vi.useFakeTimers({ now: 0, toFake: ['Date'] }); - try { - let reads = 0; - await assert.rejects( - handleAndroidAlert(device, 'dismiss', { - captureNodes: async () => { - // Every post-press capture costs wall clock; the dialog never leaves. - if (reads++ > 0) vi.setSystemTime(Date.now() + 700); - return dialog; - }, - }), - (error: unknown) => - error instanceof Error && - error.message === 'alert dismiss did not dismiss the visible alert' && - (error as { code?: string }).code === 'COMMAND_FAILED', - ); - assert.equal(runAndroidAdb.mock.calls.length, 1); - assert.ok( - reads >= 4, - `expected the re-check to poll until the budget expired, got ${reads} reads`, - ); - } finally { - vi.useRealTimers(); - } + await assert.rejects( + underFakeTimers( + async () => await handleAndroidAlert(device, 'dismiss', { captureNodes: async () => dialog }), + ), + (error: unknown) => + error instanceof Error && + error.message === 'alert dismiss did not dismiss the visible alert' && + (error as { code?: string }).code === 'COMMAND_FAILED', + ); + assert.equal(runAndroidAdb.mock.calls.length, 1); +}); + +test('wait with a spent budget still reports an alert that is already visible', async () => { + const result = await handleAndroidAlert(device, 'wait', { + timeoutMs: 0, + captureNodes: async () => dialog, + }); + + assert.equal(result.kind, 'alertWait'); + assert.equal(result.alert?.title, 'Automation confirmation'); }); diff --git a/packages/platform-android/src/__tests__/fill-verification.test.ts b/packages/platform-android/src/__tests__/fill-verification.test.ts index 39c19cd23a..f9890ac4b3 100644 --- a/packages/platform-android/src/__tests__/fill-verification.test.ts +++ b/packages/platform-android/src/__tests__/fill-verification.test.ts @@ -3,11 +3,12 @@ // passed. `am instrument` force-stops whatever is already instrumenting the helper package, so a // command-scoped capture stops the automation-helper session after every one of those reads and the // next read pays a fresh `am instrument` start. These tests pin who owns the helper session across -// the samples and how long the samples keep coming — not what one sample concludes, which -// fill-diagnostics/input-actions-fill own. The one conclusion pinned here is whether any of a -// commit reached the field, which both the soft-success guard and the IME rebind share. +// the samples and what they conclude when the text lands late or never — not what one sample +// concludes, which fill-diagnostics/input-actions-fill own. The one conclusion pinned here is +// whether any of a commit reached the field, which both the soft-success guard and the IME rebind +// share. -import { afterEach, beforeEach, test } from 'vitest'; +import { afterEach, beforeEach, test, vi } from 'vitest'; import './test-utils/android-host-test-setup.ts'; import assert from 'node:assert/strict'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -18,7 +19,6 @@ import { isAndroidFillCommitDropped, readAndroidFillTargetBeforeMutation, verifyAndroidFilledText, - type FillVerificationClock, } from '../fill-verification.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; import { @@ -101,59 +101,66 @@ test('a value the app renders late still verifies, as long as it lands before th const session = createFillHelperSession({ textForCapture: (captureIndex) => (captureIndex <= 6 ? 'Key echo' : 'chips'), }); - const clock = createFakeClock(); - const verification = await withFillHelperProvider( - session.provider, + const verification = await underFakeTimers( async () => - await verifyAndroidFilledText( - device, - 10, - 10, - 'chips', - { helperSessionScope: 'daemon-session' }, - clock, + await withFillHelperProvider( + session.provider, + async () => + await verifyAndroidFilledText(device, 10, 10, 'chips', { + helperSessionScope: 'daemon-session', + }), ), ); assert.equal(verification.ok, true); assert.equal(session.captureCount(), 8, 'six hint samples, then the value held for two'); - assert.equal(clock.now(), 1050, 'the value held from the 900 ms sample to the 1050 ms one'); }); -test('a value that never lands is a mismatch at the deadline, not before it', async () => { +test('a value that never lands is a mismatch read from the last sample', async () => { const session = createFillHelperSession({ textForCapture: () => 'Key echo' }); - const clock = createFakeClock(); + let elapsedMs = 0; - const verification = await withFillHelperProvider( - session.provider, - async () => - await verifyAndroidFilledText( - device, - 10, - 10, - 'chips', - { helperSessionScope: 'daemon-session' }, - clock, - ), - ); + const verification = await underFakeTimers(async () => { + const startedAt = Date.now(); + const result = await withFillHelperProvider( + session.provider, + async () => + await verifyAndroidFilledText(device, 10, 10, 'chips', { + helperSessionScope: 'daemon-session', + }), + ); + elapsedMs = Date.now() - startedAt; + return result; + }); assert.equal(verification.ok, false); assert.equal(verification.reason, 'text_mismatch'); - assert.equal(verification.actual, 'Key echo', 'the sample at the deadline is the answer'); - assert.equal(clock.now(), 1500, 'sampling ran to the deadline and not past it'); - assert.equal(session.captureCount(), 11, 'a sample every 150 ms from 0 to 1500'); + assert.equal(verification.actual, 'Key echo'); + assert.ok( + elapsedMs >= 1_500 && elapsedMs < 1_650, + `sampled until the deadline, took ${elapsedMs} ms`, + ); + assert.ok(session.captureCount() >= 9, 'kept sampling on its interval'); }); -/** A clock the sampler advances by sleeping, so the deadline is reached without waiting it out. */ -function createFakeClock(): FillVerificationClock & { now(): number } { - let elapsed = 0; - return { - now: () => elapsed, - sleep: async (milliseconds) => { - elapsed += milliseconds; - }, - }; +/** + * Runs the sampler with its sleeps advanced instead of waited out. The clock moves in slices + * shorter than a sample interval so the helper session's own timers never fire ahead of its replies. + */ +async function underFakeTimers(run: () => Promise): Promise { + vi.useFakeTimers(); + try { + let done = false; + const outcome = run().finally(() => { + done = true; + }); + outcome.catch(() => {}); + while (!done) await vi.advanceTimersByTimeAsync(10); + return await outcome; + } finally { + vi.useRealTimers(); + } } test('the pre-action target read shares the daemon-session helper with the samples', async () => { diff --git a/packages/platform-android/src/__tests__/settings.test.ts b/packages/platform-android/src/__tests__/settings.test.ts index a732626e93..c52336b409 100644 --- a/packages/platform-android/src/__tests__/settings.test.ts +++ b/packages/platform-android/src/__tests__/settings.test.ts @@ -1,9 +1,7 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import { - SETTINGS_APP_NOT_PACKAGE_REASON, - TEXT_SIZE_CATEGORIES, -} from '@agent-device/contracts/settings'; +import { SETTINGS_APP_NOT_PACKAGE_REASON } from '@agent-device/contracts/settings'; +import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings-catalog'; import { PRE_DISPATCH_REFUSAL_REASONS } from '@agent-device/kernel/errors'; import { readAndroidSetting, setAndroidSetting } from '../settings.ts'; import { ANDROID_EMULATOR } from './test-utils/device-fixtures.ts'; @@ -382,7 +380,7 @@ test('setAndroidSetting text-size refuses an off-ladder category without touchin await withFakeAdb(store.script, async ({ calls, device }) => { await assertRejectsAppError(() => setAndroidSetting(device, 'text-size', 'gigantic'), { code: 'INVALID_ARGS', - message: /Invalid text size: gigantic/, + message: /Invalid text-size state: gigantic/, }); assert.deepEqual(calls, []); assert.equal(store.held(), '1.0'); diff --git a/packages/platform-android/src/alert.ts b/packages/platform-android/src/alert.ts index f9a662977b..711ba999be 100644 --- a/packages/platform-android/src/alert.ts +++ b/packages/platform-android/src/alert.ts @@ -6,7 +6,7 @@ import { } from '@agent-device/contracts/alert-contract'; import { AppError } from '@agent-device/kernel/errors'; import { withDiagnosticTimer } from '@agent-device/host-kit/diagnostics'; -import { sleep } from '@agent-device/host-kit/retry'; +import { Deadline, pollUntilSettled } from '@agent-device/host-kit/retry'; import { successText } from '@agent-device/kernel/success-text'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -133,18 +133,17 @@ async function confirmAndroidAlertDismissed( action: 'accept' | 'dismiss', captureNodes: AndroidAlertOptions['captureNodes'], ): Promise { - const start = Date.now(); - for (;;) { - const current = await readAndroidAlertCandidate(captureNodes); - if (!current || !sameAndroidAlert(current.alert, dismissed)) return; - if (Date.now() - start >= ALERT_ACTION_RETRY_MS) { - throw new AppError('COMMAND_FAILED', `alert ${action} did not dismiss the visible alert`, { - alert: dismissed, - hint: 'The alert button was pressed but the dialog is still visible. Inspect alert get --json, then press the visible button by label/ref or retry.', - }); - } - await sleep(ALERT_POLL_INTERVAL_MS); - } + const { settled } = await pollUntilSettled({ + probe: () => readAndroidAlertCandidate(captureNodes), + settled: (current) => !current || !sameAndroidAlert(current.alert, dismissed), + deadline: Deadline.fromTimeoutMs(ALERT_ACTION_RETRY_MS), + intervalMs: ALERT_POLL_INTERVAL_MS, + }); + if (settled) return; + throw new AppError('COMMAND_FAILED', `alert ${action} did not dismiss the visible alert`, { + alert: dismissed, + hint: 'The alert button was pressed but the dialog is still visible. Inspect alert get --json, then press the visible button by label/ref or retry.', + }); } function sameAndroidAlert(left: AndroidAlertInfo, right: AndroidAlertInfo): boolean { @@ -155,13 +154,13 @@ async function pollAndroidAlertCandidate( captureNodes: AndroidAlertOptions['captureNodes'], timeoutMs: number, ): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const candidate = await readAndroidAlertCandidate(captureNodes); - if (candidate) return candidate; - await sleep(ALERT_POLL_INTERVAL_MS); - } - return null; + const { value } = await pollUntilSettled({ + probe: () => readAndroidAlertCandidate(captureNodes), + settled: (candidate) => candidate !== null, + deadline: Deadline.fromTimeoutMs(timeoutMs), + intervalMs: ALERT_POLL_INTERVAL_MS, + }); + return value; } async function readAndroidAlertCandidate( diff --git a/packages/platform-android/src/fill-verification.ts b/packages/platform-android/src/fill-verification.ts index d72f815249..ca5a2ae7da 100644 --- a/packages/platform-android/src/fill-verification.ts +++ b/packages/platform-android/src/fill-verification.ts @@ -1,6 +1,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, discloseDispatch } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; +import { Deadline, pollUntilSettled } from '@agent-device/host-kit/retry'; import { containsPoint } from '@agent-device/kernel/rect'; import { buildFillFailureDetails, @@ -9,7 +10,6 @@ import { type AndroidFillVerificationNode, type FillFailureDetails, } from './fill-diagnostics.ts'; -import { sleep } from './adb.ts'; import { getAndroidKeyboardState } from './device-input-state.ts'; import { isAndroidInputMethodOwnedNode } from '@agent-device/contracts/android-input-ownership'; import { captureAndroidUiHierarchyXml } from './snapshot.ts'; @@ -50,39 +50,22 @@ type AndroidFillVerificationContext = { const FILL_VERIFICATION_SAMPLE_INTERVAL_MS = 150; const FILL_VERIFICATION_DEADLINE_MS = 1500; -/** The clock the sampler paces itself by; a test hands in one it advances instead of waiting. */ -export type FillVerificationClock = Readonly<{ - now(): number; - sleep(milliseconds: number): Promise; -}>; - -const wallClock: FillVerificationClock = Object.freeze({ now: () => Date.now(), sleep }); - export async function verifyAndroidFilledText( device: DeviceInfo, x: number, y: number, expected: string, helper: AndroidHelperSessionOptions = {}, - clock: FillVerificationClock = wallClock, ): Promise { const context = await readAndroidFillVerificationContext(device); - const deadline = clock.now() + FILL_VERIFICATION_DEADLINE_MS; - let previous: AndroidFillVerification | null = null; - - for (;;) { - const verification = await inspectAndroidFilledText(device, x, y, expected, context, helper); - if (verification.reason === 'ime_capture') { - return verification; - } - const remainingMs = deadline - clock.now(); - if ((verification.ok && previous?.ok) || remainingMs <= 0) { - return verification; - } - previous = verification; - // The last sleep ends at the deadline, so the final sample starts there and not an interval later. - await clock.sleep(Math.min(FILL_VERIFICATION_SAMPLE_INTERVAL_MS, remainingMs)); - } + const { value } = await pollUntilSettled({ + probe: () => inspectAndroidFilledText(device, x, y, expected, context, helper), + settled: (verification, previous) => + verification.reason === 'ime_capture' || (verification.ok && previous?.ok === true), + deadline: Deadline.fromTimeoutMs(FILL_VERIFICATION_DEADLINE_MS), + intervalMs: FILL_VERIFICATION_SAMPLE_INTERVAL_MS, + }); + return value; } export async function readAndroidTextAtPoint( diff --git a/packages/platform-android/src/mechanics.ts b/packages/platform-android/src/mechanics.ts index 0407132e33..a175cb35e8 100644 --- a/packages/platform-android/src/mechanics.ts +++ b/packages/platform-android/src/mechanics.ts @@ -260,7 +260,7 @@ export { buildAndroidNativeToolUnavailableHint, } from './perf-native-errors.ts'; export { androidRevokedPermissionWarning, setAndroidPermission } from './settings-permission.ts'; -export { readAndroidSetting, setAndroidSetting } from './settings.ts'; +export { readAndroidSetting, setAndroidSetting, type AndroidSetting } from './settings.ts'; export { androidCaptureFailureReasonDetail, androidCaptureFailureReasonFromExitCode, diff --git a/packages/platform-android/src/runtime.test.ts b/packages/platform-android/src/runtime.test.ts index 04b70c4fe8..74066ba81c 100644 --- a/packages/platform-android/src/runtime.test.ts +++ b/packages/platform-android/src/runtime.test.ts @@ -1,12 +1,13 @@ import { expect, test, vi } from 'vitest'; import type { AndroidClipboardShellSupport } from '@agent-device/contracts/android-clipboard-support'; -import type { DeviceBinding } from '@agent-device/contracts/platform-runtime'; +import { runtimeSubjectFact, type DeviceBinding } from '@agent-device/contracts/platform-runtime'; import type { PlatformRuntimeHost, PlatformRuntimeOperations, } from '@agent-device/contracts/platform-runtime-operations'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { createAndroidPlatformRuntime } from './runtime.ts'; +import { ANDROID_SETTINGS } from './settings.ts'; import type { AndroidAdbExecutorOptions, AndroidAdbInvocation } from './adb-transport.ts'; import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; import { @@ -268,9 +269,16 @@ test('admits both clipboard halves and the app switcher on every real Android ki expect(binding.operations.triggerAppEvent).toBeTypeOf('function'); // R58: settings run over adb (`appops`, `settings put`, `pm clear`, …) on that cell too, and // so does the read leg (`settings get system font_scale`), which every Android kind can answer. - expect(binding.facts.operations.setSetting).toEqual({ available: true }); + const { setSetting, readSetting } = binding.facts.operations; + for (const setting of ANDROID_SETTINGS) { + expect(runtimeSubjectFact(setSetting, setting)).toEqual({ available: true }); + } + expect(runtimeSubjectFact(setSetting, 'reset-keychain')).toMatchObject({ + available: false, + reason: 'unsupported-platform-leaf', + }); expect(binding.operations.setSetting).toBeTypeOf('function'); - expect(binding.facts.operations.readSetting).toEqual({ available: true }); + expect(runtimeSubjectFact(readSetting, 'text-size')).toEqual({ available: true }); expect(binding.operations.readSetting).toBeTypeOf('function'); // R59: all four alert legs read the same dump and tap with the same `input tap`. for (const operation of ['readAlert', 'awaitAlert', 'acceptAlert', 'dismissAlert'] as const) { diff --git a/packages/platform-android/src/runtime.ts b/packages/platform-android/src/runtime.ts index cd5494dc89..0641f47ca1 100644 --- a/packages/platform-android/src/runtime.ts +++ b/packages/platform-android/src/runtime.ts @@ -49,6 +49,7 @@ import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard- import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime'; import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime'; import type { DeviceInfo } from '@agent-device/kernel/device'; +import type { AndroidSetting } from './settings.ts'; import { createHostAudioProbeCaptureOperations } from '@agent-device/capture-kit'; import { androidFoldableFacts, createAndroidFoldableOperations } from './foldable/runtime.ts'; import { androidAudioProbeCaptureFact } from './audio-runtime.ts'; @@ -69,6 +70,10 @@ import { const owner = localRuntimeOwner('android'); const available = Object.freeze({ available: true } as const); const elementTextKindUnavailable = unavailableFact('unsupported-device-kind'); +const androidSettingUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'Run `agent-device settings --help` for the settings each target serves.', +); /** * Parity with the retired `focus` capability bucket (`{ emulator, device, unknown }`): every * Android kind drives touch through adb except the synthetic `simulator` row, which has no device. @@ -346,12 +351,21 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor // The deep link opens through `am start`, admitted wherever the retired `ANDROID_ALL` // bucket admitted it. ...appEventRuntimeOperationFacts({ triggerAppEvent: androidTouchFact(device) }), - // Settings run over adb (`appops`, `settings put`, `pm clear`, …) on every real kind, so - // both cells are the retired `ANDROID_ALL` bucket verbatim: the same `settings get`/`put` - // pair that writes a value reads it back. ...settingsRuntimeOperationFacts({ - setSetting: androidTouchFact(device), - readSetting: androidTouchFact(device), + target: androidTouchFact(device), + unsupported: androidSettingUnavailable, + write: { + wifi: available, + airplane: available, + location: available, + animations: available, + appearance: available, + 'text-size': available, + fingerprint: available, + 'clear-app-state': available, + permission: available, + } satisfies Record, + read: { 'text-size': available }, }), // R59: Android reads alerts out of the same accessibility dump every interaction cell // depends on and presses their buttons with the same `input tap`, so all four legs take diff --git a/packages/platform-android/src/settings-permission.ts b/packages/platform-android/src/settings-permission.ts index b4cb43199e..8a6e7748c0 100644 --- a/packages/platform-android/src/settings-permission.ts +++ b/packages/platform-android/src/settings-permission.ts @@ -1,12 +1,14 @@ import { AppError } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { - type MobilePermissionTarget, parsePermissionAction, parsePermissionTarget, - type PermissionAction, type SettingOptions, } from '@agent-device/contracts/settings'; +import { + type MobilePermissionTarget, + type PermissionAction, +} from '@agent-device/contracts/settings-catalog'; import { runAndroidShell } from './adb.ts'; import { androidAdbResultError } from './adb-failure.ts'; import { diff --git a/packages/platform-android/src/settings.ts b/packages/platform-android/src/settings.ts index 960067dff7..0bfd36ee05 100644 --- a/packages/platform-android/src/settings.ts +++ b/packages/platform-android/src/settings.ts @@ -14,12 +14,15 @@ import { resolveSettingsApp, SETTINGS_APP_NOT_PACKAGE_REASON, textSizeSettingPayload, - TEXT_SIZE_CATEGORIES, - type ReadableSetting, type ReadSettingResult, type SettingOptions, type TextSizeSettingPayload, } from '@agent-device/contracts/settings'; +import { + TEXT_SIZE_CATEGORIES, + type ReadableSetting, + type SettingName, +} from '@agent-device/contracts/settings-catalog'; import { runAndroidAdb, runAndroidShell } from './adb.ts'; import { setAndroidAirplaneMode } from './settings-airplane.ts'; import { androidAdbResultError } from './adb-executor.ts'; @@ -30,6 +33,21 @@ import { setAndroidPermission, } from './settings-permission.ts'; +/** The settings {@link setAndroidSetting} applies over adb, wherever an owner runs it. */ +export const ANDROID_SETTINGS = [ + 'wifi', + 'airplane', + 'location', + 'animations', + 'appearance', + 'text-size', + 'fingerprint', + 'clear-app-state', + 'permission', +] as const satisfies readonly SettingName[]; + +export type AndroidSetting = (typeof ANDROID_SETTINGS)[number]; + const ANDROID_ANIMATION_SCALE_SETTINGS = [ 'window_animation_scale', 'transition_animation_scale', @@ -37,21 +55,10 @@ const ANDROID_ANIMATION_SCALE_SETTINGS = [ ] as const; /** - * The Android half of `settings text-size`. Android has no content-size category: its preferred text - * size is one `system font_scale` multiplier that every app scales its own fonts from. The shared - * ladder is therefore expressed here as multipliers, and only here — the command surface, the CLI, - * and the read payload all speak the ladder. - * - * The seven standard rungs are the Dynamic Type body ratios against the default size, rounded to two - * decimals: 14/17, 15/17, 16/17, 17/17, 19/17, 21/17, 23/17. The five accessibility rungs are - * Android's own larger range rather than an Apple ratio: Android has no accessibility tier below - * 1.4x, and 3.2 is the largest multiplier its own tooling documents. Every rung must stay strictly - * increasing and distinct, because a read names the nearest rung to whatever the device holds. - * - * This table lives beside the dispatcher that selects it rather than in its own module because - * `mechanics.ts` is a CLI-eager entry whose import closure is ratcheted by - * `scripts/__tests__/eager-closure-budgets.test.ts`: a new static module on this path is a growth - * the gate refuses. + * The text-size ladder as Android `system font_scale` multipliers. The seven standard rungs are the + * Dynamic Type body ratios against the default size (14/17 … 23/17); the five accessibility rungs + * are Android's own range up to 3.2, the largest multiplier its tooling documents. Rungs must stay + * strictly increasing, because a read names the nearest rung to whatever the device holds. */ const TEXT_SIZE_FONT_SCALES = { 'extra-small': '0.82', @@ -79,7 +86,9 @@ export async function setAndroidSetting( appPackage?: string, options?: SettingOptions, ): Promise | void> { - const normalized = setting.toLowerCase(); + const normalized = ANDROID_SETTINGS.find((name) => name === setting.toLowerCase()); + if (normalized === undefined) + throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); switch (normalized) { case 'wifi': { const enabled = parseSettingState(state); @@ -183,7 +192,7 @@ export async function setAndroidSetting( return await setAndroidTextSize(device, state); } default: - throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); + return normalized satisfies never; } } @@ -212,16 +221,7 @@ async function resolveAndroidSettingsPackage( return resolved.value; } -/** - * The ONE place an Android target answers `settings ` with the value it holds. The command - * surface only sends a setting its own vocabulary declares readable, so a name outside this switch - * is a leaf mismatch rather than a user typo, and says so. - */ -/** - * The Android read leg, exhaustive over the readable list: a setting joins `READABLE_SETTINGS` only - * with an answer here, so a new readable name is a compile error on this map rather than a runtime - * refusal hidden in a default case. - */ +/** Exhaustive over the readable settings, so a new one is a compile error here. */ const ANDROID_READABLE_SETTINGS = { 'text-size': readAndroidTextSize, } as const satisfies Record Promise>; diff --git a/packages/platform-apple/src/core/__tests__/app-settings.test.ts b/packages/platform-apple/src/core/__tests__/app-settings.test.ts index 807227be8e..8f07385ba7 100644 --- a/packages/platform-apple/src/core/__tests__/app-settings.test.ts +++ b/packages/platform-apple/src/core/__tests__/app-settings.test.ts @@ -37,11 +37,7 @@ import { runCmd } from '@agent-device/host-kit/command'; import { retryWithPolicy } from '@agent-device/host-kit/retry'; import { assertRejectsAppError } from '../../__tests__/app-error.ts'; import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; -import { - IOS_TEST_SIMULATOR, - MACOS_TEST_DEVICE, - TVOS_TEST_SIMULATOR, -} from './apple-core-stub-helpers.ts'; +import { IOS_TEST_SIMULATOR, MACOS_TEST_DEVICE } from './apple-core-stub-helpers.ts'; const mockRunCmd = vi.mocked(runCmd); const mockRetryWithPolicy = vi.mocked(retryWithPolicy); @@ -166,29 +162,6 @@ test('setIosSetting biometric enroll fails when the read-back state did not chan ); }); -test('setIosSetting biometric refuses an Apple TV simulator before posting anything', async () => { - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) { - return JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ - { udid: 'tvos-sim-1', state: 'Booted' }, - ], - }, - }); - } - return unexpectedArgs(args); - }, - async () => { - await assertRejectsAppError(() => setIosSetting(TVOS_TEST_SIMULATOR, 'faceid', 'match'), { - code: 'UNSUPPORTED_OPERATION', - message: /supported on iOS and iPadOS simulators/, - }); - }, - ); -}); - test('setIosSetting biometric rejects an unknown state before spawning anything', async () => { await withFakeAppleTool( (args) => { @@ -296,23 +269,7 @@ test('setIosSetting rejects unsupported macOS permission deny action', async () setIosSetting(MACOS_TEST_DEVICE, 'permission', 'deny', undefined, { permissionTarget: 'accessibility', }), - { code: 'INVALID_ARGS', message: /Unsupported macOS setting: permission/i }, - ); -}); - -test('setIosSetting rejects unsupported macOS wifi setting with explicit subset guidance', async () => { - await assert.rejects( - () => setIosSetting(MACOS_TEST_DEVICE, 'wifi', 'on'), - (error: unknown) => { - assert.equal(error instanceof AppError, true); - assert.equal((error as AppError).code, 'INVALID_ARGS'); - assert.match((error as AppError).message, /Unsupported macOS setting: wifi/i); - assert.match( - (error as AppError).message, - /wifi\|airplane\|location\|animations\|text-size remain unsupported on macOS/i, - ); - return true; - }, + { code: 'INVALID_ARGS', message: /grant\|reset, not deny/ }, ); }); diff --git a/packages/platform-apple/src/core/__tests__/settings-leaf.test.ts b/packages/platform-apple/src/core/__tests__/settings-leaf.test.ts deleted file mode 100644 index e018134f72..0000000000 --- a/packages/platform-apple/src/core/__tests__/settings-leaf.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { test } from 'vitest'; -import assert from 'node:assert/strict'; - -import { - APPLE_BIOMETRIC_LEAF_REFUSAL, - APPLE_TEXT_SIZE_LEAF_REFUSAL, - type AppleSettingLeafRefusal, -} from '@agent-device/contracts/settings'; -import { AppError } from '@agent-device/kernel/errors'; -import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; -import { requireHandheldAppleSimulatorLeaf } from '../settings-leaf.ts'; -import { - IOS_TEST_DEVICE, - IOS_TEST_SIMULATOR, - IPADOS_TEST_SIMULATOR, - MACOS_TEST_DEVICE, - TVOS_TEST_SIMULATOR, - VISIONOS_TEST_SIMULATOR, -} from './apple-core-stub-helpers.ts'; - -const REFUSALS = [ - { setting: 'text-size', refusal: APPLE_TEXT_SIZE_LEAF_REFUSAL }, - { setting: 'biometric', refusal: APPLE_BIOMETRIC_LEAF_REFUSAL }, -] as const; - -/** - * The leaf every iPhone/iPad-only settings surface lives on. iPhone arrives with `appleOs` inferred - * from its target and iPad with it stored, so both readings of the predicate are exercised. - */ -const ADMITTED: readonly DeviceInfo[] = [IOS_TEST_SIMULATOR, IPADOS_TEST_SIMULATOR]; - -/** Every other Apple leaf, named with the OS the refusal has to report for it. */ -const REFUSED: readonly { device: DeviceInfo; appleOs: AppleOS }[] = [ - { device: IOS_TEST_DEVICE, appleOs: 'ios' }, - { device: TVOS_TEST_SIMULATOR, appleOs: 'tvos' }, - { device: VISIONOS_TEST_SIMULATOR, appleOs: 'visionos' }, - { device: MACOS_TEST_DEVICE, appleOs: 'macos' }, -]; - -for (const device of ADMITTED) { - for (const { setting, refusal } of REFUSALS) { - test(`requireHandheldAppleSimulatorLeaf admits ${device.name} for ${setting}`, () => { - requireHandheldAppleSimulatorLeaf(device, refusal); - }); - } -} - -/** - * A refusal carries the guard's whole report: the setting's own sentence and hint — the guard keeps - * no prose of its own, which is what keeps two settings' hints distinct — beside the leaf that was - * refused, so a machine consumer can tell a physical-device read failure from a tvOS one. - */ -function assertRefused( - device: DeviceInfo, - appleOs: AppleOS, - refusal: AppleSettingLeafRefusal, -): void { - assert.throws( - () => requireHandheldAppleSimulatorLeaf(device, refusal), - (error: unknown) => { - assert.ok(error instanceof AppError); - assert.equal(error.code, 'UNSUPPORTED_OPERATION'); - assert.equal(error.message, refusal.message); - assert.deepEqual(error.details, { - deviceId: device.id, - appleOs, - deviceKind: device.kind, - reason: 'setting-unsupported-on-leaf', - hint: refusal.hint, - }); - return true; - }, - ); -} - -for (const { device, appleOs } of REFUSED) { - for (const { setting, refusal } of REFUSALS) { - test(`requireHandheldAppleSimulatorLeaf refuses ${device.name} for ${setting}`, () => { - assertRefused(device, appleOs, refusal); - }); - } -} diff --git a/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts b/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts index 1a8eab6d09..eb9b8515c0 100644 --- a/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts +++ b/packages/platform-apple/src/core/__tests__/settings-text-size.test.ts @@ -4,11 +4,7 @@ import assert from 'node:assert/strict'; import { readIosSetting, setIosSetting } from '../app-settings.ts'; import { assertRejectsAppError } from '../../__tests__/app-error.ts'; import { withFakeAppleTool, type FakeAppleToolResponse } from '../../__tests__/fake-apple-tool.ts'; -import { - IOS_TEST_SIMULATOR, - MACOS_TEST_DEVICE, - TVOS_TEST_SIMULATOR, -} from './apple-core-stub-helpers.ts'; +import { IOS_TEST_SIMULATOR } from './apple-core-stub-helpers.ts'; // `simctl ui content_size` is reached through the production tool-provider scope that // `withFakeAppleTool` installs, so nothing here shells out for real. @@ -92,33 +88,6 @@ test('setIosSetting text-size refuses an off-ladder category without calling sim ); }); -test('setIosSetting text-size refuses a simulator whose content size was never verified', async () => { - // The `settings` admission is one cell covering every Apple simulator, so the per-setting leaf - // rule lives here: an Apple TV simulator would otherwise answer `Text size set to …` for a - // setting nobody has observed it hold. - await withFakeAppleTool( - (args) => { - if (isSimctlListDevices(args)) - return JSON.stringify({ - devices: { - 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ - { udid: 'tvos-sim-1', state: 'Booted' }, - ], - }, - }); - return unexpectedArgs(args); - }, - async ({ calls, device }) => { - await assertRejectsAppError(() => setIosSetting(device, 'text-size', 'large'), { - code: 'UNSUPPORTED_OPERATION', - message: /iOS and iPadOS simulators/, - }); - assert.deepEqual(simctlUiCalls(calls), []); - }, - { device: TVOS_TEST_SIMULATOR }, - ); -}); - test('readIosSetting text-size reports the category and the value the simulator answered with', async () => { await withFakeAppleTool( (args) => { @@ -189,19 +158,3 @@ test('readIosSetting text-size reports a failing simctl read', async () => { }, ); }); - -test('readIosSetting text-size refuses a leaf that holds no content size', async () => { - for (const device of [MACOS_TEST_DEVICE, TVOS_TEST_SIMULATOR]) { - await withFakeAppleTool( - (args) => (isSimctlListDevices(args) ? BOOTED_SIM_LIST_JSON : unexpectedArgs(args)), - async ({ calls }) => { - await assertRejectsAppError(() => readIosSetting(device, 'text-size'), { - code: 'UNSUPPORTED_OPERATION', - message: /iOS and iPadOS simulators/, - }); - assert.deepEqual(simctlUiCalls(calls), []); - }, - { device }, - ); - } -}); diff --git a/packages/platform-apple/src/core/app-settings.ts b/packages/platform-apple/src/core/app-settings.ts index 90ae4764f3..aea8ce2201 100644 --- a/packages/platform-apple/src/core/app-settings.ts +++ b/packages/platform-apple/src/core/app-settings.ts @@ -1,13 +1,15 @@ import { - APPLE_BIOMETRIC_LEAF_REFUSAL, - getUnsupportedMacOsSettingMessage, parsePermissionAction, parseSettingState, resolveSettingsApp, - type ReadableSetting, type ReadSettingResult, type SettingOptions, } from '@agent-device/contracts/settings'; +import { + MACOS_PERMISSION_TARGETS, + type ReadableSetting, + type SettingName, +} from '@agent-device/contracts/settings-catalog'; import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError, @@ -27,7 +29,6 @@ import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/maco import { closeIosApp } from './app-launch.ts'; import { applySimctlSetting } from './simctl-settings.ts'; import { readIosTextSize, setIosTextSize } from './settings-text-size.ts'; -import { requireHandheldAppleSimulatorLeaf } from './settings-leaf.ts'; import { resolveIosApp } from './app-resolution.ts'; import { buildSimctlArgsForDevice, readSimctlContainerPath, runSimctlForDevice } from './simctl.ts'; import { @@ -37,6 +38,25 @@ import { import { ensureBootedSimulator, requireSimulatorDevice } from './simulator.ts'; import { runXcrun } from './tool-provider.ts'; +const MACOS_HOST_SETTINGS = ['appearance', 'permission'] as const satisfies readonly SettingName[]; + +export type MacOsHostSetting = (typeof MACOS_HOST_SETTINGS)[number]; + +const APPLE_SIMULATOR_SETTINGS = [ + 'wifi', + 'airplane', + 'location', + 'appearance', + 'clear-app-state', + 'reset-keychain', + 'permission', + 'text-size', + 'faceid', + 'touchid', +] as const satisfies readonly SettingName[]; + +export type AppleSimulatorSetting = (typeof APPLE_SIMULATOR_SETTINGS)[number]; + // fallow-ignore-next-line complexity export async function setIosSetting( device: DeviceInfo, @@ -45,25 +65,12 @@ export async function setIosSetting( appBundleId?: string, options?: SettingOptions, ): Promise | void> { - if (isMacOs(device)) { - const normalizedSetting = setting.toLowerCase(); - if (normalizedSetting === 'appearance') { - await setMacOsAppearance(state); - return; - } - if (normalizedSetting === 'permission') { - const action = parsePermissionAction(state); - if (action === 'deny') { - throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage('permission')); - } - const permissionTarget = parseMacOsPermissionTarget(options?.permissionTarget); - return await runMacOsPermissionAction(action, permissionTarget); - } - throw new AppError('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); - } + if (isMacOs(device)) return await setMacOsHostSetting(setting, state, options); requireSimulatorDevice(device, 'settings'); + const normalized = APPLE_SIMULATOR_SETTINGS.find((name) => name === setting.toLowerCase()); + if (normalized === undefined) + throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); await ensureBootedSimulator(device); - const normalized = setting.toLowerCase(); switch (normalized) { case 'clear-app-state': { @@ -128,7 +135,6 @@ export async function setIosSetting( } case 'faceid': case 'touchid': { - requireHandheldAppleSimulatorLeaf(device, APPLE_BIOMETRIC_LEAF_REFUSAL); const biometricSetting = normalized as IosBiometricSetting; const biometric = IOS_BIOMETRIC_SETTINGS[biometricSetting]; const action = parseBiometricAction(state, biometricSetting); @@ -155,7 +161,35 @@ export async function setIosSetting( options, }); default: - throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`); + return normalized satisfies never; + } +} + +async function setMacOsHostSetting( + setting: string, + state: string, + options: SettingOptions | undefined, +): Promise | void> { + const normalized = MACOS_HOST_SETTINGS.find((name) => name === setting.toLowerCase()); + switch (normalized) { + case 'appearance': + await setMacOsAppearance(state); + return; + case 'permission': { + const action = parsePermissionAction(state); + if (action === 'deny') { + throw new AppError( + 'INVALID_ARGS', + 'The macOS host supports settings permission grant|reset, not deny.', + ); + } + const permissionTarget = parseMacOsPermissionTarget(options?.permissionTarget); + return await runMacOsPermissionAction(action, permissionTarget); + } + case undefined: + throw new AppError('UNSUPPORTED_OPERATION', `Unsupported macOS setting: ${setting}`); + default: + return normalized satisfies never; } } @@ -272,16 +306,11 @@ async function clearIosSimulatorAppState( function parseMacOsPermissionTarget(value: string | undefined): MacOsPermissionTarget { const normalized = value?.trim().toLowerCase(); - if ( - normalized === 'accessibility' || - normalized === 'screen-recording' || - normalized === 'input-monitoring' - ) { - return normalized; - } + const target = MACOS_PERMISSION_TARGETS.find((name) => name === normalized); + if (target !== undefined) return target; throw new AppError( 'INVALID_ARGS', - 'Unsupported macOS permission target. Use accessibility|screen-recording|input-monitoring.', + `Unsupported macOS permission target. Use ${MACOS_PERMISSION_TARGETS.join('|')}.`, ); } diff --git a/packages/platform-apple/src/core/settings-leaf.ts b/packages/platform-apple/src/core/settings-leaf.ts deleted file mode 100644 index fa2d059d4f..0000000000 --- a/packages/platform-apple/src/core/settings-leaf.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AppleSettingLeafRefusal } from '@agent-device/contracts/settings'; -import { - isHandheldAppleSimulator, - resolveDeviceAppleOs, - type DeviceInfo, -} from '@agent-device/kernel/device'; -import { AppError } from '@agent-device/kernel/errors'; - -/** - * The leaf rule every Apple setting that was only ever verified on an iPhone/iPad simulator enforces - * on its own when it is reached directly: `settings` admission is one cell covering the whole - * simulator family, so an owner whose surface is narrower has to refuse the rest itself rather than - * report a change on a device whose setting may not exist. The predicate is the same - * `isHandheldAppleSimulator` the runtime's read fact reads; only the prose differs, and the caller - * passes the refusal its setting declares in `@agent-device/contracts/settings`. - */ -export function requireHandheldAppleSimulatorLeaf( - device: DeviceInfo, - refusal: AppleSettingLeafRefusal, -): void { - if (isHandheldAppleSimulator(device)) return; - throw new AppError('UNSUPPORTED_OPERATION', refusal.message, { - deviceId: device.id, - appleOs: resolveDeviceAppleOs(device), - deviceKind: device.kind, - reason: refusal.reason, - hint: refusal.hint, - }); -} diff --git a/packages/platform-apple/src/core/settings-text-size.ts b/packages/platform-apple/src/core/settings-text-size.ts index 7f0c18c4f9..3fcb7b3871 100644 --- a/packages/platform-apple/src/core/settings-text-size.ts +++ b/packages/platform-apple/src/core/settings-text-size.ts @@ -1,5 +1,4 @@ import { - APPLE_TEXT_SIZE_LEAF_REFUSAL, parseTextSizeCategory, readTextSizeCategory, textSizeSettingPayload, @@ -10,33 +9,14 @@ import { AppError } from '@agent-device/kernel/errors'; import { requireExecSuccess } from '@agent-device/host-kit/command'; import { runSimctlForDevice } from './simctl.ts'; import { ensureBootedSimulator } from './simulator.ts'; -import { requireHandheldAppleSimulatorLeaf } from './settings-leaf.ts'; -/** - * The Apple half of `settings text-size`: `simctl ui content_size` is both the reader and - * the writer, and it names the ladder verbatim, so no translation table sits between the shared - * vocabulary and the tool. - * - * Both halves are confined to the iPhone/iPad simulator leaf. The daemon refuses a leaf outside it - * before it binds anything, so this guard is what an owner still enforces on its own when it is - * reached directly: the content-size surface was only ever verified on that leaf, and a write that - * reached an Apple TV or Vision Pro simulator would be reported as applied on a device whose setting - * may not exist. The leaf rule and the refusal are declared once — `requireHandheldAppleSimulatorLeaf` - * over `isHandheldAppleSimulator` and `APPLE_TEXT_SIZE_LEAF_REFUSAL` — and the runtime's read fact - * reads the same predicate. - */ +/** `simctl ui content_size` reads and writes the ladder's names verbatim. */ -/** - * Applies one ladder rung. The category arrives validated because `simctl` answers an unknown - * category with exit 0 and the single word `Invalid argument` — a write that delegated validation - * to the tool would report success while changing nothing. `setIosSetting` has already required a - * booted simulator for every setting it serves. - */ +/** `simctl` answers an unknown category with exit 0, so the category is parsed first. */ export async function setIosTextSize( device: DeviceInfo, state: string, ): Promise { - requireHandheldAppleSimulatorLeaf(device, APPLE_TEXT_SIZE_LEAF_REFUSAL); const category = parseTextSizeCategory(state); await runSimctlForDevice(device, ['ui', device.id, 'content_size', category]); return textSizeSettingPayload(category, category); @@ -49,7 +29,6 @@ export async function setIosTextSize( * into a category the simulator never reported. */ export async function readIosTextSize(device: DeviceInfo): Promise { - requireHandheldAppleSimulatorLeaf(device, APPLE_TEXT_SIZE_LEAF_REFUSAL); await ensureBootedSimulator(device); const result = requireExecSuccess( await runSimctlForDevice(device, ['ui', device.id, 'content_size'], { allowFailure: true }), diff --git a/packages/platform-apple/src/core/simctl-settings.ts b/packages/platform-apple/src/core/simctl-settings.ts index 548a8db986..32cde1040c 100644 --- a/packages/platform-apple/src/core/simctl-settings.ts +++ b/packages/platform-apple/src/core/simctl-settings.ts @@ -1,11 +1,11 @@ import { - type MobilePermissionTarget, parseAppearanceAction, parsePermissionAction, parsePermissionTarget, parseSettingState, type SimctlSettingRequest, } from '@agent-device/contracts/settings'; +import { type MobilePermissionTarget } from '@agent-device/contracts/settings-catalog'; import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors'; import { requireLocationCoordinates } from '@agent-device/kernel/location-coordinates'; diff --git a/packages/platform-apple/src/system/runtime.test.ts b/packages/platform-apple/src/system/runtime.test.ts index 96bd65f83a..7b0af7d066 100644 --- a/packages/platform-apple/src/system/runtime.test.ts +++ b/packages/platform-apple/src/system/runtime.test.ts @@ -1,5 +1,6 @@ import { expect, test, vi } from 'vitest'; import type { Interactor } from '@agent-device/contracts/interactor-types'; +import { runtimeSubjectFact } from '@agent-device/contracts/platform-runtime'; import type { AppleOS, DeviceInfo } from '@agent-device/kernel/device'; import { appleSystemFacts, createAppleSystemOperations } from './runtime.ts'; @@ -123,22 +124,55 @@ test.each([ }, ); -test('the read refusal names the leaf that reports a content size back', () => { - // One sentence for the host, the physical devices, and the unverified simulator families: they - // share the reason (no `simctl ui` reading observed) rather than three near-identical hints. - for (const device of [ - appleDevice('macos', 'device'), - appleDevice('tvos', 'simulator'), - appleDevice('visionos', 'simulator'), - ]) { - expect(appleSystemFacts(device).readSetting).toEqual({ +test.each([ + { + appleOs: 'ios', + kind: 'simulator', + served: ['wifi', 'location', 'text-size', 'faceid', 'touchid', 'reset-keychain'], + refused: ['animations', 'fingerprint'], + }, + { + appleOs: 'ipados', + kind: 'simulator', + served: ['appearance', 'text-size', 'faceid', 'touchid'], + refused: ['animations', 'fingerprint'], + }, + { + appleOs: 'tvos', + kind: 'simulator', + served: ['wifi', 'appearance', 'clear-app-state'], + refused: ['text-size', 'faceid', 'touchid'], + }, + { + appleOs: 'macos', + kind: 'device', + served: ['appearance', 'permission'], + refused: ['wifi', 'text-size', 'clear-app-state', 'reset-keychain'], + }, +] as const)('an Apple $appleOs $kind serves each setting on its own fact', (row) => { + const { setSetting } = appleSystemFacts(appleDevice(row.appleOs, row.kind)); + for (const setting of row.served) { + expect(runtimeSubjectFact(setSetting, setting).available).toBe(true); + } + for (const setting of row.refused) { + expect(runtimeSubjectFact(setSetting, setting)).toMatchObject({ available: false, reason: 'unsupported-platform-leaf', - hint: 'Apple targets answer a settings read only on iPhone and iPad simulators, where `simctl ui` reports the value the device holds.', }); } }); +test('a setting outside the handheld simulators names the leaf that serves it', () => { + const hint = 'Select a booted iPhone or iPad simulator: this setting runs only there.'; + const facts = appleSystemFacts(appleDevice('visionos', 'simulator')); + expect(facts.readSetting).toMatchObject({ available: false, hint }); + expect(runtimeSubjectFact(facts.setSetting, 'faceid')).toMatchObject({ available: false, hint }); + expect(appleSystemFacts(appleDevice('macos', 'device')).readSetting).toMatchObject({ + available: false, + hint: expect.stringContaining('The macOS host supports settings appearance'), + }); +}); + test('watchOS closes the read leg for the reason it closes every interactor-backed operation', () => { expect(appleSystemFacts(appleDevice('watchos', 'simulator')).readSetting).toEqual({ available: false, diff --git a/packages/platform-apple/src/system/runtime.ts b/packages/platform-apple/src/system/runtime.ts index 761101c4bd..8cee9f5b8d 100644 --- a/packages/platform-apple/src/system/runtime.ts +++ b/packages/platform-apple/src/system/runtime.ts @@ -10,9 +10,11 @@ import type { } from '@agent-device/contracts/platform-runtime'; import { isHandheldAppleSimulator, + isMacOs, resolveDeviceAppleOs, type DeviceInfo, } from '@agent-device/kernel/device'; +import type { AppleSimulatorSetting, MacOsHostSetting } from '../core/app-settings.ts'; const available = Object.freeze({ available: true } as const); @@ -31,18 +33,21 @@ const settingsLeafUnavailable = Object.freeze({ reason: 'unsupported-platform-leaf', hint: 'settings is supported on Apple simulators and the macOS host, not on physical devices of this OS.', } as const); -/** - * The read half is narrower than the write half on every axis. Content size is the only value this - * surface exposes for reading, and `simctl ui content_size` answers only on an iPhone/iPad - * simulator, so the macOS host (which serves an appearance write and reads an appearance only to - * implement `toggle`), a physical device (which has no `simctl`), and the tvOS/visionOS simulators - * (whose content size was never verified) all refuse a read their leaf may still perform a write of - * another setting on. - */ -const settingsReadHostUnavailable = Object.freeze({ +const settingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'Run `agent-device settings --help` for the settings each target serves.', +} as const); +const macOsSettingUnavailable = Object.freeze({ + available: false, + reason: 'unsupported-platform-leaf', + hint: 'The macOS host supports settings appearance and settings permission .', +} as const); +/** Content size and BiometricKit_Sim notifications answer only on iPhone and iPad simulators. */ +const handheldSimulatorUnavailable = Object.freeze({ available: false, reason: 'unsupported-platform-leaf', - hint: 'Apple targets answer a settings read only on iPhone and iPad simulators, where `simctl ui` reports the value the device holds.', + hint: 'Select a booted iPhone or iPad simulator: this setting runs only there.', } as const); /** * Parity with the retired `supportsHostOrSimulatorSurface` closure: the Apple pasteboard is @@ -143,17 +148,36 @@ function appleClipboardFamilyUnavailable(device: DeviceInfo): RuntimeOperationUn return cell.available ? clipboardLeafUnavailable : cell; } -/** - * The one leaf that can read a value back: an iOS-family simulator. `resolveDeviceAppleOs` is the - * same reading every other Apple cell takes, so watchOS stays closed for the reason it closes every - * interactor-backed operation — no constructible interactor — and the read refusal the host, a - * physical device, and the unverified simulator families share is stated once. - */ -function appleSettingsReadFact(device: DeviceInfo): RuntimeOperationFact { - if (device.kind !== 'simulator' && device.kind !== 'device') return settingsKindUnavailable; - if (resolveDeviceAppleOs(device) === 'watchos') return appleWatchOsUnavailable; - // The same predicate the owner's own guard uses: one declaration of which leaf holds the value. - return isHandheldAppleSimulator(device) ? available : settingsReadHostUnavailable; +function appleSettingsFacts(device: DeviceInfo) { + const target = appleHostOrSimulatorFact(device, settingsKindUnavailable, settingsLeafUnavailable); + if (isMacOs(device)) { + return settingsRuntimeOperationFacts({ + target, + unsupported: macOsSettingUnavailable, + write: { + appearance: available, + permission: available, + } satisfies Record, + }); + } + const handheld = isHandheldAppleSimulator(device) ? available : handheldSimulatorUnavailable; + return settingsRuntimeOperationFacts({ + target, + unsupported: settingUnavailable, + write: { + wifi: available, + airplane: available, + location: available, + appearance: available, + 'clear-app-state': available, + 'reset-keychain': available, + permission: available, + 'text-size': handheld, + faceid: handheld, + touchid: handheld, + } satisfies Record, + read: { 'text-size': handheld }, + }); } /** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */ @@ -170,14 +194,7 @@ export function appleSystemFacts(device: DeviceInfo) { }), ...alertRuntimeOperationFacts({ read: alert, wait: alert, accept: alert, dismiss: alert }), ...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }), - ...settingsRuntimeOperationFacts({ - setSetting: appleHostOrSimulatorFact( - device, - settingsKindUnavailable, - settingsLeafUnavailable, - ), - readSetting: appleSettingsReadFact(device), - }), + ...appleSettingsFacts(device), }); } diff --git a/packages/platform-harmonyos/src/runtime.test.ts b/packages/platform-harmonyos/src/runtime.test.ts index 1b7d0fc303..5f590a9f25 100644 --- a/packages/platform-harmonyos/src/runtime.test.ts +++ b/packages/platform-harmonyos/src/runtime.test.ts @@ -1,3 +1,4 @@ +import { runtimeSubjectFact } from '@agent-device/contracts/platform-runtime'; import { expect, test, vi } from 'vitest'; vi.mock('./hdc.ts', () => ({ runHarmonyShell: vi.fn() })); @@ -135,8 +136,8 @@ test.each([ }); expect(binding.operations[operation]).toBeUndefined(); } - // Settings is available through the same HDC kind gate as the interaction operations. - expect(facts.operations.setSetting).toEqual({ available: true }); + expect(runtimeSubjectFact(facts.operations.setSetting, 'clear-app-state').available).toBe(true); + expect(runtimeSubjectFact(facts.operations.setSetting, 'wifi').available).toBe(false); expect(binding.operations.setSetting).toBeTypeOf('function'); await expect(binding.operations.ensureReady?.({})).resolves.toMatchObject({ booted: true }); await expect( diff --git a/packages/platform-harmonyos/src/runtime.ts b/packages/platform-harmonyos/src/runtime.ts index f24fc1d314..1063b035e2 100644 --- a/packages/platform-harmonyos/src/runtime.ts +++ b/packages/platform-harmonyos/src/runtime.ts @@ -158,6 +158,10 @@ function harmonyFocusFact(device: DeviceInfo): RuntimeOperationFact { /** Public orientation and TV-remote operations are not supported by the HarmonyOS owner. */ const harmonyPlatformLeafUnavailable = unavailableFact('unsupported-platform-leaf'); +const harmonySettingUnavailable = unavailableFact( + 'unsupported-platform-leaf', + 'HarmonyOS supports settings clear-app-state only.', +); /** Android's live IME status read has no HarmonyOS counterpart (parity with the retired leaf, * which rejected `status`/`get` on every non-Android family). */ @@ -246,11 +250,10 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor setFoldPose: harmonyPlatformLeafUnavailable, // HarmonyOS has no trigger-app-event implementation. ...appEventRuntimeOperationFacts({ triggerAppEvent: harmonyPlatformLeafUnavailable }), - // The HDC-driven settings surface shares the interaction kind gate. No HDC leaf reads a - // setting back, so the read half states the platform-leaf denial it would otherwise throw. ...settingsRuntimeOperationFacts({ - setSetting: harmonyFocusFact(device), - readSetting: harmonyPlatformLeafUnavailable, + target: harmonyFocusFact(device), + unsupported: harmonySettingUnavailable, + write: { 'clear-app-state': available }, }), // HarmonyOS exposes no alert automation operation. ...alertRuntimeOperationFacts({ diff --git a/packages/platform-web/src/runtime.ts b/packages/platform-web/src/runtime.ts index fe205cfd65..50b1798dd3 100644 --- a/packages/platform-web/src/runtime.ts +++ b/packages/platform-web/src/runtime.ts @@ -417,8 +417,9 @@ function webRuntimeFacts( setFoldPose: navigationUnavailable, ...appEventRuntimeOperationFacts({ triggerAppEvent: navigationUnavailable }), ...settingsRuntimeOperationFacts({ - setSetting: navigationUnavailable, - readSetting: navigationUnavailable, + target: navigationUnavailable, + unsupported: navigationUnavailable, + write: {}, }), ...alertRuntimeOperationFacts({ read: navigationUnavailable, diff --git a/packages/provider-limrun/src/interaction-operations.test.ts b/packages/provider-limrun/src/interaction-operations.test.ts index 46fbb36229..02b387bb65 100644 --- a/packages/provider-limrun/src/interaction-operations.test.ts +++ b/packages/provider-limrun/src/interaction-operations.test.ts @@ -1,3 +1,4 @@ +import { runtimeSubjectFact } from '@agent-device/contracts/platform-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { bindAdmittedProviderInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog'; import type { DeviceInfo } from '@agent-device/kernel/device'; @@ -101,14 +102,16 @@ test('app-event delivery is admitted on both direct-session legs', () => { ); }); -// R58: the Android leg reuses the local family's interactor and serves both cells. The iOS leg -// writes through simctl and cannot read a setting back. -test('settings write on both legs and read back only on Android', () => { - expect(limrunSettingsOperationFacts(androidMobileDevice).setSetting).toEqual({ available: true }); - expect(limrunSettingsOperationFacts(androidMobileDevice).readSetting).toEqual({ - available: true, +test('the iOS leg serves the simctl settings only and reads nothing back', () => { + const android = limrunSettingsOperationFacts(androidMobileDevice); + expect(runtimeSubjectFact(android.setSetting, 'animations').available).toBe(true); + expect(runtimeSubjectFact(android.readSetting, 'text-size').available).toBe(true); + const ios = limrunSettingsOperationFacts(iosDevice).setSetting; + expect(runtimeSubjectFact(ios, 'appearance').available).toBe(true); + expect(runtimeSubjectFact(ios, 'wifi')).toMatchObject({ + available: false, + reason: 'unsupported-provider-mode', }); - expect(limrunSettingsOperationFacts(iosDevice).setSetting).toEqual({ available: true }); expect(limrunSettingsOperationFacts(iosDevice).readSetting).toEqual({ available: false, reason: 'unsupported-provider-mode', diff --git a/packages/provider-limrun/src/interaction-operations.ts b/packages/provider-limrun/src/interaction-operations.ts index 004fc2dbe1..91171d7413 100644 --- a/packages/provider-limrun/src/interaction-operations.ts +++ b/packages/provider-limrun/src/interaction-operations.ts @@ -18,6 +18,8 @@ import { } from '@agent-device/contracts/scroll-runtime'; import { appEventRuntimeOperationFacts } from '@agent-device/contracts/app-event-runtime'; import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime'; +import type { AndroidSetting } from '@agent-device/platform-android/mechanics'; +import type { LimrunIosSetting } from './ios-settings.ts'; import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtime'; import { systemButtonRuntimeOperationFacts } from '@agent-device/contracts/system-button-runtime'; import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime'; @@ -39,6 +41,7 @@ import { } from '@agent-device/contracts/touch-runtime'; import type { Interactor, RunnerContext } from '@agent-device/contracts/interactor-types'; import { + type RuntimeOperationFact, type RuntimeOperationUnavailability, unavailableFact, } from '@agent-device/contracts/platform-runtime'; @@ -112,6 +115,14 @@ const keyboardUnavailableIos = unavailableFact( 'unsupported-provider-mode', 'Limrun iOS direct sessions do not expose keyboard actions.', ); +const settingUnavailableIos = unavailableFact( + 'unsupported-provider-mode', + 'Limrun iOS direct sessions support settings appearance, permission, location, and clear-app-state.', +); +const settingUnavailableAndroid = unavailableFact( + 'unsupported-platform-leaf', + 'Run `agent-device settings --help` for the settings each target serves.', +); const settingsReadUnavailableIos = unavailableFact( 'unsupported-provider-mode', 'Limrun iOS direct sessions cannot read settings back.', @@ -333,21 +344,44 @@ export function limrunAppEventOperationFacts( }); } -/** - * `settings` splits the same way `app-switcher` does: the Android leg rides the local family's - * own interactor factory, while the iOS leg writes through simctl and reads nothing back. - */ +/** The Android leg runs the local Android interactor; the iOS leg runs the simctl plan. */ export function limrunSettingsOperationFacts( device: DeviceInfo, liveSessionUnavailable?: RuntimeOperationUnavailability, ) { - const write = liveSessionUnavailable ?? available; - const read = - liveSessionUnavailable ?? - (device.platform === 'android' ? available : settingsReadUnavailableIos); - return Object.freeze({ - ...settingsRuntimeOperationFacts({ setSetting: write, readSetting: read }), - }); + const target = liveSessionUnavailable ?? available; + return Object.freeze( + settingsRuntimeOperationFacts( + device.platform === 'android' + ? { + target, + unsupported: settingUnavailableAndroid, + write: { + wifi: available, + airplane: available, + location: available, + animations: available, + appearance: available, + 'text-size': available, + fingerprint: available, + 'clear-app-state': available, + permission: available, + } satisfies Record, + read: { 'text-size': available }, + } + : { + target, + unsupported: settingUnavailableIos, + write: { + appearance: available, + permission: available, + location: available, + 'clear-app-state': available, + } satisfies Record, + read: { 'text-size': settingsReadUnavailableIos }, + }, + ), + ); } /** diff --git a/packages/provider-limrun/src/ios-settings.ts b/packages/provider-limrun/src/ios-settings.ts index 049fc6385e..903ae1e9d5 100644 --- a/packages/provider-limrun/src/ios-settings.ts +++ b/packages/provider-limrun/src/ios-settings.ts @@ -1,7 +1,17 @@ import type { SettingOptions } from '@agent-device/contracts/settings'; +import type { SettingName } from '@agent-device/contracts/settings-catalog'; import { AppError, sessionAppRequiredDetails } from '@agent-device/kernel/errors'; import type { LimrunIosSession } from './ios.ts'; +const LIMRUN_IOS_SETTINGS = [ + 'appearance', + 'permission', + 'location', + 'clear-app-state', +] as const satisfies readonly SettingName[]; + +export type LimrunIosSetting = (typeof LIMRUN_IOS_SETTINGS)[number]; + /** * Changes one setting on a Limrun iOS instance. `appearance`, `permission`, and `location` run the * Apple simctl plan on the instance's one simulator; `clear-app-state` uses Limrun's data reset, @@ -14,7 +24,14 @@ export async function setLimrunIosSetting( appId: string | undefined, options: SettingOptions | undefined, ): Promise | void> { - const normalized = setting.toLowerCase(); + const normalized = LIMRUN_IOS_SETTINGS.find((name) => name === setting.toLowerCase()); + if (normalized === undefined) { + throw new AppError( + 'UNSUPPORTED_OPERATION', + `Limrun iOS direct sessions support appearance, permission, location, and clear-app-state settings, not ${setting}.`, + { command: 'settings' }, + ); + } switch (normalized) { case 'appearance': case 'permission': @@ -32,11 +49,7 @@ export async function setLimrunIosSetting( case 'clear-app-state': return await clearAppState(session, state, appId); default: - throw new AppError( - 'UNSUPPORTED_OPERATION', - `Limrun iOS direct sessions support appearance, permission, location, and clear-app-state settings, not ${setting}.`, - { command: 'settings' }, - ); + return normalized satisfies never; } } diff --git a/packages/provider-webdriver/src/platform-runtime.ts b/packages/provider-webdriver/src/platform-runtime.ts index 180b0b3c70..d1b29c95b1 100644 --- a/packages/provider-webdriver/src/platform-runtime.ts +++ b/packages/provider-webdriver/src/platform-runtime.ts @@ -626,8 +626,9 @@ function webDriverFacts( triggerAppEvent: interactorCell(reachable, appEventUnavailable), }), ...settingsRuntimeOperationFacts({ - setSetting: settingsUnavailable, - readSetting: settingsUnavailable, + target: settingsUnavailable, + unsupported: settingsUnavailable, + write: {}, }), // R59 cell delta, deliberate: the retired `supportsAlertSurface` closure ADMITTED `alert` on // a provider-owned physical iOS device (it keyed on `appleOs === 'ios'` alone), and the diff --git a/scripts/layering/contracts-exports.snapshot.json b/scripts/layering/contracts-exports.snapshot.json index 5a5e4c8e2b..e337196f20 100644 --- a/scripts/layering/contracts-exports.snapshot.json +++ b/scripts/layering/contracts-exports.snapshot.json @@ -118,6 +118,7 @@ "@agent-device/contracts/selector-observation-runtime", "@agent-device/contracts/session", "@agent-device/contracts/settings", + "@agent-device/contracts/settings-catalog", "@agent-device/contracts/settings-runtime", "@agent-device/contracts/snapshot", "@agent-device/contracts/snapshot-runtime", diff --git a/src/commands/capture/settings.test.ts b/src/commands/capture/settings.test.ts index 4f3ec437e7..6d68843ffc 100644 --- a/src/commands/capture/settings.test.ts +++ b/src/commands/capture/settings.test.ts @@ -1,44 +1,13 @@ -import fc from 'fast-check'; import { describe, expect, test } from 'vitest'; import type { CliFlags } from '@agent-device/contracts/command'; -import { TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings'; -import { PROPERTY_RUNS } from '@agent-device/selectors/snapshot-geometry-fixtures'; +import { SETTING_NAMES, TEXT_SIZE_CATEGORIES } from '@agent-device/contracts/settings-catalog'; import { formatCliOutput } from '../cli-output.ts'; import { settingsCliReader, settingsCommandFacet, settingsDaemonWriter } from './settings.ts'; -const MOBILE_TARGETS = [ - 'all', - 'camera', - 'microphone', - 'photos', - 'contacts', - 'contacts-limited', - 'notifications', - 'calendar', - 'location', - 'location-always', - 'media-library', - 'motion', - 'reminders', - 'siri', -] as const; - -const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitoring'] as const; - -// Fixed expected data (#2614): the CLI's own permission vocabulary is written out here, so a shared -// declaration that widens, narrows, or reorders what this command accepts fails this file. -const PERMISSION_TARGETS = [...MOBILE_TARGETS, ...MACOS_ONLY_TARGETS]; -const PERMISSION_TARGETS_MESSAGE = 'settings permission requires a permission target.'; -const PERMISSION_MODES = ['full', 'limited'] as const; - function flags(): CliFlags { return {} as CliFlags; } -function readGrant(permission: string): unknown { - return settingsCliReader(['permission', 'grant', permission], flags()); -} - function expectInvalidArgs(run: () => unknown, message: string): void { expect(run).toThrow(expect.objectContaining({ code: 'INVALID_ARGS', message })); } @@ -78,85 +47,40 @@ describe('settings CLI', () => { expect(rendered.text).toBe('Updated setting: permission'); }); - test('keeps the documented macOS permission form in the command detail', () => { - expect(settingsCommandFacet.text.cliDetail).toContain( - 'settings permission ', - ); - }); - - test('declares exactly the accepted permission modes on its input schema', () => { - const mode = settingsCommandFacet.metadata.inputSchema.properties?.mode as + test('lists every setting in its help and its input schema', () => { + for (const name of SETTING_NAMES) { + expect(settingsCommandFacet.text.cliDetail).toContain(`\n ${name}: `); + } + const setting = settingsCommandFacet.metadata.inputSchema.properties?.setting as | { enum?: unknown } | undefined; - expect(mode?.enum).toEqual([...PERMISSION_MODES]); + expect(setting?.enum).toEqual(SETTING_NAMES); }); }); -describe('settings CLI permission vocabulary', () => { - test.each(PERMISSION_TARGETS)('accepts the %s permission target', (permission) => { - expect(readGrant(permission)).toMatchObject({ - setting: 'permission', - state: 'grant', - permission, - }); - }); - - test('accepts every permission action', () => { - for (const state of ['grant', 'deny', 'reset']) { - expect(settingsCliReader(['permission', state, 'camera'], flags())).toMatchObject({ - setting: 'permission', - state, - permission: 'camera', - }); - } - }); - - test('accepts each permission mode', () => { - for (const mode of PERMISSION_MODES) { - expect(settingsCliReader(['permission', 'grant', 'photos', mode], flags())).toMatchObject({ - permission: 'photos', - mode, - }); - } - }); - +describe('settings CLI permission', () => { test('rejects an unknown permission mode', () => { expectInvalidArgs( () => settingsCliReader(['permission', 'grant', 'photos', 'partial'], flags()), - 'settings permission mode must be full or limited.', + 'settings permission mode must be full|limited.', ); }); - test('rejects a permission mode that is not the exact spelling', () => { - for (const mode of ['FULL', ' Limited ']) { - expectInvalidArgs( - () => settingsCliReader(['permission', 'grant', 'photos', mode], flags()), - 'settings permission mode must be full or limited.', - ); - } - }); - - test('rejects a target outside the vocabulary without normalizing it', () => { - for (const permission of ['CAMERA', ' all', 'location-always ']) { - expectInvalidArgs(() => readGrant(permission), PERMISSION_TARGETS_MESSAGE); - } + test('carries a macOS-only target to the daemon request', () => { + const input = settingsCliReader(['permission', 'grant', 'screen-recording'], flags()); + expect(settingsDaemonWriter(input)).toMatchObject({ + command: 'settings', + positionals: ['permission', 'grant', 'screen-recording'], + }); }); - test('rejects an unknown permission action before reading a target', () => { + test('refuses an action or mode the macOS target does not take', () => { expectInvalidArgs( - () => settingsCliReader(['permission', 'allow', 'camera'], flags()), - 'Invalid settings arguments.', + () => settingsCliReader(['permission', 'deny', 'screen-recording'], flags()), + 'settings permission deny does not apply to screen-recording. Use grant|reset.', ); }); - test('carries a macOS-only target and its mode to the daemon request', () => { - const input = settingsCliReader(['permission', 'deny', 'screen-recording', 'full'], flags()); - expect(settingsDaemonWriter(input)).toMatchObject({ - command: 'settings', - positionals: ['permission', 'deny', 'screen-recording', 'full'], - }); - }); - // #3179: an app-scoped change can name an app the session never opened. `app` is no CLI flag key, // so the request carries it as input the way a gesture payload does. test('carries --app on a permission grant to the daemon request input', () => { @@ -230,6 +154,14 @@ describe('settings CLI permission vocabulary', () => { ).toThrow(/names two apps: com.example.app and com.example.other/); }); + test('passes a setting outside the catalog to the daemon, which refuses it', () => { + expect( + settingsDaemonWriter({ setting: 'bluetooth', state: 'on' } as unknown as Parameters< + typeof settingsDaemonWriter + >[0]), + ).toMatchObject({ positionals: ['bluetooth', 'on'] }); + }); + test('omits the request input when no app is named', () => { expect( settingsDaemonWriter(settingsCliReader(['animations', 'off'], flags())).input, @@ -253,7 +185,7 @@ describe('settings CLI permission vocabulary', () => { }); describe('settings CLI text-size', () => { - const LADDER_MESSAGE = `Invalid text size: gigantic. Use ${TEXT_SIZE_CATEGORIES.join('|')}.`; + const LADDER_MESSAGE = `Invalid text-size state: gigantic. Use ${TEXT_SIZE_CATEGORIES.join('|')}.`; test('reads with a lone positional and writes with a category', () => { expect(settingsCliReader(['text-size'], flags())).toMatchObject({ setting: 'text-size' }); @@ -271,10 +203,6 @@ describe('settings CLI text-size', () => { }); }); - test.each([...TEXT_SIZE_CATEGORIES])('accepts the %s category', (category) => { - expect(settingsCliReader(['text-size', category], flags())).toMatchObject({ state: category }); - }); - test('refuses an off-ladder category and lists the whole ladder', () => { expectInvalidArgs(() => settingsCliReader(['text-size', 'gigantic'], flags()), LADDER_MESSAGE); }); @@ -285,58 +213,4 @@ describe('settings CLI text-size', () => { const usage = (settingsCommandFacet.cliSchema as { usageOverride: string }).usageOverride; expect(usage).toContain(`text-size [${TEXT_SIZE_CATEGORIES.join('|')}]`); }); - - test('a category is accepted exactly when it names a ladder rung, any casing or padding', () => { - // The deliberate contrast with `permission`, which refuses a target it would have to normalize: - // a text size has one spelling per rung and `simctl`/`font_scale` both take the canonical one, - // so the reader canonicalizes instead of refusing. - fc.assert( - fc.property(fc.string(), (value) => { - const canonical = value.trim().toLowerCase(); - if ((TEXT_SIZE_CATEGORIES as readonly string[]).includes(canonical)) { - expect(settingsCliReader(['text-size', value], flags())).toMatchObject({ - state: canonical, - }); - return; - } - expect(() => settingsCliReader(['text-size', value], flags())).toThrow( - expect.objectContaining({ code: 'INVALID_ARGS' }), - ); - }), - { numRuns: PROPERTY_RUNS }, - ); - }); -}); - -describe('settings CLI permission membership properties', () => { - test('a target is accepted exactly when it is spelled as a vocabulary name', () => { - const vocabulary = new Set(PERMISSION_TARGETS); - fc.assert( - fc.property(fc.string(), (value) => { - if (vocabulary.has(value)) { - expect(readGrant(value)).toMatchObject({ permission: value }); - return; - } - expectInvalidArgs(() => readGrant(value), PERMISSION_TARGETS_MESSAGE); - }), - { numRuns: PROPERTY_RUNS }, - ); - }); - - test('case and padding never widen what a vocabulary name is accepted as', () => { - fc.assert( - fc.property( - fc.constantFrom(...PERMISSION_TARGETS), - fc.boolean(), - fc.boolean(), - (name, uppercased, padded) => { - const spelled = uppercased ? name.toUpperCase() : name; - const value = padded ? ` ${spelled} ` : spelled; - if (value === name) return; - expectInvalidArgs(() => readGrant(value), PERMISSION_TARGETS_MESSAGE); - }, - ), - { numRuns: PROPERTY_RUNS }, - ); - }); }); diff --git a/src/commands/capture/settings.ts b/src/commands/capture/settings.ts index e0652d2bbe..50b610c090 100644 --- a/src/commands/capture/settings.ts +++ b/src/commands/capture/settings.ts @@ -1,29 +1,24 @@ import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; import type { SettingsUpdateOptions } from '@agent-device/contracts/client'; +import { resolveSettingsRuntimePlan } from '@agent-device/contracts/platform-runtime-operations'; import { - APPEARANCE_ACTIONS, - MACOS_PERMISSION_TARGETS, - MOBILE_PERMISSION_TARGETS, - parseTextSizeCategory, - PERMISSION_ACTIONS, - PERMISSION_MODES, - SETTINGS_MACOS_PERMISSION_USAGE, + parseSettingsMutation, + settingsHelpLines, SETTINGS_USAGE_OVERRIDE, - type PermissionMode, } from '@agent-device/contracts/settings'; +import { + PERMISSION_MODES, + settingDefinition, + SETTING_NAMES, + type SettingName, +} from '@agent-device/contracts/settings-catalog'; import type { CommandSchemaOverride } from '@agent-device/command-registry/command-schema'; import type { CliFlags } from '@agent-device/contracts/command'; import { AppError } from '@agent-device/kernel/errors'; import { readLocationCoordinate } from '@agent-device/kernel/location-coordinates'; import { compactRecord } from '../input-readers.ts'; import { enumField, numberField, requiredField, stringField } from '../command-input.ts'; -import { - isOneOf, - optionalString, - request, - selectionOptionsFromFlags, - setOf, -} from '../cli-grammar/common.ts'; +import { optionalString, request, selectionOptionsFromFlags } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineCommandFacet } from '../family/types.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; @@ -37,13 +32,11 @@ const settingsCommandMetadata = defineFieldCommandMetadata( SETTINGS_COMMAND_NAME, settingsCommandDescription, { - setting: requiredField(stringField()), - // Optional because a readable setting answers with no state: `settings text-size` asks for the - // value the target holds. Every other setting still requires one, which the command's own parse - // and the daemon enforce per setting rather than the shared field map. + setting: requiredField(enumField(SETTING_NAMES)), + // A readable setting answers with no state; the parse decides per setting. state: stringField(), app: stringField( - 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app 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); an app that resolves to nothing is refused with `app_not_installed` before anything changes. An app named here needs no session and need not be running (Android `deny|reset` of a held permission does kill a running one); without it those changes use the session app, and a change that is device-wide refuses one with `setting_app_not_consumed` (`location set` on any target, the Android `location on|off` toggle, macOS permissions). The CLI consumes it only when this invocation passes it: an `AGENT_DEVICE_TARGET_APP` or config `targetApp` never retargets a settings mutation.', + 'App the change targets, by bundle id, package name, or app name: the app `clear-app-state` clears, or the app a `permission` change (or an iOS-simulator `location on|off` privacy change) lands on. A name resolves to the installed app as `open` resolves it: an iOS simulator matches display names and accepts only an installed bundle 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. One that resolves to nothing is refused with `app_not_installed` before anything changes. The app need not be running. Without it the session app is used; a device-wide change (`location set` on any target, the Android `location on|off` toggle, macOS permissions) refuses one with `setting_app_not_consumed`. The CLI reads it only from this invocation, never from `AGENT_DEVICE_TARGET_APP` or config `targetApp`.', ), latitude: numberField(), longitude: numberField(), @@ -56,32 +49,38 @@ const settingsCliSchema = { usageOverride: SETTINGS_USAGE_OVERRIDE, listUsageOverride: 'settings [area] [options]', positionalArgs: ['setting', 'state?', 'target?', 'mode?'], - // `--app` names the app an app-scoped change lands on (`settings permission grant camera --app - // com.example.app`), shared with `doctor`. It rides the request's input payload, not a positional, - // because the setting's own positionals already carry the permission target and mode. allowedFlags: ['targetApp'], - // A settings mutation changes state on the app it resolves, so the app must come only from this - // invocation: `doctor` reads `--app` to check someone's default app, and the same env or config - // key must never redirect a `grant` or a destructive `clear-app-state` away from the session app. + // A mutation must never be redirected by an env or config default app. explicitOnlyFlags: ['targetApp'], } as const satisfies CommandSchemaOverride; export const settingsCliReader: CliReader = (positionals, flags) => { - const options = readSettingsOptionsFromPositionals(positionals, flags); - // `clear-app-state` resolves `--app` against its own positional slot while it parses, so the - // option is added here only for the settings whose app has no positional. Whether a mutation can - // consume an app at all is the daemon's scope table to decide once it knows the resolved target. - return options.setting === 'clear-app-state' || flags.targetApp === undefined - ? options - : { ...options, app: flags.targetApp }; + const base = selectionOptionsFromFlags(flags); + const plan = resolveSettingsRuntimePlan(positionals); + if (plan.kind === 'read') return { ...base, setting: plan.setting }; + const mutation = parseSettingsMutation(positionals); + const app = + settingDefinition(mutation.setting)?.app === 'positional' + ? readPositionalApp(mutation.setting, mutation.app, flags) + : flags.targetApp; + return { + ...base, + setting: mutation.setting, + state: mutation.state, + ...(mutation.permission === undefined ? {} : { permission: mutation.permission }), + ...(mutation.mode === undefined ? {} : { mode: mutation.mode }), + ...(mutation.latitude === undefined + ? {} + : { + latitude: readLocationCoordinate(mutation.latitude, 'latitude'), + longitude: readLocationCoordinate(mutation.longitude, 'longitude'), + }), + ...(app === undefined ? {} : { app }), + } as SettingsUpdateOptions; }; export const settingsDaemonWriter: DaemonWriter = (input) => { const options = input as SettingsUpdateOptions; - // The app-scoped `app` has no positional of its own and `app` is no CLI flag key, so it crosses - // the daemon boundary as request input the way a gesture payload does: read back by the handler, - // never reconstructed from the command line. `clear-app-state` keeps carrying its app - // positionally, as it always has. const settingsInput = compactRecord({ app: settingsInputApp(options) }); return request( PUBLIC_COMMANDS.settings, @@ -91,21 +90,23 @@ export const settingsDaemonWriter: DaemonWriter = (input) => { ); }; -/** The settings whose `app` reaches the daemon through request input rather than a positional. */ +/** An app with no positional slot crosses the daemon boundary as request input. */ function settingsInputApp(input: SettingsUpdateOptions): string | undefined { - if (input.setting === 'permission') return input.app; - // Every location state forwards the app: `on|off` consumes it on Apple, and `set` is device-wide - // for everyone, so the daemon's scope table refuses the named app instead of the writer dropping - // it without a word. - if (input.setting === 'location') return input.app; - return undefined; + return settingDefinition(input.setting)?.app === 'input' && 'app' in input + ? input.app + : undefined; } export const settingsCommandFacet = defineCommandFacet({ name: SETTINGS_COMMAND_NAME, text: { summary: 'Change OS settings and app permissions', - cliDetail: `macOS supports only settings appearance and settings ${SETTINGS_MACOS_PERMISSION_USAGE}; wifi|airplane|location|animations|text-size remain unsupported on macOS. Mobile permission actions default to the active session app; pass --app (or the app input) to aim a permission change, or an iOS-simulator location on|off, at an installed app no session has opened; a name resolves to the installed app as open resolves it (a Limrun iOS session resolves only the settings alias), and an app that resolves to nothing refuses with app_not_installed before anything changes; no app needs to be running, and the CLI consumes the app only when this invocation names it, never from AGENT_DEVICE_TARGET_APP or config targetApp. clear-app-state takes its app positionally or with --app, and refuses two different apps. An installed app with no data container (a simulator-runtime system app such as com.apple.Preferences) refuses with app-no-data-container instead of scanning a path simctl never gave. A device-wide change refuses an app with setting_app_not_consumed: location set moves the device's own coordinates on every target, the Android location toggle writes the device's location_mode, and a macOS permission is a host-level TCC grant. On Android, deny|reset of a permission the app currently holds kills a running app; the response reports priorGrantState (granted|not_granted|unknown) and warns for granted and unknown, with open --relaunch to restore it. Permission changes require a resolvable foreground user and fail without mutating if adb cannot report one. Android settings airplane on|off is applied by the connectivity service (Android 11+) and reports the airplaneMode that service holds; older builds fail without changing device state. settings reset-keychain clear is iOS-simulator-only and resets the whole simulator keychain, not just the selected app: simctl exposes no per-app keychain reset, so every app on that simulator loses its keychain-backed credentials (e.g. Firebase auth). clear-app-state does not touch the keychain, so a full fresh-install reset needs both; relaunch the app afterward to observe the signed-out state. settings text-size reads the preferred text size the target holds and settings text-size applies one, on iPhone and iPad simulators (simctl content size) and on Android targets (system font_scale); tvOS and visionOS simulators, physical Apple devices, and the macOS host refuse it. Android has no category ladder of its own, so the read names the nearest rung and reports the exact multiplier as platformValue; an already-running app adopts a changed size at its next configuration change, so relaunch the app under test to observe it.`, + cliDetail: [ + 'A bare readable setting reads the value the target holds. A target that does not serve a setting refuses it with UNSUPPORTED_OPERATION before anything changes.', + '', + 'Settings:', + ...settingsHelpLines().map((line) => ` ${line}`), + ].join('\n'), }, metadata: settingsCommandMetadata, run: (client, input) => client.settings.update(input as SettingsUpdateOptions), @@ -116,122 +117,32 @@ export const settingsCommandFacet = defineCommandFacet({ cliOutputFormatter: messageOutput, }); -// fallow-ignore-next-line complexity -function readSettingsOptionsFromPositionals( - positionals: string[], +/** The positional app, or `--app` when no positional names one; two different apps are refused. */ +function readPositionalApp( + setting: SettingName, + positionalApp: string | undefined, flags: CliFlags, -): SettingsUpdateOptions { - const base = selectionOptionsFromFlags(flags); - const setting = positionals[0]; - const state = positionals[1]; - if (isOneOf(setting, ON_OFF_SETTINGS) && isOneOf(state, ON_OFF_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'location' && state === 'set') { - return { - ...base, - setting, - state, - latitude: readLocationCoordinate(positionals[2], 'latitude'), - longitude: readLocationCoordinate(positionals[3], 'longitude'), - }; - } - if (setting === 'appearance' && isOneOf(state, APPEARANCE_STATES)) { - return { ...base, setting, state }; - } - // A bare `settings text-size` reads the category the target holds; a category applies it. The - // category is parsed here rather than handed to the tool, which answers an unknown one with - // success, and the refusal lists the whole ladder. - if (setting === 'text-size') { - return state === undefined - ? { ...base, setting } - : { ...base, setting, state: parseTextSizeCategory(state) }; - } - if (isOneOf(setting, BIOMETRIC_SETTINGS) && isOneOf(state, BIOMETRIC_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'fingerprint' && isOneOf(state, FINGERPRINT_STATES)) { - return { ...base, setting, state }; - } - if (setting === 'permission' && isOneOf(state, PERMISSION_STATES)) { - return { - ...base, - setting, - state, - permission: readPermission(positionals[2]), - mode: readPermissionMode(positionals[3]), - }; - } - if (setting === 'clear-app-state') { - return { ...base, setting, state: 'clear', app: readClearAppStateApp(positionals, flags) }; - } - if (setting === 'reset-keychain' && state === 'clear' && positionals.length === 2) { - return { ...base, setting, state }; - } - throw new AppError('INVALID_ARGS', 'Invalid settings arguments.'); -} - -/** - * The app `clear-app-state` clears: its positional, or `--app` when no positional names one. A - * positional and a different `--app` is a contradiction refused here, because clearing either one - * would silently drop the other, and an unnamed slot would fall through to the session app. - */ -function readClearAppStateApp(positionals: string[], flags: CliFlags): string | undefined { - const positionalApp = positionals[1] === 'clear' ? positionals[2] : positionals[1]; +): string | undefined { const flagApp = flags.targetApp; if (positionalApp !== undefined && flagApp !== undefined && positionalApp !== flagApp) { throw new AppError( 'INVALID_ARGS', - `settings clear-app-state names two apps: ${positionalApp} and ${flagApp}. Name one.`, + `settings ${setting} names two apps: ${positionalApp} and ${flagApp}. Name one.`, ); } return positionalApp ?? flagApp; } function settingsPositionals(input: SettingsUpdateOptions): string[] { - if (input.setting === 'clear-app-state') { - return [input.setting, ...optionalString(input.app)]; - } - // No state is the read leg: the daemon admits the owner's read fact for a lone positional. - if (input.setting === 'text-size') { - return [input.setting, ...optionalString(input.state)]; + if (settingDefinition(input.setting)?.app === 'positional') { + return [input.setting, ...optionalString('app' in input ? input.app : undefined)]; } - if (input.setting === 'location' && input.state === 'set') { + if (input.state === undefined) return [input.setting]; + if ('latitude' in input) { return [input.setting, input.state, String(input.latitude), String(input.longitude)]; } - if (input.setting === 'permission') { + if ('permission' in input) { return [input.setting, input.state, input.permission, ...optionalString(input.mode)]; } return [input.setting, input.state]; } - -function readPermission(value: string | undefined): PermissionTarget { - if (isOneOf(value, PERMISSION_TARGETS)) return value; - throw new AppError('INVALID_ARGS', 'settings permission requires a permission target.'); -} - -function readPermissionMode(value: string | undefined): PermissionMode | undefined { - if (value === undefined || isOneOf(value, PERMISSION_MODE_VALUES)) return value; - throw new AppError('INVALID_ARGS', 'settings permission mode must be full or limited.'); -} - -type PermissionTarget = Extract['permission']; -type OnOffSetting = Extract['setting']; -type OnOffState = Extract['state']; -type BiometricSetting = Extract< - SettingsUpdateOptions, - { setting: 'faceid' | 'touchid' } ->['setting']; -type BiometricState = Extract['state']; -type FingerprintState = Extract['state']; -type AppearanceState = Extract['state']; - -const ON_OFF_SETTINGS = setOf('wifi', 'airplane', 'location', 'animations'); -const ON_OFF_STATES = setOf('on', 'off'); -const APPEARANCE_STATES = setOf(...APPEARANCE_ACTIONS); -const BIOMETRIC_SETTINGS = setOf('faceid', 'touchid'); -const BIOMETRIC_STATES = setOf('match', 'nonmatch', 'enroll', 'unenroll'); -const FINGERPRINT_STATES = setOf('match', 'nonmatch'); -const PERMISSION_MODE_VALUES = setOf(...PERMISSION_MODES); -const PERMISSION_STATES = setOf(...PERMISSION_ACTIONS); -const PERMISSION_TARGETS = setOf(...MOBILE_PERMISSION_TARGETS, ...MACOS_PERMISSION_TARGETS); diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts index b18260bf55..b40b6427ed 100644 --- a/src/commands/cli-grammar/common.ts +++ b/src/commands/cli-grammar/common.ts @@ -239,17 +239,6 @@ function optionalTrimmedText(values: string[]): string | undefined { return text || undefined; } -export function setOf(...values: T[]): ReadonlySet { - return new Set(values); -} - -export function isOneOf( - value: string | undefined, - values: ReadonlySet, -): value is T { - return value !== undefined && values.has(value as T); -} - export function isFiniteNumberString(value: string | undefined): boolean { if (value === undefined || value.trim() === '') return false; return Number.isFinite(Number(value)); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 47f95e63e8..0f6894cc06 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -662,6 +662,7 @@ Platform support: - macOS supports only `settings appearance ` and `settings permission `. - `settings appearance` sets macOS appearance, iOS simulator appearance, or Android night mode. - `settings location set ` sets coordinates on iOS simulators and Android emulators. +- A target that does not serve a setting refuses it with `UNSUPPORTED_OPERATION` and `error.details.reason` (for example `unsupported-platform-leaf`) before anything changes. A request that names an app the change cannot take is refused first, with `INVALID_ARGS` and `setting_app_not_consumed`. `settings --help` describes where each setting runs. Device settings: