react-x11 2.15.1 → 2.15.3

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/src/trayhooks.js CHANGED
@@ -1,25 +1,72 @@
1
1
  // `useTray()` — an icon in the system tray for as long as a component is
2
2
  // mounted, on the backends that have one.
3
3
  //
4
- // Today that is the cocoa backend's menu-bar extra (`NSStatusItem`,
5
- // src/cocoa/statusitem.js). The freedesktop counterpart, StatusNotifierItem
6
- // over D-Bus, is react-x11#353's open question; until it lands the hook is
7
- // inert on X11 and says so — `available: false`, and a one-time development
8
- // note — rather than pretending, so an app can keep the feature behind the
9
- // answer. The inert-props policy of docs/macos.md, for a hook.
4
+ // Two rungs, found by capability and never by naming a platform:
5
+ //
6
+ // 1. **the app's own status bar** the cocoa backend's menu-bar extra
7
+ // (`NSStatusItem`, src/cocoa/statusitem.js), found by the app carrying
8
+ // `createStatusItem`.
9
+ // 2. **`org.kde.StatusNotifierItem`** over the session bus
10
+ // (`statusnotifier.js`) — the freedesktop tray, and the same protocol
11
+ // under X11, XWayland and Wayland. Used when a **live**
12
+ // `org.kde.StatusNotifierWatcher` is hosting one.
13
+ //
14
+ // Where neither answers there is no tray, and `available` is `false` — a
15
+ // stock GNOME session with no AppIndicator extension is the common case, and
16
+ // it is a configuration rather than a fault. An app reads `available` and
17
+ // keeps its tray feature behind it; nothing is logged, because "this desktop
18
+ // has no tray" is not news.
19
+ //
20
+ // ## What the hook returns, and why it is not just a boolean
21
+ //
22
+ // `{ available, backend, features, error }` — the same shape
23
+ // `useDesktopCapability('tray')` resolves to, and built from the same probe so the
24
+ // two can never disagree. The difference is which question they answer:
25
+ //
26
+ // - **this hook measures.** `available` is whether *this item* was taken by
27
+ // a host. It tried; it knows.
28
+ // - **`useDesktopCapability` predicts.** It answers "would a tray icon be taken if
29
+ // one were asked for", which is what a settings screen needs — a "Show
30
+ // tray icon" checkbox must render correctly *without* putting an icon in
31
+ // the tray to find out.
32
+ //
33
+ // Prefer this one wherever the feature is actually mounted. `features` is the
34
+ // portable vocabulary from `capabilities.js` — `features.clickModifiers` is
35
+ // false on the freedesktop rung, because the protocol carries no modifier
36
+ // state, and an app that dims a shift-click affordance reads it here rather
37
+ // than testing the platform.
38
+ //
39
+ // `error` is the fourth field because "no tray on this desktop" and "there is
40
+ // a tray and it refused me" are different facts with different fixes, and
41
+ // collapsing them into `available: false` loses the one an app could act on.
42
+ //
43
+ // ## `available` settles, it does not start true
44
+ //
45
+ // The cocoa rung can be answered synchronously — the method is either on the
46
+ // app object or it is not. The freedesktop one cannot: it takes a bus
47
+ // connection and a `NameHasOwner` round trip. So `available` is **render
48
+ // state that settles**, false on the first frame and true a tick later if a
49
+ // watcher is there, and it follows the watcher for the life of the item —
50
+ // a panel that exits takes the icon with it and flips this back to false.
51
+ //
52
+ // That is the shape every capability in this family should have, and
53
+ // docs/desktop.md says so: a synchronous "can I?" that is honest on the first
54
+ // frame is not available for anything that has to ask the desktop.
10
55
 
11
56
  import { useEffect, useRef, useState } from 'react';
57
+ import { decodeImage } from 'ntk';
12
58
 
13
59
  import { useAppOrNull } from './appcontext.js';
