@quickgui/native 0.0.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.
package/tray.ts ADDED
@@ -0,0 +1,399 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { resolve as resolvePath } from "node:path";
3
+ import * as binding from "./binding.js";
4
+
5
+ export type TrayIconSource =
6
+ | string
7
+ | {
8
+ /** Encoded image bytes, or RGBA8 when width and height are supplied. */
9
+ data: Uint8Array;
10
+ width?: number;
11
+ height?: number;
12
+ };
13
+
14
+ export interface TrayMenuActionItem {
15
+ type?: "action";
16
+ label: string;
17
+ enabled?: boolean;
18
+ checked?: boolean;
19
+ click?: () => void;
20
+ }
21
+
22
+ export interface TrayMenuSeparatorItem {
23
+ type: "separator";
24
+ }
25
+
26
+ export interface TrayMenuSubmenuItem {
27
+ type: "submenu";
28
+ label: string;
29
+ enabled?: boolean;
30
+ items: readonly TrayMenuItem[];
31
+ }
32
+
33
+ export type TrayMenuItem =
34
+ | TrayMenuActionItem
35
+ | TrayMenuSeparatorItem
36
+ | TrayMenuSubmenuItem;
37
+
38
+ export interface TrayIconOptions {
39
+ icon: TrayIconSource;
40
+ tooltip?: string | undefined;
41
+ /** Status-bar title on macOS and compatible Linux hosts; unsupported on Windows. */
42
+ title?: string | undefined;
43
+ /** Treat the image as a monochrome template on macOS. */
44
+ iconIsTemplate?: boolean;
45
+ /** Show the menu for a primary click on macOS and Windows. */
46
+ menuOnLeftClick?: boolean;
47
+ visible?: boolean;
48
+ menu?: readonly TrayMenuItem[];
49
+ }
50
+
51
+ export type TrayEventType =
52
+ | "click"
53
+ | "double-click"
54
+ | "enter"
55
+ | "move"
56
+ | "leave"
57
+ | "menu-item"
58
+ | "scroll";
59
+
60
+ export interface TrayEvent {
61
+ kind: TrayEventType;
62
+ button?: "left" | "right" | "middle";
63
+ position?: { x: number; y: number };
64
+ pressed?: boolean;
65
+ scrollDelta?: number;
66
+ horizontal?: boolean;
67
+ }
68
+
69
+ type AppContext = {
70
+ appId: number;
71
+ hosted: boolean;
72
+ };
73
+
74
+ let resolveContext: (() => AppContext) | undefined;
75
+ const pendingRequests = new Map<
76
+ number,
77
+ { resolve: () => void; reject: (error: Error) => void }
78
+ >();
79
+ const icons = new Map<number, TrayIcon>();
80
+ let nextRequest = 1;
81
+ let nextIcon = 1;
82
+ let nextMenuAction = 1;
83
+
84
+ export function configureTrayContext(context: () => AppContext): void {
85
+ resolveContext = context;
86
+ }
87
+
88
+ function context(): AppContext {
89
+ if (!resolveContext) throw new Error("QuickGUI's native tray context is not configured");
90
+ return resolveContext();
91
+ }
92
+
93
+ function allocateBoundedId(next: number, used: ReadonlyMap<number, unknown>): number {
94
+ let candidate = next;
95
+ for (let attempt = 0; attempt <= used.size; attempt += 1) {
96
+ if (!used.has(candidate)) return candidate;
97
+ candidate = candidate >= 0xffff_ffff ? 1 : candidate + 1;
98
+ }
99
+ throw new Error("the native tray id space is exhausted");
100
+ }
101
+
102
+ function operation(
103
+ action: "set" | "remove" | "show-menu",
104
+ id: number,
105
+ options?: binding.NativeTrayIconOptions,
106
+ ): Promise<void> {
107
+ try {
108
+ const current = context();
109
+ const request = allocateBoundedId(nextRequest, pendingRequests);
110
+ nextRequest = request >= 0xffff_ffff ? 1 : request + 1;
111
+ return new Promise<void>((resolve, reject) => {
112
+ pendingRequests.set(request, { resolve, reject });
113
+ try {
114
+ if (action === "set") {
115
+ if (!options) throw new Error("setting a tray icon requires options");
116
+ if (current.hosted) binding.setHostedTrayIcon(current.appId, request, options);
117
+ else binding.setTrayIcon(current.appId, request, options);
118
+ } else if (action === "remove") {
119
+ if (current.hosted) binding.removeHostedTrayIcon(current.appId, request, id);
120
+ else binding.removeTrayIcon(current.appId, request, id);
121
+ } else if (current.hosted) {
122
+ binding.showHostedTrayMenu(current.appId, request, id);
123
+ } else {
124
+ binding.showTrayMenu(current.appId, request, id);
125
+ }
126
+ } catch (error) {
127
+ pendingRequests.delete(request);
128
+ reject(error instanceof Error ? error : new Error(String(error)));
129
+ }
130
+ });
131
+ } catch (error) {
132
+ return Promise.reject(error instanceof Error ? error : new Error(String(error)));
133
+ }
134
+ }
135
+
136
+ function nativeMenu(
137
+ items: readonly TrayMenuItem[],
138
+ callbacks: Map<number, () => void>,
139
+ ): unknown[] {
140
+ return items.map((item) => {
141
+ if (item.type === "separator") return { type: "separator" };
142
+ if (item.type === "submenu") {
143
+ return {
144
+ type: "submenu",
145
+ label: item.label,
146
+ enabled: item.enabled ?? true,
147
+ items: nativeMenu(item.items, callbacks),
148
+ };
149
+ }
150
+ const id = allocateBoundedId(nextMenuAction, callbacks);
151
+ nextMenuAction = id >= 0xffff_ffff ? 1 : id + 1;
152
+ callbacks.set(id, item.click ?? (() => {}));
153
+ return {
154
+ type: "action",
155
+ id,
156
+ label: item.label,
157
+ enabled: item.enabled ?? true,
158
+ checked: item.checked ?? false,
159
+ };
160
+ });
161
+ }
162
+
163
+ function nativeOptions(
164
+ id: number,
165
+ options: TrayIconOptions,
166
+ callbacks: Map<number, () => void>,
167
+ ): binding.NativeTrayIconOptions {
168
+ const native: binding.NativeTrayIconOptions = {
169
+ id,
170
+ menu: JSON.stringify(nativeMenu(options.menu ?? [], callbacks)),
171
+ };
172
+ if (typeof options.icon === "string") {
173
+ native.iconPath = resolvePath(options.icon);
174
+ } else {
175
+ native.iconData = Buffer.from(
176
+ options.icon.data.buffer,
177
+ options.icon.data.byteOffset,
178
+ options.icon.data.byteLength,
179
+ );
180
+ if (options.icon.width !== undefined) native.width = options.icon.width;
181
+ if (options.icon.height !== undefined) native.height = options.icon.height;
182
+ }
183
+ if (options.tooltip !== undefined) native.tooltip = options.tooltip;
184
+ if (options.title !== undefined) native.title = options.title;
185
+ native.iconIsTemplate = options.iconIsTemplate ?? false;
186
+ native.menuOnLeftClick = options.menuOnLeftClick ?? true;
187
+ native.visible = options.visible ?? true;
188
+ return native;
189
+ }
190
+
191
+ /** One application-owned native tray/status icon. */
192
+ export class TrayIcon {
193
+ readonly id: number;
194
+ #options: TrayIconOptions;
195
+ #menuCallbacks = new Map<number, () => void>();
196
+ #listeners = new Map<TrayEventType, Set<(event: TrayEvent) => void>>();
197
+ #operations = Promise.resolve();
198
+ #destroyed = false;
199
+
200
+ private constructor(id: number, options: TrayIconOptions) {
201
+ this.id = id;
202
+ this.#options = { ...options, menu: [...(options.menu ?? [])] };
203
+ }
204
+
205
+ static async create(id: number, options: TrayIconOptions): Promise<TrayIcon> {
206
+ const icon = new TrayIcon(id, options);
207
+ await icon.#sync();
208
+ return icon;
209
+ }
210
+
211
+ get destroyed(): boolean {
212
+ return this.#destroyed;
213
+ }
214
+
215
+ on(type: TrayEventType, listener: (event: TrayEvent) => void): () => void {
216
+ if (this.#destroyed) return () => {};
217
+ const listeners = this.#listeners.get(type) ?? new Set();
218
+ listeners.add(listener);
219
+ this.#listeners.set(type, listeners);
220
+ return () => {
221
+ listeners.delete(listener);
222
+ if (listeners.size === 0) this.#listeners.delete(type);
223
+ };
224
+ }
225
+
226
+ async setIcon(icon: TrayIconSource): Promise<void> {
227
+ await this.update({ icon });
228
+ }
229
+
230
+ async setMenu(menu: readonly TrayMenuItem[]): Promise<void> {
231
+ await this.update({ menu });
232
+ }
233
+
234
+ async setTooltip(tooltip?: string): Promise<void> {
235
+ await this.update({ tooltip });
236
+ }
237
+
238
+ async setTitle(title?: string): Promise<void> {
239
+ await this.update({ title });
240
+ }
241
+
242
+ async setVisible(visible: boolean): Promise<void> {
243
+ await this.update({ visible });
244
+ }
245
+
246
+ update(options: Partial<TrayIconOptions>): Promise<void> {
247
+ return this.#enqueue(async () => {
248
+ this.#assertAlive();
249
+ const next = { ...this.#options, ...options };
250
+ if (options.menu) next.menu = [...options.menu];
251
+ const previous = this.#options;
252
+ this.#options = next;
253
+ try {
254
+ await this.#sync();
255
+ } catch (error) {
256
+ this.#options = previous;
257
+ throw error;
258
+ }
259
+ });
260
+ }
261
+
262
+ showMenu(): Promise<void> {
263
+ return this.#enqueue(async () => {
264
+ this.#assertAlive();
265
+ await operation("show-menu", this.id);
266
+ });
267
+ }
268
+
269
+ destroy(): Promise<void> {
270
+ return this.#enqueue(async () => {
271
+ if (this.#destroyed) return;
272
+ await operation("remove", this.id);
273
+ this.#destroyed = true;
274
+ this.#menuCallbacks.clear();
275
+ this.#listeners.clear();
276
+ icons.delete(this.id);
277
+ });
278
+ }
279
+
280
+ _dispatch(event: TrayEvent & { menuItemId?: number }): void {
281
+ if (this.#destroyed) return;
282
+ if (event.kind === "menu-item" && event.menuItemId !== undefined) {
283
+ this.#menuCallbacks.get(event.menuItemId)?.();
284
+ }
285
+ for (const listener of this.#listeners.get(event.kind) ?? []) listener(event);
286
+ }
287
+
288
+ _didDestroy(): void {
289
+ if (this.#destroyed) return;
290
+ this.#destroyed = true;
291
+ this.#menuCallbacks.clear();
292
+ this.#listeners.clear();
293
+ }
294
+
295
+ async #sync(): Promise<void> {
296
+ const callbacks = new Map<number, () => void>();
297
+ await operation("set", this.id, nativeOptions(this.id, this.#options, callbacks));
298
+ this.#menuCallbacks = callbacks;
299
+ }
300
+
301
+ #enqueue(task: () => Promise<void>): Promise<void> {
302
+ const pending = this.#operations.then(task, task);
303
+ this.#operations = pending.catch(() => {});
304
+ return pending;
305
+ }
306
+
307
+ #assertAlive(): void {
308
+ if (this.#destroyed) throw new Error("this native tray icon has been destroyed");
309
+ }
310
+ }
311
+
312
+ /** Native tray/status icons. Linux support uses StatusNotifierItem over D-Bus. */
313
+ export const Tray = Object.freeze({
314
+ isSupported(): boolean {
315
+ return (
316
+ process.platform === "darwin" ||
317
+ process.platform === "win32" ||
318
+ process.platform === "linux"
319
+ );
320
+ },
321
+
322
+ async create(options: TrayIconOptions): Promise<TrayIcon> {
323
+ const id = allocateBoundedId(nextIcon, icons);
324
+ nextIcon = id >= 0xffff_ffff ? 1 : id + 1;
325
+ const icon = await TrayIcon.create(id, options);
326
+ icons.set(id, icon);
327
+ return icon;
328
+ },
329
+ });
330
+
331
+ export function rejectPendingTrayRequests(error: Error): void {
332
+ for (const [request, pending] of pendingRequests) {
333
+ pendingRequests.delete(request);
334
+ pending.reject(error);
335
+ }
336
+ for (const icon of icons.values()) icon._didDestroy();
337
+ icons.clear();
338
+ }
339
+
340
+ export function dispatchTrayEvent(event: binding.NativeEvent): boolean {
341
+ if (event.kind === "tray-operation") {
342
+ const pending = pendingRequests.get(event.target);
343
+ if (!pending) return true;
344
+ pendingRequests.delete(event.target);
345
+ if (event.error !== undefined) pending.reject(new Error(event.error));
346
+ else pending.resolve();
347
+ return true;
348
+ }
349
+ if (event.kind !== "tray-event") return false;
350
+ const tray = icons.get(event.target);
351
+ if (!tray) return true;
352
+ try {
353
+ const value = JSON.parse(event.value ?? "{}") as {
354
+ kind?: unknown;
355
+ menuItemId?: unknown;
356
+ button?: unknown;
357
+ position?: unknown;
358
+ pressed?: unknown;
359
+ scrollDelta?: unknown;
360
+ horizontal?: unknown;
361
+ };
362
+ const kinds: readonly TrayEventType[] = [
363
+ "click",
364
+ "double-click",
365
+ "enter",
366
+ "move",
367
+ "leave",
368
+ "menu-item",
369
+ "scroll",
370
+ ];
371
+ if (typeof value.kind !== "string" || !kinds.includes(value.kind as TrayEventType)) {
372
+ return true;
373
+ }
374
+ const trayEvent: TrayEvent & { menuItemId?: number } = {
375
+ kind: value.kind as TrayEventType,
376
+ };
377
+ if (typeof value.menuItemId === "number") trayEvent.menuItemId = value.menuItemId;
378
+ if (value.button === "left" || value.button === "right" || value.button === "middle") {
379
+ trayEvent.button = value.button;
380
+ }
381
+ if (
382
+ typeof value.position === "object" &&
383
+ value.position !== null &&
384
+ "x" in value.position &&
385
+ "y" in value.position &&
386
+ typeof value.position.x === "number" &&
387
+ typeof value.position.y === "number"
388
+ ) {
389
+ trayEvent.position = { x: value.position.x, y: value.position.y };
390
+ }
391
+ if (typeof value.pressed === "boolean") trayEvent.pressed = value.pressed;
392
+ if (typeof value.scrollDelta === "number") trayEvent.scrollDelta = value.scrollDelta;
393
+ if (typeof value.horizontal === "boolean") trayEvent.horizontal = value.horizontal;
394
+ tray._dispatch(trayEvent);
395
+ } catch {
396
+ // Invalid native events fail closed.
397
+ }
398
+ return true;
399
+ }