react-x11 2.16.1 → 2.17.1

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 (55) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/appcontext.js +8 -0
  6. package/src/appearance.js +36 -0
  7. package/src/{cocoa → backend}/context2d.js +27 -7
  8. package/src/capabilities.js +99 -1
  9. package/src/cocoa/app.js +17 -9
  10. package/src/cocoa/fonts.js +1 -1
  11. package/src/cocoa/glarea.js +48 -10
  12. package/src/cocoa/overlay.js +2 -2
  13. package/src/cocoa/panewindow.js +2 -2
  14. package/src/cocoa/presenter.js +2 -2
  15. package/src/cocoa/surface.js +3 -3
  16. package/src/cocoa/window.js +23 -2
  17. package/src/events.js +21 -0
  18. package/src/foreignnodes.js +8 -3
  19. package/src/frame/index.js +30 -4
  20. package/src/glnodes.js +21 -7
  21. package/src/idle.js +59 -1
  22. package/src/index.d.ts +41 -0
  23. package/src/index.js +30 -3
  24. package/src/launcher.js +17 -8
  25. package/src/launcherhooks.js +24 -10
  26. package/src/node.d.ts +1 -1
  27. package/src/nodes/cascade.js +9 -0
  28. package/src/nodes/node.js +6 -1
  29. package/src/nodes/window/hints.js +21 -2
  30. package/src/nodes/window/window.js +2 -2
  31. package/src/notifications.js +39 -14
  32. package/src/taskbarhooks.js +164 -0
  33. package/src/transfer.js +20 -1
  34. package/src/trayhooks.js +1 -1
  35. package/src/types/capabilities.d.ts +32 -3
  36. package/src/types/elements.d.ts +23 -1
  37. package/src/types/events.d.ts +16 -0
  38. package/src/types/launcher.d.ts +20 -6
  39. package/src/types/taskbar.d.ts +79 -0
  40. package/src/wayland/context2d.js +1 -1
  41. package/src/win32/a11y.js +604 -0
  42. package/src/win32/app.js +768 -0
  43. package/src/win32/bezels.js +158 -0
  44. package/src/win32/dnd.js +283 -0
  45. package/src/win32/fonts.js +497 -0
  46. package/src/win32/glarea.js +548 -0
  47. package/src/win32/ime.js +267 -0
  48. package/src/win32/keymap.js +116 -0
  49. package/src/win32/native.js +54 -0
  50. package/src/win32/panehost.js +106 -0
  51. package/src/win32/panewindow.js +343 -0
  52. package/src/win32/shell.js +426 -0
  53. package/src/win32/surface.js +192 -0
  54. package/src/win32/window.js +659 -0
  55. package/src/windowid.js +66 -0
package/README.md CHANGED
@@ -18,9 +18,10 @@ Build GUI programs for a Linux desktop, for a display at the other end of an
18
18
  ssh connection, or as a native Mac app, with your React / React Native
19
19
  experience — flexbox layout, components, hooks, synthetic events.
20
20
 
21
- ### Two backends, one tree
21
+ ### Four backends, one tree
22
22
 
23
- The same components, the same hooks and the same `style` objects run on both:
23
+ The same components, the same hooks and the same `style` objects run on all
24
+ of them. The two that are mature:
24
25
 
25
26
  - **X11** ([the flagship](docs/remote.md)) — a Linux desktop, a display
26
27
  forwarded over `ssh -X`, `Xvfb` in CI, a thin client, or macOS through
@@ -35,20 +36,25 @@ The same components, the same hooks and the same `style` objects run on both:
35
36
  thin mechanism-only Objective-C++ bridge — an optional dependency shipping
36
37
  prebuilds, absent on Linux installs.
37
38
 
38
- `createRoot()` picks for you: **Cocoa on macOS** when the bridge is
39
- installed, X11 via `$DISPLAY` everywhere else, and X11 on a Mac without the
40
- bridge so an XQuartz setup keeps working. `createRoot({ backend: 'x11' })`
41
- or `REACT_X11_BACKEND=x11` pins it. On macOS the app's JS runs on a worker
39
+ `createRoot()` picks for you: **Cocoa on macOS** and **win32 on Windows**
40
+ when the matching bridge is installed, X11 via `$DISPLAY` everywhere else
41
+ and X11 on either of those without its bridge, so an XQuartz or WSLg setup
42
+ keeps working. `createRoot({ backend: 'x11' })` or `REACT_X11_BACKEND=x11`
43
+ pins it. On macOS the app's JS runs on a worker
42
44
  while AppKit keeps the main thread, so a menu, a drag or a live resize never
