react-x11 2.6.0 → 2.7.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 +358 -20
- package/src/cocoa/bezels.js +51 -1
- package/src/cocoa/context2d.js +271 -25
- package/src/cocoa/dnd.js +347 -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 +190 -2
- package/src/cocoa/statusitem.js +112 -0
- package/src/cocoa/window.js +85 -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 +92 -3
- package/src/errors.js +6 -3
- package/src/filedialog.js +81 -16
- package/src/index.d.ts +17 -1
- package/src/index.js +17 -0
- package/src/launcher.js +170 -0
- package/src/launcherhooks.js +81 -0
- package/src/nodes.js +553 -35
- 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/screens.js +39 -4
- 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/screens.js
CHANGED
|
@@ -72,12 +72,17 @@
|
|
|
72
72
|
* Letting the WM have the last word costs a clamped-to-the-edge window one
|
|
73
73
|
* correction it would have made anyway.
|
|
74
74
|
*
|
|
75
|
-
* **A per-monitor work area.** `_NET_WORKAREA` is one rect for the
|
|
76
|
-
* virtual desktop. Deriving a real per-monitor one means reading
|
|
75
|
+
* **A per-monitor work area, on X11.** `_NET_WORKAREA` is one rect for the
|
|
76
|
+
* whole virtual desktop. Deriving a real per-monitor one means reading
|
|
77
77
|
* `_NET_WM_STRUT_PARTIAL` off every window on the screen and intersecting
|
|
78
78
|
* the reservations that fall on each head — a full window-tree walk, redone
|
|
79
79
|
* whenever any panel changes. `available` below is the per-axis
|
|
80
80
|
* approximation instead, and says so.
|
|
81
|
+
*
|
|
82
|
+
* A backend that *does* know each monitor's usable rect says so directly
|
|
83
|
+
* instead: a `visible` rect on the monitor record, which `usable()` prefers
|
|
84
|
+
* over the whole-desktop compromise. Cocoa's `NSScreen.visibleFrame` is
|
|
85
|
+
* exactly that, so the macOS backend never goes through the approximation.
|
|
81
86
|
*/
|
|
82
87
|
|
|
83
88
|
import { requireExtension } from './extensions.js';
|
|
@@ -206,9 +211,36 @@ function monitorAt(monitors, point) {
|
|
|
206
211
|
return best;
|
|
207
212
|
}
|
|
208
213
|
|
|
214
|
+
/** The overlap of two rects, or `null` where they do not touch. */
|
|
215
|
+
function intersect(a, b) {
|
|
216
|
+
const x0 = Math.max(a.x, b.x);
|
|
217
|
+
const y0 = Math.max(a.y, b.y);
|
|
218
|
+
const x1 = Math.min(a.x + a.width, b.x + b.width);
|
|
219
|
+
const y1 = Math.min(a.y + a.height, b.y + b.height);
|
|
220
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
221
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
222
|
+
}
|
|
223
|
+
|
|
209
224
|
/**
|
|
210
|
-
* The
|
|
211
|
-
*
|
|
225
|
+
* The usable part of one monitor.
|
|
226
|
+
*
|
|
227
|
+
* Two ways to get there, and the first one is the real answer:
|
|
228
|
+
*
|
|
229
|
+
* - A monitor record may carry its **own** usable rect as `visible`, in the
|
|
230
|
+
* same screen coordinates as the monitor itself. That is what Cocoa's
|
|
231
|
+
* `NSScreen.visibleFrame` is — per screen, minus that screen's menu bar
|
|
232
|
+
* and Dock — and it is taken as a rect, intersected with the monitor in
|
|
233
|
+
* case a backend reports one that overhangs.
|
|
234
|
+
* - Otherwise the monitor rect clamped **per axis** by `_NET_WORKAREA`,
|
|
235
|
+
* which is one rect for the whole virtual desktop and so cannot be
|
|
236
|
+
* positioned against a single head — see the note at the top of the file.
|
|
237
|
+
*
|
|
238
|
+
* The distinction matters the moment the monitors differ in size. A global
|
|
239
|
+
* work area no wider than the primary, applied as a width bound to a wider
|
|
240
|
+
* second display, moves that display's right edge inward by the difference
|
|
241
|
+
* and every anchored popup with it. On X11 the property spans the virtual
|
|
242
|
+
* desktop and the clamp is a no-op on the width, which is why the
|
|
243
|
+
* approximation held there and only there.
|
|
212
244
|
*
|
|
213
245
|
* Always **only** a rect. A monitor record carries a name, a primary flag and
|
|
214
246
|
* physical sizes as well, and spreading it here put all of that inside
|
|
@@ -221,6 +253,7 @@ function usable(monitor, work) {
|
|
|
221
253
|
width: monitor.width,
|
|
222
254
|
height: monitor.height,
|
|
223
255
|
};
|
|
256
|
+
if (monitor.visible) return intersect(rect, monitor.visible) ?? rect;
|
|
224
257
|
if (!work) return rect;
|
|
225
258
|
rect.width = Math.min(rect.width, work.width);
|
|
226
259
|
rect.height = Math.min(rect.height, work.height);
|
|
@@ -756,6 +789,8 @@ export function endScreens(app) {
|
|
|
756
789
|
* `monitors` entries may carry the RandR fields (`name`, `primary`,
|
|
757
790
|
* `widthMM`, `heightMM`, `refreshRate`, `rotation`) as well as the rect, so
|
|
758
791
|
* a test can state a named two-head desktop without a server that has RandR.
|
|
792
|
+
* An entry may also carry its own `visible` rect — the monitor's usable
|
|
793
|
+
* area, which takes precedence over the whole-desktop `workArea`.
|
|
759
794
|
*/
|
|
760
795
|
export function setScreensForTests(app, { monitors = null, workArea = null }) {
|
|
761
796
|
let session = sessions.get(app);
|
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
|
|