Skip to content
8 changes: 6 additions & 2 deletions packages/contracts/src/client-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,12 @@ export type SettingsUpdateOptions =
permission: PermissionTarget;
mode?: PermissionMode;
/**
* The app the permission changes, by bundle id or package name. Without it the app bound to
* the session is used; with it no app has to be running or open, because `simctl privacy` and
* 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
Comment thread
thymikee marked this conversation as resolved.
* 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.
*/
Expand Down
39 changes: 38 additions & 1 deletion packages/contracts/src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AppError } from '@agent-device/kernel/errors';
import { AppError, appNotInstalledDetails, discloseDispatch } from '@agent-device/kernel/errors';

/**
* The `settings permission` vocabulary, declared once. These collections are what the parsers
Expand Down Expand Up @@ -172,6 +172,8 @@ export type SimctlSettingRequest = {
setting: SimctlSetting;
state: string;
appBundleId?: string;
/** Resolves the app a privacy change names to the bundle id `simctl privacy` writes. */
resolveApp: (app: string) => Promise<string>;
options?: SettingOptions;
};

Expand Down Expand Up @@ -281,6 +283,41 @@ export function settingsAppNotConsumedRefusal(
};
}

const SETTINGS_APP_NOT_INSTALLED_HINT =
'Run apps --all for the exact bundle id or package name, or install the app, then retry with that id in --app.';

/**
* The reason an Android app-scoped mutation refuses an app that resolves to an intent rather than a
* package: a permission or app-data change needs a package, so the recovery is naming one.
*/
export const SETTINGS_APP_NOT_PACKAGE_REASON = 'setting_app_not_package';

/**
* Resolves the app an app-scoped mutation lands on through the owner's own app resolution, ahead of
* anything that reaches the device. A failed resolution therefore dispatched nothing, and an app that
* resolves to no installed app answers `app_not_installed` rather than reaching the device as a
* literal id.
*/
export async function resolveSettingsApp<Resolved>(
app: string,
resolve: (app: string) => Promise<Resolved>,
): Promise<Resolved> {
try {
return await resolve(app);
} catch (error) {
if (!(error instanceof AppError)) throw error;
if (error.code === 'APP_NOT_INSTALLED') {
error.details = {
...error.details,
...appNotInstalledDetails(),
app,
hint: SETTINGS_APP_NOT_INSTALLED_HINT,
};
}
throw discloseDispatch(error, 'no');
}
}

const SETTINGS_WIFI_USAGE = '<wifi|airplane|location> <on|off>';
const SETTINGS_LOCATION_SET_USAGE = 'location set <lat> <lon>';
const SETTINGS_ANIMATIONS_USAGE = 'animations <on|off>';
Expand Down
8 changes: 8 additions & 0 deletions packages/kernel/src/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import {
AppError,
appNotInstalledDetails,
defaultHintForCode,
discloseDispatch,
discloseDispatchAfterSteps,
Expand All @@ -19,6 +20,7 @@ test('the pre-dispatch refusal reasons are the values consumers branch on', () =
assert.deepEqual(PRE_DISPATCH_REFUSAL_REASONS, {
sessionAppRequired: 'session_app_required',
sessionOrDeviceSelectorRequired: 'session_or_device_selector_required',
appNotInstalled: 'app_not_installed',
});
});

Expand All @@ -35,6 +37,12 @@ for (const [label, details, code, message] of [
'INVALID_ARGS',
'clipboard requires an active session or an explicit device selector (e.g. --platform ios).',
],
[
'appNotInstalled',
appNotInstalledDetails(),
'APP_NOT_INSTALLED',
'No app found matching "Maps"',
],
] as const) {
test(`${label} refusal carries its reason and dispatched:no through normalize and the wire`, () => {
const normalized = normalizeError(new AppError(code, message, details));
Expand Down
16 changes: 16 additions & 0 deletions packages/kernel/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,12 @@ export const PRE_DISPATCH_REFUSAL_REASONS = {
* find a device for it; the command it asks for stays in the message.
*/
sessionOrDeviceSelectorRequired: 'session_or_device_selector_required',
/**
* An app-scoped setting named an app that resolves to no installed app on the target, so nothing
* was granted, revoked, or cleared under that name. Recovery: run `apps` for the exact bundle id
* or package, or install the app, then retry.
*/
appNotInstalled: 'app_not_installed',
} as const;

export type PreDispatchRefusalReason =
Expand All @@ -295,6 +301,16 @@ export function sessionAppRequiredDetails(): RefusalDetails<
};
}

/** The details of an app-scoped setting refused because its named app resolves to no installed app. */
export function appNotInstalledDetails(): RefusalDetails<
(typeof PRE_DISPATCH_REFUSAL_REASONS)['appNotInstalled']
> {
return {
reason: PRE_DISPATCH_REFUSAL_REASONS.appNotInstalled,
dispatched: 'no',
};
}

/** The details of a command refused for want of a target to route to. */
export function sessionOrDeviceSelectorRequiredDetails(): RefusalDetails<
(typeof PRE_DISPATCH_REFUSAL_REASONS)['sessionOrDeviceSelectorRequired']
Expand Down
21 changes: 21 additions & 0 deletions packages/platform-android/src/__tests__/app-deployment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,27 @@ test('resolveAndroidApp does not treat file paths as package names', async () =>
);
});

test('resolveAndroidApp keeps a user-scoped listing out of the device-wide name cache', async () => {
await withFakeAdb(
(args) => {
const flat = args.join(' ');
if (flat === 'shell pm list packages --user 10') return 'package:com.example.secondonly';
if (flat === 'shell pm list packages') return 'package:com.example.demo';
return { stderr: `unexpected args: ${flat}`, exitCode: 1 };
},
async ({ calls, device }) => {
assert.deepEqual(
await resolveAndroidApp(device, 'secondonly', { installedOnly: true, userId: 10 }),
{ type: 'package', value: 'com.example.secondonly' },
);
await assertRejectsAppError(() => resolveAndroidApp(device, 'secondonly'), {
code: 'APP_NOT_INSTALLED',
});
assert.deepEqual(calls.at(-1), ['shell', 'pm', 'list', 'packages']);
},
);
});

test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => {
await withFakeAdb(
(args) => {
Expand Down
Loading
Loading