react-x11 2.47.2 → 2.47.4

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.
@@ -0,0 +1,485 @@
1
+ // The clipboard on the cocoa backend: ntk's `app.clipboard` shape over
2
+ // `NSPasteboard.general`, which is what src/clipboard.js and the text
3
+ // controls call.
4
+ //
5
+ // ## One pasteboard, and it is CLIPBOARD's
6
+ //
7
+ // X's other everyday selection, PRIMARY, is taken by *selecting text at all*
8
+ // and pasted with a middle click, and core takes it on every selection
9
+ // gesture (textselection.js `own()`, the text controls' `_ownSelection`).
10
+ // Sent to the pasteboard, each of those gestures was a Copy: a drag across a
11
+ // paragraph replaced the link the user had just copied in another app.
12
+ //
13
+ // So every other name — PRIMARY, SECONDARY, one of the app's own — is a
14
+ // selection nobody on this desktop can paste from: a write resolves and
15
+ // changes nothing, a read finds no owner, `targets()` is empty, `clear()`
16
+ // leaves the pasteboard alone and `watch()` never fires. Deliberately not a
17
+ // PRIMARY kept in this process: its point is the paste in *another*
18
+ // application, and inside one all it would keep is a middle click that types
19
+ // into a text field, which no Mac text field does. An app that wants
20
+ // select-to-copy anyway — a terminal — writes CLIPBOARD from
21
+ // `onSelectionChange` (docs/clipboard.md, "Two clipboards").
22
+ //
23
+ // ## Reading what is there
24
+ //
25
+ // `targets()` and `read({ target })` see the pasteboard's items and the
26
+ // types each offers, in react-x11's vocabulary (dnd.js's table, then the
27
+ // system's): `public.png` is `image/png`, text is the X names and the MIME
28
+ // ones at once, and a type with no MIME type keeps its own name — which is
29
+ // how `org.nspasteboard.ConcealedType`, the marker a password manager puts
30
+ // beside a secret so clipboard tools leave it out of their history, reaches
31
+ // an app that wants to honour it. Two rungs, the way the file dialog and the
32
+ // notifications have them:
33
+ //
34
+ // 1. **`pasteboardItems()` and `pasteboardItemData(index, type)` on the
35
+ // bridge**, where it has them — the general pasteboard's twins of the
36
+ // drag pasteboard's `dragItems`/`dragItemData`, and asked the same way: in
37
+ // the call on the main thread, through a callback from a worker.
38
+ // 2. **`osascript -l JavaScript`** beneath them: the same two questions asked
39
+ // of `NSPasteboard` through the scripting bridge, one short-lived child
40
+ // each — about 60 ms of wall clock and as much CPU. Affordable because nothing
41
+ // asks it on a clock: a `targets()` or a `read()` the app made, and one
42
+ // per change while something is watching; and the item list is kept
43
+ // against the change count, so a second `targets()` with nothing copied
44
+ // in between asks nobody.
45
+ //
46
+ // Text is neither: `pasteboardReadText`, the verb every bridge has, answers
47
+ // a text read on both rungs, so a paste in a text field never waits on a
48
+ // child process.
49
+ //
50
+ // ## Knowing when it changes
51
+ //
52
+ // Nothing announces a write to the pasteboard — macOS has no counterpart of
53
+ // XFixes' selection events — so `watch()` asks `NSPasteboard.changeCount`,
54
+ // which every write bumps, every `WATCH_POLL_MS` while something is watching
55
+ // and never otherwise (#937). `pasteboardChangeCount()` is a published copy
56
+ // on a worker (the bridge re-reads it on that same quarter second) and a
57
+ // call into AppKit on the main thread; either is under a microsecond. A bridge
58
+ // without it rejects the watch, the way an X server without XFixes does, and
59
+ // the edit menu's Paste row stays enabled there, which is where it started.
60
+
61
+ import { TEXT_TARGETS } from '../transfer.js';
62
+ import { mimeFromUti, utiFromMime, wire } from './dnd.js';
63
+
64
+ /** CLIPBOARD, named or by default: the one selection that is the pasteboard. */
65
+ const isClipboard = (selection = 'CLIPBOARD') => selection === 'CLIPBOARD';
66
+
67
+ const TEXT = 'public.utf8-plain-text';
68
+ const FILE_URL = 'public.file-url';
69
+ const URL_TYPE = 'public.url';
70
+
71
+ /**
72
+ * How often a watched pasteboard's change count is asked, in ms. The bridge
73
+ * republishes it for a worker on the same quarter second, so asking faster
74
+ * there would only read the same number again; and a quarter second is under
75
+ * the time it takes to move from the app that copied to a menu in this one.
76
+ */
77
+ export const WATCH_POLL_MS = 250;
78
+
79
+ /** What text on the pasteboard is called here: its MIME names and X's, best
80
+ * first, so `read('text')` and `readText()` both find it. */
81
+ const TEXT_NAMES = TEXT_TARGETS.filter(
82
+ (t) => t !== 'TEXT' && t !== 'COMPOUND_TEXT',
83
+ );
84
+
85
+ /** A reverse-DNS type identifier. The pasteboard also carries the legacy
86
+ * names AppKit still hands out (`NSStringPboardType`, `Apple PNG pasteboard
87
+ * type`), which name nothing an app could ask for. */
88
+ const isUti = (type) => /^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/.test(type);
89
+
90
+ /** Where `owner` would name a window on X11: the pasteboard has content, and
91
+ * no window of anybody's owns it. Never 0, which is "nothing to paste". */
92
+ export const PASTEBOARD_OWNER = -1;
93
+
94
+ /**
95
+ * The pasteboard's types — every item's, in the order the writer offered
96
+ * them — as the target names `targets()` answers with.
97
+ */
98
+ export function pasteboardTargets(types, native) {
99
+ const out = [];
100
+ const add = (t) => t && !out.includes(t) && out.push(t);
101
+ for (const type of types) {
102
+ if (type === TEXT) {
103
+ for (const name of TEXT_NAMES) add(name);
104
+ continue;
105
+ }
106
+ const mime = mimeFromUti(type, native);
107
+ if (mime) add(mime);
108
+ // a dynamic type with no MIME type behind it is no name anybody could
109
+ // have asked for: it is the system's hash of one
110
+ else if (isUti(type) && !type.startsWith('dyn.')) add(type);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** The pasteboard types a target is read from, best first. */
116
+ function typesFor(target, native) {
117
+ if (target === 'text/uri-list') return [FILE_URL, URL_TYPE];
118
+ const uti = utiFromMime(target, native);
119
+ if (uti) return [uti];
120
+ return isUti(target) ? [target] : [];
121
+ }
122
+
123
+ // --- the osascript rung --------------------------------------------------------
124
+
125
+ /** The pasteboard through the scripting bridge: `items` answers the change
126
+ * count and each item's types, `data <type>` each item's bytes of it. */
127
+ const JXA = `ObjC.import('AppKit');
128
+ function run(argv) {
129
+ const pb = $.NSPasteboard.generalPasteboard;
130
+ const items = ObjC.unwrap(pb.pasteboardItems) || [];
131
+ if (argv[0] === 'items') {
132
+ return JSON.stringify({
133
+ count: Number(pb.changeCount),
134
+ items: items.map((item) => ObjC.deepUnwrap(item.types) || []),
135
+ });
136
+ }
137
+ return JSON.stringify(items.map((item) => {
138
+ const data = item.dataForType(argv[1]);
139
+ return data.isNil() ? null : data.base64EncodedStringWithOptions(0).js;
140
+ }));
141
+ }`;
142
+
143
+ const OSASCRIPT = '/usr/bin/osascript';
144
+
145
+ /**
146
+ * Test seam, not public: what runs `osascript`, as `(file, args) =>
147
+ * Promise<stdout>`. A fake here lets the rung run off a Mac, and lets a test
148
+ * on one leave the real pasteboard alone.
149
+ */
150
+ let runOsascript = null;
151
+ export function _setOsascriptForTests(fn) {
152
+ runOsascript = fn;
153
+ }
154
+
155
+ async function execOsascript(file, args) {
156
+ const { execFile } = await import('node:child_process');
157
+ return new Promise((resolve, reject) => {
158
+ execFile(
159
+ file,
160
+ args,
161
+ // an image on the pasteboard comes back as base64 on stdout
162
+ { encoding: 'utf8', timeout: 10_000, maxBuffer: 512 * 1024 * 1024 },
163
+ (err, out) => (err ? reject(err) : resolve(String(out ?? ''))),
164
+ );
165
+ });
166
+ }
167
+
168
+ const jxa = async (...args) =>
169
+ JSON.parse(
170
+ await (runOsascript ?? execOsascript)(OSASCRIPT, [
171
+ '-l',
172
+ 'JavaScript',
173
+ '-e',
174
+ JXA,
175
+ ...args,
176
+ ]),
177
+ );
178
+
179
+ const toBuffer = (value) =>
180
+ value == null
181
+ ? null
182
+ : typeof value === 'string'
183
+ ? Buffer.from(value, 'utf8')
184
+ : Buffer.from(value);
185
+
186
+ // --- the clipboard -------------------------------------------------------------
187
+
188
+ export class CocoaClipboard {
189
+ constructor(app, native) {
190
+ this._app = app;
191
+ this._native = native;
192
+ this._warnedFlavours = false;
193
+ this._warnedRead = false;
194
+ // the osascript rung's last item list, and the change count it is for
195
+ this._cached = null;
196
+ this._watchers = new Set();
197
+ this._timer = null;
198
+ this._lastCount = null;
199
+ this._checking = false;
200
+ }
201
+
202
+ /**
203
+ * A write is one pasteboard item with every flavour under its UTI —
204
+ * dnd.js's table, then the system's — the way a drag offers its payload.
205
+ * Text alone keeps pasteboardWriteText, the verb every bridge has;
206
+ * anything else needs pasteboardWrite (windowkit/appkit#119), and a bridge
207
+ * without it keeps the text and says, once, what it dropped — an image
208
+ * that silently never reaches the pasteboard looks like a Copy that did
209
+ * nothing.
210
+ */
211
+ write(data, { selection } = {}) {
212
+ const native = this._native;
213
+ if (!isClipboard(selection)) return Promise.resolve();
214
+ if (typeof data === 'string') {
215
+ native.pasteboardWriteText(data);
216
+ return Promise.resolve();
217
+ }
218
+ const item = {};
219
+ for (const [type, value] of Object.entries(data ?? {})) {
220
+ const uti = utiFromMime(type, native);
221
+ if (uti && value != null && !(uti in item)) item[uti] = wire(value);
222
+ }
223
+ const raw = item[TEXT];
224
+ const text =
225
+ raw == null
226
+ ? ''
227
+ : typeof raw === 'string'
228
+ ? raw
229
+ : Buffer.from(raw).toString('utf8');
230
+ const others = Object.keys(item).filter((uti) => uti !== TEXT);
231
+ if (others.length === 0) {
232
+ native.pasteboardWriteText(text);
233
+ } else if (typeof native.pasteboardWrite === 'function') {
234
+ native.pasteboardWrite(item);
235
+ } else {
236
+ if (!this._warnedFlavours && process.env.NODE_ENV !== 'production') {
237
+ this._warnedFlavours = true;
238
+ console.warn(
239
+ `react-x11: clipboard.write() kept only the text — this ` +
240
+ `@windowkit/appkit has no pasteboardWrite, so ` +
241
+ `${others.join(', ')} did not reach the pasteboard. ` +
242
+ 'Update @windowkit/appkit to a release with pasteboardWrite ' +
243
+ '(windowkit/appkit#119).',
244
+ );
245
+ }
246
+ native.pasteboardWriteText(text);
247
+ }
248
+ return Promise.resolve();
249
+ }
250
+
251
+ clear(selection) {
252
+ if (isClipboard(selection)) this._native.pasteboardClear();
253
+ return Promise.resolve();
254
+ }
255
+
256
+ async targets({ selection } = {}) {
257
+ if (!isClipboard(selection)) return [];
258
+ let items;
259
+ try {
260
+ items = await this._items();
261
+ } catch (err) {
262
+ // No rung could list the types. The text is still worth reporting:
263
+ // it is what this answered before there were rungs at all.
264
+ this._warnRead(err);
265
+ const text = await this._app._ask('pasteboardReadText');
266
+ return text == null ? [] : [...TEXT_NAMES];
267
+ }
268
+ return pasteboardTargets(items.flat(), this._native);
269
+ }
270
+
271
+ async read({ selection, target } = {}) {
272
+ if (!isClipboard(selection)) {
273
+ throw new Error(
274
+ `clipboard: nothing to paste — ${selection} is an X11 ` +
275
+ 'selection, and on macOS only CLIPBOARD is the pasteboard',
276
+ );
277
+ }
278
+ if (target === undefined || TEXT_TARGETS.includes(target)) {
279
+ const text = await this._app._ask('pasteboardReadText');
280
+ if (text == null) {
281
+ throw new Error(
282
+ 'clipboard: nothing to paste — the pasteboard is empty',
283
+ );
284
+ }
285
+ return target === undefined ? text : Buffer.from(text, 'utf8');
286
+ }
287
+ let empty = true;
288
+ for (const type of typesFor(target, this._native)) {
289
+ let values;
290
+ try {
291
+ values = await this._dataOf(type);
292
+ } catch (err) {
293
+ this._warnRead(err);
294
+ throw new Error(
295
+ `clipboard: cannot read ${target} from the pasteboard — ` +
296
+ `${err?.message ?? err}`,
297
+ );
298
+ }
299
+ if (values.length) empty = false;
300
+ const present = values.filter((v) => v != null);
301
+ if (!present.length) continue;
302
+ // A Finder copy of three files is three items of one URL each; a
303
+ // uri-list is how the rest of react-x11 hears about several files.
304
+ if (type === FILE_URL || type === URL_TYPE) {
305
+ return Buffer.from(
306
+ present.map((v) => v.toString('utf8')).join('\r\n') + '\r\n',
307
+ );
308
+ }
309
+ return present[0];
310
+ }
311
+ throw new Error(
312
+ empty
313
+ ? 'clipboard: nothing to paste — the pasteboard is empty'
314
+ : `clipboard: cannot convert the pasteboard to ${target}`,
315
+ );
316
+ }
317
+
318
+ /**
319
+ * `handler` with ntk's `SelectionChange` whenever the pasteboard's change
320
+ * count moves: `owner` 0 when it was left empty and `PASTEBOARD_OWNER`
321
+ * when it holds anything, `changeCount` the pasteboard's own count. Copies
322
+ * closer together than one poll are one change.
323
+ */
324
+ watch(selection, handler) {
325
+ if (typeof handler !== 'function') {
326
+ return Promise.reject(
327
+ new TypeError('clipboard: watch needs a handler function'),
328
+ );
329
+ }
330
+ if (!isClipboard(selection)) return Promise.resolve(() => {});
331
+ if (typeof this._native.pasteboardChangeCount !== 'function') {
332
+ return Promise.reject(
333
+ new Error(
334
+ 'clipboard: this @windowkit/appkit has no pasteboardChangeCount, ' +
335
+ 'so pasteboard changes cannot be watched — update ' +
336
+ '@windowkit/appkit',
337
+ ),
338
+ );
339
+ }
340
+ const entry = { handler };
341
+ if (!this._watchers.size) this._startPolling();
342
+ this._watchers.add(entry);
343
+ let stopped = false;
344
+ return Promise.resolve(() => {
345
+ if (stopped) return;
346
+ stopped = true;
347
+ this._watchers.delete(entry);
348
+ if (!this._watchers.size) this._stopPolling();
349
+ });
350
+ }
351
+
352
+ /** Stop asking; the app is closing. */
353
+ close() {
354
+ this._watchers.clear();
355
+ this._stopPolling();
356
+ }
357
+
358
+ // --- internals ---------------------------------------------------------------
359
+
360
+ _changeCount() {
361
+ if (typeof this._native.pasteboardChangeCount !== 'function') return null;
362
+ const count = this._native.pasteboardChangeCount();
363
+ return typeof count === 'number' ? count : null;
364
+ }
365
+
366
+ _hasItemVerbs() {
367
+ const native = this._native;
368
+ return (
369
+ typeof native.pasteboardItems === 'function' &&
370
+ typeof native.pasteboardItemData === 'function'
371
+ );
372
+ }
373
+
374
+ /** Each item on the pasteboard, as the types it offers. */
375
+ async _items() {
376
+ if (this._hasItemVerbs()) {
377
+ const items = await this._app._ask('pasteboardItems');
378
+ return Array.isArray(items)
379
+ ? items.map((item) => (Array.isArray(item?.types) ? item.types : []))
380
+ : [];
381
+ }
382
+ const count = this._changeCount();
383
+ if (count != null && this._cached?.count === count) {
384
+ return this._cached.items;
385
+ }
386
+ const answer = await jxa('items');
387
+ const items = Array.isArray(answer?.items) ? answer.items : [];
388
+ this._cached = { count: answer?.count, items };
389
+ return items;
390
+ }
391
+
392
+ /** Each item's bytes of `type`, null where an item has none. */
393
+ async _dataOf(type) {
394
+ if (this._hasItemVerbs()) {
395
+ const items = await this._items();
396
+ const values = await Promise.all(
397
+ items.map((types, index) =>
398
+ types.includes(type)
399
+ ? this._app._ask('pasteboardItemData', index, type)
400
+ : null,
401
+ ),
402
+ );
403
+ return values.map(toBuffer);
404
+ }
405
+ const values = await jxa('data', type);
406
+ return Array.isArray(values)
407
+ ? values.map((v) => (v == null ? null : Buffer.from(v, 'base64')))
408
+ : [];
409
+ }
410
+
411
+ /** A Mac always has `osascript`, so one that cannot run it is a sandbox or
412
+ * a stripped image, and the clipboard would read as text-only there with
413
+ * nothing to say why. Said once, in development. */
414
+ _warnRead(err) {
415
+ if (this._warnedRead || process.env.NODE_ENV === 'production') return;
416
+ this._warnedRead = true;
417
+ console.warn(
418
+ 'react-x11: the clipboard cannot list what is on the pasteboard — ' +
419
+ 'this @windowkit/appkit has no pasteboardItems, and ' +
420
+ `\`${OSASCRIPT}\` failed: ${err?.message ?? err}. targets() ` +
421
+ 'reports text alone meanwhile, and a read of anything else rejects.',
422
+ );
423
+ }
424
+
425
+ _startPolling() {
426
+ this._lastCount = this._changeCount();
427
+ this._timer = setInterval(() => this._poll(), WATCH_POLL_MS);
428
+ // a watch never holds the process open, as an X subscription does not
429
+ this._timer.unref?.();
430
+ }
431
+
432
+ _stopPolling() {
433
+ if (this._timer) clearInterval(this._timer);
434
+ this._timer = null;
435
+ this._lastCount = null;
436
+ }
437
+
438
+ _poll() {
439
+ // one question about the contents at a time; a change during it is
440
+ // still a change when it is over, since the count is compared again
441
+ if (this._checking) return;
442
+ const count = this._changeCount();
443
+ if (count == null || count === this._lastCount) return;
444
+ this._lastCount = count;
445
+ this._checking = true;
446
+ this._holdsAnything()
447
+ .then((full) => {
448
+ if (!this._timer) return;
449
+ const change = {
450
+ selection: 'CLIPBOARD',
451
+ owner: full ? PASTEBOARD_OWNER : 0,
452
+ reason: 'new-owner',
453
+ changeCount: count,
454
+ };
455
+ // a throwing handler must not cost the others their event
456
+ for (const { handler } of [...this._watchers]) {
457
+ try {
458
+ handler(change);
459
+ } catch (err) {
460
+ console.warn(
461
+ `react-x11: clipboard watch handler threw: ${err?.message ?? err}`,
462
+ );
463
+ }
464
+ }
465
+ })
466
+ .finally(() => {
467
+ this._checking = false;
468
+ });
469
+ }
470
+
471
+ /** Whether a paste would find anything. Unknown means yes, as it does in
472
+ * src/pastestate.js: a Paste row left enabled does nothing worse than
473
+ * paste nothing. */
474
+ async _holdsAnything() {
475
+ try {
476
+ return (await this._items()).some((types) => types.length > 0);
477
+ } catch {
478
+ try {
479
+ return (await this._app._ask('pasteboardReadText')) != null;
480
+ } catch {
481
+ return true;
482
+ }
483
+ }
484
+ }
485
+ }
@@ -0,0 +1,92 @@
1
+ // How long since anybody touched the Mac — the number `useIdle()` asks the
2
+ // app for as `lastInputMs()` (src/idle.js), the same shape the win32 backend
3
+ // answers with `GetLastInputInfo`. Without it nothing on this backend could
4
+ // answer: every other rung of that ladder reaches into `app.X`, and the cocoa
5
+ // `X` is a stub with no SYNC and no MIT-SCREEN-SAVER behind it, so
6
+ // `useIdle()` stayed `false` on a Mac that had sat untouched for an hour
7
+ // (#938).
8
+ //
9
+ // Two rungs, the bridge's verb over a shell-out, the way the file dialog and
10
+ // the notifications have them:
11
+ //
12
+ // 1. **`lastInputMs()` on the bridge**, where it has one: CoreGraphics'
13
+ // `CGEventSourceSecondsSinceLastEventType` over the combined session state
14
+ // and any input event type, in milliseconds. A call into the window
15
+ // server's own count, needing no AppKit and no main thread, so it is
16
+ // asked synchronously from a worker too.
17
+ // 2. **`ioreg`**, beneath it. IOHIDSystem publishes `HIDIdleTime` —
18
+ // nanoseconds since the last HID event, on any device, whichever app it
19
+ // went to — as a registry property, and `ioreg` prints it: one short-lived
20
+ // child, about 20 ms of wall clock and a millisecond of CPU. That is only
21
+ // affordable because of how src/idle.js asks: the remaining time while the
22
+ // user is there (one child per timeout), and a scaled interval while they
23
+ // are away (a quarter of the timeout, from a second up to half a minute:
24
+ // every 30 s for five minutes) — and only while a component is subscribed.
25
+ // Two timeouts asking at once share one child.
26
+ //
27
+ // Both count input on the whole session rather than in this process, which
28
+ // is what an idle timeout means.
29
+
30
+ /** IOHIDSystem's property, in nanoseconds; `-d 1` stops at the one entry. */
31
+ const IOREG = '/usr/sbin/ioreg';
32
+ const IOREG_ARGS = ['-c', 'IOHIDSystem', '-r', '-d', '1', '-k', 'HIDIdleTime'];
33
+
34
+ /**
35
+ * Test seam, not public: what runs `ioreg`, as `(file, args) => Promise<stdout>`.
36
+ * A fake here lets the rung run off a Mac, where the real one cannot.
37
+ */
38
+ let runIoreg = null;
39
+ export function _setIoregForTests(fn) {
40
+ runIoreg = fn;
41
+ }
42
+
43
+ async function execIoreg(file, args) {
44
+ const { execFile } = await import('node:child_process');
45
+ return new Promise((resolve, reject) => {
46
+ execFile(file, args, { encoding: 'utf8', timeout: 5_000 }, (err, out) =>
47
+ err ? reject(err) : resolve(String(out ?? '')),
48
+ );
49
+ });
50
+ }
51
+
52
+ /** `"HIDIdleTime" = 3588594533583` → milliseconds, or null. */
53
+ export function hidIdleMs(stdout) {
54
+ const match = /"HIDIdleTime"\s*=\s*(\d+)/.exec(stdout);
55
+ return match ? Number(match[1]) / 1e6 : null;
56
+ }
57
+
58
+ /**
59
+ * The app's `lastInputMs`: the bridge's verb where it has one, and the
60
+ * `ioreg` reader where it does not. Never throws; the reader resolves `null`
61
+ * when `ioreg` could not answer, which src/idle.js takes as "ask again
62
+ * later" rather than as an answer.
63
+ */
64
+ export function lastInputReader(native) {
65
+ if (typeof native.lastInputMs === 'function') {
66
+ return () => native.lastInputMs();
67
+ }
68
+ let pending = null;
69
+ let warned = false;
70
+ return () => {
71
+ pending ??= (runIoreg ?? execIoreg)(IOREG, IOREG_ARGS)
72
+ .then(hidIdleMs, (err) => {
73
+ // A Mac always has `ioreg`, so one that cannot run it is a sandbox or
74
+ // a stripped image, and `useIdle()` would sit at `false` there with
75
+ // nothing to say why. Said once, in development.
76
+ if (!warned && process.env.NODE_ENV !== 'production') {
77
+ warned = true;
78
+ console.warn(
79
+ 'react-x11: useIdle() cannot read the idle time — this ' +
80
+ '@windowkit/appkit has no lastInputMs, and ' +
81
+ `\`${IOREG}\` failed: ${err?.message ?? err}. It answers ` +
82
+ 'false meanwhile, and asks ioreg again a timeout later.',
83
+ );
84
+ }
85
+ return null;
86
+ })
87
+ .finally(() => {
88
+ pending = null;
89
+ });
90
+ return pending;
91
+ };
92
+ }
@@ -18,7 +18,7 @@
18
18
  // each item's allocator starts in a stride of its own (`ID_STRIDE`), the
19
19
  // ids of two trays' menus are disjoint by construction, and the app asks
20
20
  // each item whether the id is one of its own.
21
- import { IdAllocator, snapshot } from '../dbusmenu.js';
21
+ import { IdAllocator, diffSnapshots, snapshot } from '../dbusmenu.js';
22
22
  import { menuItemsSpec } from './globalmenu.js';
23
23
 
24
24
  /** How far apart two items' id ranges start. A menu with a million rows is
@@ -48,6 +48,9 @@ export class CocoaStatusItem {
48
48
  this.alloc = new IdAllocator();
49
49
  this.alloc.next = nextStride++ * ID_STRIDE + 1;
50
50
  this.nodes = null;
51
+ // the `items` last handed to `setMenu`; undefined until the first, which
52
+ // is always sent
53
+ this.items = undefined;
51
54
  this.removed = false;
52
55
  this.onClick = options.onClick ?? null;
53
56
  this.handle = app._native.createStatusItem(statusItemSpec(options));
@@ -65,16 +68,33 @@ export class CocoaStatusItem {
65
68
  this.setMenu(options.menu ?? null);
66
69
  }
67
70
 
68
- /** `items`, or null to go back to reporting clicks. */
71
+ /**
72
+ * `items`, or null to go back to reporting clicks.
73
+ *
74
+ * Sent only when it would build a different menu (#939). The bridge makes
75
+ * a new `NSMenu` for every `setStatusItemMenu`, and `useTray` passes the
76
+ * menu with every patch, so a tray whose title ticks once a second rebuilt
77
+ * its menu once a second. The same array is the same menu; a new one —
78
+ * a menu written inline is new every render — is snapshotted, which keeps
79
+ * this render's `onSelect`s, and is sent only if the snapshot differs from
80
+ * the last: the spec is built from nothing else.
81
+ */
69
82
  setMenu(items) {
70
- if (this.removed) return;
71
- if (!items) {
72
- this.nodes = null;
73
- this.app._native.setStatusItemMenu(this.handle, null);
74
- return;
75
- }
76
- this.nodes = snapshot(items, this.alloc);
77
- this.app._native.setStatusItemMenu(this.handle, menuItemsSpec(this.nodes));
83
+ if (this.removed || items === this.items) return;
84
+ const first = this.items === undefined;
85
+ this.items = items;
86
+ const nodes = items ? snapshot(items, this.alloc) : null;
87
+ const same =
88
+ !first &&
89
+ (nodes && this.nodes
90
+ ? diffSnapshots(this.nodes, nodes).kind === 'none'
91
+ : nodes === this.nodes);
92
+ this.nodes = nodes;
93
+ if (same) return;
94
+ this.app._native.setStatusItemMenu(
95
+ this.handle,
96
+ nodes ? menuItemsSpec(nodes) : null,
97
+ );
78
98
  }
79
99
 
80
100
  /** Is this `menu-activate` id one of this item's menu's? */