@x-tech-laboratories/x-libs 0.6.0 → 0.8.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.
Files changed (65) hide show
  1. package/README.md +40 -11
  2. package/dist/backups.d.ts +31 -0
  3. package/dist/backups.js +43 -0
  4. package/dist/durations.d.ts +1 -0
  5. package/dist/durations.js +10 -0
  6. package/dist/electron/app.d.ts +13 -0
  7. package/dist/electron/app.js +27 -0
  8. package/dist/electron/bridge.d.ts +3 -1
  9. package/dist/electron/bridge.js +21 -0
  10. package/dist/electron/events.d.ts +21 -0
  11. package/dist/electron/events.js +25 -0
  12. package/dist/electron/fetch.d.ts +25 -0
  13. package/dist/electron/fetch.js +67 -0
  14. package/dist/electron/window.d.ts +11 -0
  15. package/dist/electron/window.js +49 -6
  16. package/dist/log.d.ts +30 -0
  17. package/dist/log.js +79 -0
  18. package/dist/server/backup_files.d.ts +3 -0
  19. package/dist/server/backup_files.js +25 -0
  20. package/dist/web/drag.d.ts +3 -0
  21. package/dist/web/drag.js +13 -0
  22. package/dist/web/format.d.ts +1 -0
  23. package/dist/web/format.js +5 -0
  24. package/dist/web/prefs.d.ts +12 -0
  25. package/dist/web/prefs.js +62 -0
  26. package/dist/web/react/controls.d.ts +39 -4
  27. package/dist/web/react/controls.js +51 -7
  28. package/dist/web/react/dialog.d.ts +12 -2
  29. package/dist/web/react/dialog.js +7 -3
  30. package/dist/web/react/layout.d.ts +26 -6
  31. package/dist/web/react/layout.js +15 -6
  32. package/dist/web/react/list_picker.d.ts +19 -0
  33. package/dist/web/react/list_picker.js +50 -0
  34. package/dist/web/react/media.d.ts +24 -0
  35. package/dist/web/react/media.js +18 -0
  36. package/dist/web/react/menu_button.d.ts +10 -0
  37. package/dist/web/react/menu_button.js +12 -0
  38. package/dist/web/react/menus.js +26 -2
  39. package/dist/web/react/prefs.d.ts +2 -0
  40. package/dist/web/react/prefs.js +11 -0
  41. package/dist/web/react/reveal.d.ts +10 -0
  42. package/dist/web/react/reveal.js +30 -0
  43. package/dist/web/react/shell.d.ts +8 -2
  44. package/dist/web/react/shell.js +17 -6
  45. package/dist/web/react/table.d.ts +31 -1
  46. package/dist/web/react/table.js +209 -24
  47. package/dist/web/react/time.d.ts +1 -0
  48. package/dist/web/react/time.js +11 -1
  49. package/dist/web/react/toasts.d.ts +12 -0
  50. package/dist/web/react/toasts.js +31 -0
  51. package/dist/web/react/tree.d.ts +7 -2
  52. package/dist/web/react/tree.js +22 -7
  53. package/dist/web/react/watch.d.ts +3 -0
  54. package/dist/web/react/watch.js +5 -0
  55. package/dist/web/selection.d.ts +25 -0
  56. package/dist/web/selection.js +106 -0
  57. package/dist/web/tables.d.ts +1 -0
  58. package/dist/web/tables.js +8 -0
  59. package/dist/web/tree.d.ts +8 -3
  60. package/dist/web/tree.js +31 -8
  61. package/dist/web/virtual.d.ts +7 -0
  62. package/dist/web/virtual.js +10 -0
  63. package/dist/web/watch.d.ts +6 -0
  64. package/dist/web/watch.js +17 -0
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -6,10 +6,15 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
6
6
 
7
7
  | Import | What it holds |
8
8
  | ---------------------------------------- | ----------------------------------------------------------------------------- |