43
45
  stops it — `node app.js` is enough, and `REACT_X11_THREADED=0` keeps the
44
46
  main thread ([docs/macos.md](docs/macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)).
45
47
 
46
- Two more backends are on the way, and the goal they serve is **full
47
- cross-platform support**: **Windows** Win32 windows, Direct2D and
48
- DirectWrite, DWM compositing — with [docs/windows.md](docs/windows.md) as
49
- its PRD, and **native Wayland**, with [docs/wayland.md](docs/wayland.md)
50
- as its RFC. Both arrive as backends beside these two, not replacements for
51
- either: the same tree, the same components, the same `style` objects.
48
+ Two more backends exist beside these, serving the goal of **full
49
+ cross-platform support**, and both are younger than the two above.
50
+ **native Wayland** ([docs/wayland.md](docs/wayland.md)) is opt-in rather than
51
+ automatic, because X11 stays the default on Linux — the remote case is the
52
+ flagship reason this project exists and Wayland has no network transparency.
53
+ **Windows** ([docs/windows.md](docs/windows.md)) Win32 windows, Direct2D
54
+ and DirectWrite, DWM compositing through DirectComposition — renders and
55
+ takes mouse input, with the desktop integrations still to come. Each is a
56
+ backend beside the others rather than a replacement for any of them: the
57
+ same tree, the same components, the same `style` objects.
52
58
 
