react-x11 2.6.1 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/package.json +10 -3
- package/src/activate.js +12 -0
- package/src/anchor.js +6 -0
- package/src/appearance.js +351 -28
- package/src/appearancehooks.js +5 -2
- package/src/application.js +41 -0
- package/src/cocoa/app.js +367 -3
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/dnd.js +358 -0
- package/src/cocoa/dock.js +39 -0
- package/src/cocoa/filepanels.js +155 -0
- package/src/cocoa/fonts.js +93 -2
- package/src/cocoa/globalmenu.js +41 -33
- package/src/cocoa/notifications.js +244 -0
- package/src/cocoa/permissions.js +74 -0
- package/src/cocoa/presenter.js +274 -33
- package/src/cocoa/promotion.js +708 -0
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +113 -4
- package/src/components/Button.js +20 -1
- package/src/components/Checkbox.js +17 -2
- package/src/components/Menu.js +108 -38
- package/src/components/Radio.js +17 -2
- package/src/components/Select.js +159 -27
- package/src/components/Switch.js +8 -1
- package/src/components/native.js +99 -0
- package/src/components/theme.js +37 -20
- package/src/desktopsettings.js +34 -2
- package/src/dnd.js +137 -11
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +29 -2
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +604 -37
- package/src/notificationhooks.js +56 -0
- package/src/notifications.js +558 -0
- package/src/palette.js +144 -8
- package/src/permissionhooks.js +89 -0
- package/src/permissions.js +196 -0
- package/src/style.d.ts +10 -4
- package/src/style.js +1 -0
- package/src/styles.js +161 -15
- package/src/textselection.js +1 -4
- package/src/trayhooks.js +90 -0
- package/src/types/appearance.d.ts +24 -0
- package/src/types/components.d.ts +10 -0
- package/src/types/elements.d.ts +14 -0
- package/src/types/events.d.ts +14 -0
- package/src/types/filedialog.d.ts +18 -7
- package/src/types/launcher.d.ts +43 -0
- package/src/types/notifications.d.ts +113 -0
- package/src/types/permissions.d.ts +100 -0
- package/src/types/style.d.ts +30 -2
- package/src/types/system.d.ts +5 -3
- package/src/types/tray.d.ts +54 -0
- package/src/windowid.js +23 -0
package/src/palette.js
CHANGED
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
//
|
|
10
10
|
// **The default follows the desktop.** A react-x11 app that says nothing
|
|
11
11
|
// about colour is dark on a dark desktop and light on a light one, the way a
|
|
12
|
-
// GTK or Qt app is
|
|
13
|
-
//
|
|
12
|
+
// GTK or Qt app is, and its accent is the desktop's where the desktop has
|
|
13
|
+
// one; `<ThemeProvider>` and `colorScheme` are how an app that wants
|
|
14
|
+
// otherwise says so. See docs/appearance.md.
|
|
14
15
|
|
|
15
16
|
import { appearanceSnapshot } from './appearance.js';
|
|
16
|
-
import { readableInk, stepBeyond } from './styles.js';
|
|
17
|
+
import { readableInk, stepBeyond, tint } from './styles.js';
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The language subtags written right-to-left — CLDR's set, by the language
|
|
@@ -171,6 +172,17 @@ export const DefaultTheme = {
|
|
|
171
172
|
focusRing: '#2980b9',
|
|
172
173
|
focusRingWidth: 2,
|
|
173
174
|
focusRingOffset: 1,
|
|
175
|
+
// The highlight behind selected text. A *tint* rather than an opaque
|
|
176
|
+
// fill, so the ink keeps its own contrast on both palettes; derived from
|
|
177
|
+
// `accent` for any palette that moves the accent and not this. A desktop
|
|
178
|
+
// that names one (macOS's Highlight colour) gives an opaque fill here.
|
|
179
|
+
selection: tint('#2980b9', 0.35),
|
|
180
|
+
// The insertion caret. `null` is the text's own colour, which is what a
|
|
181
|
+
// caret is unless the desktop says otherwise — macOS draws it in the
|
|
182
|
+
// Highlight colour's accent.
|
|
183
|
+
caret: null,
|
|
184
|
+
// Ink for a link. Not the accent: a link is blue everywhere, as a note is.
|
|
185
|
+
link: '#1c6ea4',
|
|
174
186
|
// shape
|
|
175
187
|
radius: 4,
|
|
176
188
|
radiusSmall: 3,
|
|
@@ -268,6 +280,9 @@ const TEXT_FROM = {
|
|
|
268
280
|
infoText: 'info',
|
|
269
281
|
};
|
|
270
282
|
|
|
283
|
+
// The selection tint follows the accent it is a tint of.
|
|
284
|
+
const TINT_FROM = { selection: ['accent', 0.35] };
|
|
285
|
+
|
|
271
286
|
// And the same for the floating-surface radii, which are a function of the
|
|
272
287
|
// text they wrap: a palette that sets `fontSize` and nothing else still gets
|
|
273
288
|
// menus in proportion to it.
|
|
@@ -323,6 +338,11 @@ export function resolveTheme(value, base = DefaultTheme) {
|
|
|
323
338
|
continue;
|
|
324
339
|
merged[token] = readableInk(merged[fill], [merged.text, merged.background]);
|
|
325
340
|
}
|
|
341
|
+
for (const [token, [fill, alpha]] of Object.entries(TINT_FROM)) {
|
|
342
|
+
if (named[token] == null && named[fill] != null) {
|
|
343
|
+
merged[token] = tint(merged[fill], alpha);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
326
346
|
if (named.fontSize != null) {
|
|
327
347
|
for (const [token, from] of Object.entries(RADIUS_FROM_FONT)) {
|
|
328
348
|
if (named[token] == null) merged[token] = from(merged.fontSize);
|
|
@@ -377,18 +397,134 @@ export const DarkTheme = resolveTheme({
|
|
|
377
397
|
hoverText: 'white',
|
|
378
398
|
borderFocus: '#5aa4e6',
|
|
379
399
|
focusRing: '#5aa4e6',
|
|
400
|
+
link: '#5aa4e6',
|
|
380
401
|
});
|
|
381
402
|
|
|
403
|
+
/**
|
|
404
|
+
* `#rrggbb` moved `amount` of the way toward `toward`. The one colour
|
|
405
|
+
* operation the desktop palette needs, on the one shape the ladder
|
|
406
|
+
* guarantees an accent has (`sanitize`, `accentFromPortal`).
|
|
407
|
+
*/
|
|
408
|
+
function mixHex(hex, toward, amount) {
|
|
409
|
+
const channel = (c, i) => parseInt(c.slice(1 + 2 * i, 3 + 2 * i), 16);
|
|
410
|
+
return (
|
|
411
|
+
'#' +
|
|
412
|
+
[0, 1, 2]
|
|
413
|
+
.map((i) => {
|
|
414
|
+
const a = channel(hex, i);
|
|
415
|
+
const b = channel(toward, i);
|
|
416
|
+
return Math.round(a + (b - a) * amount)
|
|
417
|
+
.toString(16)
|
|
418
|
+
.padStart(2, '0');
|
|
419
|
+
})
|
|
420
|
+
.join('')
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The built-in palette with the desktop's accent on it.
|
|
426
|
+
*
|
|
427
|
+
* Only the accent family moves — `accent`, its hover, the menu-row highlight
|
|
428
|
+
* (the desktop's own selection shade where it names one: on macOS that is a
|
|
429
|
+
* darker cut of the accent, and a row lit in the raw accent beside a native
|
|
430
|
+
* menu reads as too bright; the accent itself otherwise, as in both built-in
|
|
431
|
+
* palettes), and the focus ring —
|
|
432
|
+
* and the steps are taken the way each palette takes them: the hover sinks
|
|
433
|
+
* into a light ground and lifts off a dark one, and `resolveTheme` derives
|
|
434
|
+
* the press from the pair. `info` stays its own blue, as it does under a
|
|
435
|
+
* theme with a green accent: a note looks like a note everywhere.
|
|
436
|
+
*
|
|
437
|
+
* The ink is the desktop's where the desktop named one — AppKit writes white
|
|
438
|
+
* on every accent a Mac offers, including the ones a contrast ratio would
|
|
439
|
+
* put dark letters on, and the point of following the desktop is to look
|
|
440
|
+
* like the controls beside ours. Where the source has no ink (the portal)
|
|
441
|
+
* `resolveTheme` picks the legible one, as it does for any theme that names
|
|
442
|
+
* a fill and stops there.
|
|
443
|
+
*/
|
|
444
|
+
function withDesktopAccent(scheme, accent, ink, selection) {
|
|
445
|
+
const dark = scheme.scheme === 'dark';
|
|
446
|
+
const accentHover = dark
|
|
447
|
+
? mixHex(accent, '#ffffff', 0.2)
|
|
448
|
+
: mixHex(accent, '#000000', 0.22);
|
|
449
|
+
const focus = dark ? accentHover : accent;
|
|
450
|
+
const value = {
|
|
451
|
+
accent,
|
|
452
|
+
accentHover,
|
|
453
|
+
hoverBackground: selection ?? accent,
|
|
454
|
+
borderFocus: focus,
|
|
455
|
+
focusRing: focus,
|
|
456
|
+
};
|
|
457
|
+
if (ink) {
|
|
458
|
+
value.accentText = ink;
|
|
459
|
+
value.hoverText = ink;
|
|
460
|
+
}
|
|
461
|
+
return resolveTheme(value, scheme);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// A desktop that names its whole palette (macOS, through the semantic
|
|
465
|
+
// colours AppKit paints with) is merged over the scheme's built-in one the
|
|
466
|
+
// way a `<ThemeProvider>` value is: every colour it named, and the shape
|
|
467
|
+
// tokens, the derived inks and the pressed steps from `resolveTheme`. Keyed
|
|
468
|
+
// on the palette object itself — the store replaces it, never mutates it.
|
|
469
|
+
const desktopPalettesByObject = new WeakMap();
|
|
470
|
+
function withDesktopPalette(scheme, palette) {
|
|
471
|
+
let byScheme = desktopPalettesByObject.get(palette);
|
|
472
|
+
if (!byScheme) {
|
|
473
|
+
byScheme = new Map();
|
|
474
|
+
desktopPalettesByObject.set(palette, byScheme);
|
|
475
|
+
}
|
|
476
|
+
let resolved = byScheme.get(scheme);
|
|
477
|
+
if (!resolved) {
|
|
478
|
+
resolved = resolveTheme(palette, scheme);
|
|
479
|
+
byScheme.set(scheme, resolved);
|
|
480
|
+
}
|
|
481
|
+
return resolved;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// One palette per desktop answer, so the unprovided palette keeps the
|
|
485
|
+
// identity `useTheme()` and the `$token` resolution cache both count on: a
|
|
486
|
+
// fresh object per read would re-resolve every token in the tree on every
|
|
487
|
+
// paint. Bounded because a desktop's accent changes a handful of times in the
|
|
488
|
+
// life of a process, never per frame.
|
|
489
|
+
const desktopPalettes = new Map();
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The built-in palette for a desktop that looks like `appearance` — the
|
|
493
|
+
* scheme's own, with the desktop's accent on it where the desktop named one.
|
|
494
|
+
*
|
|
495
|
+
* **The default follows the accent as well as the scheme.** An app that says
|
|
496
|
+
* nothing about colour is asking to look like it belongs on this desktop, and
|
|
497
|
+
* on the one that reports an accent every native control beside it is already
|
|
498
|
+
* that colour — the Cocoa backend draws its bezels with AppKit, so the app's
|
|
499
|
+
* own checkbox is orange while its `<Tabs>` indicator stayed blue. An app
|
|
500
|
+
* with a brand names `accent` in its `<ThemeProvider>` and keeps it; a pinned
|
|
501
|
+
* `colorScheme` follows nothing, the accent included.
|
|
502
|
+
*
|
|
503
|
+
* `'no-preference'` means *use your own default*, which is the light one.
|
|
504
|
+
*/
|
|
505
|
+
export function paletteFor(appearance) {
|
|
506
|
+
const scheme = appearance.colorScheme === 'dark' ? DarkTheme : DefaultTheme;
|
|
507
|
+
if (appearance.palette) return withDesktopPalette(scheme, appearance.palette);
|
|
508
|
+
const { accent, accentText, selection } = appearance;
|
|
509
|
+
if (!accent) return scheme;
|
|
510
|
+
const key = `${scheme.scheme} ${accent} ${accentText ?? ''} ${selection ?? ''}`;
|
|
511
|
+
let palette = desktopPalettes.get(key);
|
|
512
|
+
if (!palette) {
|
|
513
|
+
if (desktopPalettes.size >= 8) desktopPalettes.clear();
|
|
514
|
+
palette = withDesktopAccent(scheme, accent, accentText, selection);
|
|
515
|
+
desktopPalettes.set(key, palette);
|
|
516
|
+
}
|
|
517
|
+
return palette;
|
|
518
|
+
}
|
|
519
|
+
|
|
382
520
|
/**
|
|
383
521
|
* The palette in force where nothing has been said — which is to say, the
|
|
384
522
|
* desktop's.
|
|
385
523
|
*
|
|
386
524
|
* Read synchronously and cheaply: `appearanceSnapshot()` is a frozen object
|
|
387
|
-
* seeded from disk before the first render, so this is a property lookup
|
|
388
|
-
* a
|
|
389
|
-
*
|
|
390
|
-
* `'no-preference'` means *use your own default*, which is the light one.
|
|
525
|
+
* seeded from disk before the first render, so this is a property lookup, a
|
|
526
|
+
* comparison and a map hit, and it is called from the paint path.
|
|
391
527
|
*/
|
|
392
528
|
export function baseTheme() {
|
|
393
|
-
return appearanceSnapshot()
|
|
529
|
+
return paletteFor(appearanceSnapshot());
|
|
394
530
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// `usePermission()` — a permission as a component sees it: the status as
|
|
2
|
+
// render state, a request that updates it, and whether this backend can
|
|
3
|
+
// answer at all.
|
|
4
|
+
//
|
|
5
|
+
// The bare functions in `permissions.js` are complete; what a component
|
|
6
|
+
// wants on top is binding — the tree's connection, the status re-read after
|
|
7
|
+
// a request — not another rung. There is no change notification behind a
|
|
8
|
+
// status on any platform, so the status is what was last read: on mount,
|
|
9
|
+
// and after each `request()`. An app that wants it fresher re-reads with
|
|
10
|
+
// `refresh()` on its own cue (a window coming back to the front, say).
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
import { useAppOrNull } from './appcontext.js';
|
|
15
|
+
import {
|
|
16
|
+
openPrivacySettings,
|
|
17
|
+
permissionBackend,
|
|
18
|
+
permissionStatus,
|
|
19
|
+
requestPermission,
|
|
20
|
+
} from './permissions.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ```jsx
|
|
24
|
+
* const camera = usePermission('camera');
|
|
25
|
+
*
|
|
26
|
+
* {camera.available && camera.status !== 'granted' && (
|
|
27
|
+
* <Button
|
|
28
|
+
* label={camera.status === 'prompt' ? 'Allow camera' : 'Open Settings'}
|
|
29
|
+
* onPress={() =>
|
|
30
|
+
* camera.status === 'prompt' ? camera.request() : camera.openSettings()
|
|
31
|
+
* }
|
|
32
|
+
* />
|
|
33
|
+
* )}
|
|
34
|
+
* ```
|
|
35
|
+
*
|
|
36
|
+
* `status` starts `'unknown'` and settles once read. `request()` resolves
|
|
37
|
+
* with the status after the user answered and updates `status` with it;
|
|
38
|
+
* while one is in flight a second call returns the same promise.
|
|
39
|
+
* `available` is whether this backend has an authorization API — false on
|
|
40
|
+
* X11 today — and the honest branch to keep a feature behind.
|
|
41
|
+
*/
|
|
42
|
+
export function usePermission(kind, options = {}) {
|
|
43
|
+
const app = useAppOrNull();
|
|
44
|
+
const target = options.target;
|
|
45
|
+
const available = permissionBackend({ app }) !== null;
|
|
46
|
+
const [status, setStatus] = useState('unknown');
|
|
47
|
+
const inflight = useRef(null);
|
|
48
|
+
|
|
49
|
+
const refresh = useCallback(async () => {
|
|
50
|
+
const next = await permissionStatus(kind, { app, target });
|
|
51
|
+
setStatus(next);
|
|
52
|
+
return next;
|
|
53
|
+
}, [kind, app, target]);
|
|
54
|
+
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let alive = true;
|
|
57
|
+
permissionStatus(kind, { app, target }).then(
|
|
58
|
+
(next) => alive && setStatus(next),
|
|
59
|
+
() => alive && setStatus('unknown'),
|
|
60
|
+
);
|
|
61
|
+
return () => {
|
|
62
|
+
alive = false;
|
|
63
|
+
};
|
|
64
|
+
}, [kind, app, target]);
|
|
65
|
+
|
|
66
|
+
const request = useCallback(() => {
|
|
67
|
+
if (inflight.current) return inflight.current;
|
|
68
|
+
const run = requestPermission(kind, { app, target })
|
|
69
|
+
.then((next) => {
|
|
70
|
+
setStatus(next);
|
|
71
|
+
return next;
|
|
72
|
+
})
|
|
73
|
+
.finally(() => {
|
|
74
|
+
inflight.current = null;
|
|
75
|
+
});
|
|
76
|
+
inflight.current = run;
|
|
77
|
+
return run;
|
|
78
|
+
}, [kind, app, target]);
|
|
79
|
+
|
|
80
|
+
const openSettings = useCallback(
|
|
81
|
+
() => openPrivacySettings(kind, { app }),
|
|
82
|
+
[kind, app],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
return useMemo(
|
|
86
|
+
() => ({ status, available, request, refresh, openSettings }),
|
|
87
|
+
[status, available, request, refresh, openSettings],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Permissions: may this app use the camera, the microphone, the screen, the
|
|
2
|
+
// accessibility APIs — and how does it ask?
|
|
3
|
+
//
|
|
4
|
+
// The file dialog's ladder again (docs/filedialog.md), with one rung that
|
|
5
|
+
// answers and one that only points:
|
|
6
|
+
//
|
|
7
|
+
// 1. **the platform's own authorization** — the cocoa backend, where the
|
|
8
|
+
// bridge reads macOS's privacy (TCC) status per kind and raises the
|
|
9
|
+
// system prompt where a framework offers one (src/cocoa/permissions.js).
|
|
10
|
+
// Found by the app the tree renders through carrying `permissions`,
|
|
11
|
+
// never by naming a backend here.
|
|
12
|
+
// 2. **the Settings pane** — `open x-apple.systempreferences:…` on a Mac
|
|
13
|
+
// with no bridge (the X11 backend under XQuartz). It cannot read a
|
|
14
|
+
// status or raise a prompt; it can put the user in front of the switch,
|
|
15
|
+
// which is what `openPrivacySettings` promises and no more.
|
|
16
|
+
//
|
|
17
|
+
// On Linux nothing answers yet. The freedesktop counterparts are the
|
|
18
|
+
// per-device portals (`org.freedesktop.portal.Camera`, `.Location`), which
|
|
19
|
+
// need no bridge and are react-x11#466's remaining half; until then a
|
|
20
|
+
// status query says `'unknown'` and a request rejects with a **typed**
|
|
21
|
+
// error, the `NoFileDialogError` rule — a signal to keep the feature behind
|
|
22
|
+
// the answer, not a crash.
|
|
23
|
+
//
|
|
24
|
+
// ## Two things the vocabulary decides
|
|
25
|
+
//
|
|
26
|
+
// - A status is one of five words. `'granted'`, `'denied'` and
|
|
27
|
+
// `'restricted'` (MDM or parental controls: the user cannot grant it) are
|
|
28
|
+
// the platform's; `'prompt'` is "not decided yet — a request would ask";
|
|
29
|
+
// `'unknown'` is "nothing here can say", which is a fact about the machine
|
|
30
|
+
// rather than about the permission, and the reason a query never throws.
|
|
31
|
+
// - A request answers with the status **after** the user has, never with a
|
|
32
|
+
// bare boolean, because `'restricted'` and `'denied'` want different UI —
|
|
33
|
+
// one is a Settings switch the user can flip, the other is not.
|
|
34
|
+
|
|
35
|
+
import { liveApps } from './trace-registry.js';
|
|
36
|
+
|
|
37
|
+
/** The kinds a status can be asked for. `automation` wants `{ target }`. */
|
|
38
|
+
export const PERMISSION_KINDS = Object.freeze([
|
|
39
|
+
'camera',
|
|
40
|
+
'microphone',
|
|
41
|
+
'screen-recording',
|
|
42
|
+
'accessibility',
|
|
43
|
+
'input-monitoring',
|
|
44
|
+
'automation',
|
|
45
|
+
'location',
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/** The Settings panes, the kinds above plus the two that have no API at all
|
|
49
|
+
* because reading the folder *is* the prompt. */
|
|
50
|
+
const SETTINGS_PANES = Object.freeze({
|
|
51
|
+
camera: 'Privacy_Camera',
|
|
52
|
+
microphone: 'Privacy_Microphone',
|
|
53
|
+
'screen-recording': 'Privacy_ScreenCapture',
|
|
54
|
+
accessibility: 'Privacy_Accessibility',
|
|
55
|
+
'input-monitoring': 'Privacy_ListenEvent',
|
|
56
|
+
automation: 'Privacy_Automation',
|
|
57
|
+
location: 'Privacy_LocationServices',
|
|
58
|
+
'files-and-folders': 'Privacy_FilesAndFolders',
|
|
59
|
+
'full-disk-access': 'Privacy_AllFiles',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Nothing on this machine can ask. A **typed** rejection, so a caller keeps
|
|
64
|
+
* the feature behind it rather than crashing — `usePermission().available`
|
|
65
|
+
* is that branch as render state.
|
|
66
|
+
*/
|
|
67
|
+
export class NoPermissionServiceError extends Error {
|
|
68
|
+
constructor(kind, cause) {
|
|
69
|
+
super(
|
|
70
|
+
`react-x11: nothing here can ask for the ${kind} permission — this ` +
|
|
71
|
+
'backend has no authorization API (the cocoa backend has; the ' +
|
|
72
|
+
'freedesktop device portals are not implemented yet). Read ' +
|
|
73
|
+
"permissionStatus() first: 'unknown' is this machine's answer.",
|
|
74
|
+
{ cause },
|
|
75
|
+
);
|
|
76
|
+
this.name = 'NoPermissionServiceError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function checkKind(kind) {
|
|
81
|
+
if (!PERMISSION_KINDS.includes(kind)) {
|
|
82
|
+
throw new TypeError(
|
|
83
|
+
`react-x11: ${JSON.stringify(kind)} is not a permission kind. ` +
|
|
84
|
+
`Expected one of ${PERMISSION_KINDS.join(', ')}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The deep link into System Settings for a pane, or the Privacy pane. */
|
|
90
|
+
export function privacySettingsUrl(kind) {
|
|
91
|
+
const pane = kind == null ? null : SETTINGS_PANES[kind];
|
|
92
|
+
if (kind != null && !pane) {
|
|
93
|
+
throw new TypeError(
|
|
94
|
+
`react-x11: ${JSON.stringify(kind)} is not a privacy pane. Expected ` +
|
|
95
|
+
`one of ${Object.keys(SETTINGS_PANES).join(', ')}.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
'x-apple.systempreferences:com.apple.preference.security' +
|
|
100
|
+
(pane ? `?${pane}` : '?Privacy')
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The app whose authorization API to ask when the caller did not say. */
|
|
105
|
+
function soleApp() {
|
|
106
|
+
const apps = liveApps();
|
|
107
|
+
if (apps.length <= 1) return apps[0] ?? null;
|
|
108
|
+
const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
|
|
109
|
+
return showing.length === 1 ? showing[0] : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function serviceFor({ app } = {}) {
|
|
113
|
+
const target = app ?? soleApp();
|
|
114
|
+
return target?.permissions ?? null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Which rung answers here, without asking anything: `'cocoa'` where the
|
|
119
|
+
* tree's backend has an authorization API, `null` where a status would be
|
|
120
|
+
* `'unknown'` and a request would reject. Synchronous — a capability, not
|
|
121
|
+
* a probe.
|
|
122
|
+
*
|
|
123
|
+
* @returns {'cocoa' | null}
|
|
124
|
+
*/
|
|
125
|
+
export function permissionBackend(options = {}) {
|
|
126
|
+
return serviceFor(options) ? 'cocoa' : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Whether this app may use `kind`, without prompting.
|
|
131
|
+
*
|
|
132
|
+
* ```js
|
|
133
|
+
* const status = await permissionStatus('camera');
|
|
134
|
+
* // 'granted' | 'denied' | 'restricted' | 'prompt' | 'unknown'
|
|
135
|
+
* ```
|
|
136
|
+
*
|
|
137
|
+
* Never rejects for anything about the machine: `'unknown'` is the answer
|
|
138
|
+
* where nothing can say. `automation` asks about one target app, by bundle
|
|
139
|
+
* id, and only a running one has an answer (`{ target }`).
|
|
140
|
+
*/
|
|
141
|
+
export async function permissionStatus(kind, options = {}) {
|
|
142
|
+
checkKind(kind);
|
|
143
|
+
const service = serviceFor(options);
|
|
144
|
+
if (!service) return 'unknown';
|
|
145
|
+
return service.status(kind, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Ask for `kind`, raising the system's prompt where the platform has one,
|
|
150
|
+
* and resolve with the status once the user has answered.
|
|
151
|
+
*
|
|
152
|
+
* ```js
|
|
153
|
+
* const status = await requestPermission('microphone');
|
|
154
|
+
* if (status === 'granted') startRecording();
|
|
155
|
+
* else if (status === 'denied') await openPrivacySettings('microphone');
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* A status the user has already decided answers at once, without a prompt.
|
|
159
|
+
* Where the platform's only "prompt" is a dialog that sends the user to
|
|
160
|
+
* Settings (screen recording, accessibility) the request resolves as soon
|
|
161
|
+
* as that dialog is up, with the status as it stands — `'denied'` until the
|
|
162
|
+
* switch is flipped, and for screen recording a restart after it.
|
|
163
|
+
*
|
|
164
|
+
* Rejects with {@link NoPermissionServiceError} where nothing can ask —
|
|
165
|
+
* which `permissionStatus` foretells with `'unknown'`.
|
|
166
|
+
*/
|
|
167
|
+
export async function requestPermission(kind, options = {}) {
|
|
168
|
+
checkKind(kind);
|
|
169
|
+
const service = serviceFor(options);
|
|
170
|
+
if (!service) throw new NoPermissionServiceError(kind);
|
|
171
|
+
return service.request(kind, options);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Put the user in front of the switch: System Settings › Privacy & Security
|
|
176
|
+
* › `kind`, or the Privacy pane itself with no kind. The two panes with no
|
|
177
|
+
* API — `files-and-folders`, `full-disk-access` — are reachable here too.
|
|
178
|
+
*
|
|
179
|
+
* Through the bridge where there is one, and by `open` on any Mac
|
|
180
|
+
* otherwise, since a deep link needs no framework. Resolves to whether
|
|
181
|
+
* anything opened: `false` off macOS, where there is no such pane.
|
|
182
|
+
*/
|
|
183
|
+
export async function openPrivacySettings(kind, options = {}) {
|
|
184
|
+
const url = privacySettingsUrl(kind);
|
|
185
|
+
const service = serviceFor(options);
|
|
186
|
+
if (service) return service.openSettings(kind);
|
|
187
|
+
if (process.platform !== 'darwin') return false;
|
|
188
|
+
const { execFile } = await import('node:child_process');
|
|
189
|
+
return new Promise((resolve) => {
|
|
190
|
+
try {
|
|
191
|
+
execFile('open', [url], (error) => resolve(!error));
|
|
192
|
+
} catch {
|
|
193
|
+
resolve(false);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
package/src/style.d.ts
CHANGED
|
@@ -58,21 +58,27 @@ export function styleHasSizeQueries(style: StyleProperties): boolean;
|
|
|
58
58
|
/** Does the style carry a `'@supports …'` block? Those are re-resolved when
|
|
59
59
|
* the server's answer changes, not when the window is laid out. */
|
|
60
60
|
export function styleHasSupportsQueries(style: StyleProperties): boolean;
|
|
61
|
+
/** Does the style carry a `'@container …'` block? Those are re-resolved after
|
|
62
|
+
* a layout pass moved the container they ask about. */
|
|
63
|
+
export function styleHasContainerQueries(style: StyleProperties): boolean;
|
|
61
64
|
/** `resolveQueries` with only the size half — the shape this had before
|
|
62
65
|
* capability blocks existed. */
|
|
63
66
|
export function resolveSizeQueries(
|
|
64
67
|
style: StyleProperties,
|
|
65
68
|
size: { width: number; height: number },
|
|
66
69
|
): StyleProperties;
|
|
67
|
-
/** Merge every matching `@` block — size and capability alike —
|
|
68
|
-
* declaration order. `supports` maps feature name to whether the window
|
|
69
|
-
* actually do it;
|
|
70
|
-
*
|
|
70
|
+
/** Merge every matching `@` block — size, container and capability alike —
|
|
71
|
+
* in declaration order. `supports` maps feature name to whether the window
|
|
72
|
+
* can actually do it; `containers` maps a container name (`''` for the
|
|
73
|
+
* unnamed query) to that container's size in the node's logical pixels. A
|
|
74
|
+
* missing map, or a name it does not hold, matches nothing, which is the
|
|
75
|
+
* safe way round. */
|
|
71
76
|
export function resolveQueries(
|
|
72
77
|
style: StyleProperties,
|
|
73
78
|
context?: {
|
|
74
79
|
size?: { width: number; height: number } | null;
|
|
75
80
|
supports?: Record<string, boolean> | null;
|
|
81
|
+
containers?: Record<string, { width: number; height: number }> | null;
|
|
76
82
|
},
|
|
77
83
|
): StyleProperties;
|
|
78
84
|
|