@n1h/sweetview 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/LICENSE-0BSD +12 -0
- package/README.md +268 -3
- package/docs/README.md +26 -0
- package/docs/app.md +523 -0
- package/docs/build.md +169 -0
- package/docs/ipc-ws.md +202 -0
- package/docs/ipc.md +323 -0
- package/docs/native.md +1066 -0
- package/docs/page.md +71 -0
- package/docs/settings-store.md +125 -0
- package/docs/webview-library.md +104 -0
- package/docs/window-chrome.md +221 -0
- package/docs/worker.md +72 -0
- package/file-imports.d.ts +19 -0
- package/package.json +66 -4
- package/src/app.ts +350 -0
- package/src/build.ts +118 -0
- package/src/html.ts +71 -0
- package/src/index.ts +8 -0
- package/src/ipc/bridge.ts +41 -0
- package/src/ipc/frame.ts +16 -0
- package/src/ipc/index.ts +16 -0
- package/src/ipc/memory.ts +33 -0
- package/src/ipc/service.ts +75 -0
- package/src/ipc-ws/client.ts +111 -0
- package/src/ipc-ws/index.ts +5 -0
- package/src/ipc-ws/server.ts +133 -0
- package/src/make-app.ts +93 -0
- package/src/messages.ts +16 -0
- package/src/native/app-icon.ts +23 -0
- package/src/native/app-menu.ts +18 -0
- package/src/native/darwin/app-icon.ts +10 -0
- package/src/native/darwin/app-menu.ts +88 -0
- package/src/native/darwin/folder-picker.ts +29 -0
- package/src/native/darwin/image.ts +47 -0
- package/src/native/darwin/menu.ts +89 -0
- package/src/native/darwin/notifier.ts +95 -0
- package/src/native/darwin/objc.ts +246 -0
- package/src/native/darwin/overlay.ts +352 -0
- package/src/native/darwin/tray.ts +129 -0
- package/src/native/darwin/window.ts +64 -0
- package/src/native/folder-picker.ts +15 -0
- package/src/native/hit-regions.ts +162 -0
- package/src/native/icon.ts +530 -0
- package/src/native/index.ts +38 -0
- package/src/native/menu.ts +43 -0
- package/src/native/notifications.ts +40 -0
- package/src/native/overlay.ts +182 -0
- package/src/native/platform.ts +25 -0
- package/src/native/tray.ts +41 -0
- package/src/native/win32/api.ts +234 -0
- package/src/native/win32/app-icon.ts +5 -0
- package/src/native/win32/app-menu.ts +4 -0
- package/src/native/win32/folder-picker.ts +121 -0
- package/src/native/win32/icon.ts +85 -0
- package/src/native/win32/menu.ts +107 -0
- package/src/native/win32/message-window.ts +72 -0
- package/src/native/win32/notifier.ts +28 -0
- package/src/native/win32/overlay.ts +499 -0
- package/src/native/win32/tray.ts +228 -0
- package/src/native/win32/window.ts +77 -0
- package/src/native/window.ts +40 -0
- package/src/page.ts +48 -0
- package/src/ring.ts +110 -0
- package/src/settings-store.ts +101 -0
- package/src/webview-lib.ts +60 -0
- package/src/window-chrome.ts +118 -0
- package/src/worker.ts +72 -0
package/docs/page.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @n1h/sweetview/page
|
|
2
|
+
|
|
3
|
+
The page module gives browser code a transport to the window host. It runs in the browser page inside a sweetview window. Its only import is a type from `@n1h/sweetview/ipc`, so it bundles for the browser without Bun or `bun:ffi`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { isWebview, webviewTransport } from "@n1h/sweetview/page";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Reference
|
|
10
|
+
|
|
11
|
+
### isWebview
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export const isWebview = (): boolean
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Reports whether the page is running inside a sweetview window. It checks that `window` exists and that `window.__sweetview_rpc` is a function.
|
|
18
|
+
|
|
19
|
+
Returns: `true` inside a sweetview window, `false` elsewhere, including outside a browser.
|
|
20
|
+
|
|
21
|
+
### webviewTransport
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
export function webviewTransport(): Transport
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Creates a `Transport` for the page's sweetview window. `Transport` is documented in [ipc.md](ipc.md). Pass the result to `createBridge` for the typed surface.
|
|
28
|
+
|
|
29
|
+
| Field | Type | Meaning |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| `kind` | `TransportKind` | Always `"webview"`. |
|
|
32
|
+
| `call(method, params)` | `Promise<unknown>` | Calls `__sweetview_rpc(method, params)`. Resolves with the result. A result of `undefined` arrives as `null`. Rejects with an `Error` whose message is the rejection value's `message` field as a string, or the value as a string when it has no `message` field. |
|
|
33
|
+
| `notify(method, params)` | `void` | Sends the same call as `call` and discards the result. A failure is logged with `console.error` as `"<method> failed:"` and the message. |
|
|
34
|
+
| `onEvent(fn)` | `() => void` | Registers `fn` for every event. Returns a function that removes it. |
|
|
35
|
+
|
|
36
|
+
Every transport created on one page shares one set of event listeners. The first `webviewTransport()` call installs `window.__sweetview_emit`, unless the page already defined it. In that case `onEvent` listeners receive no events. Events the host emits before the first `webviewTransport()` call find no `__sweetview_emit` and are lost.
|
|
37
|
+
|
|
38
|
+
Returns: a `Transport` whose `kind` is `"webview"`.
|
|
39
|
+
|
|
40
|
+
Throws: `Error` with the message `"not running inside sweetview"` when `window.__sweetview_rpc` is not defined.
|
|
41
|
+
|
|
42
|
+
Outside a browser, where there is no global `window`, it throws the same `Error`.
|
|
43
|
+
|
|
44
|
+
Thread: the browser page.
|
|
45
|
+
|
|
46
|
+
## Globals
|
|
47
|
+
|
|
48
|
+
The module declares both globals as optional properties of `Window`.
|
|
49
|
+
|
|
50
|
+
| Global | Type | Installed by | Meaning |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| `__sweetview_rpc` | `(method: string, params: unknown) => Promise<unknown>` | The UI thread, with `webview_bind`, in `createWebviewApp` | Sends a call to the host. A method in `uiMethods` is answered on the UI thread. Any other method goes to the worker. In a window without a worker, any other method rejects. A result of `undefined` resolves as `null`. Rejects with `{ message }` on failure. |
|
|
53
|
+
| `__sweetview_emit` | `(name: string, payload: unknown) => void` | `webviewTransport()`, on its first call | The UI thread evaluates `__sweetview_emit(name, payload)` once per event. A payload of `undefined` arrives as `null`. It passes the event to every listener registered with `onEvent`. |
|
|
54
|
+
|
|
55
|
+
The host side of both is in [app.md](app.md).
|
|
56
|
+
|
|
57
|
+
## Example
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
import { createBridge } from "@n1h/sweetview/ipc";
|
|
61
|
+
import { isWebview, webviewTransport } from "@n1h/sweetview/page";
|
|
62
|
+
|
|
63
|
+
type Methods = { "notes.list": [void, string[]] };
|
|
64
|
+
type Events = { "notes.changed": { count: number } };
|
|
65
|
+
|
|
66
|
+
if (isWebview()) {
|
|
67
|
+
const bridge = createBridge<Methods, Events>(webviewTransport());
|
|
68
|
+
bridge.on("notes.changed", ({ count }) => console.log(count));
|
|
69
|
+
console.log(await bridge.rpc("notes.list"));
|
|
70
|
+
}
|
|
71
|
+
```
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# @n1h/sweetview/settings-store
|
|
2
|
+
|
|
3
|
+
A settings file for a small app: one JSON document in the user's per-app folder, read once at start and written whole on every change. Every call is synchronous, so the UI thread can use it after `run()` has blocked it in the OS loop, and it runs on any thread and in any build or test script. It imports only `node:fs`, `node:os` and `node:path`, and loads neither `bun:ffi` nor the webview library.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { appDataDir, createSettingsStore, type SettingsStore, type SettingsStoreOptions } from "@n1h/sweetview/settings-store";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Reference
|
|
10
|
+
|
|
11
|
+
### appDataDir
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export function appDataDir(app: string): string
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Returns the per-user folder an app keeps its own files in. It does not create the folder.
|
|
18
|
+
|
|
19
|
+
| Field | Type | Meaning |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `app` | `string` | The app's folder name, joined onto the per-user base folder. |
|
|
22
|
+
|
|
23
|
+
The base folder depends on `process.platform`:
|
|
24
|
+
|
|
25
|
+
| Platform | Folder | When the variable is unset |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| Windows (`win32`) | `%APPDATA%\<app>` | `<home>\AppData\Roaming\<app>` |
|
|
28
|
+
| macOS (`darwin`) | `~/Library/Application Support/<app>` | not applicable |
|
|
29
|
+
| Linux and every other platform | `$XDG_CONFIG_HOME/<app>` | `~/.config/<app>` |
|
|
30
|
+
|
|
31
|
+
Returns: the folder path. `<home>` and `~` are `os.homedir()`.
|
|
32
|
+
|
|
33
|
+
Platforms: Windows / macOS / Linux: implemented, with the base folder from the table.
|
|
34
|
+
|
|
35
|
+
### SettingsStoreOptions
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
export type SettingsStoreOptions<T> = {
|
|
39
|
+
app: string;
|
|
40
|
+
file?: string;
|
|
41
|
+
dir?: string;
|
|
42
|
+
defaults: T;
|
|
43
|
+
sanitize?: (input: unknown) => T;
|
|
44
|
+
};
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The options of `createSettingsStore`.
|
|
48
|
+
|
|
49
|
+
| Field | Type | Default | Meaning |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `app` | `string` | required | The folder name under `appDataDir`. Not used when `dir` is set. |
|
|
52
|
+
| `file` | `string` | `"settings.json"` | The file name inside the folder. |
|
|
53
|
+
| `dir` | `string` | `appDataDir(app)` | The folder itself, in place of the app-data folder. For tests, or an app that keeps settings elsewhere. |
|
|
54
|
+
| `defaults` | `T` | required | The base of the default `sanitize`. `load()` returns a shallow copy of it when `sanitize` throws. |
|
|
55
|
+
| `sanitize` | `(input: unknown) => T` | shallow merge over `defaults` | Turns whatever the file held into a valid `T`: fill gaps, clamp ranges, drop junk. Receives the parsed JSON, or `undefined` when the file is missing, cannot be read or is not valid JSON. |
|
|
56
|
+
|
|
57
|
+
The default `sanitize` returns `{ ...defaults, ...input }` when `input` is a non-null object, and a shallow copy of `defaults` otherwise. The merge is one level deep: a nested object in the file replaces the default's nested object whole. Values are not type-checked. Keys that `defaults` lacks are kept. A JSON array is not an object here: it gives a copy of `defaults`.
|
|
58
|
+
|
|
59
|
+
### SettingsStore
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
export type SettingsStore<T> = {
|
|
63
|
+
readonly path: string;
|
|
64
|
+
load(): T;
|
|
65
|
+
save(settings: T): void;
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The handle `createSettingsStore` returns.
|
|
70
|
+
|
|
71
|
+
| Field | Type | Meaning |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| `path` | `string` | The full path of the settings file. The file need not exist yet. |
|
|
74
|
+
| `load` | `() => T` | Reads the file and returns the settings. |
|
|
75
|
+
| `save` | `(settings: T) => void` | Writes the whole document. |
|
|
76
|
+
|
|
77
|
+
`load()` handles each file state as follows. Logging goes to stderr through `console.error`, with `<message>` the error's message.
|
|
78
|
+
|
|
79
|
+
| File state | Logged | Returns |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| Missing | nothing | `sanitize(undefined)`; with the default `sanitize`, a copy of `defaults` |
|
|
82
|
+
| Valid JSON | nothing | `sanitize(parsed)` |
|
|
83
|
+
| Not valid JSON | `settings: <path> unreadable, using defaults: <message>` | `sanitize(undefined)`, as for a missing file |
|
|
84
|
+
| Cannot be read, for example no permission or a folder at the path | same as above | same as above |
|
|
85
|
+
| `sanitize` throws, on any path | `settings: sanitize failed for <path>, using defaults: <message>`, after the line for the file state when there is one | a shallow copy of `defaults` |
|
|
86
|
+
|
|
87
|
+
`save(settings)` creates the folder and any missing parents, then writes `JSON.stringify(settings, null, 2)` followed by a newline, as UTF-8. It writes to `<path>.<pid>.tmp` beside the file and renames that over the file, so an interrupted write leaves the previous document rather than a truncated one. It does not call `sanitize`. When creating the folder, serialising, writing or renaming fails, it deletes the temporary file, logs `settings: could not write <path>: <message>` to stderr and returns normally.
|
|
88
|
+
|
|
89
|
+
Neither `load()` nor `save()` throws. Failures are logged to stderr.
|
|
90
|
+
|
|
91
|
+
Thread: any thread. Both calls block the calling thread on file I/O.
|
|
92
|
+
|
|
93
|
+
### createSettingsStore
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
export function createSettingsStore<T extends object>(options: SettingsStoreOptions<T>): SettingsStore<T>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Creates a store for one settings file. It touches no file at creation. The file path is `dir`, or `appDataDir(app)` when `dir` is unset, joined with `file`, or `settings.json` when `file` is unset.
|
|
100
|
+
|
|
101
|
+
| Field | Type | Default | Meaning |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `options` | `SettingsStoreOptions<T>` | required | See `SettingsStoreOptions`. |
|
|
104
|
+
|
|
105
|
+
Returns: a `SettingsStore<T>` for that path.
|
|
106
|
+
|
|
107
|
+
## Example
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { createSettingsStore } from "@n1h/sweetview/settings-store";
|
|
111
|
+
|
|
112
|
+
type Settings = { volume: number; muted: boolean };
|
|
113
|
+
const DEFAULTS: Settings = { volume: 50, muted: false };
|
|
114
|
+
|
|
115
|
+
const store = createSettingsStore<Settings>({
|
|
116
|
+
app: "my-app",
|
|
117
|
+
defaults: DEFAULTS,
|
|
118
|
+
sanitize: (input) => {
|
|
119
|
+
const s = (typeof input === "object" && input !== null ? input : {}) as Partial<Settings>;
|
|
120
|
+
return { volume: typeof s.volume === "number" ? Math.min(100, Math.max(0, s.volume)) : DEFAULTS.volume, muted: s.muted === true };
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
let settings = store.load();
|
|
124
|
+
store.save((settings = { ...settings, muted: true }));
|
|
125
|
+
```
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# The webview library
|
|
2
|
+
|
|
3
|
+
The library `@n1h/sweetview` runs on, and what differs from upstream webview.
|
|
4
|
+
|
|
5
|
+
- Shipped by [`@n1h/sweetview-webview-bun`](https://github.com/n0t-invented-here/sweetview-webview-bun)
|
|
6
|
+
in its `build/` folder for Windows x64, macOS (universal) and Linux x64.
|
|
7
|
+
- Built from https://github.com/n0t-invented-here/sweetview-webview, branch
|
|
8
|
+
`sweetview`: [webview/webview](https://github.com/webview/webview) at
|
|
9
|
+
`f1a9d6b` plus the two C API calls below.
|
|
10
|
+
- The Linux arm64 file in the same folder, `libwebview-arm64.so`, is the
|
|
11
|
+
upstream build and lacks the two calls.
|
|
12
|
+
|
|
13
|
+
```c
|
|
14
|
+
webview_error_t webview_set_background_color(webview_t w, int r, int g, int b, int a);
|
|
15
|
+
webview_error_t webview_set_asset(webview_t w, const char *path, const char *mime, const void *data, size_t len);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
`webview_set_background_color`:
|
|
19
|
+
|
|
20
|
+
- Windows: sets `ICoreWebView2Controller2::DefaultBackgroundColor`. For the
|
|
21
|
+
desktop to show through, the host window also needs composition that
|
|
22
|
+
honours alpha.
|
|
23
|
+
- macOS: sets `drawsBackground` on the `WKWebView` to false when `a` is 0 and
|
|
24
|
+
to true otherwise. It makes the `NSWindow` non-opaque when `a` is below 255
|
|
25
|
+
and sets the window's background colour to the given colour.
|
|
26
|
+
- Linux: accepted, does nothing.
|
|
27
|
+
|
|
28
|
+
Each of `r`, `g`, `b` and `a` is 0 to 255.
|
|
29
|
+
|
|
30
|
+
| Return | When |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `WEBVIEW_ERROR_OK` (0) | Success. |
|
|
33
|
+
| `WEBVIEW_ERROR_INVALID_ARGUMENT` (-2) | A component outside 0 to 255. |
|
|
34
|
+
| `WEBVIEW_ERROR_INVALID_STATE` (-3) | Windows: the browser controller does not exist yet. macOS: there is no `WKWebView`. |
|
|
35
|
+
| `WEBVIEW_ERROR_UNSPECIFIED` (-1) | Windows: `ICoreWebView2Controller2` is unavailable or refuses the colour. |
|
|
36
|
+
|
|
37
|
+
The fork also changes `webview_set_size` on macOS: for a window passed to
|
|
38
|
+
`webview_create`, it does nothing, so that window keeps its own style, size
|
|
39
|
+
and position.
|
|
40
|
+
|
|
41
|
+
`webview_set_asset` serves bytes at a path of the webview's own origin.
|
|
42
|
+
|
|
43
|
+
- A page is loaded with `webview_navigate` instead of `webview_set_html`,
|
|
44
|
+
which Windows caps at 2 MB.
|
|
45
|
+
- The page fetches scripts, styles, images and models by relative URL.
|
|
46
|
+
- The bytes are copied into the library; nothing reaches the network.
|
|
47
|
+
|
|
48
|
+
- `path` is the URL path without the leading `/`; leading slashes are
|
|
49
|
+
stripped. A path that needs percent-encoding is not supported.
|
|
50
|
+
- A request's query string and fragment are ignored when matching.
|
|
51
|
+
- `mime` is sent as the `Content-Type` header unchanged.
|
|
52
|
+
- Calling again with the same path replaces the bytes. No call removes an
|
|
53
|
+
asset.
|
|
54
|
+
- A request for a path with no asset gets a 404 with an empty body.
|
|
55
|
+
|
|
56
|
+
| Return | When |
|
|
57
|
+
|---|---|
|
|
58
|
+
| `WEBVIEW_ERROR_OK` (0) | Success. |
|
|
59
|
+
| `WEBVIEW_ERROR_INVALID_ARGUMENT` (-2) | `path` or `mime` is null, or `data` is null while `len` is not 0. |
|
|
60
|
+
|
|
61
|
+
- Windows: origin `http://webview.localhost`. A `WebResourceRequested` filter
|
|
62
|
+
for that origin answers every request from the table with
|
|
63
|
+
`CreateWebResourceResponse` (200 with the given Content-Type, or 404).
|
|
64
|
+
- macOS: origin `webview://localhost`. A `WKURLSchemeHandler` for the scheme
|
|
65
|
+
`webview` is registered on the `WKWebViewConfiguration` before the
|
|
66
|
+
`WKWebView` is created. It answers every `webview:` request from the same
|
|
67
|
+
table with an HTTP response: 200 with the given Content-Type and the byte
|
|
68
|
+
length, or 404.
|
|
69
|
+
- Linux: accepted and stored, not served.
|
|
70
|
+
|
|
71
|
+
`@n1h/sweetview` loads the library file the way `@n1h/sweetview-webview-bun`
|
|
72
|
+
does.
|
|
73
|
+
|
|
74
|
+
- `WEBVIEW_PATH` when set; otherwise `libwebview.dll`, `libwebview.dylib` or
|
|
75
|
+
`libwebview-<arch>.so` from `@n1h/sweetview-webview-bun/build/`.
|
|
76
|
+
- The webview, the worker's `webview_dispatch` and the two extra calls use
|
|
77
|
+
that one file, and `bun build --compile` embeds it.
|
|
78
|
+
- `createWebviewApp` looks the two symbols up at run time ([app.md](app.md)):
|
|
79
|
+
|
|
80
|
+
| Situation | Behaviour |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `transparent: true` and no `webview_set_background_color` | Logs a warning; the page stays opaque. |
|
|
83
|
+
| `webview_set_background_color` returns an error | Throws `sweetview: webview_set_background_color failed (<code>)`. |
|
|
84
|
+
| No `webview_set_asset` | Throws; the page cannot load without it. |
|
|
85
|
+
| `webview_set_asset` returns an error | Throws `sweetview: webview_set_asset(<path>) failed (<code>)`. |
|
|
86
|
+
| Asset `mime` | From the path's extension; unknown extensions get `application/octet-stream`. |
|
|
87
|
+
|
|
88
|
+
## Source
|
|
89
|
+
|
|
90
|
+
- Library: https://github.com/n0t-invented-here/sweetview-webview, default
|
|
91
|
+
branch `sweetview`.
|
|
92
|
+
- Bindings: https://github.com/n0t-invented-here/sweetview-webview-bun,
|
|
93
|
+
default branch `sweetview`, with the library as its submodule; published as
|
|
94
|
+
`@n1h/sweetview-webview-bun`.
|
|
95
|
+
- Files the change touches: `core/include/webview/api.h`, `c_api_impl.hh`,
|
|
96
|
+
`detail/engine_base.hh`, `detail/backends/win32_edge.hh`,
|
|
97
|
+
`detail/backends/cocoa_webkit.hh`, `detail/platform/windows/webview2/loader.hh`.
|
|
98
|
+
|
|
99
|
+
## Rebuilding
|
|
100
|
+
|
|
101
|
+
The recipes that clone the forks and build the library from them live in
|
|
102
|
+
[`webview/`](../webview/README.md) in this repository. A change to the library
|
|
103
|
+
reaches this package through a new `@n1h/sweetview-webview-bun` release and a
|
|
104
|
+
matching bump of the `@n1h/sweetview-webview-bun` peer dependency.
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# @n1h/sweetview/window-chrome
|
|
2
|
+
|
|
3
|
+
A window chrome drawn by the page: the title bar, the minimise, maximise and close buttons and the resize handles are HTML, and the UI thread carries them out on the native window. The module has two halves joined by one method table: `windowChromeMethods` runs on the UI thread, and `windowChromeControls` and `resizeHandles` run in the browser page. Its imports from `@n1h/sweetview/ipc` and `@n1h/sweetview/native` are type-only, so it loads neither `bun:ffi` nor the webview library and a page bundle can import it.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { resizeHandles, windowChromeControls, windowChromeMethods, type ResizeHandle, type WindowChromeControls, type WindowChromeInfo, type WindowChromeKind, type WindowChromeMethods, type WindowChromeMethodTable, type WindowChromeOptions } from "@n1h/sweetview/window-chrome";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Shared types
|
|
10
|
+
|
|
11
|
+
These types describe the contract between the two halves. They are usable in both environments.
|
|
12
|
+
|
|
13
|
+
`ResizeEdge` comes from `@n1h/sweetview/native`, see [native.md](native.md). It is `"left" | "right" | "top" | "bottom" | "top-left" | "top-right" | "bottom-left" | "bottom-right"`.
|
|
14
|
+
|
|
15
|
+
### WindowChromeKind
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
export type WindowChromeKind = "page" | "os";
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Who draws the window frame. The value is only reported to the page through `window.info`; it does not change the window.
|
|
22
|
+
|
|
23
|
+
| Value | Meaning |
|
|
24
|
+
|---|---|
|
|
25
|
+
| `"page"` | The page draws its own chrome. |
|
|
26
|
+
| `"os"` | The window has an OS frame, and the page renders without its own chrome. |
|
|
27
|
+
|
|
28
|
+
### WindowChromeInfo
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
export type WindowChromeInfo = { chrome: WindowChromeKind; maximized: boolean };
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The answer to `window.info`.
|
|
35
|
+
|
|
36
|
+
| Field | Type | Meaning |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `chrome` | `WindowChromeKind` | Who draws the frame. |
|
|
39
|
+
| `maximized` | `boolean` | Windows: `true` while `window.maximize` has the window maximised; the OS's own maximised state is not read. macOS: whether the window is zoomed. |
|
|
40
|
+
|
|
41
|
+
### WindowChromeMethods
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
export type WindowChromeMethods = {
|
|
45
|
+
"window.info": [void, WindowChromeInfo];
|
|
46
|
+
"window.drag": [void, void];
|
|
47
|
+
"window.resize": [ResizeEdge, void];
|
|
48
|
+
"window.minimize": [void, void];
|
|
49
|
+
"window.maximize": [void, void];
|
|
50
|
+
"window.close": [void, void];
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The UI-thread methods a page-drawn chrome calls, as `[params, result]` pairs. Add it to the method map `M` that `createWebviewApp` is called with: `type Methods = WindowChromeMethods & { ... }`.
|
|
55
|
+
|
|
56
|
+
| Method | Params | Result | What `windowChromeMethods` does on the UI thread |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `window.info` | `void` | `WindowChromeInfo` | Returns the configured `chrome` kind and `window.maximized()`. |
|
|
59
|
+
| `window.drag` | `void` | `void` | Calls `window.startDrag()`: the window follows the cursor until the mouse button is released. Windows: the OS's own move loop runs. macOS: the drag starts from the application's current event. |
|
|
60
|
+
| `window.resize` | `ResizeEdge` | `void` | Calls `window.startResize(edge)`. Windows: the edge or corner follows the cursor until the left mouse button is released. macOS: nothing happens. |
|
|
61
|
+
| `window.minimize` | `void` | `void` | Calls `window.minimize()`. |
|
|
62
|
+
| `window.maximize` | `void` | `void` | Calls `window.toggleMaximize()`: maximised, or back to the previous bounds when already maximised. |
|
|
63
|
+
| `window.close` | `void` | `void` | Calls the `close` option. Without one, it calls `window.hide()`: the window disappears and the app keeps running. |
|
|
64
|
+
|
|
65
|
+
### WindowChromeMethodTable
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
export type WindowChromeMethodTable = { [K in keyof WindowChromeMethods]: (params: WindowChromeMethods[K][0]) => WindowChromeMethods[K][1] };
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
One handler per method of `WindowChromeMethods`, each taking that method's params and returning its result. `windowChromeMethods` returns one.
|
|
72
|
+
|
|
73
|
+
## UI thread
|
|
74
|
+
|
|
75
|
+
This half runs on the UI thread, inside the process that owns the window. It answers the `window.*` calls through the `uiMethods` option of `createWebviewApp`, see [app.md](app.md). `windowChromeMethods`, `WindowChromeInfo`, `WindowChromeKind`, `WindowChromeMethods` and `WindowChromeOptions` are also exported from `@n1h/sweetview`.
|
|
76
|
+
|
|
77
|
+
### WindowChromeOptions
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
export type WindowChromeOptions = {
|
|
81
|
+
chrome?: WindowChromeKind;
|
|
82
|
+
close?: () => void;
|
|
83
|
+
};
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The options of `windowChromeMethods`.
|
|
87
|
+
|
|
88
|
+
| Field | Type | Default | Meaning |
|
|
89
|
+
|---|---|---|---|
|
|
90
|
+
| `chrome` | `WindowChromeKind` | `"page"` | What `window.info` reports. Pass `"os"` when the same page is hosted in a plain framed window. |
|
|
91
|
+
| `close` | `() => void` | `() => window.hide()` | What `window.close` does. The default hides the window and does not quit, so a tray or a menu can show it again. Pass `app.quit` for a main window, so the close button ends the app. |
|
|
92
|
+
|
|
93
|
+
### windowChromeMethods
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
export function windowChromeMethods(window: Overlay, options: WindowChromeOptions = {}): WindowChromeMethodTable
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Builds the `window.*` handlers for one window. Spread the result into `uiMethods`. The table in `WindowChromeMethods` lists what each handler does.
|
|
100
|
+
|
|
101
|
+
| Field | Type | Default | Meaning |
|
|
102
|
+
|---|---|---|---|
|
|
103
|
+
| `window` | `Overlay` | required | The window the methods act on, from `createOverlayWindow` in `@n1h/sweetview/native`, see [native.md](native.md). Create it with `transparent: true`, `topmost: false`, `hideFromTaskbar: false` and `noActivate: false` for a page-drawn chrome. |
|
|
104
|
+
| `options` | `WindowChromeOptions` | `{}` | See `WindowChromeOptions`. |
|
|
105
|
+
|
|
106
|
+
Returns: a `WindowChromeMethodTable` bound to `window`.
|
|
107
|
+
|
|
108
|
+
Throws: on Windows, the `window.resize` handler throws `sweetnative: SetTimer failed (<code>)` when the resize timer cannot be created; `createWebviewApp` passes the message to the page as a failed call.
|
|
109
|
+
|
|
110
|
+
Platforms: Windows: implemented; `window.resize` moves the edge or corner with the cursor every 10 ms, down to 160x120 window units, and `window.maximize` toggles between the primary monitor's work area and the previous bounds. macOS: implemented except `window.resize`, which is a no-op; a frameless window has no resizable style, so the user cannot resize it; `window.maximize` zooms. Linux: not available; `createOverlayWindow` throws, so there is no `Overlay` to pass.
|
|
111
|
+
|
|
112
|
+
Thread: UI thread. Each handler calls the `Overlay` directly.
|
|
113
|
+
|
|
114
|
+
## Page
|
|
115
|
+
|
|
116
|
+
This half runs in the browser page shown in the window. It talks to the UI thread through a `Transport` from `@n1h/sweetview/ipc`, see [ipc.md](ipc.md); inside a webview window that is `webviewTransport()` from `@n1h/sweetview/page`, see [page.md](page.md). It uses no framework.
|
|
117
|
+
|
|
118
|
+
### WindowChromeControls
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
export type WindowChromeControls = {
|
|
122
|
+
info(): Promise<WindowChromeInfo>;
|
|
123
|
+
onTitlePointerDown(event: { button: number }): void;
|
|
124
|
+
onHandlePointerDown(edge: ResizeEdge): (event: { button: number }) => void;
|
|
125
|
+
minimize(): void;
|
|
126
|
+
toggleMaximize(): void;
|
|
127
|
+
close(): void;
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
What the chrome's elements call.
|
|
132
|
+
|
|
133
|
+
| Field | Type | Meaning |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `info` | `() => Promise<WindowChromeInfo>` | Calls `window.info` and resolves with the answer. Tells the page whether to draw the chrome at all, and the maximised state for the restore glyph and square corners. Nothing reports later changes, so call it again after `toggleMaximize`. Rejects when the call fails. |
|
|
136
|
+
| `onTitlePointerDown` | `(event: { button: number }) => void` | For the title bar's pointer-down. A primary-button press, `button` 0, sends `window.drag`. Other buttons are ignored. |
|
|
137
|
+
| `onHandlePointerDown` | `(edge: ResizeEdge) => (event: { button: number }) => void` | Returns the pointer-down handler for one resize handle. A primary-button press sends `window.resize` with `edge`. Other buttons are ignored. |
|
|
138
|
+
| `minimize` | `() => void` | Sends `window.minimize`. |
|
|
139
|
+
| `toggleMaximize` | `() => void` | Sends `window.maximize`. |
|
|
140
|
+
| `close` | `() => void` | Sends `window.close`. |
|
|
141
|
+
|
|
142
|
+
Every member except `info` sends through `transport.notify` and returns without waiting. With `webviewTransport()`, a failure on the UI thread is logged to the page's console as `<method> failed: <message>`.
|
|
143
|
+
|
|
144
|
+
### windowChromeControls
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
export function windowChromeControls(transport: Transport): WindowChromeControls
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Builds the page-side controls over a transport.
|
|
151
|
+
|
|
152
|
+
| Field | Type | Default | Meaning |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `transport` | `Transport` | required | The page's connection to the UI thread. In a webview window, `webviewTransport()`. |
|
|
155
|
+
|
|
156
|
+
Returns: a `WindowChromeControls`.
|
|
157
|
+
|
|
158
|
+
Thread: browser page.
|
|
159
|
+
|
|
160
|
+
### ResizeHandle
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
export type ResizeHandle = {
|
|
164
|
+
edge: ResizeEdge;
|
|
165
|
+
style: { top?: number; right?: number; bottom?: number; left?: number; width?: number; height?: number; cursor: string };
|
|
166
|
+
};
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The placement of one resize handle.
|
|
170
|
+
|
|
171
|
+
| Field | Type | Meaning |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `edge` | `ResizeEdge` | The edge or corner the handle resizes from. Pass it to `onHandlePointerDown`. |
|
|
174
|
+
| `style` | `{ top?: number; right?: number; bottom?: number; left?: number; width?: number; height?: number; cursor: string }` | Placement inside the window in CSS pixels, plus the CSS cursor. It holds no `position`; add `position: "absolute"`. |
|
|
175
|
+
|
|
176
|
+
### resizeHandles
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
export function resizeHandles(thickness = 6): ResizeHandle[]
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Returns the eight resize handles along the window's edges: four edge strips `thickness` CSS pixels wide and four corner squares twice that. Lay them over the page, and hide them while the window is maximised.
|
|
183
|
+
|
|
184
|
+
| Field | Type | Default | Meaning |
|
|
185
|
+
|---|---|---|---|
|
|
186
|
+
| `thickness` | `number` | `6` | The strip width in CSS pixels. Corners are `2 * thickness` square. |
|
|
187
|
+
|
|
188
|
+
Returns: the handles in this order, with `t` the thickness and `c` twice it:
|
|
189
|
+
|
|
190
|
+
| `edge` | Placement | Size | `cursor` |
|
|
191
|
+
|---|---|---|---|
|
|
192
|
+
| `top` | `top: 0`, `left: t`, `right: t` | `height: t` | `ns-resize` |
|
|
193
|
+
| `bottom` | `bottom: 0`, `left: t`, `right: t` | `height: t` | `ns-resize` |
|
|
194
|
+
| `left` | `left: 0`, `top: t`, `bottom: t` | `width: t` | `ew-resize` |
|
|
195
|
+
| `right` | `right: 0`, `top: t`, `bottom: t` | `width: t` | `ew-resize` |
|
|
196
|
+
| `top-left` | `top: 0`, `left: 0` | `width: c`, `height: c` | `nwse-resize` |
|
|
197
|
+
| `bottom-right` | `bottom: 0`, `right: 0` | `width: c`, `height: c` | `nwse-resize` |
|
|
198
|
+
| `top-right` | `top: 0`, `right: 0` | `width: c`, `height: c` | `nesw-resize` |
|
|
199
|
+
| `bottom-left` | `bottom: 0`, `left: 0` | `width: c`, `height: c` | `nesw-resize` |
|
|
200
|
+
|
|
201
|
+
## Example
|
|
202
|
+
|
|
203
|
+
Page side, without a framework:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
import { webviewTransport } from "@n1h/sweetview/page";
|
|
207
|
+
import { resizeHandles, windowChromeControls } from "@n1h/sweetview/window-chrome";
|
|
208
|
+
|
|
209
|
+
const chrome = windowChromeControls(webviewTransport());
|
|
210
|
+
const info = await chrome.info();
|
|
211
|
+
if (info.chrome === "page") {
|
|
212
|
+
document.getElementById("title-bar")?.addEventListener("pointerdown", chrome.onTitlePointerDown);
|
|
213
|
+
document.getElementById("close")?.addEventListener("click", () => chrome.close());
|
|
214
|
+
for (const h of info.maximized ? [] : resizeHandles()) {
|
|
215
|
+
const el = document.body.appendChild(document.createElement("div"));
|
|
216
|
+
el.style.position = "absolute";
|
|
217
|
+
for (const [key, value] of Object.entries(h.style)) el.style.setProperty(key, `${value}${typeof value === "number" ? "px" : ""}`);
|
|
218
|
+
el.addEventListener("pointerdown", chrome.onHandlePointerDown(h.edge));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
```
|
package/docs/worker.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# @n1h/sweetview/worker
|
|
2
|
+
|
|
3
|
+
The worker module hosts the app's Service behind a sweetview window. It runs inside the Bun Worker that `createWebviewApp` starts from its `worker` option, described in [app.md](app.md). The worker owns the Bun event loop while the UI thread is blocked in the OS loop.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { hostService } from "@n1h/sweetview/worker";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Reference
|
|
10
|
+
|
|
11
|
+
### hostService
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export function hostService<M extends MethodMap, E extends EventMap>(service: Service<M, E>): void
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Connects `service` to the window that started this worker. It subscribes to the Service's events and sets `self.onmessage`, replacing any handler already there, then returns.
|
|
18
|
+
|
|
19
|
+
| Field | Type | Default | Meaning |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| `service` | `Service<M, E>` | required | The Service to host. `Service`, `MethodMap` and `EventMap` are documented in [ipc.md](ipc.md). |
|
|
22
|
+
|
|
23
|
+
Thread: the Bun Worker started by `createWebviewApp`. Call once, at the top level of the worker entry. Writing a frame blocks the worker while the ring is full.
|
|
24
|
+
|
|
25
|
+
#### Messages from the UI thread
|
|
26
|
+
|
|
27
|
+
The UI thread posts two kinds of message:
|
|
28
|
+
|
|
29
|
+
| Message | Shape | Effect |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| init | `{ kind: "init"; ring: SharedArrayBuffer; webviewHandle: number; drainCallbackPtr: number; libPath: string }` | Sent once, before any call. The worker wraps `ring`, opens the webview library at `libPath`, and writes any frames produced before this message; a frame that cannot be written is logged to the worker console and the rest still go. When `dlopen` fails, the worker throws and the UI thread logs `sweetview: the worker failed: <message>`. |
|
|
32
|
+
| call | `{ kind: "call"; seq: string \| null; method: string; params: unknown }` | Runs `service.call(method, params, ctx)`. A `seq` of `null` means no reply is sent. |
|
|
33
|
+
|
|
34
|
+
Every call gets the same `ctx`: `{ session: { id: "webview", kind: "webview" } }`. One window is one session.
|
|
35
|
+
|
|
36
|
+
The worker opens the library with `dlopen` and binds one symbol, `webview_dispatch`. The UI thread runs the OS loop and does not process Bun messages. The worker wakes it with `webview_dispatch`, which queues the UI thread's drain callback on that loop. The worker does not import `@n1h/sweetview-webview-bun`. `libPath` is the library the UI thread loaded, so both threads use the same file.
|
|
37
|
+
|
|
38
|
+
#### Replies and events
|
|
39
|
+
|
|
40
|
+
Replies and events go back to the UI thread as JSON frames in the shared ring. After writing a frame, the worker calls `webview_dispatch` only when no wakeup is already pending, so a burst of frames costs one dispatch.
|
|
41
|
+
|
|
42
|
+
| Outcome | Frame sent |
|
|
43
|
+
|---|---|
|
|
44
|
+
| `service.call` resolves, `seq` set | `{ kind: "reply", seq, ok: true, result }` |
|
|
45
|
+
| `service.call` rejects, `seq` set | `{ kind: "reply", seq, ok: false, error }`, where `error` is the error's `message`, or `String(err)` for a non-`Error` value. |
|
|
46
|
+
| `service.call` rejects, `seq` is `null` | None. The worker logs `"<method> failed:"` and the message with `console.error`. |
|
|
47
|
+
| the Service emits with no `to`, or `to` equal to `"webview"` | `{ kind: "event", name, payload }` |
|
|
48
|
+
| the Service emits with any other `to` | None. The event is dropped. |
|
|
49
|
+
|
|
50
|
+
`hostService` never calls the Service's `endSession` or `dispose`. The UI thread terminates the worker when `run()` returns, or when `createWebviewApp` throws after starting it.
|
|
51
|
+
|
|
52
|
+
Writing a frame blocks the worker while the ring is full, until the UI thread drains it. A frame whose UTF-8 JSON is longer than the ring's capacity minus 4 bytes throws `RangeError`. A result or payload that `JSON.stringify` rejects, such as a `BigInt`, throws `TypeError`. For a call with a `seq`, either error becomes an error reply. For an event emitted after the init message, it is thrown out of the Service's emit call. The capacity is `ringBytes` in [app.md](app.md).
|
|
53
|
+
|
|
54
|
+
## Example
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { defineService } from "@n1h/sweetview/ipc";
|
|
58
|
+
import { hostService } from "@n1h/sweetview/worker";
|
|
59
|
+
|
|
60
|
+
type Methods = { "notes.list": [void, string[]] };
|
|
61
|
+
type Events = { "notes.changed": { count: number } };
|
|
62
|
+
|
|
63
|
+
const service = defineService<Methods, Events>((emit) => ({
|
|
64
|
+
methods: {
|
|
65
|
+
"notes.list": () => {
|
|
66
|
+
emit("notes.changed", { count: 0 });
|
|
67
|
+
return [];
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
}));
|
|
71
|
+
hostService(service);
|
|
72
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Types for the page files an app imports by path:
|
|
2
|
+
//
|
|
3
|
+
// import js from "./build/frontend/main.js" with { type: "file" };
|
|
4
|
+
//
|
|
5
|
+
// The import resolves to a path the file can be read from (`Bun.file(js)` is
|
|
6
|
+
// the asset), and `bun build --compile` embeds the file. Pull these in from
|
|
7
|
+
// a .d.ts of the app's own:
|
|
8
|
+
//
|
|
9
|
+
// /// <reference types="@n1h/sweetview/file-imports" />
|
|
10
|
+
//
|
|
11
|
+
// and declare the app's other file kinds (models, images) beside it.
|
|
12
|
+
declare module "*.css" {
|
|
13
|
+
const path: string;
|
|
14
|
+
export default path;
|
|
15
|
+
}
|
|
16
|
+
declare module "*.js" {
|
|
17
|
+
const path: string;
|
|
18
|
+
export default path;
|
|
19
|
+
}
|