react-x11 2.15.2 → 2.16.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.
Files changed (64) hide show
  1. package/README.md +37 -0
  2. package/package.json +4 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/anchor.js +60 -18
  5. package/src/application.js +25 -1
  6. package/src/capabilities.js +349 -0
  7. package/src/cocoa/app.js +28 -9
  8. package/src/cocoa/context2d.js +139 -6
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/dbusmenuexport.js +243 -0
  22. package/src/desktopcapabilityhooks.js +160 -0
  23. package/src/filedialoghooks.js +3 -5
  24. package/src/frame/childmain.js +8 -20
  25. package/src/frame/env.js +2 -10
  26. package/src/globalmenu.js +3 -205
  27. package/src/icontheme.js +240 -0
  28. package/src/imagesource.js +98 -3
  29. package/src/index.d.ts +1 -0
  30. package/src/index.js +11 -2
  31. package/src/launcher.js +235 -32
  32. package/src/launcherhooks.js +47 -28
  33. package/src/node.d.ts +7 -0
  34. package/src/nodes/animation.js +17 -47
  35. package/src/nodes/cascade.js +17 -2
  36. package/src/nodes/image.js +65 -2
  37. package/src/nodes/kinds.js +12 -0
  38. package/src/nodes/layout.js +5 -1
  39. package/src/nodes/node.js +17 -3
  40. package/src/nodes/paint.js +117 -0
  41. package/src/nodes/scope.js +259 -0
  42. package/src/nodes/scrollable.js +53 -6
  43. package/src/nodes/text.js +2 -0
  44. package/src/nodes/textarea.js +1 -1
  45. package/src/nodes/textinput.js +1 -1
  46. package/src/nodes/window/anchoring.js +45 -18
  47. package/src/nodes/window/flush.js +6 -5
  48. package/src/nodes/window/popup.js +10 -0
  49. package/src/nodes/window/size.js +40 -2
  50. package/src/nodes/window/window.js +41 -14
  51. package/src/registry.js +2 -1
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +752 -0
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +193 -29
  58. package/src/types/capabilities.d.ts +139 -0
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/launcher.d.ts +50 -4
  62. package/src/types/style.d.ts +57 -0
  63. package/src/types/system.d.ts +104 -0
  64. package/src/types/tray.d.ts +64 -6
