Skip to content

Commit 7dc44d6

Browse files
committed
refactor(settings): derive the permission vocabulary from one declaration
packages/contracts/src/settings.ts now declares the settings permission actions, modes, app-scoped targets and macOS targets once, and the two parsers, the settings help fragments and its invalid-args message, the public client permission types, and the CLI's membership sets are built from those collections. Accepted names, normalization, error strings, help ordering and daemon positionals are unchanged; no surface widened what it accepts. The macOS action list stays a literal because it is a support fact owned by platform-apple. Part of #2614.
1 parent 2cafab3 commit 7dc44d6

7 files changed

Lines changed: 528 additions & 132 deletions

File tree

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, expectTypeOf, test, vi } from 'vitest';
2+
import type { SettingsUpdateOptions } from './client-settings.ts';
3+
4+
type Permission = Extract<SettingsUpdateOptions, { setting: 'permission' }>;
5+
6+
const MOBILE_TARGETS = [
7+
'camera',
8+
'microphone',
9+
'photos',
10+
'contacts',
11+
'contacts-limited',
12+
'notifications',
13+
'calendar',
14+
'location',
15+
'location-always',
16+
'media-library',
17+
'motion',
18+
'reminders',
19+
'siri',
20+
] as const;
21+
22+
const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitoring'] as const;
23+
24+
// Fixed expected data (#2614): the public client vocabulary is written out here so a shared
25+
// declaration can neither widen the accepted permission names nor drop the macOS-only ones.
26+
describe('public client permission vocabulary', () => {
27+
test('names exactly the app-scoped targets plus the macOS-only ones', () => {
28+
expectTypeOf<Permission['permission']>().toEqualTypeOf<
29+
(typeof MOBILE_TARGETS)[number] | (typeof MACOS_ONLY_TARGETS)[number]
30+
>();
31+
});
32+
33+
test('does not name a permission the vocabulary does not declare', () => {
34+
expectTypeOf<'all'>().not.toMatchTypeOf<Permission['permission']>();
35+
expectTypeOf<'bluetooth'>().not.toMatchTypeOf<Permission['permission']>();
36+
});
37+
38+
test('keeps the permission actions and modes it already declared', () => {
39+
expectTypeOf<Permission['state']>().toEqualTypeOf<'grant' | 'deny' | 'reset'>();
40+
expectTypeOf<Permission['mode']>().toEqualTypeOf<'full' | 'limited' | undefined>();
41+
});
42+
});
43+
44+
/**
45+
* The client-facing vocabulary is a type surface. Deriving it from `settings.ts` must not put that
46+
* module, and with it `AppError`, on the client's runtime path — a value import here would load it.
47+
*/
48+
describe('public client settings cost no runtime module graph', () => {
49+
test('importing the client vocabulary never loads the settings contract', async () => {
50+
const loaded: string[] = [];
51+
vi.resetModules();
52+
vi.doMock('./settings.ts', () => {
53+
loaded.push('settings');
54+
return {};
55+
});
56+
57+
await import('./client-settings.ts');
58+
59+
vi.doUnmock('./settings.ts');
60+
expect(loaded).toEqual([]);
61+
});
62+
63+
test('contributes no runtime export of its own to the client surface', async () => {
64+
await import('./client-settings.ts');
65+
expect(Object.keys(await import('./client-settings.ts'))).toEqual([]);
66+
});
67+
});

packages/contracts/src/client-settings.ts

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
// The public API vocabulary for device settings and permission grants.
22

33
import type { DeviceCommandBaseOptions } from './client-connection.ts';
4+
import type {
5+
MACOS_PERMISSION_TARGETS,
6+
MOBILE_PERMISSION_TARGETS,
7+
PermissionAction,
8+
PermissionMode,
9+
} from './settings.ts';
410

11+
/**
12+
* Every permission the public client can name: the app-scoped subset plus the macOS-only one, both
13+
* from the owning declaration. Type-only on purpose — naming a permission must not pull
14+
* `settings.ts` and its `AppError` dependency onto the client's runtime path.
15+
*/
516
export type PermissionTarget =
6-
| 'camera'
7-
| 'microphone'
8-
| 'photos'
9-
| 'contacts'
10-
| 'contacts-limited'
11-
| 'notifications'
12-
| 'calendar'
13-
| 'location'
14-
| 'location-always'
15-
| 'media-library'
16-
| 'motion'
17-
| 'reminders'
18-
| 'siri'
19-
| 'accessibility'
20-
| 'screen-recording'
21-
| 'input-monitoring';
17+
| (typeof MOBILE_PERMISSION_TARGETS)[number]
18+
| (typeof MACOS_PERMISSION_TARGETS)[number];
2219