14
-
15
- let warnedInert = false;
60
+ import { currentRegistration } from './application.js';
61
+ import { NO_CAPABILITY, desktopCapability } from './capabilities.js';
62
+ import { StatusNotifierItem, allocateItemSlot } from './statusnotifier.js';
16
63
 
17
64
  /**
18
65
  * An icon in the system tray while this component is mounted.
19
66
  *
20
67
  * ```jsx
21
68
  * const { available } = useTray({
22
- * icon: 'bell.badge', // an SF Symbol name, or PNG bytes
69
+ * icon: 'bell.badge', // a themed icon name (an SF Symbol on macOS), or PNG bytes
23
70
  * tooltip: 'Notifications',
24
71
  * menu: [{ label: 'Open', onSelect: open }, { label: 'Quit', onSelect: quit }],
25
72
  * });
@@ -27,37 +74,52 @@ let warnedInert = false;
27
74
  *
28
75
  * With `menu`, a click opens it — the same `items` vocabulary `MenuBar` and
29
76
  * `useDockMenu` take, an item's `onSelect` firing when picked. Without one,
30
- * `onClick` is called with the button and the item's screen rect, which is
31
- * where to anchor a popup of your own. `null` means no item. Every field
32
- * follows its value while mounted; the item is removed on unmount.
77
+ * `onClick` is called with the button and, where the backend knows it, the
78
+ * item's screen rect. `null` means no item. Every field follows its value
79
+ * while mounted; the item is removed on unmount.
33
80
  *
34
- * `available` is whether this backend has a tray at all: false on X11
35
- * today (#353), and the honest answer to branch on.
81
+ * `available` is whether this backend has a tray at all, and it **settles**:
82
+ * false on the first frame, true once a tray has been found. Branch on it for
83
+ * a "show tray icon" setting; do not branch on it to decide whether to call
84
+ * the hook, which would break the rules of hooks the moment it changed.
36
85
  */
