react-x11 2.15.3 → 2.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/acceleratorhooks.js +40 -6
  5. package/src/anchor.js +79 -19
  6. package/src/capabilities.js +29 -4
  7. package/src/cocoa/app.js +211 -11
  8. package/src/cocoa/context2d.js +23 -0
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/desktopcapabilityhooks.js +29 -6
  22. package/src/filedialoghooks.js +3 -5
  23. package/src/frame/childmain.js +8 -20
  24. package/src/frame/env.js +2 -10
  25. package/src/icontheme.js +240 -0
  26. package/src/imagesource.js +83 -1
  27. package/src/index.d.ts +10 -1
  28. package/src/index.js +3 -0
  29. package/src/keysymchars.js +47 -0
  30. package/src/keysyms.d.ts +19 -1
  31. package/src/keysyms.js +107 -8
  32. package/src/node.d.ts +7 -0
  33. package/src/nodes/animation.js +17 -47
  34. package/src/nodes/cascade.js +17 -2
  35. package/src/nodes/image.js +63 -1
  36. package/src/nodes/kinds.js +12 -0
  37. package/src/nodes/layout.js +5 -1
  38. package/src/nodes/node.js +17 -3
  39. package/src/nodes/paint.js +117 -0
  40. package/src/nodes/scope.js +259 -0
  41. package/src/nodes/scrollable.js +53 -6
  42. package/src/nodes/text.js +2 -0
  43. package/src/nodes/textarea.js +1 -1
  44. package/src/nodes/textinput.js +1 -1
  45. package/src/nodes/window/anchoring.js +45 -18
  46. package/src/nodes/window/flush.js +6 -5
  47. package/src/nodes/window/popup.js +10 -0
  48. package/src/nodes/window/size.js +40 -2
  49. package/src/nodes/window/window.js +41 -14
  50. package/src/registry.js +2 -1
  51. package/src/screens.js +159 -24
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +164 -17
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +21 -5
  58. package/src/types/capabilities.d.ts +13 -1
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/events.d.ts +5 -0
  62. package/src/types/filedialog.d.ts +3 -1
  63. package/src/types/style.d.ts +57 -0
  64. package/src/types/system.d.ts +104 -0
  65. package/src/types/tray.d.ts +14 -2
  66. package/src/wayland/xkb.js +170 -59
  67. package/src/windowid.js +62 -20