9
- | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration` in months down to seconds, `format_age`. |
9
+ | `durations` | `parse_duration` for settings such as `"2h30m"`, `format_duration` in months down to seconds, `format_age`, and `format_clock` for a playing time such as `"3:07"`. |
10
+ | `log` | Loggers by scope and level from a setting such as `"warn,play:debug"`; a malformed setting falls back instead of silencing logging, and a setting read through a function applies to loggers made before it arrived. |
10
11
  | `dates` | Local dates as `"2026-06-10"` or `"2026-06-10T09:30"`: reading, writing, adding days and months, overdue, and Yesterday/Today/Tomorrow labels. |
11
- | `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server. |
12
- | `electron/window` | `create_window`: links open in the browser, the background follows the system theme, and the size and place are remembered. |
12
+ | `backups` | Named backups of an app's data in any store, newest first, with daily automatic ones kept to a set number. |
13
+ | `electron/bridge` | `define_bridge`: one method table for the preload, the main process, and a web page's calls to its server; `with_cloneable_arguments` flattens what structured clone refuses, such as a Proxy. |
14
+ | `electron/events` | `define_events`: messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name. |
15
+ | `electron/app` | `hold_single_instance`, `bring_to_front`, and `first_argument` for a link handed over on the command line. |
16
+ | `electron/window` | `create_window`: links and other sites open in the browser, the background follows the system theme, and the size and place are remembered, through the app's write guard if it has one; timers can keep running while hidden. |
17
+ | `electron/fetch` | `create_main_fetch` and `create_page_fetch`: the page's requests sent from the main process past cross-origin rules, to an allow-list of hosts, with headers such as `Origin` pinned. |
13
18
  | `agent/commands` | `run_command` and `stream_command`: one shell command, in its own process group. |
14
19
  | `agent/long_runs` | `LongRuns`: commands that outlive the agent, their output and exit code kept on disk. |
15
20
  | `agent/keys` | Reads and makes OpenSSH ed25519 agent keys; signs and checks the login challenge. |
@@ -20,16 +25,24 @@ Code the X-apps share, published as one npm package, `@x-tech-laboratories/x-lib
20
25
  | `server/agents/connection`, `agent`, `local`, `route` | One connected agent, the `Agent` interface, `LocalAgent`, and the Fastify route. |
21
26
  | `server/passwords` | scrypt hashes and the `name:hash` accounts file. |
22
27
  | `server/hash_password` | The `hash-password` command. |
23
- | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters, hash router and theme choice, kept in this browser or the app's settings. |
28
+ | `server/backup_files` | `file_backup_store`: the `backups` store as files in a folder on disk. |
29
+ | `web/dom`, `web/format`, `web/router`, `web/theme` | The page's element builder, formatters (including `compact_count`, such as `"1.4M"`), hash router and theme choice, kept in this browser or the app's settings. |
24
30
  | `web/states`, `web/tables`, `web/menus`, `web/split` | `LoginNeeded`; sorting by several columns, trees and column order; menu items, placement and keys; pane sizing. |
25
- | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, and where a dropped or keyed row lands. |
26
- | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar, resizable, theme button, toasts, dialogs and menus; `use_route`. |
27
- | `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows; badges and chips; `use_resource` and its loading states. |
28
- | `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover. |
29
- | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes, selects; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, and cards on phones; a pager. |
31
+ | `web/tree` | Rows with `parent_id` and `position`: nesting, subtrees, tree order, paths, and where a dropped or keyed row lands, its siblings ordered by position or, with `by_list_order`, as the list gives them. |
32
+ | `web/watch`, `web/react/watch` | `create_watch`: a change counter a store bumps; `use_watch` redraws only the components that read that store. |
33
+ | `web/prefs`, `web/react/prefs` | Remembered view settings that read as their default when stored corrupt, stale or of the wrong shape; `use_pref` keeps one as component state. |
34
+ | `web/selection`, `web/virtual`, `web/drag` | Picking rows with plain, Ctrl and Shift clicks from a fixed anchor, or as lists of ids with the anchor kept by id, the row an arrow or page key moves to; which rows of a long list a viewport needs; a badge such as "3 tasks" as the drag image. |
35
+ | `web/react/shell` | `Shell`: top bar, section links or the app's own sidebar in a `.sidebar-content` box, resizable, theme button, a `status_bar` at the foot, toasts, dialogs and menus, with or without logins (no account UI without a `user`, `logged_out` hiding the sidebar and tools), its phone menu held by the Shell or the app; `use_route`. |
36
+ | `web/react/layout`, `badges`, `states` | Panels, facts, tiles, cards, tabs, crumbs, steppers, settings rows, `RowActions` and `moved` for ordered lists, `SelectionBar` for picked rows; badges and chips; `use_resource` and its loading states. |
37
+ | `web/react/time` | `Age`: a time as how long ago, with the full stamp on hover; `use_now`, the time read again each minute. |
38
+ | `web/react/reveal` | `use_reveal`: a long list shown a page at a time, the next page added as its end scrolls into view, starting from the count shown before the page was left if given. |
39
+ | `web/react/media` | `Avatar`, a round picture or the name's first letter; `MediaTile` and `MediaGrid`: videos, albums or playlists as tiles in a grid or as rows, with text over the picture's corner such as a length, lines under the title, a faded look, and buttons in the corner. |
40
+ | `web/react/menu_button` | `MenuButton`: a ⋮ button that opens a menu under itself, even inside a link. |
41
+ | `web/react/list_picker` | `pick_list_dialog`: adds one thing to any of the app's lists, such as a video to playlists, ticking the lists that hold it and making a new one. |
42
+ | `web/react/controls`, `table` | Segmented choices, button groups, switches, search boxes that can take focus and follow a value cleared from outside, selects, `CommitInput`, `DateTimeField`, `ColorPicker`, `TreeSelect`; `DataTable` on TanStack Table, sorting by several columns, with columns to move, resize, hide and filter, tree rows, groups under headings, rows the app drags and styles, rows that come sorted from a database, rows worked by keyboard, rows that draw again only when they change, `virtual` rows drawn only where they show, a last column that fills the room left, group headings that hide when the filter empties them, and cards on phones; a pager. |
30
43
  | `web/react/shortcuts` | `use_shortcuts` for keys such as `"mod+z"`, quiet while typing or while a dialog or popover is open; `describe_keys` for help text. |
31
- | `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, and reorder by dragging or with Alt and the arrow keys. |
32
- | `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed; modal, confirm and text dialogs openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere; resize handles and splits. |
44
+ | `web/react/tree` | `Tree`: nested rows that fold, walk with the arrow keys, reorder by dragging or with Alt and the arrow keys, show several picked, and take drops from outside. |
45
+ | `web/react/toasts`, `dialog`, `menus`, `split` | Toasts with a link or Undo that say how they closed, and `undoable` actions that commit or undo exactly once; modal dialogs with a subtitle, header actions, a full-window size and a `form` whose submit button sends it, and confirm and text dialogs, openable from anywhere; context menus and popovers worked by keyboard, openable from anywhere, their keep-open items ticking at once; resize handles and splits. |
33
46
 
34
47
  ```ts