2320
export type SettingsUpdateOptions =
2421
| (DeviceCommandBaseOptions & {
@@ -58,7 +55,7 @@ export type SettingsUpdateOptions =
5855
})
5956
| (DeviceCommandBaseOptions & {
6057
setting: 'permission';
61-
state: 'grant' | 'deny' | 'reset';
58+
state: PermissionAction;
6259
permission: PermissionTarget;
63-
mode?: 'full' | 'limited';
60+
mode?: PermissionMode;
6461
});
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { describe, expect, expectTypeOf, test } from 'vitest';
2+
import {
3+
getUnsupportedMacOsSettingMessage,
4+
isMacOsSettingSupported,
5+
MACOS_PERMISSION_TARGETS,
6+
MOBILE_PERMISSION_TARGETS,
7+
parsePermissionAction,
8+
parsePermissionTarget,
9+
PERMISSION_ACTIONS,
10+
PERMISSION_MODES,
11+
SETTINGS_INVALID_ARGS_MESSAGE,
12+
SETTINGS_MACOS_PERMISSION_USAGE,
13+
SETTINGS_USAGE_OVERRIDE,
14+
type PermissionAction,
15+
type PermissionTarget,
16+
} from './settings.ts';
17+
18+
// Fixed expected data on purpose (#2614): this file is the witness that a shared permission
19+
// declaration neither widened nor narrowed what any settings surface already accepted, and that it
20+
// kept the accepted names in the order `settings` help has always listed them.
21+
const MOBILE_TARGETS = [
22+
'camera',
23+
'microphone',
24+
'photos',
25+
'contacts',
26+
'contacts-limited',
27+
'notifications',
28+
'calendar',
29+
'location',
30+
'location-always',
31+
'media-library',
32+
'motion',
33+
'reminders',
34+
'siri',
35+
] as const;
36+
37+
const MACOS_ONLY_TARGETS = ['accessibility', 'screen-recording', 'input-monitoring'] as const;
38+
39+
const SETTINGS_FORMS = [
40+
'<wifi|airplane|location> <on|off>',
41+
'location set <lat> <lon>',
42+
'animations <on|off>',
43+
'appearance <light|dark|toggle>',
44+
'faceid <match|nonmatch|enroll|unenroll>',
45+
'touchid <match|nonmatch|enroll|unenroll>',
46+
'fingerprint <match|nonmatch>',
47+
'clear-app-state [app-id]',
48+
'reset-keychain clear',
49+
`permission <grant|deny|reset> <${MOBILE_TARGETS.join('|')}> [full|limited]`,
50+
'permission <grant|reset> <accessibility|screen-recording|input-monitoring>',
51+
] as const;
52+
53+
// The whole normalization the parsers promise, written out rather than sampled.
54+
const NORMALIZATIONS = [
55+
(name: string) => name,
56+
(name: string) => name.toUpperCase(),
57+
(name: string) => name.charAt(0).toUpperCase() + name.slice(1),
58+
(name: string) => ` ${name} `,
59+
(name: string) => `\t${name}\n`,
60+
] as const;
61+
62+
const REJECTED_TARGETS = [
63+
...MACOS_ONLY_TARGETS,
64+
'all',
65+
'bluetooth',
66+
'camera-x',
67+
'camera limited',
68+
'',
69+
' ',
70+
undefined,
71+
] as const;
72+
73+
function expectInvalidArgs(run: () => unknown, message: string): void {
74+
expect(run).toThrow(
75+
expect.objectContaining({
76+
code: 'INVALID_ARGS',
77+
message: expect.stringContaining(message),
78+
}),
79+
);
80+
}
81+
82+
describe('the declared permission vocabulary', () => {
83+
test('holds the names each surface already accepted, in help order', () => {
84+
expect([...MOBILE_PERMISSION_TARGETS]).toEqual([...MOBILE_TARGETS]);
85+
expect([...MACOS_PERMISSION_TARGETS]).toEqual([...MACOS_ONLY_TARGETS]);
86+
expect([...PERMISSION_ACTIONS]).toEqual(['grant', 'deny', 'reset']);
87+
expect([...PERMISSION_MODES]).toEqual(['full', 'limited']);
88+
});
89+
});
90+
91+
describe('settings usage and error strings', () => {
92+
test('help lists every settings form in its documented order', () => {
93+
expect(SETTINGS_USAGE_OVERRIDE.split(' | ')).toEqual(
94+
SETTINGS_FORMS.map((form) => `settings ${form}`),
95+
);
96+
});
97+
98+
test('the invalid-args message lists the same forms, with the last one as an alternative', () => {
99+
expect(SETTINGS_INVALID_ARGS_MESSAGE).toBe(
100+
`settings requires ${SETTINGS_FORMS.slice(0, -1).join(', ')}, or ${SETTINGS_FORMS.at(-1)}`,
101+
);
102+
});
103+
104+
test('the macOS permission form keeps the actions it serves and the names it accepts', () => {
105+
expect(SETTINGS_MACOS_PERMISSION_USAGE).toBe(
106+
'permission <grant|reset> <accessibility|screen-recording|input-monitoring>',
107+
);
108+
});
109+
110+
test('the macOS guidance names the permission form it supports', () => {
111+
expect(getUnsupportedMacOsSettingMessage('wifi')).toBe(
112+
'Unsupported macOS setting: wifi. macOS supports only settings appearance <light|dark|toggle> ' +
113+
'and settings permission <grant|reset> <accessibility|screen-recording|input-monitoring>. ' +
114+
'wifi|airplane|location|animations remain unsupported on macOS.',
115+
);
116+
});
117+
118+
test('only appearance and permission are supported macOS settings', () => {
119+
expect(isMacOsSettingSupported(' Permission ')).toBe(true);
120+
expect(isMacOsSettingSupported('appearance')).toBe(true);
121+
expect(isMacOsSettingSupported('wifi')).toBe(false);
122+
});
123+
});
124+
125+
describe('parsePermissionTarget', () => {
126+
test('accepts every mobile target under each normalization', () => {
127+
for (const target of MOBILE_TARGETS) {
128+
for (const normalize of NORMALIZATIONS) {
129+
expect(parsePermissionTarget(normalize(target))).toBe(target);
130+
}
131+
}
132+
});
133+
134+
test('refuses every name outside the mobile vocabulary, including the macOS-only ones', () => {
135+
for (const target of REJECTED_TARGETS) {
136+
expectInvalidArgs(
137+
() => parsePermissionTarget(target),
138+
`permission setting requires a target: ${MOBILE_TARGETS.join('|')}`,
139+
);
140+
}
141+
});
142+
});
143+
144+
describe('parsePermissionAction', () => {
145+
test('accepts each action under each normalization', () => {
146+
for (const action of ['grant', 'deny', 'reset']) {
147+
for (const normalize of NORMALIZATIONS) {
148+
expect(parsePermissionAction(normalize(action))).toBe(action);
149+
}
150+
}
151+
});
152+
153+
test('refuses an action outside the vocabulary with the accepted list', () => {
154+
for (const action of ['allow', 'revoke', '', ' ', 'deny-me']) {
155+
expectInvalidArgs(
156+
() => parsePermissionAction(action),
157+
`Invalid permission action: ${action}. Use grant|deny|reset.`,
158+
);
159+
}
160+
});
161+
});
162+
163+
// The shared declaration must not move either exported type: these pins are what the public
164+
// client and the platform owners compile against today.
165+
describe('permission vocabulary types', () => {
166+
test('the contract vocabulary stays the mobile subset', () => {
167+
expectTypeOf<PermissionTarget>().toEqualTypeOf<(typeof MOBILE_TARGETS)[number]>();
168+
expectTypeOf<PermissionAction>().toEqualTypeOf<'grant' | 'deny' | 'reset'>();
169+
});
170+
171+
test('the mobile vocabulary does not grow the macOS-only names', () => {
172+
expectTypeOf<'accessibility'>().not.toMatchTypeOf<PermissionTarget>();
173+
expectTypeOf<'screen-recording'>().not.toMatchTypeOf<PermissionTarget>();
174+
expectTypeOf<'input-monitoring'>().not.toMatchTypeOf<PermissionTarget>();
175+
expectTypeOf<'all'>().not.toMatchTypeOf<PermissionTarget>();
176+
});
177+
});

0 commit comments

Comments
 (0)