@@ -0,0 +1,332 @@
1
+ // What an app remembers between launches (#592): a store of JSON values in
2
+ // the per-user directory for this app, read once, written atomically, and
3
+ // coalesced so a slider's stream of changes is one write when it settles.
4
+ //
5
+ // const settings = createSettings({
6
+ // appId: 'com.example.Hush',
7
+ // defaults: { noiseType: 'brown', volume: 0.5, dark: false },
8
+ // });
9
+ // const [volume, setVolume] = settings.use('volume');
10
+ //
11
+ // Every app that remembered anything did this by hand — a directory per
12
+ // platform, serialize, write, debounce — and the easy version of it is wrong
13
+ // in three quiet ways: a crash mid-write leaves half a file, a drag writes on
14
+ // every event, and quitting inside the debounce loses the last change.
15
+ //
16
+ // ## Where
17
+ //
18
+ // One JSON file, `settings.json`, in the app's own directory: under
19
+ // `~/Library/Application Support` on macOS and `$XDG_CONFIG_HOME` (`~/.config`)
20
+ // elsewhere. A file rather than `NSUserDefaults` on macOS, so the format and
21
+ // the behaviour are one thing on every platform. The directory is named by
22
+ // the app id, which is a reverse-DNS name like the one `registerApplication`
23
+ // takes — the store does not register anything, it only needs a name no
24
+ // other app is using.
25
+ //
26
+ // ## When it is read and written
27
+ //
28
+ // Read **synchronously**, the first time a value is asked for, so the first
29
+ // render already has what was saved: an asynchronous read would render the
30
+ // defaults and then jump. It is a small file read once. Written
31
+ // **asynchronously**, a quarter second after the last change and at least
32
+ // once a second while changes keep coming, through a temporary file and a
33
+ // rename, which is atomic: the file on disk is always the old one or the new
34
+ // one. Whatever is still waiting is written synchronously when the process
35
+ // exits, and `flush()` writes it now.
36
+ //
37
+ // Two processes of the same app share the file and the last write wins;
38
+ // nothing here watches for another process changing it.
39
+
40
+ import * as nodeFs from 'node:fs';
41
+ import { homedir } from 'node:os';
42
+ import { dirname, join } from 'node:path';
43
+ import { useCallback, useSyncExternalStore } from 'react';
44
+
45
+ /** A reverse-DNS app id: two or more dot-separated elements, the grammar
46
+ * `registerApplication` checks, which is also a safe directory name. */
47
+ const APP_ID_RE = /^[A-Za-z_-][A-Za-z0-9_-]*(\.[A-Za-z_-][A-Za-z0-9_-]*)+$/;
48
+
49
+ /** How long a change waits for the next one, and how long a stream of
50
+ * changes may put a write off. */
51
+ const DELAY_MS = 250;
52
+ const MAX_WAIT_MS = 1000;
53
+
54
+ /** The per-user directory apps keep their settings under, for a platform. */
55
+ export function settingsBaseDir({
56
+ platform = process.platform,
57
+ env = process.env,
58
+ home = homedir(),
59
+ } = {}) {
60
+ if (platform === 'darwin') {
61
+ return join(home, 'Library', 'Application Support');
62
+ }
63
+ if (platform === 'win32') {
64
+ return env.APPDATA || join(home, 'AppData', 'Roaming');
65
+ }
66
+ return env.XDG_CONFIG_HOME || join(home, '.config');
67
+ }
68
+
69
+ /** One store per file in a process, so a module evaluated twice — a hot
70
+ * reload — and two call sites naming the same app share their values
71
+ * rather than overwriting each other's writes. */
72
+ const stores = new Map();
73
+
74
+ /**
75
+ * The settings store for `appId`: values read from and written to its
76
+ * `settings.json`, with `defaults` for what was never saved.
77
+ */
78
+ export function createSettings(options = {}) {
79
+ const { appId, defaults = {}, directory, fs = nodeFs } = options;
80
+ if (typeof appId !== 'string' || !APP_ID_RE.test(appId)) {
81
+ throw new Error(
82
+ `react-x11: createSettings({ appId: ${JSON.stringify(appId)} }) — the ` +
83
+ "app id names the app's settings directory, so it is a reverse-DNS " +
84
+ 'name no other app uses: two or more dot-separated elements of ' +
85
+ '[A-Za-z_-][A-Za-z0-9_-]*, like "com.example.myapp".',
86
+ );
87
+ }
88
+ if (defaults === null || typeof defaults !== 'object') {
89
+ throw new Error(
90
+ 'react-x11: createSettings({ defaults }) — expected an object of each ' +
91
+ "setting's value when nothing was saved, like { volume: 0.5 }.",
92
+ );
93
+ }
94
+ const path = join(
95
+ directory ?? join(settingsBaseDir(), appId),
96
+ 'settings.json',
97
+ );
98
+ let store = stores.get(path);
99
+ if (!store) {
100
+ store = new SettingsStore(path, fs, options);
101
+ stores.set(path, store);
102
+ }
103
+ // the latest module's defaults win, as a reload's edit to them should
104
+ store._defaults = { ...defaults };
105
+ return store.api;
106
+ }
107
+
108
+ class SettingsStore {
109
+ constructor(path, fs, { delay = DELAY_MS, maxWait = MAX_WAIT_MS }) {
110
+ this.path = path;
111
+ this.fs = fs;
112
+ this.delay = delay;
113
+ this.maxWait = maxWait;
114
+ this._defaults = {};
115
+ this._fallbacks = new Map(); // key -> the first object fallback asked with
116
+ this._values = null; // what was saved, read on first use
117
+ this._listeners = new Set();
118
+ this._timer = null;
119
+ this._firstPending = 0; // when the oldest unwritten change was made
120
+ // Changes are counted, and a write records the count it wrote, so a
121
+ // change is unsaved until a write *finished* with it in — which is what
122
+ // the exit path asks, since a write still in flight when the process
123
+ // exits never finishes.
124
+ this._version = 0;
125
+ this._savedVersion = 0;
126
+ this._writing = null; // the write in flight
127
+ this._onExit = () => this._flushSync();
128
+
129
+ const store = this;
130
+ this.api = {
131
+ path,
132
+ get: (key, fallback) => store.get(key, fallback),
133
+ set: (key, value) => store.set(key, value),
134
+ reset: (key) => store.reset(key),
135
+ flush: () => store.flush(),
136
+ subscribe: (listener) => store.subscribe(listener),
137
+ /**
138
+ * `[value, setValue]` for one setting, like `useState` — and every
139
+ * component using the same key, in any window, sees the same value.
140
+ */
141
+ use(key, fallback) {
142
+ const subscribe = useCallback(
143
+ (listener) => store.subscribe(listener),
144
+ [],
145
+ );
146
+ const value = useSyncExternalStore(subscribe, () =>
147
+ store.get(key, fallback),
148
+ );
149
+ const setValue = useCallback(
150
+ (next) =>
151
+ store.set(
152
+ key,
153
+ typeof next === 'function'
154
+ ? next(store.get(key, fallback))
155
+ : next,
156
+ ),
157
+ [key, fallback],
158
+ );
159
+ return [value, setValue];
160
+ },
161
+ };
162
+ }
163
+
164
+ _load() {
165
+ if (this._values) return this._values;
166
+ this._values = {};
167
+ let text;
168
+ try {
169
+ text = this.fs.readFileSync(this.path, 'utf8');
170
+ } catch (err) {
171
+ // never saved is the ordinary first launch; anything else is worth a line
172
+ if (err?.code !== 'ENOENT') {
173
+ console.warn(
174
+ `react-x11: settings at ${this.path} could not be read ` +
175
+ `(${err.message}); the defaults stand.`,
176
+ );
177
+ }
178
+ return this._values;
179
+ }
180
+ try {
181
+ const parsed = JSON.parse(text);
182
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
183
+ this._values = parsed;
184
+ return this._values;
185
+ }
186
+ throw new Error('not an object');
187
+ } catch (err) {
188
+ console.warn(
189
+ `react-x11: settings at ${this.path} are not a JSON object ` +
190
+ `(${err.message}); the defaults stand, and the next change ` +
191
+ 'replaces the file.',
192
+ );
193
+ }
194
+ return this._values;
195
+ }
196
+
197
+ get(key, fallback) {
198
+ const values = this._load();
199
+ if (Object.hasOwn(values, key)) return values[key];
200
+ if (Object.hasOwn(this._defaults, key)) return this._defaults[key];
201
+ // An object literal is a new object every render, and a hook's snapshot
202
+ // that changes every time it is read never settles: the first one stands.
203
+ if (fallback !== null && typeof fallback === 'object') {
204
+ if (!this._fallbacks.has(key)) this._fallbacks.set(key, fallback);
205
+ return this._fallbacks.get(key);
206
+ }
207
+ return fallback;
208
+ }
209
+
210
+ set(key, value) {
211
+ let text;
212
+ try {
213
+ text = JSON.stringify(value);
214
+ } catch (err) {
215
+ throw new TypeError(
216
+ `react-x11: settings.set(${JSON.stringify(key)}) — the value has to ` +
217
+ `be JSON: ${err.message}`,
218
+ );
219
+ }
220
+ if (text === undefined) {
221
+ throw new TypeError(
222
+ `react-x11: settings.set(${JSON.stringify(key)}) — ${typeof value} is ` +
223
+ 'not a value JSON can keep; use reset() to go back to the default.',
224
+ );
225
+ }
226
+ const values = this._load();
227
+ if (Object.hasOwn(values, key) && values[key] === value) return;
228
+ this._values = { ...values, [key]: value };
229
+ this._changed();
230
+ }
231
+
232
+ reset(key) {
233
+ const values = this._load();
234
+ if (!Object.hasOwn(values, key)) return;
235
+ const rest = { ...values };
236
+ delete rest[key];
237
+ this._values = rest;
238
+ this._changed();
239
+ }
240
+
241
+ subscribe(listener) {
242
+ this._listeners.add(listener);
243
+ return () => this._listeners.delete(listener);
244
+ }
245
+
246
+ get _unsaved() {
247
+ return this._savedVersion !== this._version;
248
+ }
249
+
250
+ _changed() {
251
+ for (const listener of [...this._listeners]) listener();
252
+ if (!this._unsaved) {
253
+ this._firstPending = Date.now();
254
+ process.once('exit', this._onExit);
255
+ }
256
+ this._version++;
257
+ clearTimeout(this._timer);
258
+ const waited = Date.now() - this._firstPending;
259
+ const wait = Math.max(0, Math.min(this.delay, this.maxWait - waited));
260
+ this._timer = setTimeout(() => {
261
+ this._timer = null;
262
+ this.flush().catch((err) => {
263
+ console.warn(
264
+ `react-x11: settings at ${this.path} could not be written ` +
265
+ `(${err.message}).`,
266
+ );
267
+ });
268
+ }, wait);
269
+ // a pending write must not be what keeps the process alive
270
+ this._timer.unref?.();
271
+ }
272
+
273
+ /** Write what is waiting now. Resolves when it is on disk. */
274
+ async flush() {
275
+ clearTimeout(this._timer);
276
+ this._timer = null;
277
+ // one write at a time, and a change made during one is written after it;
278
+ // the one before failing is that write's to report, not this one's
279
+ while (this._writing) await this._writing.catch(() => {});
280
+ if (!this._unsaved) return;
281
+ const version = this._version;
282
+ const text = `${JSON.stringify(this._values, null, 2)}\n`;
283
+ this._writing = this._write(text).finally(() => {
284
+ this._writing = null;
285
+ });
286
+ await this._writing;
287
+ this._savedVersion = version;
288
+ // saved, unless something changed while it was written
289
+ if (!this._unsaved) process.removeListener('exit', this._onExit);
290
+ }
291
+
292
+ async _write(text) {
293
+ const fs = this.fs.promises;
294
+ const temp = `${this.path}.${process.pid}.tmp`;
295
+ await fs.mkdir(dirname(this.path), { recursive: true });
296
+ const handle = await fs.open(temp, 'w');
297
+ try {
298
+ await handle.writeFile(text);
299
+ await handle.sync();
300
+ } finally {
301
+ await handle.close();
302
+ }
303
+ await fs.rename(temp, this.path);
304
+ }
305
+
306
+ /** The exit path, where nothing asynchronous runs any more. */
307
+ _flushSync() {
308
+ if (!this._unsaved) return;
309
+ this._savedVersion = this._version;
310
+ try {
311
+ const temp = `${this.path}.${process.pid}.tmp`;
312
+ this.fs.mkdirSync(dirname(this.path), { recursive: true });
313
+ this.fs.writeFileSync(temp, `${JSON.stringify(this._values, null, 2)}\n`);
314
+ this.fs.renameSync(temp, this.path);
315
+ } catch (err) {
316
+ console.warn(
317
+ `react-x11: settings at ${this.path} could not be written on exit ` +
318
+ `(${err.message}).`,
319
+ );
320
+ }
321
+ }
322
+ }
323
+
324
+ /** Forget every store — for tests that create stores over temporary
325
+ * directories and want the next `createSettings` to read afresh. */
326
+ export function resetSettingsForTests() {
327
+ for (const store of stores.values()) {
328
+ clearTimeout(store._timer);
329
+ process.removeListener('exit', store._onExit);
330
+ }
331
+ stores.clear();
332
+ }
@@ -37,14 +37,55 @@
37
37
  //
