@timmo001/groundwork-desktop 0.0.0-stage → 0.27.0

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/src/main.ts ADDED
@@ -0,0 +1,613 @@
1
+ /**
2
+ * The main process half of `@timmo001/groundwork-desktop`: opens an app's
3
+ * window with the browser defaults its `desktop.config` leaves on, such as
4
+ * the tray, remembered size and zoom, one instance only, external links in
5
+ * the system browser and the requests the view part makes. With `runInTray`
6
+ * the app keeps running in the tray once the window closes.
7
+ *
8
+ * It takes the app's own Electrobun, so the app keeps its pinned release:
9
+ *
10
+ * import Electrobun, { BrowserView } from "electrobun/main";
11
+ * await openDesktopWindow(Electrobun, { title, url, frame, config, createRPC });
12
+ */
13
+ import { join } from "node:path";
14
+
15
+ import { Option, Schema } from "effect";
16
+
17
+ import {
18
+ readOmarchyTheme,
19
+ watchOmarchyTheme,
20
+ } from "@timmo001/groundwork-tokens/omarchy-current";
21
+
22
+ import {
23
+ isExternal,
24
+ resolveConfig,
25
+ type DesktopConfig,
26
+ type ResolvedDesktopConfig,
27
+ } from "./config.ts";
28
+ import { hiddenVariable, loginItem } from "./login.ts";
29
+ import type { DesktopRequests, Notice, OmarchyThemeMessage } from "./rpc.ts";
30
+
31
+ export * from "./config.ts";
32
+
33
+ export { hiddenVariable } from "./login.ts";
34
+
35
+ export type * from "./rpc.ts";
36
+
37
+ interface Frame {
38
+ readonly x: number;
39
+ readonly y: number;
40
+ readonly width: number;
41
+ readonly height: number;
42
+ }
43
+
44
+ /** A webview event: the URL, or for links and downloads what the webview sent. */
45
+ interface WebviewEvent {
46
+ readonly data: {
47
+ readonly detail: string | { readonly url?: string; readonly path?: string };
48
+ };
49
+ }
50
+
51
+ interface MainView {
52
+ readonly id: number;
53
+ setPageZoom(zoom: number): void;
54
+ findInPage(text: string, options?: { forward?: boolean }): void;
55
+ stopFindInPage(): void;
56
+ toggleDevTools(): void;
57
+ setNavigationRules(rules: string[]): void;
58
+ }
59
+
60
+ interface MainWindow {
61
+ readonly webview: MainView;
62
+ on(name: "resize" | "move" | "close", handler: () => void): void;
63
+ getFrame(): Frame;
64
+ show(): void;
65
+ activate(): void;
66
+ unminimize(): void;
67
+ }
68
+
69
+ interface MainRPC {
70
+ readonly send: {
71
+ notice(notice: Notice): void;
72
+ deepLink(link: { url: string }): void;
73
+ omarchyTheme(message: OmarchyThemeMessage): void;
74
+ };
75
+ }
76
+
77
+ /**
78
+ * An item in the tray's menu: a divider, or a label with the action
79
+ * `onTrayAction` hears. `enabled: false` greys it out, such as for a status
80
+ * line, and `checked` ticks it.
81
+ */
82
+ export type TrayItem =
83
+ | { readonly type: "divider" }
84
+ | {
85
+ readonly type: "normal";
86
+ readonly label: string;
87
+ readonly action?: string;
88
+ readonly enabled?: boolean;
89
+ readonly checked?: boolean;
90
+ };
91
+
92
+ interface MainTray {
93
+ readonly id: number;
94
+ setMenu(menu: TrayItem[]): void;
95
+ }
96
+
97
+ /** The parts of Electrobun's main process this uses, with the app's RPC type. */
98
+ export interface ElectrobunMain<RPC extends MainRPC> {
99
+ readonly BrowserWindow: new (options: {
100
+ title: string;
101
+ url: string;
102
+ frame: { x?: number; y?: number; width: number; height: number };
103
+ rpc: RPC;
104
+ spellCheck: boolean;
105
+ preload: string | null;
106
+ }) => MainWindow;
107
+ readonly Tray: new (options: {
108
+ title?: string;
109
+ image?: string;
110
+ template?: boolean;
111
+ width?: number;
112
+ height?: number;
113
+ }) => MainTray;
114
+ readonly Utils: {
115
+ openExternal(url: string): boolean;
116
+ clipboardReadText(): string | null;
117
+ clipboardWriteText(text: string): void;
118
+ showNotification(options: { title: string; body?: string }): void;
119
+ quit(code?: number): boolean;
120
+ readonly paths: { readonly userData: string };
121
+ };
122
+ readonly BuildConfig: { getSync(): { channel: string } };
123
+ readonly events: {
124
+ on(
125
+ name: "open-url",
126
+ handler: (event: { readonly data: { readonly url: string } }) => void,
127
+ ): void;
128
+ /** Webview events, per webview id; Electrobun's typed `webview.on` passes `unknown`. */
129
+ on(
130
+ name: `${"dom-ready" | "will-navigate" | "download-completed" | "new-window-open"}-${number}`,
131
+ handler: (event: WebviewEvent) => void,
132
+ ): void;
133
+ /** Tray clicks, per tray id. */
134
+ on(
135
+ name: `tray-clicked-${number}`,
136
+ handler: (event: { readonly data: { readonly action: string } }) => void,
137
+ ): void;
138
+ };
139
+ }
140
+
141
+ /** What `openDesktopWindow` opens. */
142
+ export interface DesktopWindowOptions<RPC extends MainRPC> {
143
+ readonly title: string;
144
+ /**
145
+ * The page, such as `views://mainview/index.html`, or one from an origin in
146
+ * the config's `ownOrigins`.
147
+ */
148
+ readonly url: string;
149
+ /** The first size and place, before one is remembered. */
150
+ readonly frame: {
151
+ readonly width: number;
152
+ readonly height: number;
153
+ readonly x?: number;
154
+ readonly y?: number;
155
+ };
156
+ readonly config?: DesktopConfig;
157
+ /**
158
+ * Defines the window's RPC from the desktop requests, beside the app's own:
159
+ *
160
+ * (requests: DesktopRequests) => BrowserView.defineRPC<DesktopSchema<AppRequests>>({
161
+ * handlers: { requests: { ...requests, ...appRequests } },
162
+ * })
163
+ *
164
+ * Type `requests` as here, so the window takes the RPC's own type.
165
+ */
166
+ readonly createRPC: (requests: DesktopRequests) => RPC;
167
+ /**
168
+ * JavaScript run in every page before the page's own, such as a built
169
+ * script that calls `connectDesktop`, for a page served from elsewhere.
170
+ * The script's text, not its address: Linux takes no `views://` address.
171
+ */
172
+ readonly preload?: string;
173
+ /**
174
+ * Runs once this copy is the only one, before the window first opens, such
175
+ * as to start the server the page comes from.
176
+ */
177
+ readonly ready?: () => Promise<void>;
178
+ /** The app's own tray items, between the show item and the rest. */
179
+ readonly trayItems?: readonly TrayItem[];
180
+ /** Called with the action of each of the app's own tray items picked. */
181
+ readonly onTrayAction?: (action: string) => void;
182
+ }
183
+
184
+ /** The app's window and tray, and a way to show a notice in the window. */
185
+ export interface DesktopWindow<RPC extends MainRPC> {
186
+ /** The window open now; with `runInTray`, none while it's closed. */
187
+ readonly window: MainWindow | undefined;
188
+ readonly rpc: RPC;
189
+ readonly config: ResolvedDesktopConfig;
190
+ /** A system notification when the config turns them on, otherwise in the app. */
191
+ readonly notify: (notice: Notice) => void;
192
+ /** Opens the window, or brings it to the front. */
193
+ readonly show: () => void;
194
+ /** Replaces the app's own tray items, such as a status line that changed. */
195
+ readonly setTrayItems: (items: readonly TrayItem[]) => void;
196
+ }
197
+
198
+ interface SavedState {
199
+ frame?: Frame;
200
+ zoom?: number;
201
+ }
202
+
203
+ /** The state file as written, before it's checked. */
204
+ interface SavedFile {
205
+ readonly frame?: Partial<Frame>;
206
+ readonly zoom?: number;
207
+ }
208
+
209
+ const zoomSteps = [0.5, 0.67, 0.75, 0.8, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2];
210
+
211
+ const decodeNavigation = Schema.decodeUnknownOption(
212
+ Schema.fromJsonString(Schema.Struct({ url: Schema.String })),
213
+ );
214
+
215
+ // Navigation events send their detail as JSON text, `{"url", "allowed"}`;
216
+ // others send the URL itself or an object
217
+ const detailUrl = ({ data }: WebviewEvent): string | undefined => {
218
+ const { detail } = data;
219
+
220
+ if (detail instanceof Object) return detail.url;
221
+
222
+ return detail.startsWith("{")
223
+ ? Option.getOrUndefined(
224
+ Option.map(decodeNavigation(detail), ({ url }) => url),
225
+ )
226
+ : detail;
227
+ };
228
+
229
+ // GTK 3 only takes whole numbers in GDK_SCALE and drops the fraction, so on
230
+ // Linux the page zoom makes up the rest: 1.6 renders at 1 and zooms by 1.6
231
+ const toolkitZoom = (): number => {
232
+ const scale = Number(process.env.GDK_SCALE);
233
+
234
+ return process.platform === "linux" && scale > 0
235
+ ? scale / Math.max(1, Math.floor(scale))
236
+ : 1;
237
+ };
238
+
239
+ // Electrobun runs GTK on X11, where nothing hands GTK the system's color
240
+ // scheme, so WebKitGTK reports light to `prefers-color-scheme`. WebKitGTK also
241
+ // takes a `:dark` GTK_THEME as dark, so set one from the desktop portal's
242
+ // scheme, unless the user set GTK_THEME themselves. It goes through libc's
243
+ // setenv, since Bun's `process.env` doesn't reach the native side
244
+ const followSystemScheme = async () => {
245
+ if (process.platform !== "linux" || process.env.GTK_THEME !== undefined)
246
+ return;
247
+
248
+ try {
249
+ const result = Bun.spawnSync([
250
+ "gdbus",
251
+ "call",
252
+ "--session",
253
+ "--dest=org.freedesktop.portal.Desktop",
254
+ "--object-path=/org/freedesktop/portal/desktop",
255
+ "--method=org.freedesktop.portal.Settings.ReadOne",
256
+ "org.freedesktop.appearance",
257
+ "color-scheme",
258
+ ]);
259
+
260
+ // 1 is prefer dark; 0 (no preference) and 2 (prefer light) stay light
261
+ if (!(result.success && /\buint32 1\b/.test(result.stdout.toString())))
262
+ return;
263
+
264
+ const { dlopen, FFIType } = await import("bun:ffi");
265
+
266
+ const { symbols } = dlopen("libc.so.6", {
267
+ setenv: {
268
+ args: [FFIType.cstring, FFIType.cstring, FFIType.i32],
269
+ returns: FFIType.i32,
270
+ },
271
+ });
272
+
273
+ symbols.setenv(
274
+ Buffer.from("GTK_THEME\0"),
275
+ Buffer.from("Adwaita:dark\0"),
276
+ 0,
277
+ );
278
+ } catch {
279
+ // No gdbus or no portal: the webview keeps GTK's own scheme
280
+ }
281
+ };
282
+
283
+ const toFrame = (frame: Partial<Frame> = {}): Frame | undefined => {
284
+ const { x, y, width, height } = frame;
285
+
286
+ if (
287
+ x === undefined ||
288
+ y === undefined ||
289
+ width === undefined ||
290
+ height === undefined
291
+ )
292
+ return undefined;
293
+
294
+ return [x, y, width, height].every(Number.isFinite) && width > 0 && height > 0
295
+ ? { x, y, width, height }
296
+ : undefined;
297
+ };
298
+
299
+ const readState = async (path: string): Promise<SavedState> => {
300
+ const state: SavedState = {};
301
+
302
+ try {
303
+ const saved: SavedFile = await Bun.file(path).json();
304
+ const frame = toFrame(saved.frame);
305
+
306
+ if (frame !== undefined) state.frame = frame;
307
+
308
+ if (saved.zoom !== undefined && zoomSteps.includes(saved.zoom))
309
+ state.zoom = saved.zoom;
310
+ } catch {
311
+ // No state yet, or a broken file: the app opens as if new
312
+ }
313
+
314
+ return state;
315
+ };
316
+
317
+ // The first copy listens on a socket in the app's data folder. A second one
318
+ // finds it there, asks it to come to the front and quits.
319
+ const claimInstance = async (
320
+ socket: string,
321
+ onShow: () => void,
322
+ ): Promise<boolean> => {
323
+ try {
324
+ const other = await Bun.connect({ unix: socket, socket: { data() {} } });
325
+
326
+ other.write("show");
327
+ other.end();
328
+
329
+ return false;
330
+ } catch {
331
+ await Bun.file(socket)
332
+ .delete()
333
+ .catch(() => undefined);
334
+ }
335
+
336
+ try {
337
+ Bun.listen({ unix: socket, socket: { data: onShow } });
338
+ } catch {
339
+ // Without a socket each copy runs on its own
340
+ }
341
+
342
+ return true;
343
+ };
344
+
345
+ /** Opens the app's window with the defaults its config leaves on. */
346
+ export const openDesktopWindow = async <RPC extends MainRPC>(
347
+ electrobun: ElectrobunMain<NoInfer<RPC>>,
348
+ options: DesktopWindowOptions<RPC>,
349
+ ): Promise<DesktopWindow<RPC>> => {
350
+ const { Utils } = electrobun;
351
+ const config = resolveConfig(options.config);
352
+ const dev = electrobun.BuildConfig.getSync().channel === "dev";
353
+ const statePath = join(Utils.paths.userData, "desktop-state.json");
354
+
355
+ const state =
356
+ config.windowState || config.zoomKeys ? await readState(statePath) : {};
357
+
358
+ let window: MainWindow | undefined;
359
+ // Set once the window can open; a second copy asking before then just waits
360
+ let show = () => {};
361
+
362
+ if (
363
+ config.singleInstance &&
364
+ !(await claimInstance(join(Utils.paths.userData, "instance.sock"), () =>
365
+ show(),
366
+ ))
367
+ ) {
368
+ Utils.quit(0);
369
+
370
+ return new Promise(() => {});
371
+ }
372
+
373
+ const baseZoom =
374
+ toolkitZoom() *
375
+ (Number(process.env.GROUNDWORK_DESKTOP_ZOOM) || 1) *
376
+ config.zoom;
377
+
378
+ let zoom = config.zoomKeys ? (state.zoom ?? 1) : 1;
379
+ let saveTimer: ReturnType<typeof setTimeout> | undefined;
380
+
381
+ const save = () => {
382
+ clearTimeout(saveTimer);
383
+ saveTimer = setTimeout(() => {
384
+ const next: SavedState = {};
385
+
386
+ if (config.windowState && window !== undefined) {
387
+ next.frame = window.getFrame();
388
+ // A window opened again from the tray opens where this one was
389
+ state.frame = next.frame;
390
+ }
391
+
392
+ if (config.zoomKeys) next.zoom = zoom;
393
+ void Bun.write(statePath, JSON.stringify(next));
394
+ }, 500);
395
+ };
396
+
397
+ const desktopRequests: DesktopRequests = {
398
+ config: () => ({ config, dev }),
399
+ openExternal: ({ url }) =>
400
+ config.externalLinks &&
401
+ isExternal(url, config) &&
402
+ Utils.openExternal(url),
403
+ clipboardRead: () =>
404
+ config.clipboard ? (Utils.clipboardReadText() ?? "") : "",
405
+ clipboardWrite: ({ text }) => {
406
+ if (!config.clipboard) return false;
407
+ Utils.clipboardWriteText(text);
408
+
409
+ return true;
410
+ },
411
+ find: ({ text, forward }) => {
412
+ if (!config.find) return false;
413
+ window?.webview.findInPage(text, { forward });
414
+
415
+ return true;
416
+ },
417
+ stopFind: () => {
418
+ window?.webview.stopFindInPage();
419
+
420
+ return true;
421
+ },
422
+ zoom: ({ step }) => {
423
+ if (!config.zoomKeys) return zoom;
424
+ const index = zoomSteps.indexOf(zoom);
425
+
426
+ const next = Math.min(
427
+ zoomSteps.length - 1,
428
+ Math.max(0, index + (step === "in" ? 1 : -1)),
429
+ );
430
+
431
+ zoom = step === "reset" ? 1 : (zoomSteps[next] ?? 1);
432
+ window?.webview.setPageZoom(baseZoom * zoom);
433
+ save();
434
+
435
+ return zoom;
436
+ },
437
+ devTools: () => {
438
+ if (!(config.devTools && dev)) return false;
439
+ window?.webview.toggleDevTools();
440
+
441
+ return true;
442
+ },
443
+ notify: ({ heading, body }) => {
444
+ if (!config.notifications) return false;
445
+ Utils.showNotification(
446
+ body === undefined ? { title: heading } : { title: heading, body },
447
+ );
448
+
449
+ return true;
450
+ },
451
+ omarchyTheme: async () => ({
452
+ theme: config.omarchyTheme ? await readOmarchyTheme() : null,
453
+ }),
454
+ };
455
+
456
+ const rpc = options.createRPC(desktopRequests);
457
+
458
+ const notify = (notice: Notice) => {
459
+ if (!desktopRequests.notify(notice)) rpc.send.notice(notice);
460
+ };
461
+
462
+ // The rules and listeners go on each window's webview, since a window
463
+ // opened again from the tray has a new one
464
+ const openWindow = () => {
465
+ const opened = new electrobun.BrowserWindow({
466
+ title: options.title,
467
+ url: options.url,
468
+ frame: (config.windowState ? state.frame : undefined) ?? options.frame,
469
+ rpc,
470
+ spellCheck: config.spellcheck,
471
+ preload: options.preload ?? null,
472
+ });
473
+
474
+ const applyZoom = () => opened.webview.setPageZoom(baseZoom * zoom);
475
+ const id = opened.webview.id;
476
+
477
+ window = opened;
478
+ applyZoom();
479
+ electrobun.events.on(`dom-ready-${id}`, applyZoom);
480
+ opened.on("close", () => {
481
+ if (window === opened) window = undefined;
482
+ });
483
+
484
+ if (config.windowState) {
485
+ opened.on("resize", save);
486
+ opened.on("move", save);
487
+ }
488
+
489
+ if (config.externalLinks) {
490
+ const openOutside = (event: WebviewEvent) => {
491
+ const url = detailUrl(event);
492
+
493
+ if (url !== undefined && isExternal(url, config))
494
+ Utils.openExternal(url);
495
+ };
496
+
497
+ // Only the app's own pages load in the window; links out go to the browser
498
+ opened.webview.setNavigationRules([
499
+ "^*",
500
+ "views://*",
501
+ ...config.ownOrigins.map((origin) => `${new URL(origin).origin}/*`),
502
+ ]);
503
+ electrobun.events.on(`will-navigate-${id}`, openOutside);
504
+ electrobun.events.on(`new-window-open-${id}`, openOutside);
505
+ }
506
+
507
+ if (config.downloads) {
508
+ electrobun.events.on(`download-completed-${id}`, ({ data }) => {
509
+ const path =
510
+ data.detail instanceof Object ? data.detail.path : data.detail;
511
+
512
+ const heading = config.labels.downloadFinished;
513
+
514
+ notify(
515
+ path === undefined || path === ""
516
+ ? { heading, tone: "success" }
517
+ : { heading, body: path, tone: "success" },
518
+ );
519
+ });
520
+ }
521
+ };
522
+
523
+ show = () => {
524
+ if (window === undefined) return openWindow();
525
+ window.unminimize();
526
+ window.show();
527
+ window.activate();
528
+ };
529
+
530
+ await options.ready?.();
531
+ await followSystemScheme();
532
+
533
+ if (!(config.runInTray && process.env[hiddenVariable] === "1")) openWindow();
534
+
535
+ if (config.deepLinks) {
536
+ electrobun.events.on("open-url", ({ data }) => {
537
+ show();
538
+ rpc.send.deepLink({ url: data.url });
539
+ });
540
+ }
541
+
542
+ if (config.omarchyTheme) {
543
+ watchOmarchyTheme((theme) => rpc.send.omarchyTheme({ theme }));
544
+ }
545
+
546
+ let setTrayItems = (_items: readonly TrayItem[]) => {};
547
+
548
+ if (config.tray !== false) {
549
+ const tray = new electrobun.Tray({
550
+ title: config.tray.title,
551
+ image: config.tray.image,
552
+ template: false,
553
+ width: 22,
554
+ height: 22,
555
+ });
556
+
557
+ const login = config.startAtLogin ? await loginItem() : undefined;
558
+ let atLogin = (await login?.enabled()) ?? false;
559
+ let appItems = options.trayItems ?? [];
560
+
561
+ const setMenu = () =>
562
+ tray.setMenu([
563
+ {
564
+ type: "normal",
565
+ label: config.labels.show ?? `Show ${options.title}`,
566
+ action: "show",
567
+ },
568
+ ...(appItems.length > 0
569
+ ? [{ type: "divider" } as const, ...appItems]
570
+ : []),
571
+ { type: "divider" },
572
+ ...(login === undefined
573
+ ? []
574
+ : [
575
+ {
576
+ type: "normal",
577
+ label: config.labels.startAtLogin,
578
+ action: "start-at-login",
579
+ checked: atLogin,
580
+ } as const,
581
+ ]),
582
+ { type: "normal", label: config.labels.quit, action: "quit" },
583
+ ]);
584
+
585
+ setTrayItems = (items) => {
586
+ appItems = items;
587
+ setMenu();
588
+ };
589
+
590
+ setMenu();
591
+ electrobun.events.on(`tray-clicked-${tray.id}`, ({ data }) => {
592
+ if (data.action === "quit") Utils.quit(0);
593
+ else if (data.action === "show" || data.action === "") show();
594
+ else if (data.action === "start-at-login" && login !== undefined) {
595
+ void login.set(!atLogin).then(async () => {
596
+ atLogin = await login.enabled();
597
+ setMenu();
598
+ });
599
+ } else options.onTrayAction?.(data.action);
600
+ });
601
+ }
602
+
603
+ return {
604
+ get window() {
605
+ return window;
606
+ },
607
+ rpc,
608
+ config,
609
+ notify,
610
+ show: () => show(),
611
+ setTrayItems: (items) => setTrayItems(items),
612
+ };
613
+ };
package/src/rpc.ts ADDED
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The messages between a desktop app's main process and its view: what the
3
+ * view asks for, and what the main process tells it. An app adds its own
4
+ * requests beside these.
5
+ */
6
+ import type { OmarchyCurrentTheme } from "@timmo001/groundwork-tokens/runtime";
7
+
8
+ import type { ResolvedDesktopConfig } from "./config.ts";
9
+
10
+ /** The desktop's Omarchy theme, or `null` where there's none or it's off. */
11
+ export interface OmarchyThemeMessage {
12
+ readonly theme: OmarchyCurrentTheme | null;
13
+ }
14
+
15
+ /** A notice for the view to show in the app, such as a finished download. */
16
+ export interface Notice {
17
+ readonly heading: string;
18
+ readonly body?: string;
19
+ readonly tone?: "neutral" | "info" | "success" | "warning" | "danger";
20
+ }
21
+
22
+ /** What a request may answer with: anything that survives JSON. */
23
+ export type Reply = string | number | boolean | null | void | object;
24
+
25
+ /** An app's own requests from its view, by name. */
26
+ export type RequestHandlers = Record<
27
+ string,
28
+ (params: never) => Reply | Promise<Reply>
29
+ >;
30
+
31
+ /** What the view asks the main process for. */
32
+ export interface DesktopRequests {
33
+ /** The resolved config and whether this is a dev build. */
34
+ readonly config: () => {
35
+ readonly config: ResolvedDesktopConfig;
36
+ readonly dev: boolean;
37
+ };
38
+ /** Opens a URL in the system browser, when the config allows its scheme. */
39
+ readonly openExternal: (params: { readonly url: string }) => boolean;
40
+ readonly clipboardRead: () => string;
41
+ readonly clipboardWrite: (params: { readonly text: string }) => boolean;
42
+ /** Finds text in the page, or the next match. */
43
+ readonly find: (params: {
44
+ readonly text: string;
45
+ readonly forward: boolean;
46
+ }) => boolean;
47
+ readonly stopFind: () => boolean;
48
+ /** Steps the remembered zoom in or out, or back to 1. */
49
+ readonly zoom: (params: { readonly step: "in" | "out" | "reset" }) => number;
50
+ /** Opens or closes the dev tools, in dev builds only. */
51
+ readonly devTools: () => boolean;
52
+ /** A system notification when the config turns them on, otherwise false. */
53
+ readonly notify: (params: Notice) => boolean;
54
+ /** The desktop's Omarchy theme, when the config passes it on. */
55
+ readonly omarchyTheme: () => Promise<OmarchyThemeMessage>;
56
+ }
57
+
58
+ /** What the main process tells the view. */
59
+ export interface DesktopMessages {
60
+ readonly notice: Notice;
61
+ /** A deep link the app was opened with. */
62
+ readonly deepLink: { readonly url: string };
63
+ /** The desktop switched its Omarchy theme. */
64
+ readonly omarchyTheme: OmarchyThemeMessage;
65
+ }
66
+
67
+ /** Requests in the `{ params, response }` shape Electrobun's schemas use. */
68
+ export type RequestSchemas<R> = {
69
+ [K in keyof R]: R[K] extends () => infer T
70
+ ? { params: undefined; response: Awaited<T> }
71
+ : R[K] extends (params: infer P) => infer T
72
+ ? { params: P; response: Awaited<T> }
73
+ : never;
74
+ };
75
+
76
+ /**
77
+ * The app's Electrobun RPC schema: the desktop requests and the app's own,
78
+ * and the desktop messages. Pass it to `defineRPC` on both sides:
79
+ *
80
+ * BrowserView.defineRPC<DesktopSchema<AppRequests>>(...)
81
+ * Electroview.defineRPC<DesktopSchema<AppRequests>>(...)
82
+ */
83
+ export type DesktopSchema<AppRequests extends RequestHandlers = {}> = {
84
+ bun: {
85
+ requests: RequestSchemas<DesktopRequests & AppRequests>;
86
+ messages: {};
87
+ };
88
+ webview: { requests: {}; messages: DesktopMessages };
89
+ };
90
+
91
+ /** The view's handlers for the desktop messages, for `Electroview.defineRPC`. */
92
+ export interface DesktopMessageHandlers {
93
+ readonly notice: (notice: Notice) => void;
94
+ readonly deepLink: (link: { readonly url: string }) => void;
95
+ readonly omarchyTheme: (message: OmarchyThemeMessage) => void;
96
+ }