35
48
  import { AgentHub } from "@x-tech-laboratories/x-libs/server/agents/hub";
@@ -96,6 +109,22 @@ create_window(BrowserWindow, {
96
109
  window.x_notes = notes_bridge.web_rpc((method, args) => post_json(`api/${method}`, args));
97
110
  ```
98
111
 
112
+ A page that must reach another site, such as an API that sends no CORS headers, sends its requests through the main
113
+ process with `electron/fetch`. The main process allows only the hosts it lists; the page gets a `fetch` to hand to a
114
+ library.
115
+
116
+ ```ts
117
+ // shared
118
+ export const media_bridge = define_bridge<{ http_fetch: FetchMethod }>("x_media", ["http_fetch"]);
119
+ // main
120
+ media_bridge.handle(ipcMain, { http_fetch: create_main_fetch({ hosts: ["api.example.com", "*.cdn.example.com"] }) });
121
+ // web page
122
+ const page_fetch = create_page_fetch((request) => window.x_media.http_fetch(request), { pinned_headers: { Origin: "https://example.com" } });
123
+ ```
124
+
125
+ `create_window` keeps the app's own pages in the window: a link or a navigation to another site opens in the browser,
126
+ and only web and mail links leave the app.
127
+
99
128
  ## The app and its tasks
100
129
 
101
130
  The server and every agent are given the same `AgentApp`: the app's name, its protocol version, and its tasks.
@@ -0,0 +1,31 @@
1
+ export interface Backup {
2
+ name: string;
3
+ taken_at: string;
4
+ is_automatic: boolean;
5
+ size_bytes: number;
6
+ }
7
+ export interface BackupNaming {
8
+ pattern: RegExp;
9
+ name_for: (taken_at: Date, is_automatic: boolean) => string;
10
+ describe: (name: string, size_bytes: number) => Backup;
11
+ }
12
+ export declare function backup_naming(prefix: string, extension: string): BackupNaming;
13
+ export interface BackupStore {
14
+ list: () => Promise<{
15
+ name: string;
16
+ size_bytes: number;
17
+ }[]> | {
18
+ name: string;
19
+ size_bytes: number;
20
+ }[];
21
+ take: (name: string) => Promise<number> | number;
22
+ remove: (name: string) => Promise<void> | void;
23
+ }
24
+ export interface Backups {
25
+ naming: BackupNaming;
26
+ list: () => Promise<Backup[]>;
27
+ create: (is_automatic?: boolean) => Promise<Backup>;
28
+ remove: (name: string) => Promise<void>;
29
+ keep_automatic: (to_keep: number, interval_in_milliseconds?: number, now?: Date) => Promise<void>;
30
+ }
31
+ export declare function backups(naming: BackupNaming, store: BackupStore): Backups;
@@ -0,0 +1,43 @@
1
+ // Named backups of an app's data: the name says when each was taken and whether it was automatic, and daily ones keep a set number.
2
+ const escaped = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3
+ // Names such as tasks-2026-06-10T09-30-00Z-auto.db for the prefix "tasks" and the extension ".db".
4
+ export function backup_naming(prefix, extension) {
5
+ const pattern = new RegExp(`^${escaped(prefix)}-(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})Z(-auto)?${escaped(extension)}$`);
6
+ return {
7
+ pattern,
8
+ name_for: (taken_at, is_automatic) => `${prefix}-${taken_at.toISOString().slice(0, 19).replaceAll(":", "-")}Z${is_automatic ? "-auto" : ""}${extension}`,
9
+ describe: (name, size_bytes) => {
10
+ const match = pattern.exec(name);
11
+ if (!match)
12
+ throw new Error(`not a backup name: ${name}`);
13
+ const [, date, hours, minutes, seconds, automatic] = match;
14
+ return { name, taken_at: `${date}T${hours}:${minutes}:${seconds}Z`, is_automatic: automatic !== undefined, size_bytes };
15
+ },
16
+ };
17
+ }
18
+ const day_in_milliseconds = 24 * 60 * 60 * 1000;
19
+ export function backups(naming, store) {
20
+ const list = async () => (await store.list())
21
+ .filter((file) => naming.pattern.test(file.name))
22
+ .map((file) => naming.describe(file.name, file.size_bytes))
23
+ .sort((first, second) => second.taken_at.localeCompare(first.taken_at));
24
+ const remove = async (name) => {
25
+ if (!naming.pattern.test(name))
26
+ throw new Error(`not a backup name: ${name}`);
27
+ await store.remove(name);
28
+ };
29
+ const create = async (is_automatic = false, now = new Date()) => {
30
+ const name = naming.name_for(now, is_automatic);
31
+ const existing = (await list()).find((backup) => backup.name === name);
32
+ return existing ?? naming.describe(name, await store.take(name));
33
+ };
34
+ const keep_automatic = async (to_keep, interval_in_milliseconds = day_in_milliseconds, now = new Date()) => {
35
+ const newest = (await list()).find((backup) => backup.is_automatic);
36
+ if (!newest || now.getTime() - Date.parse(newest.taken_at) >= interval_in_milliseconds)
37
+ await create(true, now);
38
+ const automatic = (await list()).filter((backup) => backup.is_automatic);
39
+ for (const backup of automatic.slice(Math.max(0, to_keep)))
40
+ await remove(backup.name);
41
+ };
42
+ return { naming, list, create: (is_automatic) => create(is_automatic), remove, keep_automatic };
43
+ }
@@ -7,4 +7,5 @@ export interface DurationWording {
7
7
  export declare function parse_duration(value: unknown, key: string): number;
8
8
  export declare function format_duration(seconds: number, wording?: DurationWording): string;
9
9
  export declare function format_age(seconds: number): string;
10
+ export declare function format_clock(milliseconds: number | null | undefined, empty?: string): string;
10
11
  export {};
package/dist/durations.js CHANGED
@@ -37,3 +37,13 @@ export function format_duration(seconds, wording = {}) {
37
37
  export function format_age(seconds) {
38
38
  return seconds < 0 ? "never" : `${format_duration(seconds)} ago`;
39
39
  }
40
+ // A playing time as a clock, such as "3:07", or "1:02:03" from an hour.
41
+ export function format_clock(milliseconds, empty = "0:00") {
42
+ if (milliseconds === null || milliseconds === undefined || !Number.isFinite(milliseconds))
43
+ return empty;
44
+ const total = Math.max(0, Math.round(milliseconds / 1000));
45
+ const hours = Math.floor(total / 3600);
46
+ const minutes = Math.floor((total % 3600) / 60);
47
+ const seconds = String(total % 60).padStart(2, "0");
48
+ return hours > 0 ? `${hours}:${String(minutes).padStart(2, "0")}:${seconds}` : `${minutes}:${seconds}`;
49
+ }
@@ -0,0 +1,13 @@
1
+ export interface AppLike {
2
+ requestSingleInstanceLock(): boolean;
3
+ quit(): void;
4
+ on(event: "second-instance", listener: (event: unknown, argv: string[]) => void): unknown;
5
+ }
6
+ export interface FocusableWindowLike {
7
+ isMinimized(): boolean;
8
+ restore(): void;
9
+ focus(): void;
10
+ }
11
+ export declare function hold_single_instance(app: AppLike, on_second_launch: (argv: string[]) => void): boolean;
12
+ export declare function bring_to_front(window: FocusableWindowLike): void;
13
+ export declare function first_argument<Value>(argv: readonly string[], parse: (argument: string) => Value | null): Value | null;
@@ -0,0 +1,27 @@
1
+ // The app's place on the desktop: one copy running at a time, its window brought forward, and links handed over on the command line.
2
+ // ── One copy ─────────────────────────────────────────────────────────────────
3
+ // Keeps one copy running: a second launch hands its command line to the first and quits; returns false in the copy that must quit.
4
+ export function hold_single_instance(app, on_second_launch) {
5
+ if (!app.requestSingleInstanceLock()) {
6
+ app.quit();
7
+ return false;
8
+ }
9
+ app.on("second-instance", (_event, argv) => on_second_launch(argv));
10
+ return true;
11
+ }
12
+ // Brings a window forward, restoring it first when minimised.
13
+ export function bring_to_front(window) {
14
+ if (window.isMinimized())
15
+ window.restore();
16
+ window.focus();
17
+ }
18
+ // ── Command line ─────────────────────────────────────────────────────────────
19
+ // The first command-line argument a parser accepts, such as a task link, or null.
20
+ export function first_argument(argv, parse) {
21
+ for (const argument of argv) {
22
+ const value = parse(argument);
23
+ if (value !== null)
24
+ return value;
25
+ }
26
+ return null;
27
+ }
@@ -13,7 +13,7 @@ export type Client<Methods extends Record<keyof Methods, AnyMethod>> = {
13
13
  [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Promise<Awaited<ReturnType<Methods[Name]>>>;
14
14
  };
15
15
  export type Implementation<Methods extends Record<keyof Methods, AnyMethod>> = {
16
- [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => ReturnType<Methods[Name]> | Promise<Awaited<ReturnType<Methods[Name]>>>;
16
+ [Name in keyof Methods]: (...args: Parameters<Methods[Name]>) => Awaited<ReturnType<Methods[Name]>> | Promise<Awaited<ReturnType<Methods[Name]>>>;
17
17
  };
18
18
  export type CallRemote = (method: string, args: unknown[]) => Promise<unknown>;
19
19
  export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
@@ -27,4 +27,6 @@ export interface Bridge<Methods extends Record<keyof Methods, AnyMethod>> {
27
27
  dispatch: (implementation: Implementation<Methods>, method: string, args: unknown[]) => Promise<unknown>;
28
28
  }
29
29
  export declare function define_bridge<Methods extends Record<keyof Methods, AnyMethod>>(api_name: string, method_names: readonly (keyof Methods & string)[]): Bridge<Methods>;
30
+ export declare function to_cloneable(value: unknown): unknown;
31
+ export declare function with_cloneable_arguments<Methods extends Record<keyof Methods, AnyMethod>>(client: Client<Methods>): Client<Methods>;
30
32
  export {};
@@ -30,3 +30,24 @@ export function define_bridge(api_name, method_names) {
30
30
  web_rpc: client_through,
31
31
  };
32
32
  }
33
+ // ── Cloning ──────────────────────────────────────────────────────────────────
34
+ // A value that survives structured clone passes as it is, and anything else, such as a Proxy, crosses as plain JSON.
35
+ export function to_cloneable(value) {
36
+ if (value === null || typeof value !== "object")
37
+ return value;
38
+ try {
39
+ structuredClone(value);
40
+ return value;
41
+ }
42
+ catch {
43
+ return JSON.parse(JSON.stringify(value));
44
+ }
45
+ }
46
+ // The same client with every argument made cloneable first, since Electron clones arguments before the preload sees them.
47
+ export function with_cloneable_arguments(client) {
48
+ const wrapped = {};
49
+ for (const [name, method] of Object.entries(client)) {
50
+ wrapped[name] = (...args) => method(...args.map(to_cloneable));
51
+ }
52
+ return wrapped;
53
+ }
@@ -0,0 +1,21 @@
1
+ import type { ContextBridgeLike } from "./bridge.ts";
2
+ export interface IpcRendererEventsLike {
3
+ on(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
4
+ removeListener(channel: string, listener: (event: unknown, message: unknown) => void): unknown;
5
+ }
6
+ export interface WindowLike {
7
+ webContents: {
8
+ send(channel: string, message: unknown): void;
9
+ };
10
+ }
11
+ export interface EventsClient<Events extends Record<string, unknown>> {
12
+ on<Name extends keyof Events & string>(name: Name, handler: (payload: Events[Name]) => void): () => void;
13
+ }
14
+ export interface EventsBridge<Events extends Record<string, unknown>> {
15
+ api_name: string;
16
+ channel: string;
17
+ ipc_client: (ipc_renderer: IpcRendererEventsLike) => EventsClient<Events>;
18
+ expose: (context_bridge: ContextBridgeLike, ipc_renderer: IpcRendererEventsLike) => void;
19
+ emit: <Name extends keyof Events & string>(windows: Iterable<WindowLike>, name: Name, payload: Events[Name]) => void;
20
+ }
21
+ export declare function define_events<Events extends Record<string, unknown>>(api_name: string): EventsBridge<Events>;
@@ -0,0 +1,25 @@
1
+ // Messages the main process pushes to its pages, such as progress, on one channel and subscribed to by name.
2
+ export function define_events(api_name) {
3
+ const channel = `${api_name}:event`;
4
+ const ipc_client = (ipc_renderer) => ({
5
+ on: (name, handler) => {
6
+ const listener = (_event, message) => {
7
+ const { name: sent_name, payload } = message;
8
+ if (sent_name === name)
9
+ handler(payload);
10
+ };
11
+ ipc_renderer.on(channel, listener);
12
+ return () => void ipc_renderer.removeListener(channel, listener);
13
+ },
14
+ });
15
+ return {
16
+ api_name,
17
+ channel,
18
+ ipc_client,
19
+ expose: (context_bridge, ipc_renderer) => context_bridge.exposeInMainWorld(api_name, ipc_client(ipc_renderer)),
20
+ emit: (windows, name, payload) => {
21
+ for (const window of windows)
22
+ window.webContents.send(channel, { name, payload });
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,25 @@
1
+ export type HeaderList = [string, string][];
2
+ export interface FetchRequest {
3
+ url: string;
4
+ method: string;
5
+ headers: HeaderList;
6
+ body?: Uint8Array<ArrayBuffer>;
7
+ }
8
+ export interface FetchAnswer {
9
+ status: number;
10
+ status_text: string;
11
+ headers: HeaderList;
12
+ body: Uint8Array<ArrayBuffer> | null;
13
+ }
14
+ export type FetchMethod = (request: FetchRequest) => FetchAnswer;
15
+ export interface FetchPolicy {
16
+ hosts: readonly string[];
17
+ allow_http?: boolean;
18
+ }
19
+ export declare function is_allowed_url(url: string, policy: FetchPolicy): boolean;
20
+ export declare function create_main_fetch(policy: FetchPolicy, send?: typeof fetch): (request: FetchRequest) => Promise<FetchAnswer>;
21
+ export interface PageFetchOptions {
22
+ pinned_headers?: Record<string, string>;
23
+ }
24
+ export declare function to_response(answer: FetchAnswer): Response;
25
+ export declare function create_page_fetch(send: (request: FetchRequest) => Promise<FetchAnswer>, options?: PageFetchOptions): (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -0,0 +1,67 @@
1
+ // Fetching from Electron's main process for a page whose own requests the browser's cross-origin rules would block.
2
+ const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
3
+ export function is_allowed_url(url, policy) {
4
+ let parsed;
5
+ try {
6
+ parsed = new URL(url);
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ if (parsed.protocol !== "https:" && !(policy.allow_http && parsed.protocol === "http:"))
12
+ return false;
13
+ return policy.hosts.some((host) => host.startsWith("*.") ? parsed.hostname.endsWith(host.slice(1)) : parsed.host === host || parsed.hostname === host);
14
+ }
15
+ // The body arrives already unpacked, so the headers describing the packed body are dropped.
16
+ const DROPPED_ANSWER_HEADERS = new Set(["content-encoding", "content-length"]);
17
+ // The main process's half: sends the page's request with Node's fetch, refusing any URL outside the policy.
18
+ export function create_main_fetch(policy, send = fetch) {
19
+ return async (request) => {
20
+ if (!is_allowed_url(request.url, policy))
21
+ throw new Error(`fetching ${request.url} is not allowed`);
22
+ const response = await send(request.url, { method: request.method, headers: request.headers, body: request.body });
23
+ const headers = [];
24
+ response.headers.forEach((value, name) => {
25
+ if (!DROPPED_ANSWER_HEADERS.has(name))
26
+ headers.push([name, value]);
27
+ });
28
+ const has_body = !NULL_BODY_STATUSES.has(response.status) && request.method !== "HEAD";
29
+ return {
30
+ status: response.status,
31
+ status_text: response.statusText,
32
+ headers,
33
+ body: has_body ? new Uint8Array(await response.arrayBuffer()) : null,
34
+ };
35
+ };
36
+ }
37
+ function abort_error() {
38
+ return new DOMException("The request was aborted", "AbortError");
39
+ }
40
+ // Rejects once the signal aborts, though the main process still finishes the request.
41
+ function until_aborted(promise, signal) {
42
+ if (!signal)
43
+ return promise;
44
+ if (signal.aborted)
45
+ return Promise.reject(abort_error());
46
+ return new Promise((resolve, reject) => {
47
+ signal.addEventListener("abort", () => reject(abort_error()), { once: true });
48
+ promise.then(resolve, reject);
49
+ });
50
+ }
51
+ export function to_response(answer) {
52
+ const body = NULL_BODY_STATUSES.has(answer.status) ? null : answer.body;
53
+ return new Response(body, { status: answer.status, statusText: answer.status_text, headers: answer.headers });
54
+ }
55
+ // The page's half: a fetch that sends each request through the bridge method, for libraries that take a fetch.
56
+ export function create_page_fetch(send, options = {}) {
57
+ return async (input, init) => {
58
+ const request = new Request(input, init);
59
+ // A plain Headers keeps names a Request would drop, such as Origin and Referer.
60
+ const headers = new Headers(request.headers);
61
+ for (const [name, value] of Object.entries(options.pinned_headers ?? {}))
62
+ headers.set(name, value);
63
+ const body = request.method === "GET" || request.method === "HEAD" ? undefined : new Uint8Array(await request.arrayBuffer());
64
+ const answer = await until_aborted(send({ url: request.url, method: request.method, headers: [...headers.entries()], body }), init?.signal ?? request.signal);
65
+ return to_response(answer);
66
+ };
67
+ }
@@ -15,13 +15,18 @@ export interface BrowserWindowOptionsLike {
15
15
  minHeight?: number;
16
16
  show: boolean;
17
17
  backgroundColor: string;
18
+ titleBarStyle?: "default" | "hidden" | "hiddenInset";
18
19
  webPreferences: {
19
20
  preload?: string;
20
21
  sandbox: boolean;
21
22
  contextIsolation: boolean;
22
23
  nodeIntegration: boolean;
24
+ backgroundThrottling: boolean;
23
25
  };
24
26
  }
27
+ export interface NavigationEventLike {
28
+ preventDefault(): void;
29
+ }
25
30
  export interface BrowserWindowLike {
26
31
  webContents: {
27
32
  setWindowOpenHandler(handler: (details: {
@@ -29,6 +34,7 @@ export interface BrowserWindowLike {
29
34
  }) => {
30
35
  action: "deny";
31
36
  }): void;
37
+ on(event: "will-navigate", listener: (event: NavigationEventLike, url: string) => void): unknown;
32
38
  };
33
39
  on(event: string, listener: () => void): unknown;
34
40
  once(event: string, listener: () => void): unknown;
@@ -74,6 +80,9 @@ export interface WindowOptions {
74
80
  dark: string;
75
81
  };
76
82
  sandbox?: boolean;
83
+ background_throttling?: boolean;
84
+ title_bar_style?: "default" | "hidden" | "hiddenInset";
85
+ before_write?: (path: string) => void;
77
86
  save_delay_ms?: number;
78
87
  }
79
88
  export interface WindowState {
@@ -82,4 +91,6 @@ export interface WindowState {
82
91
  }
83
92
  export declare function read_window_state(state_file: string): WindowState | null;
84
93
  export declare function write_window_state(state_file: string, state: WindowState): void;
94
+ export declare function is_external_link(url: string): boolean;
95
+ export declare function is_app_page(url: string, options: Pick<WindowOptions, "dev_url" | "file">): boolean;
85
96
  export declare function create_window<Window extends BrowserWindowLike>(BrowserWindow: new (options: BrowserWindowOptionsLike) => Window, options: WindowOptions): Window;
@@ -1,6 +1,7 @@
1
- // An Electron app's main window: links open in the browser, the background follows the system theme, and its size and place are remembered.
1
+ // An Electron app's main window: links and pages of other sites open in the browser, the background follows the system theme, and its size and place are remembered.
2
2
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
+ import { pathToFileURL } from "node:url";
4
5
  const DEFAULT_BACKGROUND = { light: "#f5f6f8", dark: "#0f1115" };
5
6
  const DEFAULT_SAVE_DELAY_MS = 500;
6
7
  // ── State file ───────────────────────────────────────────────────────────────
@@ -28,6 +29,34 @@ export function write_window_state(state_file, state) {
28
29
  function overlaps(first, second) {
29
30
  return first.x < second.x + second.width && second.x < first.x + first.width && first.y < second.y + second.height && second.y < first.y + first.height;
30
31
  }
32
+ // ── Links ────────────────────────────────────────────────────────────────────
33
+ const EXTERNAL_SCHEMES = new Set(["http:", "https:", "mailto:"]);
34
+ // Only web and mail links reach the browser, never a file or another app's scheme.
35
+ export function is_external_link(url) {
36
+ try {
37
+ return EXTERNAL_SCHEMES.has(new URL(url).protocol);
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ // True for the app's own pages: the dev server's origin, or files beside the built page.
44
+ export function is_app_page(url, options) {
45
+ try {
46
+ const target = new URL(url);
47
+ if (options.dev_url)
48
+ return target.origin === new URL(options.dev_url).origin;
49
+ const page_folder = new URL(".", pathToFileURL(options.file)).href;
50
+ return target.protocol === "file:" && target.href.startsWith(page_folder);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ function open_outside(shell, url) {
57
+ if (is_external_link(url))
58
+ void shell.openExternal(url);
59
+ }
31
60
  // ── Window ───────────────────────────────────────────────────────────────────
32
61
  export function create_window(BrowserWindow, options) {
33
62
  const background = options.background ?? DEFAULT_BACKGROUND;
@@ -45,7 +74,8 @@ export function create_window(BrowserWindow, options) {
45
74
  minHeight: options.min_size?.height,
46
75
  show: false,
47
76
  backgroundColor: background_color(),
48
- webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false },
77
+ ...(options.title_bar_style ? { titleBarStyle: options.title_bar_style } : {}),
78
+ webPreferences: { preload: options.preload, sandbox: options.sandbox ?? false, contextIsolation: true, nodeIntegration: false, backgroundThrottling: options.background_throttling ?? true },
49
79
  });
50
80
  window.once("ready-to-show", () => {
51
81
  if (saved?.is_maximized)
@@ -53,14 +83,21 @@ export function create_window(BrowserWindow, options) {
53
83
  window.show();
54
84
  });
55
85
  window.webContents.setWindowOpenHandler(({ url }) => {
56
- void options.shell.openExternal(url);
86
+ open_outside(options.shell, url);
57
87
  return { action: "deny" };
58
88
  });
89
+ // A link that would load another site in the window opens in the browser instead.
90
+ window.webContents.on("will-navigate", (event, url) => {
91
+ if (is_app_page(url, options))
92
+ return;
93
+ event.preventDefault();
94
+ open_outside(options.shell, url);
95
+ });
59
96
  const follow_theme = () => window.setBackgroundColor(background_color());
60
97
  options.native_theme.on("updated", follow_theme);
61
98
  window.on("closed", () => options.native_theme.removeListener("updated", follow_theme));
62
99
  if (options.state_file)
63
- remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS);
100
+ remember_state(window, options.state_file, options.save_delay_ms ?? DEFAULT_SAVE_DELAY_MS, options.before_write);
64
101
  if (options.dev_url)
65
102
  void window.loadURL(options.dev_url);
66
103
  else
@@ -68,12 +105,18 @@ export function create_window(BrowserWindow, options) {
68
105
  return window;
69
106
  }
70
107
  // Saves a moment after the last move or resize, and at once when the window closes.
71
- function remember_state(window, state_file, save_delay_ms) {
108
+ function remember_state(window, state_file, save_delay_ms, before_write) {
72
109
  let timer;
73
110
  const save_now = () => {
74
111
  clearTimeout(timer);
75
112
  timer = undefined;
76
- write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
113
+ try {
114
+ before_write?.(state_file);
115
+ write_window_state(state_file, { bounds: window.getNormalBounds(), is_maximized: window.isMaximized() });
116
+ }
117
+ catch {
118
+ // A refused or failed save keeps the last one, and the window carries on.
119
+ }
77
120
  };
78
121
  const save_soon = () => {
79
122
  clearTimeout(timer);
package/dist/log.d.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type Level = "critical" | "error" | "warn" | "info" | "debug";
2
+ export declare const levels: Level[];
3
+ export declare const default_level: Level;
4
+ export interface LevelSpec {
5
+ default: Level;
6
+ scopes: Record<string, Level>;
7
+ }
8
+ export declare function parse_level_spec(spec: string | undefined, fallback?: Level): LevelSpec;
9
+ export declare function level_for(spec: LevelSpec, scope: string): Level;
10
+ export declare function is_enabled(spec: LevelSpec, scope: string, level: Level): boolean;
11
+ export interface LogRecord {
12
+ at: number;
13
+ level: Level;
14
+ scope: string;
15
+ message: string;
16
+ data?: unknown;
17
+ }
18
+ export type LogSink = (record: LogRecord) => void;
19
+ export declare function format_record(record: LogRecord): string;
20
+ export interface Logger {
21
+ critical(message: string, data?: unknown): void;
22
+ error(message: string, data?: unknown): void;
23
+ warn(message: string, data?: unknown): void;
24
+ info(message: string, data?: unknown): void;
25
+ debug(message: string, data?: unknown): void;
26
+ enabled(level: Level): boolean;
27
+ child(scope: string): Logger;
28
+ }
29
+ export type SpecSource = LevelSpec | (() => LevelSpec);
30
+ export declare function create_logger(scope: string, spec: SpecSource, sink: LogSink): Logger;