38
38
  // The protocol carries far less about a click than AppKit does. There is no
39
39
  // click count, no modifier state, and no item rectangle — `Activate(x, y)`
40
- // gives the pointer position and nothing else. Those fields are reported as
40
+ // gives a position and nothing else. Those fields are reported as
41
41
  // `0`/`false` rather than guessed at, and `docs/desktop.md` says so, because a
42
42
  // tray menu that only opens on shift-click is an app built on a field this
43
43
  // rung cannot fill. The menu is the portable interaction; `onClick` is the
44
44
  // one that degrades.
45
+ //
46
+ // ## The position has no unit
47
+ //
48
+ // The spec says "screen coordinates" and stops, which on a scaled display is
49
+ // two different numbers — and the hosts split between them:
50
+ //
51
+ // - **X root coordinates, device pixels.** Plasma since 5.27, on purpose:
52
+ // its xembed-sni-proxy synthesises X clicks from them. GNOME's
53
+ // AppIndicator extension passes its stage coordinates through, which are
54
+ // device pixels on X11 and in Wayland's physical layout, the default up
55
+ // to GNOME 49.
56
+ // - **The host's own logical pixels.** xfce4-panel, Budgie and LXQt pass
57
+ // their toolkit's root position through, and Cinnamon sends the icon's
58
+ // corner over its UI scale. GNOME's stage is in logical pixels from 50,
59
+ // and before it on Fedora and Debian 13, which switch the layout on
60
+ // downstream. So did Plasma before 5.27.
61
+ // - **Not a screen position.** MATE sends the item's corner in its applet's
62
+ // own window, and snixembed's `Activate` is always `(0, 0)`.
63
+ //
64
+ // Dividing by the scale is right for the first group and halves every click
65
+ // in the second; passing the numbers through is the opposite. So a click is
66
+ // read both ways and the screen picks (`clickReadings`, `clickPoint`):
67
+ //
68
+ // 1. **A reading no monitor holds is not one.** A tray is on the screen,
69
+ // and a device-pixel host's item at the right or the bottom of it —
70
+ // where panels put the tray — is off the screen when read as logical
71
+ // pixels. The common device-pixel click costs nothing more.
72
+ // 2. **Then the pointer.** A host that sends a position sends the
73
+ // pointer's or its icon's, so one `QueryPointer` names the reading the
74
+ // click was at.
75
+ // 3. **Otherwise the numbers as sent.** The pointer is somewhere else
76
+ // because the click was a key, or the connection is XWayland's, which
77
+ // is told nothing of a pointer over a Wayland panel. As sent is what the
78
+ // second group needs, and the first group's usual trays were settled at
79
+ // step 1.
80
+ //
81
+ // What that leaves wrong is a device-pixel host under XWayland whose tray
82
+ // still lands on a monitor read as logical pixels: in the top-left quarter
83
+ // of the screen, or on a desk where it falls on another monitor.
45
84
 