@@ -0,0 +1,160 @@
1
+ // `useDesktopCapability()` — what this desktop can do, as render state.
2
+ //
3
+ // The vocabulary and the probes are `capabilities.js`; this is the part that
4
+ // makes them safe to branch on inside a component. Three things it has to get
5
+ // right, and each is a bug an app would otherwise hit:
6
+ //
7
+ // 1. **The first frame has no answer.** Probing takes a bus round trip, so
8
+ // the hook returns `NO_CAPABILITY` — available false, empty features —
9
+ // until one arrives. An app therefore renders its fallback first and
10
+ // upgrades, which is the right way round: the opposite order flashes a
11
+ // feature that turns out not to exist.
12
+ //
13
+ // 2. **The answer changes.** A panel restarts, an AppIndicator extension is
14
+ // toggled, a notification daemon is installed. The hook follows
15
+ // `NameOwnerChanged` for the whole session rather than sampling once, for
16
+ // the same reason `globalmenu.js` does: a cached "no" outlives the fix.
17
+ //
18
+ // 3. **The object identity must be stable.** A probe that returned a fresh
19
+ // object every time would re-render every consumer on every bus event,
20
+ // and a `features` object in a dependency array would never compare
21
+ // equal. Results are frozen and replaced only when they differ by value.
22
+
23
+ import { useEffect, useState } from 'react';
24
+
25
+ import { useAppOrNull } from './appcontext.js';
26
+ import { sessionBus } from './bus.js';
27
+ import {
28
+ NO_CAPABILITY,
29
+ capabilityNow,
30
+ desktopCapability,
31
+ } from './capabilities.js';
32
+
33
+ /** No answer yet: nothing available, and not settled. */
34
+ const PENDING = Object.freeze({ ...NO_CAPABILITY, settled: false });
35
+
36
+ /** A probe's answer, as the hook hands it out. */
37
+ const settledState = (result) => Object.freeze({ ...result, settled: true });
38
+
39
+ /** Value equality over the two levels a capability result has. */
40
+ function same(a, b) {
41
+ if (a === b) return true;
42
+ if (!a || !b) return false;
43
+ if (a.available !== b.available || a.backend !== b.backend) return false;
44
+ if (a.reason !== b.reason || a.settled !== b.settled) return false;
45
+ const ka = Object.keys(a.features);
46
+ const kb = Object.keys(b.features);
47
+ if (ka.length !== kb.length) return false;
48
+ return ka.every((k) => a.features[k] === b.features[k]);
49
+ }
50
+
51
+ /**
52
+ * What this desktop can do for one feature, as a value a component branches
53
+ * on.
54
+ *
55
+ * ```jsx
56
+ * const notifications = useDesktopCapability('notifications');
57
+ *
58
+ * // The portable question is about the feature, never about the platform.
59
+ * if (notifications.features.actions) {
60
+ * return <ReplyFromBanner />;
61
+ * }
62
+ * return <OpenAppToReply available={notifications.available} />;
63
+ * ```
64
+ *
65
+ * `{ available, backend, features }`, starting at
66
+ * {@link NO_CAPABILITY} and settling a tick later — see the header for why
67
+ * that order is deliberate. It re-probes whenever a name appears or vanishes
68
+ * on the session bus, so a panel that starts after the app does is picked up.
69
+ *
70
+ * Capability names: `'notifications'`, `'tray'`, `'launcher'`.
71
+ */
72
+ export function useDesktopCapability(name) {
73
+ // The app this component is rendered into, when there is one: a process
74
+ // with several connections has several answers, and the one that matters
75
+ // here is this tree's.
76
+ const app = useAppOrNull() ?? undefined;
77
+ // Settled from the first frame where the answer needed no asking — the
78
+ // Cocoa app's tray and Dock tile — and pending everywhere else until the
79
+ // first probe answers, so an app can hold back its fallback instead of
80
+ // flashing it.
81
+ const [state, setState] = useState(() => {
82
+ const now = capabilityNow(name, { app });
83
+ return now ? settledState(now) : PENDING;
84
+ });
85
+
86
+ useEffect(() => {
87
+ let cancelled = false;
88
+ let subscription = null;
89
+ let ref = null;
90
+ let onChanged = null;
91
+
92
+ const probe = () => {
93
+ desktopCapability(name, { app })
94
+ .then((next) => {
95
+ if (cancelled) return;
96
+ // Replaced only when it differs by value: see the header.
97
+ const settled = settledState(next);
98
+ setState((prev) => (same(prev, settled) ? prev : settled));
99
+ })
100
+ .catch(() => {});
101
+ };
102
+
103
+ probe();
104
+
105
+ // Follow the session for anything appearing or going away. Deliberately
106
+ // *not* narrowed by `arg0`: one capability can depend on several names
107
+ // (the tray watcher, the notification daemon, a launcher), and the set is
108
+ // a detail of `capabilities.js` rather than of this hook. The handler is
109
+ // a re-probe, so the cost of a wide match is a bus round trip on an event
110
+ // that is rare in a settled session.
111
+ void (async () => {
112
+ ref = await sessionBus();
113
+ if (!ref || cancelled) {
114
+ await ref?.release();
115
+ ref = null;
116
+ return;
117
+ }
118
+ try {
119
+ subscription = await ref.bus.watch(
120
+ "type='signal',sender='org.freedesktop.DBus'," +
121
+ "interface='org.freedesktop.DBus',member='NameOwnerChanged'",
122
+ );
123
+ } catch {
124
+ return;
125
+ }
126
+ // The mount/unmount race `AddMatch` always has — see
127
+ // `GlobalMenuExport.watchRegistrar` for the long version.
128
+ if (cancelled) {
129
+ await subscription.remove().catch(() => {});
130
+ subscription = null;
131
+ return;
132
+ }
133
+ const key = ref.bus.mangle(
134
+ '/org/freedesktop/DBus',
135
+ 'org.freedesktop.DBus',
136
+ 'NameOwnerChanged',
137
+ );
138
+ onChanged = () => probe();
139
+ ref.bus.signals.on(key, onChanged);
140
+ })();
141
+
142
+ return () => {
143
+ cancelled = true;
144
+ void (async () => {
145
+ if (ref && onChanged) {
146
+ const key = ref.bus.mangle(
147
+ '/org/freedesktop/DBus',
148
+ 'org.freedesktop.DBus',
149
+ 'NameOwnerChanged',
150
+ );
151
+ ref.bus.signals.removeListener(key, onChanged);
152
+ }
153
+ await subscription?.remove().catch(() => {});
154
+ await ref?.release();
155
+ })();
156
+ };
157
+ }, [name, app]);
158
+
159
+ return state;
160
+ }
@@ -40,11 +40,9 @@ async function showBuiltin(app, theme, props) {
40
40
  const root = await createRoot({ app });
41
41
  try {
42
42
  return await new Promise((resolve) => {
43
- // `<FileDialog>` renders a `<window>`, and a window may only be a root
44
- // child or nested in another window — so the palette travels as a prop
45
- // and the dialog installs the provider *inside* its own window. Wrapping
46
- // it here in `<ThemeProvider>` would put a `<box>` between the root and
47
- // the window, which throws.
43
+ // The dialog is on a root of its own, which no provider in the app's
44
+ // tree reaches — so the palette the caller resolved travels as a prop,
45
+ // and the dialog puts it on its window and in a provider inside it.
48
46
  root.render(
49
47
  React.createElement(FileDialog, { ...props, theme, onDone: resolve }),
50
48
  );
@@ -53,10 +53,7 @@ const h = React.createElement;
53
53
  /** Recreate the bridged providers around the pane's window, outermost
54
54
  * first — the parent's own nesting order, which the env Map records and
55
55
  * structured clone preserves. A key the pane never registered wraps
56
- * nothing: no module in this process reads it. Providers registered
57
- * `innermost` (the theme) wrap directly around the window, inside the
58
- * rest — see registerFrameProvider (src/frame/env.js) for why the
59
- * adjacency matters. */
56
+ * nothing: no module in this process reads it. */
60
57
  function wrapEnv(env, inner) {
61
58
  const wrap = (tree, [key, value]) => {
62
59
  const registered = registeredFrameContext(key);
@@ -69,16 +66,8 @@ function wrapEnv(env, inner) {
69
66
  tree,
70
67
  );
71
68
  };
72
- const entries = [...env];
73
- const isInnermost = ([key]) =>
74
- registeredFrameContext(key)?.innermost === true;
75
69
  let tree = inner;
76
- for (const entry of entries.filter(isInnermost).reverse()) {
77
- tree = wrap(tree, entry);
78
- }
79
- for (const entry of entries.filter((e) => !isInnermost(e)).reverse()) {
80
- tree = wrap(tree, entry);
81
- }
70
+ for (const entry of [...env].reverse()) tree = wrap(tree, entry);
82
71
  return tree;
83
72
  }
84
73
 
@@ -118,13 +107,12 @@ function Bridge({ Component, store, rect, invoke, onReady }) {
118
107
  }, [onReady]);
119
108
 
120
109
  // The bridged providers wrap the *window*, not the pane component — the
121
- // same position they held in the host. ThemeProvider plants the palette
122
- // on a window it finds among its children (theme.js, `planted`), and the
123
- // window is where it has to land: the window's own background follows the
124
- // palette, and it is the top of the node tree every `$token` beneath
125
- // resolves through. Mounted inside the window, the palette reached a box
126
- // and the window kept resolving against the pane process's own desktop —
127
- // a dark-desktop pane in a light-themed app, wrong in both directions.
110
+ // same position they held in the host. The theme has to reach the window
111
+ // itself: the window's own background follows the palette, and it is the
112
+ // top of the node tree every `$token` beneath resolves through. Mounted
113
+ // inside the window, the palette reached a box and the window kept
114
+ // resolving against the pane process's own desktop a dark-desktop pane
115
+ // in a light-themed app, wrong in both directions.
128
116
  return wrapEnv(
129
117
  snapshot.env,
130
118
  h(
package/src/frame/env.js CHANGED
@@ -69,17 +69,9 @@ export function registeredFrameContext(key) {
69
69
  * than `Context.Provider`, like `ThemeProvider`, which also plants the
70
70
  * palette on a node so `$token` styles resolve. `render(value, children)`
71
71
  * returns the wrapped element.
72
- *
73
- * `innermost: true` puts the provider directly around the pane's window,
74
- * inside every other bridged provider. ThemeProvider needs the adjacency:
75
- * it plants the palette on a window it finds among its *direct* children,
76
- * and the window is where the palette must land — the window's own
77
- * background follows it, and the node tree under it is what every `$token`
78
- * resolves through. Plain context providers have no such constraint and
79
- * keep the host's nesting order outside.
80
72
  */
81
- export function registerFrameProvider(key, render, { innermost = false } = {}) {
82
- registry.set(key, { render, innermost });
73
+ export function registerFrameProvider(key, render) {
74
+ registry.set(key, { render });
83
75
  }
84
76
 
85
77
  /**
package/src/globalmenu.js CHANGED
@@ -58,15 +58,7 @@ import { useEffect, useRef, useState } from 'react';
58
58
 
59
59
  import { loadTransport, sessionBus } from './bus.js';
60
60
  import { desktopIntegrationEnabled } from './desktopintegration.js';
61
- import {
62
- DBUSMENU_IFACE,
63
- PROPERTY_TYPES,
64
- diffSnapshots,
65
- groupProperties,
66
- layoutOf,
67
- snapshot,
68
- IdAllocator,
69
- } from './dbusmenu.js';
61
+ import { DbusMenuExport } from './dbusmenuexport.js';
70
62
  import { useAppOrNull } from './appcontext.js';
71
63
  import { useTopLevelWindow, windowIdOf } from './windowid.js';
72
64
 
@@ -76,9 +68,6 @@ export const REGISTRAR_PATH = '/com/canonical/AppMenu/Registrar';
76
68
  const KDE_SERVICE_PROPERTY = '_KDE_NET_WM_APPMENU_SERVICE_NAME';
77
69
  const KDE_PATH_PROPERTY = '_KDE_NET_WM_APPMENU_OBJECT_PATH';
78
70
 
79
- /** dbusmenu's recursive layout struct: `(id, properties, children)`. */
80
- const LAYOUT_SIGNATURE = '(ia{sv}av)';
81
-
82
71
  /**
83
72
  * How long the registrar gets to answer.
84
73
  *
@@ -162,20 +151,13 @@ function ntkWindowOf(target) {
162
151
  * it, and the parts that are awkward (a panel restarting, a window closing
163
152
  * mid-call, no bus at all) are awkward in ways React has nothing to say about.
164
153
  */
165
- export class GlobalMenuExport {
154
+ export class GlobalMenuExport extends DbusMenuExport {
166
155
  constructor({ getMenus, onSelect, onAboutToShow, target, onChange }) {
167
- this.getMenus = getMenus;
168
- this.onSelect = onSelect;
169
- this.onAboutToShow = onAboutToShow;
156
+ super({ getMenus, onSelect, onAboutToShow });
170
157
  this.target = target;
171
158
  this.onChange = onChange ?? (() => {});
172
159
 
173
- this.alloc = new IdAllocator();
174
- this.nodes = snapshot(getMenus(), this.alloc);
175
- this.revision = 1;
176
-
177
160
  this.stopped = false;
178
- this.exported = false;
179
161
  /** The in-flight `sync()`, which the next one queues behind. */
180
162
  this.syncing = null;
181
163
  this.ref = null;
@@ -443,190 +425,6 @@ export class GlobalMenuExport {
443
425
  await this.ref?.release();
444
426
  this.ref = null;
445
427
  }
446
-
447
- // ----------------------------------------------------------------- update
448
-
449
- /**
450
- * A new `menus` array. Serialise, diff, and send the *narrower* of the two
451
- * signals the protocol has — see `diffSnapshots`, where the choice is made
452
- * and why it matters.
453
- */
454
- update(menus) {
455
- const next = snapshot(menus, this.alloc);
456
- const change = diffSnapshots(this.nodes, next);
457
- this.nodes = next;
458
- if (!this.exported || change.kind === 'none') return;
459
-
460
- if (change.kind === 'properties') {
461
- this.iface?.emit.ItemsPropertiesUpdated(
462
- change.updated.map(([id, props]) => [id, this.wrap(props)]),
463
- change.removed,
464
- );
465
- return;
466
- }
467
- this.revision += 1;
468
- this.iface?.emit.LayoutUpdated(this.revision, change.parent);
469
- }
470
-
471
- /** Plain property values → the `a{sv}` the wire wants. */
472
- wrap(props) {
473
- const out = {};
474
- for (const [name, value] of Object.entries(props)) {
475
- const type = PROPERTY_TYPES[name];
476
- if (type) out[name] = new this.dbus.Variant(type, value);
477
- }
478
- return out;
479
- }
480
-
481
- /** `layoutOf`'s plain tree → the recursive `(ia{sv}av)`. */
482
- wrapLayout(node) {
483
- const [id, props, children] = node;
484
- return [
485
- id,
486
- this.wrap(props),
487
- children.map(
488
- (child) =>
489
- new this.dbus.Variant(LAYOUT_SIGNATURE, this.wrapLayout(child)),
490
- ),
491
- ];
492
- }
493
-
494
- // --------------------------------------------------------------- protocol
495
-
496
- itemFor(id) {
497
- return this.nodes.get(id)?.item ?? null;
498
- }
499
-
500
- defineMenu(dbus) {
501
- this.dbus = dbus;
502
- const fire = (id, eventId) => {
503
- const item = this.itemFor(id);
504
- if (!item) return;
505
- if (eventId === 'clicked') this.onSelect?.(item);
506
- else if (eventId === 'opened') this.onAboutToShow?.(item);
507
- };
508
-
509
- return dbus.defineInterface({
510
- name: DBUSMENU_IFACE,
511
- methods: {
512
- GetLayout: {
513
- in: { parentId: 'i', recursionDepth: 'i', propertyNames: 'as' },
514
- out: { revision: 'u', layout: LAYOUT_SIGNATURE },
515
- handler: ({ parentId, recursionDepth, propertyNames }) => {
516
- const tree = layoutOf(
517
- this.nodes,
518
- parentId,
519
- recursionDepth ?? -1,
520
- propertyNames,
521
- );
522
- // An id the shell remembers from before a structural change is the
523
- // normal way to arrive here, not a protocol violation: answer with
524
- // an empty item rather than an error, and let the LayoutUpdated it
525
- // has already been sent bring it back for the real tree.
526
- return {
527
- revision: this.revision,
528
- layout: this.wrapLayout(tree ?? [parentId, {}, []]),
529
- };
530
- },
531
- },
532
- GetGroupProperties: {
533
- in: { ids: 'ai', propertyNames: 'as' },
534
- out: { properties: 'a(ia{sv})' },
535
- handler: ({ ids, propertyNames }) =>
536
- groupProperties(this.nodes, ids, propertyNames).map(
537
- ([id, props]) => [id, this.wrap(props)],
538
- ),
539
- },
540
- GetProperty: {
541
- in: { id: 'i', name: 's' },
542
- out: { value: 'v' },
543
- handler: ({ id, name }) => {
544
- const props = this.nodes.get(id)?.props;
545
- // `hasOwn` on both, so a name like `constructor` reads as absent
546
- // rather than as a function that then fails to marshal.
547
- const known = Object.hasOwn(PROPERTY_TYPES, name);
548
- const value =
549
- props && Object.hasOwn(props, name) ? props[name] : undefined;
550
- // A property this item does not carry is at its default, and the
551
- // spec's own advice is to answer with it rather than to error.
552
- if (value === undefined || !known) return new dbus.Variant('s', '');
553
- return new dbus.Variant(PROPERTY_TYPES[name], value);
554
- },
555
- },
556
- Event: {
557
- in: { id: 'i', eventId: 's', data: 'v', timestamp: 'u' },
558
- out: {},
559
- handler: ({ id, eventId }) => fire(id, eventId),
560
- },
561
- EventGroup: {
562
- in: { events: 'a(isvu)' },
563
- out: { idErrors: 'ai' },
564
- handler: ({ events }) => {
565
- const errors = [];
566
- for (const [id, eventId] of events ?? []) {
567
- if (this.itemFor(id)) fire(id, eventId);
568
- else errors.push(id);
569
- }
570
- return errors;
571
- },
572
- },
573
- AboutToShow: {
574
- in: { id: 'i' },
575
- out: { needUpdate: 'b' },
576
- handler: ({ id }) => this.aboutToShow(id),
577
- },
578
- AboutToShowGroup: {
579
- in: { ids: 'ai' },
580
- out: { updatesNeeded: 'ai', idErrors: 'ai' },
581
- handler: ({ ids }) => {
582
- const errors = [];
583
- for (const id of ids ?? []) {
584
- if (this.itemFor(id)) this.aboutToShow(id);
585
- else errors.push(id);
586
- }
587
- return { updatesNeeded: [], idErrors: errors };
588
- },
589
- },
590
- },
591
- properties: {
592
- Version: { type: 'u', access: 'read', get: () => 3 },
593
- Status: { type: 's', access: 'read', get: () => 'normal' },
594
- TextDirection: { type: 's', access: 'read', get: () => 'ltr' },
595
- // Empty, and not a placeholder: `icon-name` is looked up in the
596
- // desktop's own theme, which is where an app's icons should come from.
597
- // A path here would be for icons shipped beside the app.
598
- IconThemePath: { type: 'as', access: 'read', get: () => [] },
599
- },
600
- signals: {
601
- LayoutUpdated: { args: { revision: 'u', parent: 'i' } },
602
- ItemsPropertiesUpdated: {
603
- args: { updated: 'a(ia{sv})', removed: 'a(ias)' },
604
- },
605
- ItemActivationRequested: { args: { id: 'i', timestamp: 'u' } },
606
- },
607
- });
608
- }
609
-
610
- /**
611
- * The reply has to go out **now**, and React has not rendered yet.
612
- *
613
- * `setState` from here does not produce a new tree before this function
614
- * returns, so there is no honest way to answer `true` — and answering `true`
615
- * dishonestly is worse than answering `false`: the shell then blocks on a
616
- * `GetLayout` for a subtree that has not been built, gets the old one, and
617
- * caches it.
618
- *
619
- * So: answer `false`, let the handler run, and let the ordinary update path
620
- * emit `LayoutUpdated` when the new items actually serialise. Shells listen
621
- * for that unconditionally — it is the same signal a menu changing while
622
- * open produces — so a lazily-built submenu still fills in, one round trip
623
- * later than a synchronous toolkit would manage.
624
- */
625
- aboutToShow(id) {
626
- const item = this.itemFor(id);
627
- if (item) this.onAboutToShow?.(item);
628
- return false;
629
- }
630
428
  }
631
429
 
632
430
  /**