53
59
  Layout is [yoga-layout](https://www.npmjs.com/package/yoga-layout) (WASM) on
54
60
  both, and text shaping is [fontkit](https://github.com/foliojs/fontkit) on
@@ -125,17 +131,26 @@ That is the shape of the problem this is good at:
125
131
  And the shape it is not good at, so you can stop here rather than in week
126
132
  three:
127
133
 
128
- - **Windows — today.** There is no Windows backend yet, so an app that has
129
- to ship on Windows now wants Electron or Tauri. One is coming: a native
130
- backend over Win32 windows, Direct2D and DirectWrite, composited by DWM
131
- through DirectComposition, on a mechanism-only bridge shaped like the
132
- Cocoa one. [docs/windows.md](docs/windows.md) is the PRD, from the
133
- threading model up to what each desktop integration becomes. Two targets
134
- ship meanwhile X11 and Cocoa and they are not the same app: the
135
- desktop-shell half of X11 (`<foreign>` embedding, panel struts,
136
- substructure redirect, the window-manager example below) has no macOS
137
- equivalent, and `react-x11/test` drives the X11 backend only.
138
- [docs/macos.md](docs/macos.md) says which is which.
134
+ - **Windows — today.** The Windows backend is young, and further along than
135
+ that sounds. Win32 windows composited by DWM through DirectComposition,
136
+ Direct2D and DirectWrite behind the same contracts the Cocoa backend
137
+ proved, OpenGL, the keyboard and all five mouse buttons, popups, the
138
+ clipboard, file dialogs, the tray, the taskbar, global hotkeys,
139
+ notifications and window states on a mechanism-only bridge shaped like
140
+ the Cocoa one, with a test suite and CI of its own. The examples run.
141
+ Drag and drop works both ways with the desktop, and the taskbar's own
142
+ surfaces — a thumbnail toolbar, a jump list, recent documents are there
143
+ behind a capability, so an app that also runs elsewhere asks
144
+ `useSupports()` rather than the platform. What it does not have is **IME
145
+ and screen-reader support**: an app is unusable with a screen reader and
146
+ in CJK input. So an app that needs either on Windows _this month_ still
147
+ wants Electron or Tauri.
148
+ [docs/windows-integrations.md](docs/windows-integrations.md) is the
149
+ measured status and [docs/windows.md](docs/windows.md) the design. The three
150
+ targets are not the same app: the desktop-shell half of X11 (`<foreign>`
151
+ embedding, panel struts, substructure redirect, the window-manager example
152
+ below) has no macOS or Windows equivalent, and `react-x11/test` drives the
153
+ X11 backend only. [docs/macos.md](docs/macos.md) says which is which.
139
154
  - **native Wayland — today.** There is no Wayland backend yet. Ordinary
140
155
  application windows work fine on a Wayland desktop through Xwayland, which
141
156
  is not going away — but the desktop-shell half of X11 (panel struts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.16.1",
3
+ "version": "2.17.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -33,6 +33,7 @@
33
33
  "examples:attention": "tsx examples/attention.jsx",
34
34
  "examples:badge": "tsx examples/badge.jsx",
35
35
  "examples:tray": "tsx examples/tray.jsx",
36
+ "examples:taskbar": "tsx examples/taskbar.jsx",
36
37
  "examples:desktop": "tsx examples/desktop.jsx",
37
38
  "examples:tooltips": "tsx examples/tooltips.jsx",
38
39
  "examples:chat": "tsx examples/chat.jsx",
@@ -109,6 +110,7 @@
109
110
  "optionalDependencies": {
110
111
  "@windowkit/appkit": "^0.12.0",
111
112
  "@windowkit/wayland": "^3.1.1",
113
+ "@windowkit/win32": "^0.0.1",
112
114
  "dbus-native": "^0.15.1",
113
115
  "x11-dri": "^0.9.0"
114
116
  },
package/src/Reconciler.js CHANGED
@@ -116,6 +116,9 @@ const ROOT_CONTEXT = Object.freeze({
116
116
  isInsideText: false,
117
117
  isInsideSvg: false,
118
118
  atRoot: true,
119
+ // Nothing is above the root, so a window written straight into it waits for
120
+ // no palette — unlike one under a `<ThemeProvider>` there.
121
+ underRootScope: false,
119
122
  inWindow: false,
120
123
  });
121
124
 
@@ -191,6 +194,11 @@ const HostConfig = {
191
194
  // Still at the root under a `<ThemeProvider>` written there, which
192
195
  // draws nothing; under anything else, inside a window.
193
196
  atRoot: parentHostContext.atRoot && type === THEME_SCOPE,
197
+ // *Below* one of those providers, which is a different fact: a window
198
+ // here is handed its palette when the scope inserts it, after it was
199
+ // built. `atRoot` is true at the root itself, where no provider is
200
+ // waiting to hand anything over, so the two cannot be one flag.
201
+ underRootScope: parentHostContext.atRoot && type === THEME_SCOPE,
194
202
  // Directly inside a window, where a provider can hand a nested window
195
203
  // on to it.
196
204
  inWindow: type === 'window' || type === 'popup',
@@ -255,10 +263,19 @@ const HostConfig = {
255
263
  case 'window':
256
264
  // No X11 calls here: the render phase may be discarded. The real
257
265
  // window is created top-down in the commit phase (realize).
266
+ // `underRootScope` goes in at construction, not after it: this
267
+ // constructor resolves the window's own style, and a window under a
268
+ // root `<ThemeProvider>` is handed that palette only when the scope
269
+ // inserts it (nodes/scope.js). Until then its ancestry is incomplete
270
+ // and every `$token` the provider defines would be reported as
271
+ // unknown — a warning for a style that resolves correctly a moment
272
+ // later, and under `REACT_X11_STRICT_TOKENS=1` a throw that killed an
273
+ // app whose palette was fine.
258
274
  node = new WindowNode(
259
275
  rootContainer,
260
276
  windowAttributes(props, scaleOf(rootContainer)),
261
277
  props,
278
+ { awaitsRootScope: hostContext.underRootScope },
262
279
  );
263
280
  break;
264
281
  case 'popup':
@@ -641,11 +658,18 @@ const isNtkApp = (v) =>
641
658
  */
642
659
  function resolveBackend(options) {
643
660
  const asked = options.backend ?? process.env.REACT_X11_BACKEND ?? 'auto';
644
- if (asked === 'x11' || asked === 'cocoa' || asked === 'wayland') return asked;
661
+ if (
662
+ asked === 'x11' ||
663
+ asked === 'cocoa' ||
664
+ asked === 'wayland' ||
665
+ asked === 'win32'
666
+ ) {
667
+ return asked;
668
+ }
645
669
  if (asked !== 'auto') {
646
670
  throw new Error(
647
671
  `react-x11: unknown backend ${JSON.stringify(asked)} — expected ` +
648
- "'x11', 'cocoa', 'wayland' or 'auto'.",
672
+ "'x11', 'cocoa', 'wayland', 'win32' or 'auto'.",
649
673
  );
650
674
  }
651
675
  // Naming an X endpoint is choosing X11: a `display` or a `stream` (the
@@ -655,7 +679,13 @@ function resolveBackend(options) {
655
679
  if (options.display !== undefined || options.stream !== undefined) {
656
680
  return 'x11';
657
681
  }
658
- return process.platform === 'darwin' ? 'cocoa' : 'x11';
682
+ if (process.platform === 'darwin') return 'cocoa';
683
+ // Windows has no X server to fall back to in the ordinary case, so 'auto'
684
+ // reaches for the native backend the way it does on a mac — and falls back
685
+ // to X11 below if the bridge is not installed, which is what keeps a Cygwin
686
+ // or WSLg setup with DISPLAY set working.
687
+ if (process.platform === 'win32') return 'win32';
688
+ return 'x11';
659
689
  }
660
690
 
661
691
  // What a root that opens its own connection forwards to ntk. `stream` is
@@ -769,17 +799,27 @@ export async function createRoot(options = {}) {
769
799
  // synchronous block, and only where the handshake takes longer than it. It
770
800
  // is not measurable on a Unix socket, and it is lost in the noise on a link
771
801
  // slow enough to matter. This is the right order, not a fast one.
772
- const connectX11 = () =>
773
- connect(
774
- Object.fromEntries(
775
- CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
776
- k,
777
- rest[k],
778
- ]),
779
- ),
802
+ // `REACT_X11_FONT_SOURCE` is the companion to `REACT_X11_BACKEND` above:
803
+ // an A/B run without touching code. ntk finds its fonts through fontconfig,
804
+ // and a machine with no `fc-match` — a slim container, or Windows, where
805
+ // the X11 backend is only ever run to compare it against the native one —
806
+ // has to be told where the fonts are instead. An explicit `fontSource`
807
+ // wins; this only fills the gap.
808
+ const connectX11 = () => {
809
+ const options = Object.fromEntries(
810
+ CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
811
+ k,
812
+ rest[k],
813
+ ]),
780
814
  );
815
+ options.fontSource ??= process.env.REACT_X11_FONT_SOURCE || undefined;
816
+ if (options.fontSource === undefined) delete options.fontSource;
817
+ return connect(options);
818
+ };
781
819
  const cocoaAsked =
782
820
  rest.backend === 'cocoa' || process.env.REACT_X11_BACKEND === 'cocoa';
821
+ const win32Asked =
822
+ rest.backend === 'win32' || process.env.REACT_X11_BACKEND === 'win32';
783
823
  const connecting = !owned
784
824
  ? Promise.resolve(borrowed)
785
825
  : backend === 'wayland'
@@ -791,25 +831,44 @@ export async function createRoot(options = {}) {
791
831
  import('./wayland/app.js').then(({ createWaylandApp }) =>
792
832
  createWaylandApp(rest),
793
833
  )
794
- : backend === 'cocoa'
795
- ? import('./cocoa/app.js')
796
- .then(({ createCocoaApp }) => createCocoaApp(rest))
834
+ : backend === 'win32'
835
+ ? import('./win32/app.js')
836
+ .then(({ createWin32App }) => createWin32App(rest))
797
837
  .catch((err) => {
798
- // Asked for by name, the bridge is required and its absence is
799
- // the error (it says how to install). Reached by 'auto', a mac
800
- // without it falls back to X11 so an XQuartz setup keeps
801
- // working — said once, because a silent fallback would look
802
- // like the native backend being broken rather than absent.
803
- if (cocoaAsked) throw err;
838
+ // Same bargain as cocoa's below: asked for by name the bridge is
839
+ // required and its absence is the error, reached by 'auto' a
840
+ // machine without it falls back to X11 so a WSLg or Cygwin setup
841
+ // with DISPLAY set keeps working — said once, because a silent
842
+ // fallback reads as the native backend being broken rather than
843
+ // absent.
844
+ if (win32Asked) throw err;
804
845
  if (process.env.NODE_ENV !== 'production') {
805
846
  console.warn(
806
- 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
847
+ 'react-x11: no @windowkit/win32 bridge — falling back to the ' +
807
848
  `X11 backend. (${err.message.split('\n')[0]})`,
808
849
  );
809
850
  }
810
851
  return connectX11();
811
852
  })
812
- : connectX11();
853
+ : backend === 'cocoa'
854
+ ? import('./cocoa/app.js')
855
+ .then(({ createCocoaApp }) => createCocoaApp(rest))
856
+ .catch((err) => {
857
+ // Asked for by name, the bridge is required and its absence is
858
+ // the error (it says how to install). Reached by 'auto', a mac
859
+ // without it falls back to X11 so an XQuartz setup keeps
860
+ // working — said once, because a silent fallback would look
861
+ // like the native backend being broken rather than absent.
862
+ if (cocoaAsked) throw err;
863
+ if (process.env.NODE_ENV !== 'production') {
864
+ console.warn(
865
+ 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
866
+ `X11 backend. (${err.message.split('\n')[0]})`,
867
+ );
868
+ }
869
+ return connectX11();
870
+ })
871
+ : connectX11();
813
872
  const layout = loadLayout();
814
873
  const integrations = loadIntegrations(); // null when there is nothing to install
815
874
  const [app] = await Promise.all([connecting, layout, integrations]);
@@ -843,7 +902,7 @@ export async function createRoot(options = {}) {
843
902
  // critical path — every rung that fails is a normal, silent "off"
844
903
  // (docs/accessibility.md). Deliberately not awaited: a root must not
845
904
  // wait on a bus that is not there.
846
- startA11y();
905
+ startA11y(app);
847
906
 
848
907
  // Before anything renders: the launch id has to be on the first toplevel
849
908
  // before it maps, and the environment variable has to be consumed whether
package/src/a11y.js CHANGED
@@ -1275,12 +1275,29 @@ function a11yEnabled() {
1275
1275
  * it stopped — which is the debugging story for "why does Orca not see my
1276
1276
  * app".
1277
1277
  */
1278
- export function startA11y() {
1278
+ export function startA11y(app) {
1279
1279
  if (startPromise) return startPromise;
1280
1280
  if (!a11yEnabled()) {
1281
1281
  startPromise = Promise.resolve(null);
1282
1282
  return startPromise;
1283
1283
  }
1284
+ // A backend with an accessibility bridge of its own answers first, and its
1285
+ // presence *is* the capability — the same rule the rest of the library
1286
+ // uses. The Windows backend installs one, because UI Automation asks
1287
+ // synchronously and has to be answered from a pushed copy rather than from
1288
+ // the live tree (docs/windows.md §Accessibility); AT-SPI's asynchronous
1289
+ // questions are answered from the tree itself, below.
1290
+ if (typeof app?.startAccessibility === 'function') {
1291
+ startPromise = Promise.resolve()
1292
+ .then(() => app.startAccessibility())
1293
+ .catch((err) => {
1294
+ if (process.env.REACT_X11_A11Y) {
1295
+ console.warn('react-x11: accessibility bridge failed to start:', err);
1296
+ }
1297
+ return null;
1298
+ });
1299
+ return startPromise;
1300
+ }
1284
1301
  startPromise = import('./atspi.js')
1285
1302
  .then((atspi) => atspi.start())
1286
1303
  .catch((err) => {
package/src/appcontext.js CHANGED
@@ -80,6 +80,14 @@ const FEATURES = {
80
80
  },
81
81
  embedding: { watch: NEVER_CHANGES, read: canEmbed },
82
82
  glOverlay: { watch: NEVER_CHANGES, read: canOverlay },
83
+ // Everything here is a property of the **display**: what the server, the
84
+ // compositor and the drawing pipeline can do. A desktop's own surfaces --
85
+ // a tray, a launcher icon and what hangs off it, a notification daemon --
86
+ // are a different question with a different shape, because they can appear
87
+ // and vanish while the process runs and because "available" alone is a lie
88
+ // about them. They go through `desktopCapability()` (src/capabilities.js),
89
+ // which answers `{ available, backend, features }`. Adding one here would
90
+ // flatten that to a boolean and lose the mechanism with it.
83
91
  };
84
92
 
85
93
  /**
package/src/appearance.js CHANGED
@@ -955,6 +955,38 @@ async function macosRung() {
955
955
  return runWatcher();
956
956
  }
957
957
 
958
+ // --------------------------------------------------------------------------
959
+ // Rung 4: Windows
960
+ // --------------------------------------------------------------------------
961
+
962
+ /**
963
+ * The backend answers this one rather than this file reaching for a bridge:
964
+ * the values live behind a native addon that exists only on Windows, and
965
+ * asking the app for them is the capability test every other ladder here
966
+ * makes (AGENTS.md, "Find the mechanism, not the platform").
967
+ *
968
+ * All four values come from one source — the Personalize key for light or
969
+ * dark, DWM for the accent, and the two accessibility flags from
970
+ * `SystemParametersInfo` — so this rung owns every field, which is the rule
971
+ * the ladder is ordered for.
972
+ *
973
+ * Live rather than polled: Windows broadcasts `WM_SETTINGCHANGE`,
974
+ * `WM_THEMECHANGED` and `WM_DWMCOLORIZATIONCOLORCHANGED` to every top-level
975
+ * window, and the bridge's UI thread has one.
976
+ */
977
+ async function windowsRung(app) {
978
+ if (typeof app?.systemAppearance !== 'function') return false;
979
+ const values = app.systemAppearance();
980
+ if (!values) return false;
981
+ publish(values, 'windows');
982
+ app.onAppearanceChange?.(() => {
983
+ if (owner !== 'windows') return;
984
+ const next = app.systemAppearance();
985
+ if (next) publish(next, 'windows');
986
+ });
987
+ return true;
988
+ }
989
+
958
990
  // Killed rather than left behind: `unref()` keeps it from holding *this*
959
991
  // process open, and nothing keeps it from outliving it.
960
992
  process.on('exit', () => {
@@ -975,6 +1007,10 @@ async function runLadder(app) {
975
1007
  // an XQuartz display never reaches this at all, which is correct — it
976
1008
  // cannot read that Mac's defaults.
977
1009
  ['macos', macosRung],
1010
+ // Before XSETTINGS for the same reason macOS is: where the process is
1011
+ // Windows, the desktop's own preference is the one the user set, and an
1012
+ // XSETTINGS daemon there would be something they installed by hand.
1013
+ ['windows', () => windowsRung(app)],
978
1014
  ['xsettings', () => xsettingsRung(app)],
979
1015
  ]) {
980
1016
  let answered = false;
@@ -1,9 +1,23 @@
1
- // A canvas-shaped 2d context over a @windowkit/appkit CoreGraphics surface.
1
+ // A canvas-shaped 2d context over a **verb table** — the drawing dialect
2
+ // every native backend answers, and the one place it is written down.
2
3
  //
3
- // This is the raster half of the Cocoa backend: on the surface presenter it
4
- // is the whole drawing path, and on the layer presenter it stays as the
5
- // fallback every painted-code node (<canvas>, <svg>, registered elements)
6
- // rasters through docs/macos.md §"Custom drawing on a layer tree".
4
+ // This is macos.md §"The split" step 4, taken when a second native backend
5
+ // started: the class takes its `native` as a constructor argument and calls
6
+ // nothing else, so one wrapper drives @windowkit/appkit's CoreGraphics verbs
7
+ // and @windowkit/win32's Direct2D verbs alike. A bridge that lacks an
8
+ // optional verb is feature-detected (`ctxSetBlendMode`, `blitSurface`,
9
+ // `ctxDrawSymbol`) and degrades rather than throwing.
10
+ //
11
+ // **Wayland is deliberately not here.** src/wayland/context2d.js is not
12
+ // another copy of this file — it is a GLES rasterizer, wayland.md's Tier D,
13
+ // which implements the same dialect by drawing it rather than by forwarding
14
+ // it. The two are different layers, not duplicates, and folding one into the
15
+ // other would lose that.
16
+ //
17
+ // On the surface presenter this is the whole drawing path; on the layer
18
+ // presenter it stays as the fallback every painted-code node (<canvas>,
19
+ // <svg>, registered elements) rasters through — docs/macos.md §"Custom
20
+ // drawing on a layer tree".
7
21
  //
8
22
  // The native surface holds the real graphics state (paths, CTM, clip); this
9
23
  // class keeps the JS-visible state (fillStyle strings, gradient objects,
@@ -265,7 +279,7 @@ class SolidPicture {
265
279
  }
266
280
  }
267
281
 
268
- export class CocoaContext2D {
282
+ export class BackendContext2D {
269
283
  /**
270
284
  * @param native the @windowkit/appkit module
271
285
  * @param surfaceOf () => current surface handle — the owner replaces the
@@ -1425,9 +1439,15 @@ export class CocoaContext2D {
1425
1439
  const family = m ? m[4] : 'sans-serif';
1426
1440
  const weight = m?.[2] === 'bold' ? 700 : m?.[2] ? Number(m[2]) : 400;
1427
1441
  const style = m?.[1] ? 'italic' : 'normal';
1442
+ // No colour unless the caller named one, which is what `_contextInk`
1443
+ // above is waiting for: a layout with no ink of its own is drawn with the
1444
+ // context's fill, the way `fillText` is defined to be. Defaulting the
1445
+ // base to black instead made every layout carry an ink, so `fillStyle`
1446
+ // was read, found to be irrelevant, and never applied — a `fillText`
1447
+ // under a white fill came out black on both backends.
1428
1448
  return fonts.layout(
1429
1449
  [{ text: String(text), family, size, weight, style, color }],
1430
- { family, size, weight, style, color: color ?? '#000' },
1450
+ { family, size, weight, style, color },
1431
1451
  {},
1432
1452
  );
1433
1453
  }
@@ -79,6 +79,21 @@ function soleApp() {
79
79
  return showing.length === 1 ? showing[0] : null;
80
80
  }
81
81
 
82
+ /**
83
+ * The mechanism a backend says it built one rung on, or null.
84
+ *
85
+ * Backends install the *same method names* for the same rung on purpose --
86
+ * `createStatusItem` is how `useTray` stays one hook -- so a method name
87
+ * cannot tell two mechanisms apart, and a probe that tried reported
88
+ * Shell_NotifyIcon as `cocoa`. A backend with more than one mechanism to
89
+ * distinguish declares them (`app.shellMechanisms`); one with nothing to
90
+ * disambiguate says nothing and the method checks below still answer.
91
+ */
92
+ function shellMechanism(target, rung) {
93
+ const declared = target?.shellMechanisms;
94
+ return typeof declared?.[rung] === 'string' ? declared[rung] : null;
95
+ }
96
+
82
97
  // ---------------------------------------------------------------------------
83
98
  // notifications
84
99
  // ---------------------------------------------------------------------------
@@ -154,6 +169,27 @@ async function probeNotifications({ app } = {}) {
154
169
  });
155
170
  }
156
171
 
172
+ if (backend === 'win32') {
173
+ // A Shell_NotifyIcon balloon: text with a severity, shown by the tray
174
+ // icon and kept by the Action Center afterwards. Everything richer is a
175
+ // toast, which is WinRT and a different mechanism entirely.
176
+ return frozen('win32', {
177
+ actions: false,
178
+ events: false, // NIN_BALLOONUSERCLICK is not routed
179
+ update: false,
180
+ close: false,
181
+ body: true,
182
+ bodyMarkup: false,
183
+ bodyImage: false,
184
+ // The balloon shows the severity's own glyph. NIIF_USER would put the
185
+ // caller's icon there and is not wired up, so an icon cannot be chosen.
186
+ icon: false,
187
+ sound: true, // the shell plays one; NIIF_NOSOUND is not set
188
+ persistence: true, // it lands in the Action Center
189
+ urgency: true, // low/normal/critical -> NIIF_NONE/INFO/ERROR
190
+ });
191
+ }
192
+
157
193
  // `osascript` and `notify-send`: one way, and that is the whole of it.
158
194
  // `notify-send -p` prints an id on newer libnotify, which is why `update`
159
195
  // is not flatly false there — see notifications.js.
@@ -178,6 +214,33 @@ async function probeNotifications({ app } = {}) {
178
214
 
179
215
  /** The tray on an app that has one of its own, which needs nothing asked. */
180
216
  function trayNow(target) {
217
+ // The mechanism is *declared* by the backend, not guessed from a method
218
+ // name, because two backends deliberately install the same names -- that
219
+ // sharing is what lets `useTray` be one hook -- and a probe that read
220
+ // `createStatusItem` as "this is AppKit" reported Shell_NotifyIcon as
221
+ // `cocoa`, with SF Symbols and click modifiers it does not have.
222
+ if (shellMechanism(target, 'tray') === 'shellnotifyicon') {
223
+ return frozen('shellnotifyicon', {
224
+ menu: true,
225
+ // No name vocabulary: the shell has no icon theme to look a name up
226
+ // in, so an icon here is always pixels or a file.
227
+ iconName: false,
228
+ iconBytes: true,
229
+ attention: false,
230
+ overlay: false,
231
+ tooltip: true,
232
+ // An NSStatusItem can show a label beside its icon; a notify icon is
233
+ // an icon. `title` is accepted and used as the tooltip, which is a
234
+ // fallback rather than the feature.
235
+ title: false,
236
+ click: true,
237
+ clickPosition: true,
238
+ clickRect: false,
239
+ clickModifiers: false,
240
+ // WM_MOUSEWHEEL is not delivered to a notify icon.
241
+ scroll: false,
242
+ });
243
+ }
181
244
  if (typeof target?.createStatusItem === 'function') {
182
245
  return frozen('cocoa', {
183
246
  menu: true,
@@ -234,15 +297,45 @@ async function probeTray({ app } = {}) {
234
297
 
235
298
  /** The Dock tile on an app that has one, which needs nothing asked. */
236
299
  function launcherNow(target) {
300
+ // Declared, not guessed -- see `trayNow`. This backend installs
301
+ // `setDockBadge` too, so the cocoa branch below used to answer for it and
302
+ // say `progress: false` with a working taskbar progress bar right there.
303
+ if (shellMechanism(target, 'launcher') === 'taskbar') {
304
+ return frozen('taskbar', {
305
+ badge: true,
306
+ // Drawn into the overlay icon, so it is text rather than a count --
307
+ // but a 16x16 overlay holds about three glyphs and longer labels come
308
+ // out as `99+`.
309
+ badgeText: true,
310
+ progress: true, // ITaskbarList3::SetProgressValue
311
+ urgent: true, // FlashWindowEx
312
+ // The taskbar button's menu is the jump list, whose entries start a
313
+ // *new* process; nothing on it can call back into this one. That is a
314
+ // different feature from the Dock menu, and `tasks` is its name --
315
+ // reporting `menu: true` here would promise a callback that never
316
+ // comes.
317
+ menu: false,
318
+ needsDesktopFile: false,
319
+ tasks: typeof target.jumpList === 'function',
320
+ thumbnailToolbar: typeof target.thumbnailToolbar === 'function',
321
+ recentDocuments: typeof target.noteRecentDocument === 'function',
322
+ });
323
+ }
237
324
  if (typeof target?.setDockBadge === 'function') {
238
325
  return frozen('cocoa', {
239
326
  badge: true,
240
327
  badgeText: true, // the tile takes any label
241
328
  progress: false, // NSDockTile has no progress bar
242
329
  urgent: true, // requestUserAttention, via window states
243
- menu: typeof target.setDockMenu === 'function',
330
+ menu: typeof target.setLauncherMenu === 'function',
244
331
  // The Dock always shows the app; nothing has to be installed for it.
245
332
  needsDesktopFile: false,
333
+ // The Dock has no static-task menu and no hover toolbar. macOS does
334
+ // keep a Recent list (`noteNewRecentDocumentURL:`), but this backend
335
+ // has nothing wired to it, and the map says what the backend can do.
336
+ tasks: false,
337
+ thumbnailToolbar: false,
338
+ recentDocuments: typeof target.noteRecentDocument === 'function',
246
339
  });
247
340
  }
248
341
  return null;
@@ -283,6 +376,11 @@ async function probeLauncher({ app } = {}) {
283
376
  urgent: true,
284
377
  menu: true, // the quicklist
285
378
  needsDesktopFile: true,
379
+ // The quicklist is the menu; `Actions=` in the .desktop file are the
380
+ // closest thing to static tasks and are not driven from here.
381
+ tasks: false,
382
+ thumbnailToolbar: false,
383
+ recentDocuments: false,
286
384
  });
287
385
  }
288
386