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/package.json +2 -1
- package/src/application.js +25 -1
- package/src/capabilities.js +324 -0
- package/src/cocoa/app.js +13 -0
- package/src/cocoa/context2d.js +116 -6
- package/src/dbusmenuexport.js +243 -0
- package/src/desktopcapabilityhooks.js +137 -0
- package/src/globalmenu.js +3 -205
- package/src/imagesource.js +15 -2
- package/src/index.d.ts +1 -0
- package/src/index.js +8 -2
- package/src/launcher.js +235 -32
- package/src/launcherhooks.js +47 -28
- package/src/nodes/image.js +2 -1
- package/src/statusnotifier.js +605 -0
- package/src/trayhooks.js +177 -29
- package/src/types/capabilities.d.ts +127 -0
- package/src/types/launcher.d.ts +50 -4
- package/src/types/tray.d.ts +52 -6
- package/src/wayland/context2d.js +28 -2
- package/src/wayland/device.js +23 -0
- package/src/wayland/target.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.3",
|
|
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:desktop": "tsx examples/desktop.jsx",
|
|
36
37
|
"examples:tooltips": "tsx examples/tooltips.jsx",
|
|
37
38
|
"examples:chat": "tsx examples/chat.jsx",
|
|
38
39
|
"examples:clipboard": "tsx examples/clipboard.jsx",
|
package/src/application.js
CHANGED
|
@@ -346,6 +346,18 @@ function environmentContext() {
|
|
|
346
346
|
* that was mounted after the launch it is asking about.
|
|
347
347
|
*/
|
|
348
348
|
let current = null;
|
|
349
|
+
/**
|
|
350
|
+
* The role of this process's registration, which is **not** the same question
|
|
351
|
+
* as `current`.
|
|
352
|
+
*
|
|
353
|
+
* `current` is the *primary* registration and is deliberately null for a
|
|
354
|
+
* second copy of an app, because a secondary must not own the badge or the
|
|
355
|
+
* launcher entry — the primary does. But "no registration at all" and "a
|
|
356
|
+
* registration that lost the race" are different facts, and a feature probe
|
|
357
|
+
* that cannot tell them apart tells an app author to call a function they
|
|
358
|
+
* already called. See `capabilities.js`.
|
|
359
|
+
*/
|
|
360
|
+
let currentRole = null;
|
|
349
361
|
|
|
350
362
|
/**
|
|
351
363
|
* The schemes the registration declared, kept beside it for the transports
|
|
@@ -675,6 +687,7 @@ export async function registerApplication(options = {}) {
|
|
|
675
687
|
await registration.remove().catch(() => {});
|
|
676
688
|
await forward(ref, dbus, { appId, objectPath, uris });
|
|
677
689
|
await ref.release();
|
|
690
|
+
currentRole = 'secondary';
|
|
678
691
|
const secondary = {
|
|
679
692
|
role: 'secondary',
|
|
680
693
|
appId,
|
|
@@ -711,7 +724,10 @@ export async function registerApplication(options = {}) {
|
|
|
711
724
|
async release() {
|
|
712
725
|
if (released) return;
|
|
713
726
|
released = true;
|
|
714
|
-
if (current === primaryRegistration)
|
|
727
|
+
if (current === primaryRegistration) {
|
|
728
|
+
current = null;
|
|
729
|
+
currentRole = null;
|
|
730
|
+
}
|
|
715
731
|
await ref.bus.releaseName(appId).catch(() => {});
|
|
716
732
|
await registration?.remove().catch(() => {});
|
|
717
733
|
registration = null;
|
|
@@ -721,6 +737,7 @@ export async function registerApplication(options = {}) {
|
|
|
721
737
|
primaryRegistration[Symbol.asyncDispose] = () =>
|
|
722
738
|
primaryRegistration.release();
|
|
723
739
|
current = primaryRegistration;
|
|
740
|
+
currentRole = 'primary';
|
|
724
741
|
return primaryRegistration;
|
|
725
742
|
}
|
|
726
743
|
|
|
@@ -774,6 +791,12 @@ async function forward(ref, dbus, { appId, objectPath, uris }) {
|
|
|
774
791
|
}
|
|
775
792
|
}
|
|
776
793
|
|
|
794
|
+
/** `'primary' | 'secondary' | null`. Not public — `capabilities.js` uses it to
|
|
795
|
+
* tell "never registered" from "registered, but another copy is the app". */
|
|
796
|
+
export function currentRegistrationRole() {
|
|
797
|
+
return currentRole;
|
|
798
|
+
}
|
|
799
|
+
|
|
777
800
|
/** This process's registration, or `null`. Not public; the docs use `role`. */
|
|
778
801
|
export function currentRegistration() {
|
|
779
802
|
return current;
|
|
@@ -782,6 +805,7 @@ export function currentRegistration() {
|
|
|
782
805
|
/** Test seam, not public: forget every handler, buffer and registration. */
|
|
783
806
|
export function _resetApplicationState() {
|
|
784
807
|
current = null;
|
|
808
|
+
currentRole = null;
|
|
785
809
|
currentSchemes = null;
|
|
786
810
|
openHandlers.clear();
|
|
787
811
|
activateHandlers.clear();
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// What this desktop can actually do — feature discovery for the things an app
|
|
2
|
+
// does *outside* its own windows.
|
|
3
|
+
//
|
|
4
|
+
// ## Why this is not `useSupports()`
|
|
5
|
+
//
|
|
6
|
+
// `useSupports()` answers questions about the **display**: is there a
|
|
7
|
+
// compositor, is there a 32-bit visual, did this connection get the direct GL
|
|
8
|
+
// backend. All of them are local, synchronous, and knowable before the first
|
|
9
|
+
// frame.
|
|
10
|
+
//
|
|
11
|
+
// Everything on this page is the opposite on all three counts. Whether there
|
|
12
|
+
// is a notification daemon, a tray host or a launcher listening is a fact
|
|
13
|
+
// about **another process on a bus**, it takes a round trip to learn, and it
|
|
14
|
+
// changes while the app runs — a panel restarts, an extension is toggled, a
|
|
15
|
+
// user logs into a different session type. So the shape has to be render
|
|
16
|
+
// state that settles and then follows, not a boolean that is right on the
|
|
17
|
+
// first frame.
|
|
18
|
+
//
|
|
19
|
+
// ## Why a boolean is not enough
|
|
20
|
+
//
|
|
21
|
+
// "Does this desktop have notifications" is the wrong question, because the
|
|
22
|
+
// answer is yes on machines that mean four different things by it:
|
|
23
|
+
//
|
|
24
|
+
// - a freedesktop daemon with `actions` — a banner with buttons that call
|
|
25
|
+
// back into the app, updated in place, reporting what the user did;
|
|
26
|
+
// - a freedesktop daemon **without** `actions` — GNOME's own for years,
|
|
27
|
+
// and several minimal ones — where the same call shows a banner and the
|
|
28
|
+
// buttons silently never appear;
|
|
29
|
+
// - macOS's notification centre — actions and callbacks, but only for a
|
|
30
|
+
// signed bundle, and a different vocabulary underneath;
|
|
31
|
+
// - `notify-send` or `osascript` — one-way text with an urgency and an
|
|
32
|
+
// icon, no update, no close, no events, ever.
|
|
33
|
+
//
|
|
34
|
+
// An app that wants "reply from the notification" has to know which of those
|
|
35
|
+
// it has, and the honest unit is therefore a **feature set**, not a flag. The
|
|
36
|
+
// freedesktop daemons already publish exactly this through `GetCapabilities`;
|
|
37
|
+
// this module's job is to translate every backend's answer into one portable
|
|
38
|
+
// vocabulary so an app branches on the feature and never on the platform.
|
|
39
|
+
//
|
|
40
|
+
// ## The rule for adding one
|
|
41
|
+
//
|
|
42
|
+
// A capability name is a *thing an app wants to do*, and its features are the
|
|
43
|
+
// parts of it that a backend can honestly lack. If a feature is missing
|
|
44
|
+
// everywhere but one backend it is still a feature — `badgeText` is macOS's
|
|
45
|
+
// alone and is listed — but if a "feature" is really a different way of doing
|
|
46
|
+
// the same thing, it belongs in `backend` instead. Backends are named after
|
|
47
|
+
// the mechanism (`statusnotifier`, `cocoa`, `notify-send`), never after the
|
|
48
|
+
// platform, so that a second Linux mechanism does not need a second name for
|
|
49
|
+
// Linux.
|
|
50
|
+
|
|
51
|
+
import { sessionBus } from './bus.js';
|
|
52
|
+
import { currentRegistration, currentRegistrationRole } from './application.js';
|
|
53
|
+
import {
|
|
54
|
+
notificationBackend,
|
|
55
|
+
NOTIFICATIONS_NAME,
|
|
56
|
+
NOTIFICATIONS_PATH,
|
|
57
|
+
} from './notifications.js';
|
|
58
|
+
import { WATCHER_NAME } from './statusnotifier.js';
|
|
59
|
+
import { liveApps } from './trace-registry.js';
|
|
60
|
+
|
|
61
|
+
/** Every capability this module can answer for. */
|
|
62
|
+
export const CAPABILITIES = ['notifications', 'tray', 'launcher'];
|
|
63
|
+
|
|
64
|
+
/** The shape a probe resolves to when there is no mechanism at all. */
|
|
65
|
+
const NONE = Object.freeze({ available: false, backend: null, features: {} });
|
|
66
|
+
|
|
67
|
+
const frozen = (backend, features) =>
|
|
68
|
+
Object.freeze({
|
|
69
|
+
available: true,
|
|
70
|
+
backend,
|
|
71
|
+
features: Object.freeze(features),
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/** The app to ask when the caller did not say. Mirrors `launcher.js`. */
|
|
75
|
+
function soleApp() {
|
|
76
|
+
const apps = liveApps();
|
|
77
|
+
if (apps.length <= 1) return apps[0] ?? null;
|
|
78
|
+
const showing = apps.filter((app) => (app._rootChildren ?? []).length > 0);
|
|
79
|
+
return showing.length === 1 ? showing[0] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// notifications
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The freedesktop daemon's own `GetCapabilities`, translated.
|
|
88
|
+
*
|
|
89
|
+
* The daemon publishes a flat list of strings; the names below are the ones
|
|
90
|
+
* that change what an app may do. Anything not in the list is absent, which
|
|
91
|
+
* is why every field is read with `includes` rather than defaulted true —
|
|
92
|
+
* a daemon that lists nothing supports nothing but a banner.
|
|
93
|
+
*/
|
|
94
|
+
function fromDaemonCaps(caps) {
|
|
95
|
+
const has = (name) => caps.includes(name);
|
|
96
|
+
return {
|
|
97
|
+
// The two that decide whether a notification is a conversation or a sign.
|
|
98
|
+
actions: has('actions'),
|
|
99
|
+
events: has('actions'),
|
|
100
|
+
// Every fd.o daemon can replace and close by id; neither is advertised
|
|
101
|
+
// as a capability because the protocol requires both.
|
|
102
|
+
update: true,
|
|
103
|
+
close: true,
|
|
104
|
+
body: has('body'),
|
|
105
|
+
bodyMarkup: has('body-markup'),
|
|
106
|
+
bodyImage: has('body-images'),
|
|
107
|
+
icon: has('icon-static') || has('icon-multi'),
|
|
108
|
+
sound: has('sound'),
|
|
109
|
+
// The banner survives in a tray/centre rather than expiring unseen.
|
|
110
|
+
persistence: has('persistence'),
|
|
111
|
+
urgency: true,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function probeNotifications({ app } = {}) {
|
|
116
|
+
const backend = await notificationBackend({ app });
|
|
117
|
+
if (!backend) return NONE;
|
|
118
|
+
|
|
119
|
+
if (backend === 'dbus') {
|
|
120
|
+
const ref = await sessionBus();
|
|
121
|
+
if (!ref) return NONE;
|
|
122
|
+
try {
|
|
123
|
+
const iface = await ref.bus.getInterface(
|
|
124
|
+
NOTIFICATIONS_NAME,
|
|
125
|
+
NOTIFICATIONS_PATH,
|
|
126
|
+
NOTIFICATIONS_NAME,
|
|
127
|
+
);
|
|
128
|
+
const caps = await new Promise((resolve) => {
|
|
129
|
+
iface.GetCapabilities((err, list) => resolve(err ? [] : (list ?? [])));
|
|
130
|
+
});
|
|
131
|
+
return frozen('dbus', fromDaemonCaps(caps));
|
|
132
|
+
} catch {
|
|
133
|
+
// The name is there but the object will not answer. A banner will
|
|
134
|
+
// probably still post; nothing richer should be promised.
|
|
135
|
+
return frozen('dbus', fromDaemonCaps([]));
|
|
136
|
+
} finally {
|
|
137
|
+
await ref.release();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (backend === 'cocoa') {
|
|
142
|
+
return frozen('cocoa', {
|
|
143
|
+
actions: true,
|
|
144
|
+
events: true,
|
|
145
|
+
update: true,
|
|
146
|
+
close: true,
|
|
147
|
+
body: true,
|
|
148
|
+
bodyMarkup: false, // the centre renders plain text
|
|
149
|
+
bodyImage: true,
|
|
150
|
+
icon: true,
|
|
151
|
+
sound: true,
|
|
152
|
+
persistence: true,
|
|
153
|
+
urgency: true, // mapped onto interruption levels
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `osascript` and `notify-send`: one way, and that is the whole of it.
|
|
158
|
+
// `notify-send -p` prints an id on newer libnotify, which is why `update`
|
|
159
|
+
// is not flatly false there — see notifications.js.
|
|
160
|
+
return frozen(backend, {
|
|
161
|
+
actions: false,
|
|
162
|
+
events: false,
|
|
163
|
+
update: backend === 'notify-send',
|
|
164
|
+
close: false,
|
|
165
|
+
body: true,
|
|
166
|
+
bodyMarkup: false,
|
|
167
|
+
bodyImage: false,
|
|
168
|
+
icon: true,
|
|
169
|
+
sound: false,
|
|
170
|
+
persistence: false,
|
|
171
|
+
urgency: backend === 'notify-send',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// tray
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
async function probeTray({ app } = {}) {
|
|
180
|
+
const target = app ?? soleApp();
|
|
181
|
+
if (typeof target?.createStatusItem === 'function') {
|
|
182
|
+
return frozen('cocoa', {
|
|
183
|
+
menu: true,
|
|
184
|
+
iconName: true, // SF Symbols
|
|
185
|
+
iconBytes: true,
|
|
186
|
+
attention: false, // no NeedsAttention equivalent on a status item
|
|
187
|
+
overlay: false,
|
|
188
|
+
tooltip: true,
|
|
189
|
+
title: true,
|
|
190
|
+
click: true,
|
|
191
|
+
clickPosition: true,
|
|
192
|
+
clickRect: true,
|
|
193
|
+
clickModifiers: true,
|
|
194
|
+
scroll: false,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const ref = await sessionBus();
|
|
199
|
+
if (!ref) return NONE;
|
|
200
|
+
try {
|
|
201
|
+
// A **live owner**, not an activatable name — see `statusnotifier.js`.
|
|
202
|
+
if (!(await ref.bus.nameHasOwner(WATCHER_NAME))) return NONE;
|
|
203
|
+
} catch {
|
|
204
|
+
return NONE;
|
|
205
|
+
} finally {
|
|
206
|
+
await ref.release();
|
|
207
|
+
}
|
|
208
|
+
return frozen('statusnotifier', {
|
|
209
|
+
menu: true,
|
|
210
|
+
iconName: true, // themed icon names, which is the good path here
|
|
211
|
+
iconBytes: true, // ARGB pixmaps
|
|
212
|
+
attention: true,
|
|
213
|
+
overlay: true,
|
|
214
|
+
tooltip: true,
|
|
215
|
+
title: true,
|
|
216
|
+
click: true,
|
|
217
|
+
clickPosition: true,
|
|
218
|
+
// The protocol carries none of these — see `statusnotifier.js`.
|
|
219
|
+
clickRect: false,
|
|
220
|
+
clickModifiers: false,
|
|
221
|
+
scroll: true,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// launcher
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
async function probeLauncher({ app } = {}) {
|
|
230
|
+
const target = app ?? soleApp();
|
|
231
|
+
if (typeof target?.setDockBadge === 'function') {
|
|
232
|
+
return frozen('cocoa', {
|
|
233
|
+
badge: true,
|
|
234
|
+
badgeText: true, // the tile takes any label
|
|
235
|
+
progress: false, // NSDockTile has no progress bar
|
|
236
|
+
urgent: true, // requestUserAttention, via window states
|
|
237
|
+
menu: typeof target.setDockMenu === 'function',
|
|
238
|
+
// The Dock always shows the app; nothing has to be installed for it.
|
|
239
|
+
needsDesktopFile: false,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// The Linux rung needs two things that are not the bus: an app id to
|
|
244
|
+
// attribute the entry to, and a `.desktop` file of that name for the
|
|
245
|
+
// launcher to hang it on. The first is knowable here; the second is not
|
|
246
|
+
// (it is a file on a path the launcher chooses), which is why it is
|
|
247
|
+
// reported as a *requirement* rather than as availability.
|
|
248
|
+
const ref = await sessionBus();
|
|
249
|
+
if (!ref) return NONE;
|
|
250
|
+
await ref.release();
|
|
251
|
+
if (!currentRegistration()?.appId) {
|
|
252
|
+
return Object.freeze({
|
|
253
|
+
available: false,
|
|
254
|
+
backend: null,
|
|
255
|
+
features: {},
|
|
256
|
+
// Two different "no"s, and only one is a mistake. A **secondary**
|
|
257
|
+
// instance called `registerApplication()` and lost the race for the
|
|
258
|
+
// name — the first copy owns the badge and the quicklist, which is the
|
|
259
|
+
// whole point of single-instance — so telling its author to call a
|
|
260
|
+
// function they already called sends them after a bug that is not
|
|
261
|
+
// there. The launcher is genuinely unavailable *to this process*
|
|
262
|
+
// either way; only the advice differs.
|
|
263
|
+
reason:
|
|
264
|
+
currentRegistrationRole() === 'secondary' ? 'not-primary' : 'no-app-id',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return frozen('launcherentry', {
|
|
268
|
+
badge: true,
|
|
269
|
+
badgeText: false, // the protocol carries a count and nothing else
|
|
270
|
+
progress: true,
|
|
271
|
+
urgent: true,
|
|
272
|
+
menu: true, // the quicklist
|
|
273
|
+
needsDesktopFile: true,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
const PROBES = {
|
|
280
|
+
notifications: probeNotifications,
|
|
281
|
+
tray: probeTray,
|
|
282
|
+
launcher: probeLauncher,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* What this desktop can do for one feature, as a promise.
|
|
287
|
+
*
|
|
288
|
+
* ```js
|
|
289
|
+
* const n = await desktopCapability('notifications');
|
|
290
|
+
* if (n.features.actions) postWithReplyButton();
|
|
291
|
+
* else postPlainBanner();
|
|
292
|
+
* ```
|
|
293
|
+
*
|
|
294
|
+
* Resolves to `{ available, backend, features }`. `available` false means
|
|
295
|
+
* there is no mechanism at all and `features` is empty; otherwise `backend`
|
|
296
|
+
* names the mechanism — `'dbus'`, `'cocoa'`, `'statusnotifier'`,
|
|
297
|
+
* `'launcherentry'`, `'notify-send'`, `'osascript'` — and `features` is the
|
|
298
|
+
* portable vocabulary for this capability.
|
|
299
|
+
*
|
|
300
|
+
* **Never cached.** A panel restarting, an extension being enabled or a
|
|
301
|
+
* daemon being installed all change the answer, and a cached "no" would
|
|
302
|
+
* outlive every one of them. {@link useDesktopCapability} re-probes on the same
|
|
303
|
+
* events that would change it.
|
|
304
|
+
*/
|
|
305
|
+
export async function desktopCapability(name, options = {}) {
|
|
306
|
+
const probe = PROBES[name];
|
|
307
|
+
if (!probe) {
|
|
308
|
+
throw new TypeError(
|
|
309
|
+
`react-x11: desktopCapability(${JSON.stringify(name)}) — no such ` +
|
|
310
|
+
`capability. Expected ${CAPABILITIES.join(', ')}.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
return await probe(options);
|
|
315
|
+
} catch {
|
|
316
|
+
// A probe that throws is a desktop that could not be asked, which is the
|
|
317
|
+
// same outcome for a caller as one that answered no.
|
|
318
|
+
return NONE;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The "nothing here" answer, exported so a caller can compare against it and
|
|
323
|
+
* so the hook has something stable to return on the first frame. */
|
|
324
|
+
export const NO_CAPABILITY = NONE;
|
package/src/cocoa/app.js
CHANGED
|
@@ -38,6 +38,7 @@ import { CocoaPaneWindow } from './panewindow.js';
|
|
|
38
38
|
import { CocoaColorSampler } from './screencolor.js';
|
|
39
39
|
import { CocoaFilePanels } from './filepanels.js';
|
|
40
40
|
import { CocoaFontManager } from './fonts.js';
|
|
41
|
+
import { releaseImageUpload } from './context2d.js';
|
|
41
42
|
import { CocoaSurface } from './surface.js';
|
|
42
43
|
import { CocoaWindow } from './window.js';
|
|
43
44
|
import { decodeKey, modifierMask } from './keymap.js';
|
|
@@ -449,6 +450,18 @@ export class CocoaApp {
|
|
|
449
450
|
return new CocoaSurface(this, options);
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/**
|
|
454
|
+
* The release seam for an `Image`'s upload (`freeImage`, src/imagesource.js).
|
|
455
|
+
* `ctx.drawImage(image)` here composites from a CG bitmap made for the
|
|
456
|
+
* Image on its first draw, which ntk's `Image.destroy()` — written for X,
|
|
457
|
+
* where the upload is a pixmap it tracks itself — cannot see; an owner
|
|
458
|
+
* letting go of an Image calls this too, and the bitmap is freed on the
|
|
459
|
+
* call. An Image nobody releases takes its bitmap with it when collected.
|
|
460
|
+
*/
|
|
461
|
+
releaseImage(image) {
|
|
462
|
+
releaseImageUpload(image);
|
|
463
|
+
}
|
|
464
|
+
|
|
452
465
|
/**
|
|
453
466
|
* The `useGlobalMenu` transport seam: same owner shape as the D-Bus
|
|
454
467
|
* GlobalMenuExport (start/stop/update), pointed at the macOS menu bar.
|
package/src/cocoa/context2d.js
CHANGED
|
@@ -33,6 +33,100 @@ function parseColor(value) {
|
|
|
33
33
|
return parsed;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Bytes as a Buffer over the same memory — for a view, its own window of
|
|
37
|
+
* the ArrayBuffer, not the whole buffer from offset 0. */
|
|
38
|
+
function toBuffer(data) {
|
|
39
|
+
if (Buffer.isBuffer(data)) return data;
|
|
40
|
+
if (ArrayBuffer.isView(data)) {
|
|
41
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.from(data);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- ntk Images as drawImage sources -----------------------------------------
|
|
47
|
+
//
|
|
48
|
+
// An ntk `Image` is straight RGBA in JS memory. On X, ntk uploads it to a
|
|
49
|
+
// pixmap per connection and caches that on the Image; here the upload is a
|
|
50
|
+
// CG bitmap made on the first draw and kept in this map, then composited
|
|
51
|
+
// through `ctxDrawSurface` like any surface, scaling and cropping included.
|
|
52
|
+
// The bridge's `ctxPutImageData` does the conversion — it premultiplies the
|
|
53
|
+
// straight bytes into the bitmap's BGRA (ByteOrder32Host + AlphaFirst) —
|
|
54
|
+
// so the bytes go over as the Image holds them.
|
|
55
|
+
//
|
|
56
|
+
// Images are immutable content (ntk's contract, and `<image>`'s), so an
|
|
57
|
+
// entry is never refreshed. The map is keyed weakly: an Image that is
|
|
58
|
+
// dropped takes its entry along and the handle's finalizer frees the
|
|
59
|
+
// bitmap; an owner that is done with one frees it on the call instead,
|
|
60
|
+
// through `releaseImageUpload` (the app's `releaseImage` seam).
|
|
61
|
+
|
|
62
|
+
/** Image -> { native, handle } */
|
|
63
|
+
const imageUploads = new WeakMap();
|
|
64
|
+
|
|
65
|
+
/** An ntk `Image`: the duck type `isDirectImageSource` and ntk's own
|
|
66
|
+
* `drawImage` accept, plus the pixels this backend reads in place of the
|
|
67
|
+
* picture. A bare `{ width, height, data }` is not one — `ImageData` is
|
|
68
|
+
* written between draws, and caching it by identity would show stale
|
|
69
|
+
* pixels. */
|
|
70
|
+
function isImagePixels(image) {
|
|
71
|
+
const { width, height, data } = image;
|
|
72
|
+
return (
|
|
73
|
+
typeof image.picture === 'function' &&
|
|
74
|
+
Number.isInteger(width) &&
|
|
75
|
+
Number.isInteger(height) &&
|
|
76
|
+
width > 0 &&
|
|
77
|
+
height > 0 &&
|
|
78
|
+
data?.length === width * height * 4
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function uploadImage(native, image) {
|
|
83
|
+
const held = imageUploads.get(image);
|
|
84
|
+
if (held?.native === native) return held.handle;
|
|
85
|
+
const { width, height } = image;
|
|
86
|
+
const handle = native.createSurface(width, height, 1);
|
|
87
|
+
native.ctxPutImageData(handle, toBuffer(image.data), width, height, 0, 0);
|
|
88
|
+
imageUploads.set(image, { native, handle });
|
|
89
|
+
return handle;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Free an Image's bitmap now, if it has one; drawing it again uploads it
|
|
93
|
+
* again, as ntk's `destroy()` promises for its own copies. */
|
|
94
|
+
export function releaseImageUpload(image) {
|
|
95
|
+
const held = image != null && imageUploads.get(image);
|
|
96
|
+
if (!held) return;
|
|
97
|
+
imageUploads.delete(image);
|
|
98
|
+
if (typeof held.native.releaseSurface === 'function') {
|
|
99
|
+
held.native.releaseSurface(held.handle);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const warnedSources = new Set();
|
|
104
|
+
|
|
105
|
+
/** A source this backend has no pixels for, said once per kind in
|
|
106
|
+
* development — the alternative is an empty box and no reason. */
|
|
107
|
+
function warnUndrawable(image) {
|
|
108
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
109
|
+
const kind =
|
|
110
|
+
typeof image === 'object'
|
|
111
|
+
? (image.constructor?.name ?? 'object')
|
|
112
|
+
: typeof image;
|
|
113
|
+
if (warnedSources.has(kind)) return;
|
|
114
|
+
warnedSources.add(kind);
|
|
115
|
+
const serverSide = typeof image === 'object' && 'id' in image;
|
|
116
|
+
const article = /^[aeiou]/i.test(kind) ? 'an' : 'a';
|
|
117
|
+
console.warn(
|
|
118
|
+
`react-x11: drawImage on the cocoa backend has no pixels for ${article} ${kind}, ` +
|
|
119
|
+
'and draws nothing. ' +
|
|
120
|
+
(serverSide
|
|
121
|
+
? 'It names an X server-side Picture or Drawable — <image picture>, ' +
|
|
122
|
+
'<image drawable>, an ntk Picture — and this backend has no X ' +
|
|
123
|
+
'server to composite from, so those are X11-only. Hand <image src> ' +
|
|
124
|
+
'the pixels instead: encoded PNG/JPEG bytes, raw RGBA, or an ntk Image.'
|
|
125
|
+
: 'This backend draws a Surface (react-x11/ntk) or an ntk Image; wrap ' +
|
|
126
|
+
'raw RGBA as new Image({ width, height, data }).'),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
36
130
|
class LinearGradient {
|
|
37
131
|
constructor(x0, y0, x1, y1) {
|
|
38
132
|
this._coords = [x0, y0, x1, y1];
|
|
@@ -953,8 +1047,8 @@ export class CocoaContext2D {
|
|
|
953
1047
|
}
|
|
954
1048
|
|
|
955
1049
|
drawImage(image, ...args) {
|
|
956
|
-
const src = image
|
|
957
|
-
if (!src) return;
|
|
1050
|
+
const src = this._sourceHandle(image);
|
|
1051
|
+
if (!src) return;
|
|
958
1052
|
const size = this._native.surfaceSize(src);
|
|
959
1053
|
let sx = 0;
|
|
960
1054
|
let sy = 0;
|
|
@@ -990,6 +1084,25 @@ export class CocoaContext2D {
|
|
|
990
1084
|
this._dirty();
|
|
991
1085
|
}
|
|
992
1086
|
|
|
1087
|
+
/**
|
|
1088
|
+
* The bitmap a `drawImage` source composites from: a surface's own, an
|
|
1089
|
+
* ntk Image's upload (made on its first draw, see `uploadImage`), or
|
|
1090
|
+
* none. A destroyed surface is none, silently — it had pixels once; any
|
|
1091
|
+
* other source is one this backend cannot draw at all, and development
|
|
1092
|
+
* says so once per kind.
|
|
1093
|
+
*/
|
|
1094
|
+
_sourceHandle(image) {
|
|
1095
|
+
if (image == null) return null;
|
|
1096
|
+
if (typeof image === 'object') {
|
|
1097
|
+
if ('_surfaceHandle' in image || image._surface) {
|
|
1098
|
+
return image._surfaceHandle ?? image._surface?._surfaceHandle ?? null;
|
|
1099
|
+
}
|
|
1100
|
+
if (isImagePixels(image)) return uploadImage(this._native, image);
|
|
1101
|
+
}
|
|
1102
|
+
warnUndrawable(image);
|
|
1103
|
+
return null;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
993
1106
|
/**
|
|
994
1107
|
* `drawImage` as a row memcpy, for the one shape where a copy is all it
|
|
995
1108
|
* ever was: a surface composited into another at a translate, whole
|
|
@@ -1081,12 +1194,9 @@ export class CocoaContext2D {
|
|
|
1081
1194
|
|
|
1082
1195
|
putImageData(data, x, y) {
|
|
1083
1196
|
if (!data?.data) return;
|
|
1084
|
-
const buf = Buffer.isBuffer(data.data)
|
|
1085
|
-
? data.data
|
|
1086
|
-
: Buffer.from(data.data.buffer ?? data.data);
|
|
1087
1197
|
this._native.ctxPutImageData(
|
|
1088
1198
|
this._s(),
|
|
1089
|
-
|
|
1199
|
+
toBuffer(data.data),
|
|
1090
1200
|
data.width,
|
|
1091
1201
|
data.height,
|
|
1092
1202
|
Math.round(x),
|