46
85
  import { loadTransport, sessionBus } from './bus.js';
47
86
  import { DbusMenuExport } from './dbusmenuexport.js';
87
+ import { scaleOf } from './scale.js';
88
+ import { screensSnapshot } from './screens.js';
48
89
 
49
90
  export const WATCHER_NAME = 'org.kde.StatusNotifierWatcher';
50
91
  export const WATCHER_PATH = '/StatusNotifierWatcher';
@@ -91,6 +132,43 @@ export function toPixmapArray(image) {
91
132
  return [[width, height, out]];
92
133
  }
93
134
 
135
+ /**
136
+ * How far from the pointer a click's point may be and still be the click, in
137
+ * logical pixels. A host that sends the pointer is within a pixel or two of
138
+ * it, and Cinnamon, which sends its icon's corner, within the icon. A point
139
+ * further off is taken as sent — which for Cinnamon, a logical-pixel host,
140
+ * is right anyway.
141
+ */
142
+ const CLICK_REACH = 64;
143
+
144
+ /**
145
+ * A host's `(x, y)` read both ways — as X root coordinates, and as the host's
146
+ * own logical pixels — keeping the readings some monitor holds. Each is the
147
+ * point on the screen it names, in device pixels, and what it means in
148
+ * logical ones. See "The position has no unit" in the header.
149
+ *
150
+ * `screens` are device-pixel rects, as `screensSnapshot(app).screens` holds
151
+ * them; with none known both readings stand, and with neither on a monitor
152
+ * the host sent something that is not a position.
153
+ */
154
+ function clickReadings(x, y, scale, screens = []) {
155
+ const readings = [
156
+ { device: { x, y }, logical: { x: x / scale, y: y / scale } },
157
+ { device: { x: x * scale, y: y * scale }, logical: { x, y } },
158
+ ];
159
+ if (!screens.length) return readings;
160
+ // Edges count: a host that sends an icon's far corner can land on one.
161
+ return readings.filter(({ device: p }) =>
162
+ screens.some(
163
+ (m) =>
164
+ p.x >= m.x &&
165
+ p.y >= m.y &&
166
+ p.x <= m.x + m.width &&
167
+ p.y <= m.y + m.height,
168
+ ),
169
+ );
170
+ }
171
+
94
172
  /** `visible: false` is `Passive`, which is how the spec spells "hidden". */