37
86
  export function useTray(options) {
38
87
  const app = useAppOrNull();
39
- const available = typeof app?.createStatusItem === 'function';
88
+ const native = typeof app?.createStatusItem === 'function';
40
89
  const itemRef = useRef(null);
41
90
  const [rect] = useState(null);
91
+ const [remote, setRemote] = useState(false);
92
+ const [error, setError] = useState(null);
93
+ // The feature vocabulary for whichever rung answered. Probed once per
94
+ // backend rather than per render — it describes the mechanism, not the
95
+ // item — and for *both* rungs: the cocoa one is synchronous about whether
96
+ // it has a tray, but not about what that tray can do, and an app reading
97
+ // `features.clickModifiers` to dim a shift-click affordance would
98
+ // otherwise be told `undefined` on the one backend that has modifiers.
99
+ const [caps, setCaps] = useState(NO_CAPABILITY);
100
+
101
+ useEffect(() => {
102
+ if (!options) return undefined;
103
+ let cancelled = false;
104
+ // `app` explicitly rather than letting the probe find the sole one: a
105
+ // process with several connections has several trays, and this hook
106
+ // belongs to one of them.
107
+ void desktopCapability('tray', { app }).then((c) => {
108
+ if (!cancelled) setCaps(c);
109
+ });
110
+ return () => {
111
+ cancelled = true;
112
+ };
113
+ }, [app, native, options == null]);
42
114
 
43
115
  // the options a click or a pick reads are the current render's, not the
44
116
  // ones the item was created with three minutes ago
45
117
  const live = useRef(options);
46
118
  live.current = options;
47
119
 
120
+ // ------------------------------------------------------- rung 1: the app's
48
121
  useEffect(() => {
49
- if (!available) {
50
- if (process.env.NODE_ENV !== 'production' && !warnedInert && app) {
51
- warnedInert = true;
52
- console.warn(
53
- 'react-x11: useTray() is inert on this backend — the freedesktop ' +
54
- 'tray (StatusNotifierItem) is not implemented yet (#353). Read ' +
55
- '`available` to keep the feature behind the answer.',
56
- );
57
- }
58
- return undefined;
59
- }
60
- if (!options) return undefined;
122
+ if (!native || !options) return undefined;
61
123
  const item = app.createStatusItem({
62
124
  ...options,
63
125
  onClick: (ev) => live.current?.onClick?.(ev),
@@ -70,7 +132,7 @@ export function useTray(options) {
70
132
  // Recreated only when the item comes or goes: the fields patch in
71
133
  // place below, and an `options` object rebuilt every render must not
72
134
  // rebuild the item every render.
73
- }, [app, available, options == null]);
135
+ }, [app, native, options == null]);
74
136
 
75
137
  useEffect(() => {
76
138
  const item = itemRef.current;
@@ -86,5 +148,91 @@ export function useTray(options) {
86
148
  options?.menu,
87
149
  ]);
88
150
 
89
- return { available, rect };
151
+ // ------------------------------------------- rung 2: the freedesktop tray
152
+ const sniRef = useRef(null);
153
+ const previous = useRef(options ?? {});
154
+ // One slot for the life of this hook, **not** per item object. Passing
155
+ // `null` and then options again is the same tray icon going away and coming
156
+ // back; on a fresh path the host has no way to know that and draws a second
157
+ // one beside the first. See `StatusNotifierItem.announcePassive`.
158
+ const slotRef = useRef(null);
159
+ slotRef.current ??= allocateItemSlot();
160
+
161
+ useEffect(() => {
162
+ if (native || !options) return undefined;
163
+ let cancelled = false;
164
+ // The id hosts key their hidden-icons setting on. The app's registered id
165
+ // when it has one, so the choice survives a restart.
166
+ const appId = currentRegistration()?.appId ?? 'react-x11';
167
+ const item = new StatusNotifierItem({
168
+ getOptions: () => live.current,
169
+ appId,
170
+ slot: slotRef.current,
171
+ decodeIcon: decodeIconBytes,
172
+ // A host that answered the bus and then refused the registration is a
173
+ // fact worth reporting; a desktop with no tray at all is not, and does
174
+ // not come through here.
175
+ onError: (err) => {
176
+ if (!cancelled)
177
+ setError(err instanceof Error ? err : new Error(String(err)));
178
+ },
179
+ });
180
+ sniRef.current = item;
181
+ previous.current = { ...live.current };
182
+ void item.start().then((ok) => {
183
+ if (!cancelled) setRemote(ok);
184
+ });
185
+ return () => {
186
+ cancelled = true;
187
+ sniRef.current = null;
188
+ setRemote(false);
189
+ setError(null);
190
+ void item.stop();
191
+ };
192
+ }, [native, options == null]);
193
+
194
+ // The item reads its fields through `getOptions`, so a render only has to
195
+ // say *which* of them moved — see `StatusNotifierItem.update`.
196
+ useEffect(() => {
197
+ const item = sniRef.current;
198
+ if (!item) return;
199
+ item.update(previous.current);
200
+ previous.current = { ...live.current };
201
+ }, [
202
+ options?.icon,
203
+ options?.overlayIcon,
204
+ options?.attentionIcon,
205
+ options?.title,
206
+ options?.tooltip,
207
+ options?.visible,
208
+ options?.attention,
209
+ options?.menu,
210
+ ]);
211
+
212
+ // The cocoa rung is synchronous and always itself; the freedesktop one
213
+ // reports whatever the probe found. `features` is empty until one settles,
214
+ // which is the same "render the fallback first" order `available` has.
215
+ const backend = native ? 'cocoa' : remote ? caps.backend : null;
216
+ return {
217
+ available: native || remote,
218
+ backend,
219
+ features: native || remote ? caps.features : NO_CAPABILITY.features,
220
+ error,
221
+ rect,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * PNG/JPEG bytes → raw RGBA, through ntk's decoder.
227
+ *
228
+ * Defensive: a corrupt or unsupported image should cost the tray its *icon*,
229
+ * not its tray. The pixmap ends up empty and the item still registers with
230
+ * its name, tooltip and menu intact.
231
+ */
232
+ function decodeIconBytes(bytes) {
233
+ try {
234
+ return decodeImage(bytes);
235
+ } catch {
236
+ return null;
237
+ }
90
238
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Feature discovery for the things an app does outside its own windows.
3
+ * See docs/desktop.md "Feature discovery".
4
+ */
5
+
6
+ /** Which mechanism answered. Named after the mechanism, never the platform. */
7
+ export type DesktopBackend =
8
+ | 'dbus'
9
+ | 'cocoa'
10
+ | 'osascript'
11
+ | 'notify-send'
12
+ | 'statusnotifier'
13
+ | 'launcherentry';
14
+
15
+ /** The capabilities {@link desktopCapability} can be asked about. */
16
+ export type DesktopCapabilityName = 'notifications' | 'tray' | 'launcher';
17
+
18
+ /**
19
+ * What a notification can carry here.
20
+ *
21
+ * `actions`/`events` are the pair that decide whether a notification is a
22
+ * conversation or a sign: false on the shell-out rungs and on a freedesktop
23
+ * daemon that does not advertise `actions`, where buttons silently never
24
+ * appear and nothing is ever reported back.
25
+ */
26
+ export interface NotificationFeatures {
27
+ actions: boolean;
28
+ /** `onAction`/`onClose` will fire. Tracks `actions` on every rung. */
29
+ events: boolean;
30
+ /** `update()` replaces the banner in place rather than posting a new one. */
31
+ update: boolean;
32
+ close: boolean;
33
+ body: boolean;
34
+ bodyMarkup: boolean;
35
+ bodyImage: boolean;
36
+ icon: boolean;
37
+ sound: boolean;
38
+ /** The banner survives in a tray or centre rather than expiring unseen. */
39
+ persistence: boolean;
40
+ urgency: boolean;
41
+ }
42
+
43
+ export interface TrayFeatures {
44
+ menu: boolean;
45
+ /** An icon named in the desktop's theme (an SF Symbol on the cocoa rung). */
46
+ iconName: boolean;
47
+ iconBytes: boolean;
48
+ attention: boolean;
49
+ overlay: boolean;
50
+ tooltip: boolean;
51
+ title: boolean;
52
+ click: boolean;
53
+ clickPosition: boolean;
54
+ /** The item's screen rect. False on the freedesktop rung, which has none. */
55
+ clickRect: boolean;
56
+ /** Modifier state on a click. False on the freedesktop rung. */
57
+ clickModifiers: boolean;
58
+ scroll: boolean;
59
+ }
60
+
61
+ export interface LauncherFeatures {
62
+ badge: boolean;
63
+ /** A string badge. macOS only — the launcher protocol carries a count. */
64
+ badgeText: boolean;
65
+ progress: boolean;
66
+ urgent: boolean;
67
+ /** The Dock menu / quicklist. */
68
+ menu: boolean;
69
+ /** The launcher needs an installed `.desktop` file to attach this to. */
70
+ needsDesktopFile: boolean;
71
+ }
72
+
73
+ export interface DesktopCapabilityResult<F = Record<string, boolean>> {
74
+ /** Whether there is any mechanism at all. Branch on `features` for detail. */
75
+ available: boolean;
76
+ backend: DesktopBackend | null;
77
+ /** Empty when `available` is false. */
78
+ features: Partial<F>;
79
+ /**
80
+ * Why not, when the `false` has a cause worth naming.
81
+ *
82
+ * `'no-app-id'` — the launcher needs `registerApplication({ appId })`, and
83
+ * this one is a mistake in the source.
84
+ * `'not-primary'` — it was called, and another copy of the app owns the
85
+ * identity. Correct single-instance behaviour, not a bug: the first copy
86
+ * owns the badge and the quicklist.
87
+ */
88
+ reason?: 'no-app-id' | 'not-primary';
89
+ }
90
+
91
+ export type DesktopCapabilityFor<N extends DesktopCapabilityName> =
92
+ N extends 'notifications'
93
+ ? DesktopCapabilityResult<NotificationFeatures>
94
+ : N extends 'tray'
95
+ ? DesktopCapabilityResult<TrayFeatures>
96
+ : DesktopCapabilityResult<LauncherFeatures>;
97
+
98
+ export declare const CAPABILITIES: readonly DesktopCapabilityName[];
99
+
100
+ /** The "nothing here" answer: available false, no backend, no features. */
101
+ export declare const NO_CAPABILITY: DesktopCapabilityResult;
102
+
103
+ /**
104
+ * What this desktop can do for one feature.
105
+ *
106
+ * Never cached — a panel restarting or an extension being enabled changes the
107
+ * answer, and a cached `false` would outlive the fix. Throws only for an
108
+ * unknown name, which is a mistake in the source.
109
+ */
110
+ export declare function desktopCapability<N extends DesktopCapabilityName>(
111
+ name: N,
112
+ options?: { app?: unknown },
113
+ ): Promise<DesktopCapabilityFor<N>>;
114
+
115
+ /**
116
+ * {@link desktopCapability} as render state: {@link NO_CAPABILITY} on the
117
+ * first frame, settling a tick later, and re-probed whenever a name appears
118
+ * or vanishes on the session bus.
119
+ *
120
+ * Use it for the *pre-flight* question — a settings screen that must render a
121
+ * "Show tray icon" checkbox without putting an icon in the tray. Where the
122
+ * feature is actually mounted, the feature hook's own status is a measurement
123
+ * rather than a prediction and should be preferred.
124
+ */
125
+ export declare function useDesktopCapability<N extends DesktopCapabilityName>(
126
+ name: N,
127
+ ): DesktopCapabilityFor<N>;
@@ -35,9 +35,55 @@ export declare function setBadge(
35
35
  export declare function useBadge(value: BadgeValue): void;
36
36
 
37
37
  /**
38
- * The menu behind a right-click on the Dock icon, from the same item
39
- * vocabulary `MenuBar` takes; an item's `onSelect` fires when picked.
40
- * Installed while mounted, replaced when `items` changes, taken down on
41
- * unmount. Inert off the cocoa backend.
38
+ * The menu behind a right-click on the app's icon in the Dock or launcher,
39
+ * from the same item vocabulary `MenuBar` takes; an item's `onSelect` fires
40
+ * when picked. Installed while mounted, replaced when `items` changes, taken
41
+ * down on unmount.
42
+ *
43
+ * `NSDockTile`'s menu on the cocoa backend; the launcher protocol's
44
+ * **quicklist** on Linux — a `com.canonical.dbusmenu` tree, the same menu
45
+ * protocol the tray and the global menu speak. Needs the identity
46
+ * `registerApplication({ appId })` establishes and a `.desktop` file of that
47
+ * name, like the badge.
42
48
  */
43
49
  export declare function useDockMenu(items: MenuItem[] | null): void;
50
+
51
+ /**
52
+ * A progress bar across the app's icon, `0`…`1`. `null` clears it; values
53
+ * outside the range are clamped rather than refused.
54
+ *
55
+ * The launcher protocol's `progress`. **Linux launchers only** — `NSDockTile`
56
+ * has no progress bar, so this is inert on the cocoa backend and
57
+ * `useDesktopCapability('launcher').features.progress` is the honest answer.
58
+ */
59
+ export declare function setProgress(
60
+ value: number | null | false | undefined,
61
+ options?: SetBadgeOptions,
62
+ ): Promise<boolean>;
63
+
64
+ /** {@link setProgress} while mounted; cleared on unmount. */
65
+ export declare function useProgress(
66
+ value: number | null | false | undefined,
67
+ ): void;
68
+
69
+ /**
70
+ * Ask the launcher for the user's attention, or stop asking.
71
+ *
72
+ * Distinct from `<window states={['demands_attention']}>`, which is the
73
+ * *window's* urgency hint: this marks the app's icon in the launcher whether
74
+ * or not any window is open. Inert on the cocoa backend, where the window
75
+ * state is the mechanism.
76
+ */
77
+ export declare function setUrgent(
78
+ urgent: boolean,
79
+ options?: SetBadgeOptions,
80
+ ): Promise<boolean>;
81
+
82
+ /**
83
+ * {@link useDockMenu}'s imperative twin, for code with no component. `null`
84
+ * takes the menu down.
85
+ */
86
+ export declare function setQuicklist(
87
+ items: MenuItem[] | null,
88
+ options?: SetBadgeOptions,
89
+ ): Promise<boolean>;
@@ -3,16 +3,22 @@
3
3
  */
4
4
 
5
5
  import type { MenuItem } from './components.js';
6
+ import type { DesktopBackend, TrayFeatures } from './capabilities.js';
6
7
 
7
8
  export interface TrayClickEvent {
8
9
  button: 'left' | 'right' | 'middle';
9
- /** The item's screen rect, global top-left coordinates in points — the
10
- * anchor for a popup of your own. */
10
+ /** Where the click was, in global top-left screen coordinates — the anchor
11
+ * for a popup of your own. */
11
12
  x: number;
12
13
  y: number;
14
+ /** The item's rect. `0` on the freedesktop rung, whose protocol has none —
15
+ * read `features.clickRect` rather than testing for zero. */
13
16
  width: number;
14
17
  height: number;
18
+ /** `1` on the freedesktop rung, which does not count clicks. */
15
19
  clickCount: number;
20
+ /** All `false` on the freedesktop rung, which carries no modifier state.
21
+ * `features.clickModifiers` is the honest answer. */
16
22
  shift: boolean;
17
23
  control: boolean;
18
24
  option: boolean;
@@ -20,9 +26,27 @@ export interface TrayClickEvent {
20
26
  }
21
27
 
22
28
  export interface TrayOptions {
23
- /** An SF Symbol name (`'bell.badge'`), or the bytes of a PNG. Drawn as a
24
- * template image so it follows the bar's light and dark. */
29
+ /** A themed icon name — an SF Symbol on the cocoa rung (`'bell.badge'`),
30
+ * an icon-theme name on a freedesktop one (`'mail-unread'`) or the bytes
31
+ * of a PNG. On the cocoa rung bytes are drawn as a template image so they
32
+ * follow the bar's light and dark. */
25
33
  icon?: string | Uint8Array | null;
34
+ /** Shown instead of `icon` while `attention` is set. Freedesktop only. */
35
+ attentionIcon?: string | Uint8Array | null;
36
+ /** A small badge drawn over the icon. Freedesktop only. */
37
+ overlayIcon?: string | Uint8Array | null;
38
+ /** Ask the panel to mark the item — `Status = NeedsAttention`.
39
+ * Freedesktop only; `features.attention` says so. */
40
+ attention?: boolean;
41
+ /** The spec's item category: `'ApplicationStatus'` (default),
42
+ * `'Communications'`, `'SystemServices'` or `'Hardware'`. Freedesktop
43
+ * only, and mostly affects where a panel sorts the icon. */
44
+ category?: string;
45
+ /** A directory to look `icon` up in, for icons shipped beside the app
46
+ * rather than installed in a theme. Freedesktop only. */
47
+ iconThemePath?: string;
48
+ /** A scroll over the icon. Freedesktop only. */
49
+ onScroll?: (event: { delta: number; orientation: string }) => void;
26
50
  /** Text beside the icon, or alone. */
27
51
  title?: string | null;
28
52
  tooltip?: string | null;
@@ -40,8 +64,25 @@ export interface TrayOptions {
40
64
  }
41
65
 
42
66
  export interface TrayState {
43
- /** Whether this backend has a tray at all — false on X11 today (#353). */
67
+ /**
68
+ * Whether **this item** was taken by a tray.
69
+ *
70
+ * A measurement, not a prediction: the hook tried. It **settles** — false
71
+ * on the first frame, true a tick later if a host is there — so render the
72
+ * fallback first and upgrade. It follows the host, so a panel that exits
73
+ * flips it back.
74
+ */
44
75
  available: boolean;
76
+ /** Which mechanism took it, or null. */
77
+ backend: DesktopBackend | null;
78
+ /** What that mechanism can do. Empty until `available` settles. */
79
+ features: Partial<TrayFeatures>;
80
+ /**
81
+ * A tray that answered and then **refused**, which is a different fact from
82
+ * a desktop with no tray — and the only one of the two with a fix. Null
83
+ * when there is simply no tray.
84
+ */
85
+ error: Error | null;
45
86
  /** Reserved. */
46
87
  rect: null;
47
88
  }
@@ -49,6 +90,11 @@ export interface TrayState {
49
90
  /**
50
91
  * An icon in the system tray while this component is mounted; every field
51
92
  * follows its value, and the item is removed on unmount. `null` means no
52
- * item. Inert off the cocoa backend, with `available: false`.
93
+ * item.
94
+ *
95
+ * `NSStatusItem` on the cocoa backend; `org.kde.StatusNotifierItem` over
96
+ * D-Bus on a freedesktop session, which needs something hosting a tray —
97
+ * Plasma and most panels do, GNOME needs an AppIndicator extension. Where
98
+ * neither answers, `available` stays false and nothing is logged.
53
99
  */
54
100
  export declare function useTray(options: TrayOptions | null): TrayState;
@@ -532,7 +532,7 @@ export class WaylandContext2D {
532
532
  */
533
533
  begin(width, height, timestamp = 0) {
534
534
  this.init();
535
- this._device.owner = this;
535
+ this._take();
536
536
  const t = this._target;
537
537
  if (t) {
538
538
  t.bind();
@@ -612,7 +612,7 @@ export class WaylandContext2D {
612
612
  // Mid stencil-then-cover the state is set up for the pass, not for a
613
613
  // plain batch, and the device cannot have changed hands under it.
614
614
  if (this._device.owner === this || this._stencilling) return;
615
- this._device.owner = this;
615
+ this._take();
616
616
  this._makeCurrent?.();
617
617
  this.init();
618
618
  // As `begin()` has it: a target's size *is* the projection.
@@ -624,6 +624,26 @@ export class WaylandContext2D {
624
624
  this._applyState();
625
625
  }
626
626
 
627
+ /**
628
+ * Take the device, drawing whatever the last owner still had buffered.
629
+ *
630
+ * A context batches: its quads sit in a vertex buffer until the mode
631
+ * changes, the buffer fills, or someone flushes. So the handover is where
632
+ * the outgoing owner's batch is drawn — otherwise a frame's worth of work
633
+ * can sit there while the pixels it was meant to produce are read by
634
+ * whoever comes next, and stay there until that context happens to draw
635
+ * something of a different kind (#578). The state is still the outgoing
636
+ * owner's at this point, which is what the flush needs.
637
+ *
638
+ * It is also what device.js's invariant rests on: the owner is the only
639
+ * context that can be holding anything.
640
+ */
641
+ _take() {
642
+ const previous = this._device.owner;
643
+ if (previous && previous !== this) previous._flush();
644
+ this._device.owner = this;
645
+ }
646
+
627
647
  /** Give the device up, so the next draw through this context re-takes it. */
628
648
  _release() {
629
649
  if (this._device.owner === this) this._device.owner = null;
@@ -1950,6 +1970,11 @@ export class WaylandContext2D {
1950
1970
 
1951
1971
  /** Two triangles from four corners, in order TL, TR, BR, BL. */
1952
1972
  _quad(pos, uv, color, params) {
1973
+ // Buffering is taking the device, not a step before it: the quad is GL
1974
+ // work that has been decided on and not yet issued, and the handover in
1975
+ // `_take` is the only thing that can get it issued before someone reads
1976
+ // the pixels it makes. A property compare in the common case.
1977
+ this._claim();
1953
1978
  if (this._n + 6 > MAX_VERTS) this._flush();
1954
1979
  const v = this._verts;
1955
1980
  let o = this._n * STRIDE;
@@ -1974,6 +1999,7 @@ export class WaylandContext2D {
1974
1999
 
1975
2000
  /** One triangle, for the stencil pass. Colour and params are irrelevant. */
1976
2001
  _tri(x0, y0, x1, y1, x2, y2) {
2002
+ this._claim();
1977
2003
  if (this._n + 3 > MAX_VERTS) this._flush();
1978
2004
  const v = this._verts;
1979
2005
  let o = this._n * STRIDE;
@@ -16,6 +16,15 @@
16
16
  // back. Whoever drew in between does not have to know who comes next, and a
17
17
  // caller does not have to know there is a device at all.
18
18
  //
19
+ // Claiming is also a *batch boundary*: a 2d context buffers its quads and
20
+ // draws them in one call later, so the outgoing owner is flushed on the way
21
+ // out. That is what makes the invariant this module offers true — **only the
22
+ // owner can have work still buffered** — and the invariant is what lets
23
+ // anyone about to read a target's pixels get them by flushing one context
24
+ // (`flushDevice`) rather than by knowing which one drew them. Without it a
25
+ // held surface context's whole frame could sit in a vertex buffer while the
26
+ // `drawImage` that should show it sampled the texture behind it (#578).
27
+ //
19
28
  // One record per `gl`, keyed weakly: a GPU that goes away takes its record
20
29
  // with it.
21
30
  const devices = new WeakMap();
@@ -53,3 +62,17 @@ export function releaseDevice(gl) {
53
62
  const device = shared(gl) ? devices.get(gl) : null;
54
63
  if (device) device.owner = null;
55
64
  }
65
+
66
+ /**
67
+ * Draw what is still buffered, so that a target's texture holds everything
68
+ * that has been drawn into it.
69
+ *
70
+ * The owner is the only context that can be holding anything (see above), so
71
+ * flushing it is flushing the device. Call before *reading* a target's pixels
72
+ * behind the contexts' backs — a framebuffer blit, a scroll copy — the way
73
+ * `releaseDevice` is called after writing them.
74
+ */
75
+ export function flushDevice(gl) {
76
+ const device = shared(gl) ? devices.get(gl) : null;
77
+ device?.owner?.flush();
78
+ }
@@ -21,7 +21,7 @@
21
21
  // asking EGL for it means the window's own config no longer has to carry
22
22
  // stencil bits at all; the request in glcontext.js stays as belt and braces.
23
23
 
24
- import { releaseDevice } from './device.js';
24
+ import { flushDevice, releaseDevice } from './device.js';
25
25
 
26
26
  export class GLTarget {
27
27
  /**
@@ -133,6 +133,9 @@ export class GLTarget {
133
133
  */
134
134
  blitTo(drawFbo, rects, dstWidth = this.width, dstHeight = this.height) {
135
135
  const gl = this.gl;
136
+ // Reading these pixels, so everything drawn into them has to be in the
137
+ // texture and not still in a context's vertex buffer (device.js).
138
+ flushDevice(gl);
136
139
  gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.fbo);
137
140
  gl.bindFramebuffer(gl.DRAW_FRAMEBUFFER, drawFbo);
138
141
  gl.disable(gl.SCISSOR_TEST);
@@ -201,6 +204,11 @@ export class GLTarget {
201
204
  const sy = dstY0 - dy;
202
205
 
203
206
  const gl = this.gl;
207
+ // A scroll reads this target before it writes it, so anything a context
208
+ // still has buffered has to land first — or the band that moves is the
209
+ // frame before last's, and the buffered quads arrive afterwards at the
210
+ // position the scroll just left (device.js).
211
+ flushDevice(gl);
204
212
  const scratch = scratchFor(gl, bw, bh);
205
213
  // out: this[sx,sy,bw,bh] -> scratch[0,0]
206
214
  gl.bindFramebuffer(gl.READ_FRAMEBUFFER, this.fbo);