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.
- package/package.json +2 -2
- package/src/anchor.js +48 -22
- package/src/cocoa/app.js +13 -105
- package/src/cocoa/clipboard.js +485 -0
- package/src/cocoa/idle.js +92 -0
- package/src/cocoa/statusitem.js +30 -10
- package/src/components/Dialog.js +69 -28
- package/src/components/Menu.js +64 -23
- package/src/events.js +22 -4
- package/src/frame/index.js +4 -1
- package/src/idle.js +50 -20
- package/src/idlehooks.js +5 -2
- package/src/index.d.ts +14 -3
- package/src/nodes/node.js +6 -0
- package/src/nodes/textarea.js +10 -1
- package/src/nodes/textinput.js +8 -0
- package/src/nodes/window/anchoring.js +87 -2
- package/src/nodes/window/flush.js +5 -0
- package/src/nodes/window/listeners.js +4 -2
- package/src/nodes/window/size.js +98 -7
- package/src/nodes/window/window.js +12 -0
- package/src/types/components.d.ts +35 -8
- package/src/types/elements.d.ts +5 -1
- package/src/types/system.d.ts +3 -1
|
@@ -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
|
+
}
|
package/src/cocoa/statusitem.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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? */
|