95
173
  function statusOf(options) {
96
174
  if (options?.visible === false) return 'Passive';
@@ -129,8 +207,13 @@ function iconOf(icon, decode) {
129
207
  * in quick succession cannot put two registrations in flight.
130
208
  */
131
209
  export class StatusNotifierItem {
132
- constructor({ getOptions, appId, decodeIcon, onError, slot } = {}) {
210
+ constructor({ getOptions, app, appId, decodeIcon, onError, slot } = {}) {
133
211
  this.getOptions = getOptions ?? (() => null);
212
+ // The display the tray is on — not for the protocol, which is D-Bus
213
+ // only, but for a click's position: its scale, its monitors and its
214
+ // pointer are what put the host's numbers into logical pixels. Null is
215
+ // no display to ask, and a click's numbers are then passed as sent.
216
+ this.app = app ?? null;
134
217
  this.appId = appId ?? 'react-x11';
135
218
  this.decodeIcon = decodeIcon ?? (() => null);
136
219
  this.onError = onError ?? (() => {});
@@ -453,27 +536,91 @@ export class StatusNotifierItem {
453
536
  if (this.menu) this.menu.update(next.menu ?? []);
454
537
  }
455
538
 
539
+ // ------------------------------------------------------------------ click
540
+
541
+ /**
542
+ * A click's `(x, y)` in logical screen pixels — the unit a `<popup>`'s
543
+ * `x`/`y` and `anchor={{ rect }}` take. Read both ways and settled by the
544
+ * monitors, then the pointer, then as sent: the header has why.
545
+ */
546
+ async clickPoint(x, y) {
547
+ const scale = scaleOf(this.app);
548
+ if (scale === 1) return { x, y };
549
+ const readings = clickReadings(
550
+ x,
551
+ y,
552
+ scale,
553
+ screensSnapshot(this.app).screens,
554
+ );
555
+ if (readings.length === 1) return readings[0].logical;
556
+ const pointer = readings.length ? await this.queryPointer() : null;
557
+ if (!pointer) return { x, y };
558
+ let near = null;
559
+ let nearest = CLICK_REACH * scale;
560
+ for (const reading of readings) {
561
+ const d = Math.hypot(
562
+ reading.device.x - pointer.x,
563
+ reading.device.y - pointer.y,
564
+ );
565
+ if (d <= nearest) {
566
+ near = reading;
567
+ nearest = d;
568
+ }
569
+ }
570
+ return near?.logical ?? { x, y };
571
+ }
572
+
573
+ /**
574
+ * Where the pointer is on the X screen, in device pixels, or null where
575
+ * nothing can say: a backend with no X server behind it, a failed request,
576
+ * a pointer on another screen.
577
+ */
578
+ queryPointer() {
579
+ const X = this.app?.X;
580
+ const root = X?.display?.screen?.[0]?.root;
581
+ if (typeof X?.QueryPointer !== 'function' || root == null) {
582
+ return Promise.resolve(null);
583
+ }
584
+ return new Promise((resolve) => {
585
+ try {
586
+ X.QueryPointer(root, (err, reply) =>
587
+ resolve(
588
+ err || !reply?.sameScreen
589
+ ? null
590
+ : { x: reply.rootX, y: reply.rootY },
591
+ ),
592
+ );
593
+ } catch {
594
+ resolve(null);
595
+ }
596
+ });
597
+ }
598
+
456
599
  // --------------------------------------------------------------- protocol
457
600
 
458
601
  defineItem(dbus) {
459
602
  const opts = () => this.options;
460
603
  const icon = () => iconOf(opts().icon, this.decodeIcon);
461
- const click = (button) => (args) => {
462
- // No click count, no modifiers, no item rect: the protocol has none of
463
- // them. Reported as zero rather than invented see the header.
464
- opts().onClick?.({
465
- button,
466
- x: args?.x ?? 0,
467
- y: args?.y ?? 0,
468
- width: 0,
469
- height: 0,
470
- clickCount: 1,
471
- shift: false,
472
- control: false,
473
- option: false,
474
- command: false,
604
+ // No click count, no modifiers, no item rect: the protocol has none of
605
+ // them. Reported as zero rather than invented see the header. The
606
+ // position it does have is put into logical pixels first, and the call is
607
+ // answered once the app has had the click, so a throw in `onClick` still
608
+ // reaches the host as the error it was.
609
+ const click = (button) => (args) =>
610
+ this.clickPoint(args?.x ?? 0, args?.y ?? 0).then(({ x, y }) => {
611
+ opts().onClick?.({
612
+ button,
613
+ x,
614
+ y,
615
+ width: 0,
616
+ height: 0,
617
+ clickCount: 1,
618
+ shift: false,
619
+ control: false,
620
+ option: false,
621
+ command: false,
622
+ });
475
623
  });
476
- };
477
624
 
478
625
  return dbus.defineInterface({
479
626
  name: ITEM_IFACE,