gesso-electrobun 0.1.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/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/README.md +43 -0
- package/dist/desktop.d.ts +103 -0
- package/dist/desktop.js +153 -0
- package/dist/desktop.js.map +1 -0
- package/dist/frames-BQisaYy-.js +105 -0
- package/dist/frames-BQisaYy-.js.map +1 -0
- package/dist/frames-BrrAFMqE.d.ts +85 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/main.d.ts +44 -0
- package/dist/main.js +107 -0
- package/dist/main.js.map +1 -0
- package/dist/view.d.ts +52 -0
- package/dist/view.js +108 -0
- package/dist/view.js.map +1 -0
- package/package.json +68 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# gesso-electrobun
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
First public release.
|
|
6
|
+
|
|
7
|
+
Run a Gesso application in an Electrobun window, with its stores in the main
|
|
8
|
+
process. The application layer is a process rather than a worker, and every
|
|
9
|
+
window replicates the same channels, so two windows agree by construction.
|
|
10
|
+
|
|
11
|
+
Entry points: the root for the shared frame protocol, `/main`, `/view` and
|
|
12
|
+
`/desktop`.
|
|
13
|
+
|
|
14
|
+
Checked on WebKitGTK, from a fresh scaffold: a window opened, the counter
|
|
15
|
+
pressed, the appearance flipped, and a second window replicating the first.
|
|
16
|
+
WKWebView on macOS and WebView2 on Windows have not been run.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Baker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# gesso-electrobun
|
|
2
|
+
|
|
3
|
+
Run a Gesso application in an [Electrobun](https://electrobun.dev) window, with its stores in the main process.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install gesso-core gesso-framework gesso-electrobun rxjs
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The scaffold writes a working project for you:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm create gesso-app my-app -- --template electrobun
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## The arrangement
|
|
16
|
+
|
|
17
|
+
The application layer is a **process** rather than a worker. State lives in the main process and every window replicates it, so two windows agree because they are replicas of the same channels, not because anything synchronises them.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// the main process
|
|
21
|
+
import { createDesktopApp } from 'gesso-electrobun/desktop';
|
|
22
|
+
|
|
23
|
+
createDesktopApp({ channels: [{ token: Notes, source }] }).open('/');
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Entry points
|
|
27
|
+
|
|
28
|
+
- `gesso-electrobun` -- the shared vocabulary, including the frame protocol
|
|
29
|
+
- `gesso-electrobun/main` -- the main process side
|
|
30
|
+
- `gesso-electrobun/view` -- inside a window
|
|
31
|
+
- `gesso-electrobun/desktop` -- `createDesktopApp` and `windowsChannel`
|
|
32
|
+
|
|
33
|
+
## What is checked, and what is not
|
|
34
|
+
|
|
35
|
+
A window has been opened from a fresh scaffold on **WebKitGTK**, with the counter pressed, the appearance flipped, and a second window replicating the first.
|
|
36
|
+
|
|
37
|
+
**WKWebView on macOS and WebView2 on Windows have not been run**, because the machines to run them on were not available. The adapter is written against Electrobun's own abstraction and is expected to work on both; expected is not measured, and this file will say so until it is.
|
|
38
|
+
|
|
39
|
+
## Documentation
|
|
40
|
+
|
|
41
|
+
[Gesso on Electrobun](https://github.com/kevinpbaker/gesso/blob/main/apps/docs/structure/gesso-on-electrobun.md) | [Desktop windows](https://github.com/kevinpbaker/gesso/blob/main/apps/docs/structure/desktop-windows.md)
|
|
42
|
+
|
|
43
|
+
MIT (c) Kevin Baker
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { r as GessoFrame } from "./frames-BrrAFMqE.js";
|
|
2
|
+
import { ChannelToken, ServedChannel } from "gesso-framework";
|
|
3
|
+
import { Observable } from "rxjs";
|
|
4
|
+
//#region src/desktop.d.ts
|
|
5
|
+
/** What the application does with the window it opened. */
|
|
6
|
+
interface DesktopWindowTransport {
|
|
7
|
+
/** Sends one frame to this window, over its own RPC. */
|
|
8
|
+
send: (frame: GessoFrame) => void;
|
|
9
|
+
/** Closes the native window. The adapter calls this; the platform may also close it on its own. */
|
|
10
|
+
close: () => void;
|
|
11
|
+
}
|
|
12
|
+
/** A window this application opened. */
|
|
13
|
+
interface DesktopWindowHandle {
|
|
14
|
+
/** Stable for the life of the window, and never reused. */
|
|
15
|
+
readonly id: number;
|
|
16
|
+
/** Closes the window and disposes the channels it was served. */
|
|
17
|
+
close(): void;
|
|
18
|
+
}
|
|
19
|
+
interface DesktopAppOptions {
|
|
20
|
+
/**
|
|
21
|
+
* The channels each window is served.
|
|
22
|
+
*
|
|
23
|
+
* A function when a window needs a channel of its own, which is what
|
|
24
|
+
* `windowsChannel` uses to give a window a way to close itself. It
|
|
25
|
+
* is called once per window, and the observables it returns are
|
|
26
|
+
* ordinarily the same ones every time: `provide` keeps a separate
|
|
27
|
+
* record of what each client has seen, so sharing a source between
|
|
28
|
+
* windows is what makes them agree.
|
|
29
|
+
*/
|
|
30
|
+
channels: readonly ServedChannel[] | ((window: DesktopWindowHandle) => readonly ServedChannel[]);
|
|
31
|
+
/**
|
|
32
|
+
* Opens a native window.
|
|
33
|
+
*
|
|
34
|
+
* `receive` is what the window's frames must be fed into: wire it to
|
|
35
|
+
* the RPC message the window sends frames on, before the window
|
|
36
|
+
* opens, or the first handshake is lost.
|
|
37
|
+
*/
|
|
38
|
+
open: (receive: (frame: GessoFrame) => void, window: DesktopWindowHandle) => DesktopWindowTransport;
|
|
39
|
+
/**
|
|
40
|
+
* A url a window asked to have opened outside itself, with the
|
|
41
|
+
* window that asked. `Utils.openExternal(url)` is what an Electrobun
|
|
42
|
+
* application passes here.
|
|
43
|
+
*/
|
|
44
|
+
onOpenUrl?: (url: string, window: DesktopWindowHandle) => void;
|
|
45
|
+
/**
|
|
46
|
+
* The appearance the platform is in, pushed to every window as it
|
|
47
|
+
* changes and to a new window as it opens.
|
|
48
|
+
*
|
|
49
|
+
* The application supplies it because Electrobun does not: its SDK
|
|
50
|
+
* has no appearance API at all, and the webview's own
|
|
51
|
+
* `prefers-color-scheme` is wrong on WebKitGTK
|
|
52
|
+
*. On a platform that has a
|
|
53
|
+
* signal, this is where it goes; on one that does not, an
|
|
54
|
+
* application setting is a perfectly good source.
|
|
55
|
+
*/
|
|
56
|
+
colorScheme?: Observable<'light' | 'dark'>;
|
|
57
|
+
/**
|
|
58
|
+
* Called when the last window closes. A desktop application usually
|
|
59
|
+
* stops here; one with a tray or a menu bar does not, which is why
|
|
60
|
+
* this is a callback rather than an exit.
|
|
61
|
+
*/
|
|
62
|
+
onLastWindowClosed?: () => void;
|
|
63
|
+
/** Overrides the frame size messages are split at. Only a test should need to. */
|
|
64
|
+
chunkBytes?: number;
|
|
65
|
+
}
|
|
66
|
+
interface DesktopApp {
|
|
67
|
+
/** Opens a window, serves it every channel, and returns its handle. */
|
|
68
|
+
openWindow(): DesktopWindowHandle;
|
|
69
|
+
/** The windows open now, in the order they were opened. */
|
|
70
|
+
readonly windows: readonly DesktopWindowHandle[];
|
|
71
|
+
/** How many windows are open, as something a channel can publish. */
|
|
72
|
+
readonly windowCount: Observable<number>;
|
|
73
|
+
/** Closes every window and stops serving. */
|
|
74
|
+
dispose(): void;
|
|
75
|
+
}
|
|
76
|
+
declare function createDesktopApp(options: DesktopAppOptions): DesktopApp;
|
|
77
|
+
/** What a window can see and do about the windows of its application. */
|
|
78
|
+
interface DesktopWindowsView {
|
|
79
|
+
/** How many windows this application has open. */
|
|
80
|
+
count: number;
|
|
81
|
+
/** Which one this is, so a screen can say "window 2 of 3" without asking. */
|
|
82
|
+
id: number;
|
|
83
|
+
}
|
|
84
|
+
interface DesktopWindowsCommands {
|
|
85
|
+
open: () => void;
|
|
86
|
+
closeThis: () => void;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The channel a window opens another window through.
|
|
90
|
+
*
|
|
91
|
+
* It exists because opening a window is the one native act a screen
|
|
92
|
+
* genuinely needs, and going through a channel keeps the view layer
|
|
93
|
+
* from importing an adapter. the "native menus bound to
|
|
94
|
+
* store actions" is the same idea from the other end, and on Linux it
|
|
95
|
+
* is the only end: the runtime has no application menus there, so a
|
|
96
|
+
* menu is a component and this is what it calls.
|
|
97
|
+
*/
|
|
98
|
+
declare const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCommands>;
|
|
99
|
+
/** Serves `DesktopWindows` to one window. Put it in `channels`. */
|
|
100
|
+
declare function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel;
|
|
101
|
+
//#endregion
|
|
102
|
+
export { DesktopApp, DesktopAppOptions, DesktopWindowHandle, DesktopWindowTransport, DesktopWindows, DesktopWindowsCommands, DesktopWindowsView, createDesktopApp, windowsChannel };
|
|
103
|
+
//# sourceMappingURL=desktop.d.ts.map
|
package/dist/desktop.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { serveChannelsToWindow } from "./main.js";
|
|
2
|
+
import { channel } from "gesso-framework";
|
|
3
|
+
import { BehaviorSubject } from "rxjs";
|
|
4
|
+
//#region src/desktop.ts
|
|
5
|
+
/**
|
|
6
|
+
* An application in the main process: windows, and the channels each
|
|
7
|
+
* one is served.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here imports Electrobun, and that is not fastidiousness. The
|
|
10
|
+
* SDK is projected into a project by Hutch rather than installed from
|
|
11
|
+
* a registry, so a package in
|
|
12
|
+
* this workspace could not import it even if it wanted to. What the
|
|
13
|
+
* application supplies instead is one function that opens a window,
|
|
14
|
+
* which is the only Electrobun-shaped thing this needs, and which is
|
|
15
|
+
* five lines at the call site:
|
|
16
|
+
*
|
|
17
|
+
* const app = createDesktopApp({
|
|
18
|
+
* channels: window => [
|
|
19
|
+
* { token: Catalogue, source: catalogue },
|
|
20
|
+
* windowsChannel(app, window)
|
|
21
|
+
* ],
|
|
22
|
+
* open: receive => {
|
|
23
|
+
* const rpc = BrowserView.defineRPC<GessoWindowRPC>({
|
|
24
|
+
* handlers: { requests: {}, messages: { gessoFrame: receive } }
|
|
25
|
+
* });
|
|
26
|
+
* const window = new BrowserWindow({ title: 'Notes', url: 'views://mainview/index.html', rpc });
|
|
27
|
+
* return {
|
|
28
|
+
* send: frame => window.webview.rpc.send.gessoFrame(frame),
|
|
29
|
+
* close: () => window.close()
|
|
30
|
+
* };
|
|
31
|
+
* }
|
|
32
|
+
* });
|
|
33
|
+
* app.openWindow();
|
|
34
|
+
*
|
|
35
|
+
* The arrangement it buys: every
|
|
36
|
+
* window is a replica of the same channels, so two windows agree by
|
|
37
|
+
* construction rather than by synchronisation.
|
|
38
|
+
*/
|
|
39
|
+
function createDesktopApp(options) {
|
|
40
|
+
const entries = /* @__PURE__ */ new Map();
|
|
41
|
+
/** One appearance subscription per window, ended when it closes. */
|
|
42
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
43
|
+
const order = [];
|
|
44
|
+
const count = new BehaviorSubject(0);
|
|
45
|
+
let nextId = 1;
|
|
46
|
+
let disposed = false;
|
|
47
|
+
const forget = (id, closeNative) => {
|
|
48
|
+
const entry = entries.get(id);
|
|
49
|
+
if (entry === void 0) return;
|
|
50
|
+
entries.delete(id);
|
|
51
|
+
subscriptions.get(id)?.unsubscribe();
|
|
52
|
+
subscriptions.delete(id);
|
|
53
|
+
const at = order.indexOf(entry.handle);
|
|
54
|
+
if (at >= 0) order.splice(at, 1);
|
|
55
|
+
entry.host.dispose();
|
|
56
|
+
if (closeNative) entry.transport.close();
|
|
57
|
+
count.next(order.length);
|
|
58
|
+
if (order.length === 0 && !disposed) options.onLastWindowClosed?.();
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
openWindow() {
|
|
62
|
+
if (disposed) throw new Error("This desktop application has been disposed; it cannot open a window.");
|
|
63
|
+
const id = nextId++;
|
|
64
|
+
const handle = {
|
|
65
|
+
id,
|
|
66
|
+
close: () => forget(id, true)
|
|
67
|
+
};
|
|
68
|
+
let transport;
|
|
69
|
+
const host = serveChannelsToWindow(typeof options.channels === "function" ? options.channels(handle) : options.channels, {
|
|
70
|
+
send: (frame) => transport?.send(frame),
|
|
71
|
+
chunkBytes: options.chunkBytes,
|
|
72
|
+
onOpenUrl: (url) => options.onOpenUrl?.(url, handle)
|
|
73
|
+
});
|
|
74
|
+
entries.set(id, {
|
|
75
|
+
handle,
|
|
76
|
+
transport: {
|
|
77
|
+
send: () => {},
|
|
78
|
+
close: () => {}
|
|
79
|
+
},
|
|
80
|
+
host
|
|
81
|
+
});
|
|
82
|
+
order.push(handle);
|
|
83
|
+
let greeted = false;
|
|
84
|
+
let latest;
|
|
85
|
+
transport = options.open((frame) => {
|
|
86
|
+
if (!greeted) {
|
|
87
|
+
greeted = true;
|
|
88
|
+
if (latest !== void 0) host.setColorScheme(latest);
|
|
89
|
+
}
|
|
90
|
+
host.receive(frame);
|
|
91
|
+
}, handle);
|
|
92
|
+
entries.set(id, {
|
|
93
|
+
handle,
|
|
94
|
+
transport,
|
|
95
|
+
host
|
|
96
|
+
});
|
|
97
|
+
const appearance = options.colorScheme?.subscribe((scheme) => {
|
|
98
|
+
latest = scheme;
|
|
99
|
+
if (greeted) host.setColorScheme(scheme);
|
|
100
|
+
});
|
|
101
|
+
if (appearance !== void 0) subscriptions.set(id, appearance);
|
|
102
|
+
count.next(order.length);
|
|
103
|
+
return handle;
|
|
104
|
+
},
|
|
105
|
+
get windows() {
|
|
106
|
+
return [...order];
|
|
107
|
+
},
|
|
108
|
+
windowCount: count.asObservable(),
|
|
109
|
+
dispose() {
|
|
110
|
+
disposed = true;
|
|
111
|
+
for (const id of new Set(entries.keys())) forget(id, true);
|
|
112
|
+
count.complete();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The channel a window opens another window through.
|
|
118
|
+
*
|
|
119
|
+
* It exists because opening a window is the one native act a screen
|
|
120
|
+
* genuinely needs, and going through a channel keeps the view layer
|
|
121
|
+
* from importing an adapter. the "native menus bound to
|
|
122
|
+
* store actions" is the same idea from the other end, and on Linux it
|
|
123
|
+
* is the only end: the runtime has no application menus there, so a
|
|
124
|
+
* menu is a component and this is what it calls.
|
|
125
|
+
*/
|
|
126
|
+
const DesktopWindows = channel("gesso:windows", {
|
|
127
|
+
count: 0,
|
|
128
|
+
id: 0
|
|
129
|
+
});
|
|
130
|
+
/** Serves `DesktopWindows` to one window. Put it in `channels`. */
|
|
131
|
+
function windowsChannel(app, window) {
|
|
132
|
+
return {
|
|
133
|
+
token: DesktopWindows,
|
|
134
|
+
source: {
|
|
135
|
+
view: {
|
|
136
|
+
count: app.windowCount,
|
|
137
|
+
id: new BehaviorSubject(window.id)
|
|
138
|
+
},
|
|
139
|
+
commands: {
|
|
140
|
+
open: () => {
|
|
141
|
+
app.openWindow();
|
|
142
|
+
},
|
|
143
|
+
closeThis: () => {
|
|
144
|
+
window.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
export { DesktopWindows, createDesktopApp, windowsChannel };
|
|
152
|
+
|
|
153
|
+
//# sourceMappingURL=desktop.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"desktop.js","names":[],"sources":["../src/desktop.ts"],"sourcesContent":["/**\n * An application in the main process: windows, and the channels each\n * one is served.\n *\n * Nothing here imports Electrobun, and that is not fastidiousness. The\n * SDK is projected into a project by Hutch rather than installed from\n * a registry, so a package in\n * this workspace could not import it even if it wanted to. What the\n * application supplies instead is one function that opens a window,\n * which is the only Electrobun-shaped thing this needs, and which is\n * five lines at the call site:\n *\n * const app = createDesktopApp({\n * channels: window => [\n * { token: Catalogue, source: catalogue },\n * windowsChannel(app, window)\n * ],\n * open: receive => {\n * const rpc = BrowserView.defineRPC<GessoWindowRPC>({\n * handlers: { requests: {}, messages: { gessoFrame: receive } }\n * });\n * const window = new BrowserWindow({ title: 'Notes', url: 'views://mainview/index.html', rpc });\n * return {\n * send: frame => window.webview.rpc.send.gessoFrame(frame),\n * close: () => window.close()\n * };\n * }\n * });\n * app.openWindow();\n *\n * The arrangement it buys: every\n * window is a replica of the same channels, so two windows agree by\n * construction rather than by synchronisation.\n */\nimport { channel, type ChannelToken, type ServedChannel } from 'gesso-framework';\nimport { BehaviorSubject, type Observable, type Subscription } from 'rxjs';\n\nimport type { GessoFrame } from './frames';\nimport { serveChannelsToWindow, type ChannelHost } from './main';\n\n/** What the application does with the window it opened. */\nexport interface DesktopWindowTransport {\n /** Sends one frame to this window, over its own RPC. */\n send: (frame: GessoFrame) => void;\n /** Closes the native window. The adapter calls this; the platform may also close it on its own. */\n close: () => void;\n}\n\n/** A window this application opened. */\nexport interface DesktopWindowHandle {\n /** Stable for the life of the window, and never reused. */\n readonly id: number;\n /** Closes the window and disposes the channels it was served. */\n close(): void;\n}\n\nexport interface DesktopAppOptions {\n /**\n * The channels each window is served.\n *\n * A function when a window needs a channel of its own, which is what\n * `windowsChannel` uses to give a window a way to close itself. It\n * is called once per window, and the observables it returns are\n * ordinarily the same ones every time: `provide` keeps a separate\n * record of what each client has seen, so sharing a source between\n * windows is what makes them agree.\n */\n channels: readonly ServedChannel[] | ((window: DesktopWindowHandle) => readonly ServedChannel[]);\n /**\n * Opens a native window.\n *\n * `receive` is what the window's frames must be fed into: wire it to\n * the RPC message the window sends frames on, before the window\n * opens, or the first handshake is lost.\n */\n open: (receive: (frame: GessoFrame) => void, window: DesktopWindowHandle) => DesktopWindowTransport;\n /**\n * A url a window asked to have opened outside itself, with the\n * window that asked. `Utils.openExternal(url)` is what an Electrobun\n * application passes here.\n */\n onOpenUrl?: (url: string, window: DesktopWindowHandle) => void;\n /**\n * The appearance the platform is in, pushed to every window as it\n * changes and to a new window as it opens.\n *\n * The application supplies it because Electrobun does not: its SDK\n * has no appearance API at all, and the webview's own\n * `prefers-color-scheme` is wrong on WebKitGTK\n *. On a platform that has a\n * signal, this is where it goes; on one that does not, an\n * application setting is a perfectly good source.\n */\n colorScheme?: Observable<'light' | 'dark'>;\n /**\n * Called when the last window closes. A desktop application usually\n * stops here; one with a tray or a menu bar does not, which is why\n * this is a callback rather than an exit.\n */\n onLastWindowClosed?: () => void;\n /** Overrides the frame size messages are split at. Only a test should need to. */\n chunkBytes?: number;\n}\n\nexport interface DesktopApp {\n /** Opens a window, serves it every channel, and returns its handle. */\n openWindow(): DesktopWindowHandle;\n /** The windows open now, in the order they were opened. */\n readonly windows: readonly DesktopWindowHandle[];\n /** How many windows are open, as something a channel can publish. */\n readonly windowCount: Observable<number>;\n /** Closes every window and stops serving. */\n dispose(): void;\n}\n\nexport function createDesktopApp(options: DesktopAppOptions): DesktopApp {\n interface Entry {\n handle: DesktopWindowHandle;\n transport: DesktopWindowTransport;\n host: ChannelHost;\n }\n const entries = new Map<number, Entry>();\n /** One appearance subscription per window, ended when it closes. */\n const subscriptions = new Map<number, Subscription>();\n const order: DesktopWindowHandle[] = [];\n const count = new BehaviorSubject(0);\n let nextId = 1;\n let disposed = false;\n\n const forget = (id: number, closeNative: boolean): void => {\n const entry = entries.get(id);\n if (entry === undefined) {\n return;\n }\n entries.delete(id);\n subscriptions.get(id)?.unsubscribe();\n subscriptions.delete(id);\n const at = order.indexOf(entry.handle);\n if (at >= 0) {\n order.splice(at, 1);\n }\n entry.host.dispose();\n if (closeNative) {\n entry.transport.close();\n }\n count.next(order.length);\n if (order.length === 0 && !disposed) {\n options.onLastWindowClosed?.();\n }\n };\n\n const app: DesktopApp = {\n openWindow(): DesktopWindowHandle {\n if (disposed) {\n throw new Error('This desktop application has been disposed; it cannot open a window.');\n }\n const id = nextId++;\n const handle: DesktopWindowHandle = {\n id,\n close: () => forget(id, true)\n };\n\n // The host exists before the window does, because `open` may\n // deliver a frame synchronously and a window whose first\n // handshake was dropped never replicates anything.\n let transport: DesktopWindowTransport | undefined;\n const host = serveChannelsToWindow(\n typeof options.channels === 'function' ? options.channels(handle) : options.channels,\n {\n send: frame => transport?.send(frame),\n chunkBytes: options.chunkBytes,\n onOpenUrl: url => options.onOpenUrl?.(url, handle)\n }\n );\n entries.set(id, { handle, transport: { send: () => {}, close: () => {} }, host });\n order.push(handle);\n\n // Anything pushed before the window has spoken is lost: a\n // webview's RPC is not listening until its page has loaded, and\n // the window opens well before that. Channels do not notice,\n // because a channel starts with the window asking. The\n // appearance is the one thing this side sends first, so it waits\n // for the window's first frame, whatever that frame is. A native\n // window found this; a spec with a transport that was live\n // immediately could not.\n let greeted = false;\n let latest: 'light' | 'dark' | undefined;\n transport = options.open(frame => {\n if (!greeted) {\n greeted = true;\n if (latest !== undefined) {\n host.setColorScheme(latest);\n }\n }\n host.receive(frame);\n }, handle);\n entries.set(id, { handle, transport, host });\n const appearance = options.colorScheme?.subscribe(scheme => {\n latest = scheme;\n if (greeted) {\n host.setColorScheme(scheme);\n }\n });\n if (appearance !== undefined) {\n subscriptions.set(id, appearance);\n }\n count.next(order.length);\n return handle;\n },\n get windows(): readonly DesktopWindowHandle[] {\n return [...order];\n },\n windowCount: count.asObservable(),\n dispose(): void {\n disposed = true;\n for (const id of new Set(entries.keys())) {\n forget(id, true);\n }\n count.complete();\n }\n };\n\n return app;\n}\n\n/** What a window can see and do about the windows of its application. */\nexport interface DesktopWindowsView {\n /** How many windows this application has open. */\n count: number;\n /** Which one this is, so a screen can say \"window 2 of 3\" without asking. */\n id: number;\n}\n\nexport interface DesktopWindowsCommands {\n open: () => void;\n closeThis: () => void;\n}\n\n/**\n * The channel a window opens another window through.\n *\n * It exists because opening a window is the one native act a screen\n * genuinely needs, and going through a channel keeps the view layer\n * from importing an adapter. the \"native menus bound to\n * store actions\" is the same idea from the other end, and on Linux it\n * is the only end: the runtime has no application menus there, so a\n * menu is a component and this is what it calls.\n */\nexport const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCommands> = channel<\n DesktopWindowsView,\n DesktopWindowsCommands\n>('gesso:windows', { count: 0, id: 0 });\n\n/** Serves `DesktopWindows` to one window. Put it in `channels`. */\nexport function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel {\n return {\n token: DesktopWindows,\n source: {\n view: {\n count: app.windowCount,\n id: new BehaviorSubject(window.id)\n },\n commands: {\n open: () => {\n app.openWindow();\n },\n closeThis: () => {\n window.close();\n }\n }\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmHA,SAAgB,iBAAiB,SAAwC;CAMvE,MAAM,0BAAU,IAAI,IAAmB;;CAEvC,MAAM,gCAAgB,IAAI,IAA0B;CACpD,MAAM,QAA+B,CAAC;CACtC,MAAM,QAAQ,IAAI,gBAAgB,CAAC;CACnC,IAAI,SAAS;CACb,IAAI,WAAW;CAEf,MAAM,UAAU,IAAY,gBAA+B;EACzD,MAAM,QAAQ,QAAQ,IAAI,EAAE;EAC5B,IAAI,UAAU,KAAA,GACZ;EAEF,QAAQ,OAAO,EAAE;EACjB,cAAc,IAAI,EAAE,CAAC,EAAE,YAAY;EACnC,cAAc,OAAO,EAAE;EACvB,MAAM,KAAK,MAAM,QAAQ,MAAM,MAAM;EACrC,IAAI,MAAM,GACR,MAAM,OAAO,IAAI,CAAC;EAEpB,MAAM,KAAK,QAAQ;EACnB,IAAI,aACF,MAAM,UAAU,MAAM;EAExB,MAAM,KAAK,MAAM,MAAM;EACvB,IAAI,MAAM,WAAW,KAAK,CAAC,UACzB,QAAQ,qBAAqB;CAEjC;CAyEA,OAAO;EAtEL,aAAkC;GAChC,IAAI,UACF,MAAM,IAAI,MAAM,sEAAsE;GAExF,MAAM,KAAK;GACX,MAAM,SAA8B;IAClC;IACA,aAAa,OAAO,IAAI,IAAI;GAC9B;GAKA,IAAI;GACJ,MAAM,OAAO,sBACX,OAAO,QAAQ,aAAa,aAAa,QAAQ,SAAS,MAAM,IAAI,QAAQ,UAC5E;IACE,OAAM,UAAS,WAAW,KAAK,KAAK;IACpC,YAAY,QAAQ;IACpB,YAAW,QAAO,QAAQ,YAAY,KAAK,MAAM;GACnD,CACF;GACA,QAAQ,IAAI,IAAI;IAAE;IAAQ,WAAW;KAAE,YAAY,CAAC;KAAG,aAAa,CAAC;IAAE;IAAG;GAAK,CAAC;GAChF,MAAM,KAAK,MAAM;GAUjB,IAAI,UAAU;GACd,IAAI;GACJ,YAAY,QAAQ,MAAK,UAAS;IAChC,IAAI,CAAC,SAAS;KACZ,UAAU;KACV,IAAI,WAAW,KAAA,GACb,KAAK,eAAe,MAAM;IAE9B;IACA,KAAK,QAAQ,KAAK;GACpB,GAAG,MAAM;GACT,QAAQ,IAAI,IAAI;IAAE;IAAQ;IAAW;GAAK,CAAC;GAC3C,MAAM,aAAa,QAAQ,aAAa,WAAU,WAAU;IAC1D,SAAS;IACT,IAAI,SACF,KAAK,eAAe,MAAM;GAE9B,CAAC;GACD,IAAI,eAAe,KAAA,GACjB,cAAc,IAAI,IAAI,UAAU;GAElC,MAAM,KAAK,MAAM,MAAM;GACvB,OAAO;EACT;EACA,IAAI,UAA0C;GAC5C,OAAO,CAAC,GAAG,KAAK;EAClB;EACA,aAAa,MAAM,aAAa;EAChC,UAAgB;GACd,WAAW;GACX,KAAK,MAAM,MAAM,IAAI,IAAI,QAAQ,KAAK,CAAC,GACrC,OAAO,IAAI,IAAI;GAEjB,MAAM,SAAS;EACjB;CAGO;AACX;;;;;;;;;;;AAyBA,MAAa,iBAA2E,QAGtF,iBAAiB;CAAE,OAAO;CAAG,IAAI;AAAE,CAAC;;AAGtC,SAAgB,eAAe,KAAiB,QAA4C;CAC1F,OAAO;EACL,OAAO;EACP,QAAQ;GACN,MAAM;IACJ,OAAO,IAAI;IACX,IAAI,IAAI,gBAAgB,OAAO,EAAE;GACnC;GACA,UAAU;IACR,YAAY;KACV,IAAI,WAAW;IACjB;IACA,iBAAiB;KACf,OAAO,MAAM;IACf;GACF;EACF;CACF;AACF"}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
//#region src/frames.ts
|
|
2
|
+
/**
|
|
3
|
+
* The whole wire format, which is deliberately smaller than the
|
|
4
|
+
* channel protocol it carries.
|
|
5
|
+
*
|
|
6
|
+
* Three frames in each direction. `open` says a named channel wants a
|
|
7
|
+
* stream, `data` carries one channel message, `close` ends a stream.
|
|
8
|
+
* Nothing here knows what a channel is, what a patch is, or what a
|
|
9
|
+
* command is: a frame's `body` is a string this module produced by
|
|
10
|
+
* serializing a value it never looked inside. That restraint is the
|
|
11
|
+
* rule the adapter states, and it is what keeps the
|
|
12
|
+
* webview's main thread a transport rather than a router.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* How much of a serialized message goes in one frame.
|
|
16
|
+
*
|
|
17
|
+
* Electrobun's transport fails above roughly 8 MiB in a single
|
|
18
|
+
* message, and it fails badly: the main process throws while draining
|
|
19
|
+
* and the sender sees only a timeout, so the real error is in a log
|
|
20
|
+
* nobody is reading. A megabyte leaves eight times the headroom and
|
|
21
|
+
* costs nothing at the measured rates.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_CHUNK_BYTES = 1048576;
|
|
24
|
+
function isGessoFrame(value) {
|
|
25
|
+
const kind = value?.kind;
|
|
26
|
+
return kind === "open" || kind === "data" || kind === "close" || kind === "control";
|
|
27
|
+
}
|
|
28
|
+
/** Wraps one control message. Never split: these are small by construction. */
|
|
29
|
+
function frameControl(name, payload) {
|
|
30
|
+
return {
|
|
31
|
+
kind: "control",
|
|
32
|
+
name,
|
|
33
|
+
body: JSON.stringify(payload ?? null) ?? "null"
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Serializes one channel message into the frames that carry it.
|
|
38
|
+
*
|
|
39
|
+
* JSON rather than structured clone, because the transport underneath
|
|
40
|
+
* is JSON either way: `Electroview.createTransport` stringifies every
|
|
41
|
+
* message before it encrypts it. That means `undefined` inside a value
|
|
42
|
+
* does not survive, which is true of this transport with or without
|
|
43
|
+
* this module, and which a view key cannot rely on anyway.
|
|
44
|
+
*/
|
|
45
|
+
function frameData(stream, value, chunkBytes = DEFAULT_CHUNK_BYTES) {
|
|
46
|
+
const body = JSON.stringify(value);
|
|
47
|
+
if (body === void 0) throw new Error(`A channel message for stream ${stream} could not be serialized. Only plain data crosses a channel; see requirePlainData.`);
|
|
48
|
+
if (body.length <= chunkBytes) return [{
|
|
49
|
+
kind: "data",
|
|
50
|
+
stream,
|
|
51
|
+
body
|
|
52
|
+
}];
|
|
53
|
+
const parts = Math.ceil(body.length / chunkBytes);
|
|
54
|
+
const frames = [];
|
|
55
|
+
for (let part = 0; part < parts; part++) frames.push({
|
|
56
|
+
kind: "data",
|
|
57
|
+
stream,
|
|
58
|
+
body: body.slice(part * chunkBytes, (part + 1) * chunkBytes),
|
|
59
|
+
part,
|
|
60
|
+
parts
|
|
61
|
+
});
|
|
62
|
+
return frames;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Puts split messages back together.
|
|
66
|
+
*
|
|
67
|
+
* The transport delivers in order (`Electroview` dispatches through a
|
|
68
|
+
* promise tail that preserves frame order), so a part that arrives out
|
|
69
|
+
* of turn is a bug rather than a race, and it says so instead of
|
|
70
|
+
* quietly assembling a corrupt message.
|
|
71
|
+
*/
|
|
72
|
+
var FrameAssembler = class {
|
|
73
|
+
partial = /* @__PURE__ */ new Map();
|
|
74
|
+
/**
|
|
75
|
+
* Returns the value a `data` frame completes, or `undefined` while
|
|
76
|
+
* more parts are still to come.
|
|
77
|
+
*/
|
|
78
|
+
take(frame) {
|
|
79
|
+
if (frame.parts === void 0) return JSON.parse(frame.body);
|
|
80
|
+
const held = this.partial.get(frame.stream) ?? {
|
|
81
|
+
parts: frame.parts,
|
|
82
|
+
chunks: []
|
|
83
|
+
};
|
|
84
|
+
const expected = held.chunks.length;
|
|
85
|
+
if (frame.part !== expected) {
|
|
86
|
+
this.partial.delete(frame.stream);
|
|
87
|
+
throw new Error(`Stream ${frame.stream} received part ${String(frame.part)} when part ${expected} was next. Frames are expected in order; a gap means the transport reordered or dropped one.`);
|
|
88
|
+
}
|
|
89
|
+
held.chunks.push(frame.body);
|
|
90
|
+
if (held.chunks.length < held.parts) {
|
|
91
|
+
this.partial.set(frame.stream, held);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
this.partial.delete(frame.stream);
|
|
95
|
+
return JSON.parse(held.chunks.join(""));
|
|
96
|
+
}
|
|
97
|
+
/** Drops anything half-received for a stream that has closed. */
|
|
98
|
+
forget(stream) {
|
|
99
|
+
this.partial.delete(stream);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
//#endregion
|
|
103
|
+
export { isGessoFrame as a, frameData as i, FrameAssembler as n, frameControl as r, DEFAULT_CHUNK_BYTES as t };
|
|
104
|
+
|
|
105
|
+
//# sourceMappingURL=frames-BQisaYy-.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"frames-BQisaYy-.js","names":[],"sources":["../src/frames.ts"],"sourcesContent":["/**\n * The whole wire format, which is deliberately smaller than the\n * channel protocol it carries.\n *\n * Three frames in each direction. `open` says a named channel wants a\n * stream, `data` carries one channel message, `close` ends a stream.\n * Nothing here knows what a channel is, what a patch is, or what a\n * command is: a frame's `body` is a string this module produced by\n * serializing a value it never looked inside. That restraint is the\n * rule the adapter states, and it is what keeps the\n * webview's main thread a transport rather than a router.\n */\n\n/**\n * How much of a serialized message goes in one frame.\n *\n * Electrobun's transport fails above roughly 8 MiB in a single\n * message, and it fails badly: the main process throws while draining\n * and the sender sees only a timeout, so the real error is in a log\n * nobody is reading. A megabyte leaves eight times the headroom and\n * costs nothing at the measured rates.\n */\nexport const DEFAULT_CHUNK_BYTES = 1_048_576;\n\nexport type GessoFrame =\n | { readonly kind: 'open'; readonly stream: number; readonly name: string }\n | {\n readonly kind: 'data';\n readonly stream: number;\n readonly body: string;\n /** Absent unless the message was split; then 0-based. */\n readonly part?: number;\n /** Absent unless the message was split; then how many parts to expect. */\n readonly parts?: number;\n }\n | { readonly kind: 'close'; readonly stream: number }\n /**\n * The adapter's own traffic, which is not a channel: the appearance\n * the platform is in, and a url the application wants opened\n * outside the window. It carries a name and a serialized payload for\n * the same reason a `data` frame carries a body, and it is a\n * separate kind so that nothing has to reserve a stream number.\n */\n | { readonly kind: 'control'; readonly name: string; readonly body: string };\n\nexport function isGessoFrame(value: unknown): value is GessoFrame {\n const kind = (value as { kind?: unknown } | null)?.kind;\n return kind === 'open' || kind === 'data' || kind === 'close' || kind === 'control';\n}\n\n/** Wraps one control message. Never split: these are small by construction. */\nexport function frameControl(name: string, payload: unknown): GessoFrame {\n return { kind: 'control', name, body: JSON.stringify(payload ?? null) ?? 'null' };\n}\n\n/**\n * Serializes one channel message into the frames that carry it.\n *\n * JSON rather than structured clone, because the transport underneath\n * is JSON either way: `Electroview.createTransport` stringifies every\n * message before it encrypts it. That means `undefined` inside a value\n * does not survive, which is true of this transport with or without\n * this module, and which a view key cannot rely on anyway.\n */\nexport function frameData(stream: number, value: unknown, chunkBytes = DEFAULT_CHUNK_BYTES): GessoFrame[] {\n const body = JSON.stringify(value);\n if (body === undefined) {\n throw new Error(\n `A channel message for stream ${stream} could not be serialized. Only plain data crosses a channel; see requirePlainData.`\n );\n }\n if (body.length <= chunkBytes) {\n return [{ kind: 'data', stream, body }];\n }\n const parts = Math.ceil(body.length / chunkBytes);\n const frames: GessoFrame[] = [];\n for (let part = 0; part < parts; part++) {\n frames.push({\n kind: 'data',\n stream,\n body: body.slice(part * chunkBytes, (part + 1) * chunkBytes),\n part,\n parts\n });\n }\n return frames;\n}\n\n/**\n * Puts split messages back together.\n *\n * The transport delivers in order (`Electroview` dispatches through a\n * promise tail that preserves frame order), so a part that arrives out\n * of turn is a bug rather than a race, and it says so instead of\n * quietly assembling a corrupt message.\n */\nexport class FrameAssembler {\n private readonly partial = new Map<number, { parts: number; chunks: string[] }>();\n\n /**\n * Returns the value a `data` frame completes, or `undefined` while\n * more parts are still to come.\n */\n take(frame: GessoFrame & { kind: 'data' }): unknown {\n if (frame.parts === undefined) {\n return JSON.parse(frame.body);\n }\n const held = this.partial.get(frame.stream) ?? { parts: frame.parts, chunks: [] };\n const expected = held.chunks.length;\n if (frame.part !== expected) {\n this.partial.delete(frame.stream);\n throw new Error(\n `Stream ${frame.stream} received part ${String(frame.part)} when part ${expected} was next. ` +\n 'Frames are expected in order; a gap means the transport reordered or dropped one.'\n );\n }\n held.chunks.push(frame.body);\n if (held.chunks.length < held.parts) {\n this.partial.set(frame.stream, held);\n return undefined;\n }\n this.partial.delete(frame.stream);\n return JSON.parse(held.chunks.join(''));\n }\n\n /** Drops anything half-received for a stream that has closed. */\n forget(stream: number): void {\n this.partial.delete(stream);\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAa,sBAAsB;AAuBnC,SAAgB,aAAa,OAAqC;CAChE,MAAM,OAAQ,OAAqC;CACnD,OAAO,SAAS,UAAU,SAAS,UAAU,SAAS,WAAW,SAAS;AAC5E;;AAGA,SAAgB,aAAa,MAAc,SAA8B;CACvE,OAAO;EAAE,MAAM;EAAW;EAAM,MAAM,KAAK,UAAU,WAAW,IAAI,KAAK;CAAO;AAClF;;;;;;;;;;AAWA,SAAgB,UAAU,QAAgB,OAAgB,aAAa,qBAAmC;CACxG,MAAM,OAAO,KAAK,UAAU,KAAK;CACjC,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,MACR,gCAAgC,OAAO,mFACzC;CAEF,IAAI,KAAK,UAAU,YACjB,OAAO,CAAC;EAAE,MAAM;EAAQ;EAAQ;CAAK,CAAC;CAExC,MAAM,QAAQ,KAAK,KAAK,KAAK,SAAS,UAAU;CAChD,MAAM,SAAuB,CAAC;CAC9B,KAAK,IAAI,OAAO,GAAG,OAAO,OAAO,QAC/B,OAAO,KAAK;EACV,MAAM;EACN;EACA,MAAM,KAAK,MAAM,OAAO,aAAa,OAAO,KAAK,UAAU;EAC3D;EACA;CACF,CAAC;CAEH,OAAO;AACT;;;;;;;;;AAUA,IAAa,iBAAb,MAA4B;CAC1B,0BAA2B,IAAI,IAAiD;;;;;CAMhF,KAAK,OAA+C;EAClD,IAAI,MAAM,UAAU,KAAA,GAClB,OAAO,KAAK,MAAM,MAAM,IAAI;EAE9B,MAAM,OAAO,KAAK,QAAQ,IAAI,MAAM,MAAM,KAAK;GAAE,OAAO,MAAM;GAAO,QAAQ,CAAC;EAAE;EAChF,MAAM,WAAW,KAAK,OAAO;EAC7B,IAAI,MAAM,SAAS,UAAU;GAC3B,KAAK,QAAQ,OAAO,MAAM,MAAM;GAChC,MAAM,IAAI,MACR,UAAU,MAAM,OAAO,iBAAiB,OAAO,MAAM,IAAI,EAAE,aAAa,SAAS,6FAEnF;EACF;EACA,KAAK,OAAO,KAAK,MAAM,IAAI;EAC3B,IAAI,KAAK,OAAO,SAAS,KAAK,OAAO;GACnC,KAAK,QAAQ,IAAI,MAAM,QAAQ,IAAI;GACnC;EACF;EACA,KAAK,QAAQ,OAAO,MAAM,MAAM;EAChC,OAAO,KAAK,MAAM,KAAK,OAAO,KAAK,EAAE,CAAC;CACxC;;CAGA,OAAO,QAAsB;EAC3B,KAAK,QAAQ,OAAO,MAAM;CAC5B;AACF"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//#region src/frames.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The whole wire format, which is deliberately smaller than the
|
|
4
|
+
* channel protocol it carries.
|
|
5
|
+
*
|
|
6
|
+
* Three frames in each direction. `open` says a named channel wants a
|
|
7
|
+
* stream, `data` carries one channel message, `close` ends a stream.
|
|
8
|
+
* Nothing here knows what a channel is, what a patch is, or what a
|
|
9
|
+
* command is: a frame's `body` is a string this module produced by
|
|
10
|
+
* serializing a value it never looked inside. That restraint is the
|
|
11
|
+
* rule the adapter states, and it is what keeps the
|
|
12
|
+
* webview's main thread a transport rather than a router.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* How much of a serialized message goes in one frame.
|
|
16
|
+
*
|
|
17
|
+
* Electrobun's transport fails above roughly 8 MiB in a single
|
|
18
|
+
* message, and it fails badly: the main process throws while draining
|
|
19
|
+
* and the sender sees only a timeout, so the real error is in a log
|
|
20
|
+
* nobody is reading. A megabyte leaves eight times the headroom and
|
|
21
|
+
* costs nothing at the measured rates.
|
|
22
|
+
*/
|
|
23
|
+
declare const DEFAULT_CHUNK_BYTES = 1048576;
|
|
24
|
+
type GessoFrame = {
|
|
25
|
+
readonly kind: 'open';
|
|
26
|
+
readonly stream: number;
|
|
27
|
+
readonly name: string;
|
|
28
|
+
} | {
|
|
29
|
+
readonly kind: 'data';
|
|
30
|
+
readonly stream: number;
|
|
31
|
+
readonly body: string;
|
|
32
|
+
/** Absent unless the message was split; then 0-based. */
|
|
33
|
+
readonly part?: number;
|
|
34
|
+
/** Absent unless the message was split; then how many parts to expect. */
|
|
35
|
+
readonly parts?: number;
|
|
36
|
+
} | {
|
|
37
|
+
readonly kind: 'close';
|
|
38
|
+
readonly stream: number;
|
|
39
|
+
} |
|
|
40
|
+
/**
|
|
41
|
+
* The adapter's own traffic, which is not a channel: the appearance
|
|
42
|
+
* the platform is in, and a url the application wants opened
|
|
43
|
+
* outside the window. It carries a name and a serialized payload for
|
|
44
|
+
* the same reason a `data` frame carries a body, and it is a
|
|
45
|
+
* separate kind so that nothing has to reserve a stream number.
|
|
46
|
+
*/
|
|
47
|
+
{
|
|
48
|
+
readonly kind: 'control';
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly body: string;
|
|
51
|
+
};
|
|
52
|
+
declare function isGessoFrame(value: unknown): value is GessoFrame;
|
|
53
|
+
/**
|
|
54
|
+
* Serializes one channel message into the frames that carry it.
|
|
55
|
+
*
|
|
56
|
+
* JSON rather than structured clone, because the transport underneath
|
|
57
|
+
* is JSON either way: `Electroview.createTransport` stringifies every
|
|
58
|
+
* message before it encrypts it. That means `undefined` inside a value
|
|
59
|
+
* does not survive, which is true of this transport with or without
|
|
60
|
+
* this module, and which a view key cannot rely on anyway.
|
|
61
|
+
*/
|
|
62
|
+
declare function frameData(stream: number, value: unknown, chunkBytes?: number): GessoFrame[];
|
|
63
|
+
/**
|
|
64
|
+
* Puts split messages back together.
|
|
65
|
+
*
|
|
66
|
+
* The transport delivers in order (`Electroview` dispatches through a
|
|
67
|
+
* promise tail that preserves frame order), so a part that arrives out
|
|
68
|
+
* of turn is a bug rather than a race, and it says so instead of
|
|
69
|
+
* quietly assembling a corrupt message.
|
|
70
|
+
*/
|
|
71
|
+
declare class FrameAssembler {
|
|
72
|
+
private readonly partial;
|
|
73
|
+
/**
|
|
74
|
+
* Returns the value a `data` frame completes, or `undefined` while
|
|
75
|
+
* more parts are still to come.
|
|
76
|
+
*/
|
|
77
|
+
take(frame: GessoFrame & {
|
|
78
|
+
kind: 'data';
|
|
79
|
+
}): unknown;
|
|
80
|
+
/** Drops anything half-received for a stream that has closed. */
|
|
81
|
+
forget(stream: number): void;
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
export { isGessoFrame as a, frameData as i, FrameAssembler as n, GessoFrame as r, DEFAULT_CHUNK_BYTES as t };
|
|
85
|
+
//# sourceMappingURL=frames-BrrAFMqE.d.ts.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/main.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { r as GessoFrame } from "./frames-BrrAFMqE.js";
|
|
2
|
+
import { ServedChannel } from "gesso-framework";
|
|
3
|
+
//#region src/main.d.ts
|
|
4
|
+
interface ChannelHostOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Sends one frame to the window, which is
|
|
7
|
+
* `window.webview.rpc.send.<name>`. One host per window: each keeps
|
|
8
|
+
* its own streams, and `provide` already keeps a separate record of
|
|
9
|
+
* what each client has seen, so two windows agree without anything
|
|
10
|
+
* here arranging it.
|
|
11
|
+
*/
|
|
12
|
+
send: (frame: GessoFrame) => void;
|
|
13
|
+
/** Overrides `DEFAULT_CHUNK_BYTES`. Only a test should need to. */
|
|
14
|
+
chunkBytes?: number;
|
|
15
|
+
/**
|
|
16
|
+
* A url the window asked to have opened outside itself.
|
|
17
|
+
*
|
|
18
|
+
* `Utils.openExternal(url)` is what an Electrobun application passes
|
|
19
|
+
* here. It is not called for the application: opening something is
|
|
20
|
+
* an act, and which urls an application is willing to hand to the
|
|
21
|
+
* operating system is the application's decision.
|
|
22
|
+
*/
|
|
23
|
+
onOpenUrl?: (url: string) => void;
|
|
24
|
+
}
|
|
25
|
+
interface ChannelHost {
|
|
26
|
+
/** Call from the RPC handler that receives frames from the window. */
|
|
27
|
+
receive(frame: GessoFrame): void;
|
|
28
|
+
/** Tells the window which appearance the platform is in. */
|
|
29
|
+
setColorScheme(scheme: 'light' | 'dark'): void;
|
|
30
|
+
/** Stops serving and disposes every channel this host provided. */
|
|
31
|
+
dispose(): void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Serves an application's channels to one window.
|
|
35
|
+
*
|
|
36
|
+
* const host = serveChannelsToWindow(
|
|
37
|
+
* [{ token: Catalogue, source: { view: { … }, commands: { … } } }],
|
|
38
|
+
* { send: frame => window.webview.rpc.send.gessoFrame(frame) }
|
|
39
|
+
* );
|
|
40
|
+
*/
|
|
41
|
+
declare function serveChannelsToWindow(channels: readonly ServedChannel[], options: ChannelHostOptions): ChannelHost;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { ChannelHost, ChannelHostOptions, serveChannelsToWindow };
|
|
44
|
+
//# sourceMappingURL=main.d.ts.map
|
package/dist/main.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { i as frameData, n as FrameAssembler, r as frameControl } from "./frames-BQisaYy-.js";
|
|
2
|
+
import { serveChannels } from "gesso-framework";
|
|
3
|
+
//#region src/main.ts
|
|
4
|
+
/**
|
|
5
|
+
* The main process's half of the bridge.
|
|
6
|
+
*
|
|
7
|
+
* `serveChannels` publishes an application's channels over a handshake
|
|
8
|
+
* that arrives on a worker's global scope with a `MessagePort`
|
|
9
|
+
* attached. No process boundary outside a worker can carry a port, so
|
|
10
|
+
* this synthesises both: an `open` frame becomes the handshake, and
|
|
11
|
+
* the port it hands over writes back as frames on the same stream.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in `serveChannels`, `provide` or a view model changes for
|
|
14
|
+
* this. That is the point of the seam: the application layer does not
|
|
15
|
+
* learn it is talking to a window instead of a page.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Serves an application's channels to one window.
|
|
19
|
+
*
|
|
20
|
+
* const host = serveChannelsToWindow(
|
|
21
|
+
* [{ token: Catalogue, source: { view: { … }, commands: { … } } }],
|
|
22
|
+
* { send: frame => window.webview.rpc.send.gessoFrame(frame) }
|
|
23
|
+
* );
|
|
24
|
+
*/
|
|
25
|
+
function serveChannelsToWindow(channels, options) {
|
|
26
|
+
const chunkBytes = options.chunkBytes ?? 1048576;
|
|
27
|
+
const assembler = new FrameAssembler();
|
|
28
|
+
/** The synthetic port each stream is served through. */
|
|
29
|
+
const ports = /* @__PURE__ */ new Map();
|
|
30
|
+
const host = { onmessage: null };
|
|
31
|
+
const stop = serveChannels(channels, host);
|
|
32
|
+
return {
|
|
33
|
+
setColorScheme(scheme) {
|
|
34
|
+
options.send(frameControl("colorScheme", { scheme }));
|
|
35
|
+
},
|
|
36
|
+
receive(frame) {
|
|
37
|
+
if (frame.kind === "control") {
|
|
38
|
+
if (frame.name === "openUrl") {
|
|
39
|
+
const payload = JSON.parse(frame.body);
|
|
40
|
+
if (typeof payload.url === "string") options.onOpenUrl?.(payload.url);
|
|
41
|
+
}
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (frame.kind === "open") {
|
|
45
|
+
const port = new StreamPort(frame.stream, options.send, chunkBytes);
|
|
46
|
+
ports.set(frame.stream, port);
|
|
47
|
+
host.onmessage?.({
|
|
48
|
+
data: {
|
|
49
|
+
type: "gesso:port",
|
|
50
|
+
key: frame.name
|
|
51
|
+
},
|
|
52
|
+
ports: [port]
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (frame.kind === "close") {
|
|
57
|
+
assembler.forget(frame.stream);
|
|
58
|
+
ports.delete(frame.stream);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const value = assembler.take(frame);
|
|
62
|
+
if (value === void 0) return;
|
|
63
|
+
const port = ports.get(frame.stream);
|
|
64
|
+
if (port === void 0) throw new Error(`A frame arrived for stream ${frame.stream}, which was never opened. The window and the main process disagree about what is running.`);
|
|
65
|
+
port.deliver(value);
|
|
66
|
+
},
|
|
67
|
+
dispose() {
|
|
68
|
+
for (const stream of ports.keys()) options.send({
|
|
69
|
+
kind: "close",
|
|
70
|
+
stream
|
|
71
|
+
});
|
|
72
|
+
ports.clear();
|
|
73
|
+
stop();
|
|
74
|
+
host.onmessage = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* One channel's port, as the application layer sees it.
|
|
80
|
+
*
|
|
81
|
+
* `provide` sets `onmessage` and calls `postMessage`, and that is the
|
|
82
|
+
* entire surface it uses, which is why a channel can be served over
|
|
83
|
+
* something that is not a `MessagePort` at all.
|
|
84
|
+
*/
|
|
85
|
+
var StreamPort = class {
|
|
86
|
+
stream;
|
|
87
|
+
send;
|
|
88
|
+
chunkBytes;
|
|
89
|
+
onmessage = null;
|
|
90
|
+
constructor(stream, send, chunkBytes) {
|
|
91
|
+
this.stream = stream;
|
|
92
|
+
this.send = send;
|
|
93
|
+
this.chunkBytes = chunkBytes;
|
|
94
|
+
}
|
|
95
|
+
postMessage(value) {
|
|
96
|
+
for (const frame of frameData(this.stream, value, this.chunkBytes)) this.send(frame);
|
|
97
|
+
}
|
|
98
|
+
deliver(value) {
|
|
99
|
+
this.onmessage?.({ data: value });
|
|
100
|
+
}
|
|
101
|
+
/** `provide` closes a port it is done with; there is nothing to close. */
|
|
102
|
+
close() {}
|
|
103
|
+
};
|
|
104
|
+
//#endregion
|
|
105
|
+
export { serveChannelsToWindow };
|
|
106
|
+
|
|
107
|
+
//# sourceMappingURL=main.js.map
|
package/dist/main.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.js","names":[],"sources":["../src/main.ts"],"sourcesContent":["/**\n * The main process's half of the bridge.\n *\n * `serveChannels` publishes an application's channels over a handshake\n * that arrives on a worker's global scope with a `MessagePort`\n * attached. No process boundary outside a worker can carry a port, so\n * this synthesises both: an `open` frame becomes the handshake, and\n * the port it hands over writes back as frames on the same stream.\n *\n * Nothing in `serveChannels`, `provide` or a view model changes for\n * this. That is the point of the seam: the application layer does not\n * learn it is talking to a window instead of a page.\n */\nimport { serveChannels, type PortHost, type ServedChannel } from 'gesso-framework';\n\nimport { DEFAULT_CHUNK_BYTES, FrameAssembler, frameControl, frameData, type GessoFrame } from './frames';\n\nexport interface ChannelHostOptions {\n /**\n * Sends one frame to the window, which is\n * `window.webview.rpc.send.<name>`. One host per window: each keeps\n * its own streams, and `provide` already keeps a separate record of\n * what each client has seen, so two windows agree without anything\n * here arranging it.\n */\n send: (frame: GessoFrame) => void;\n /** Overrides `DEFAULT_CHUNK_BYTES`. Only a test should need to. */\n chunkBytes?: number;\n /**\n * A url the window asked to have opened outside itself.\n *\n * `Utils.openExternal(url)` is what an Electrobun application passes\n * here. It is not called for the application: opening something is\n * an act, and which urls an application is willing to hand to the\n * operating system is the application's decision.\n */\n onOpenUrl?: (url: string) => void;\n}\n\nexport interface ChannelHost {\n /** Call from the RPC handler that receives frames from the window. */\n receive(frame: GessoFrame): void;\n /** Tells the window which appearance the platform is in. */\n setColorScheme(scheme: 'light' | 'dark'): void;\n /** Stops serving and disposes every channel this host provided. */\n dispose(): void;\n}\n\n/**\n * Serves an application's channels to one window.\n *\n * const host = serveChannelsToWindow(\n * [{ token: Catalogue, source: { view: { … }, commands: { … } } }],\n * { send: frame => window.webview.rpc.send.gessoFrame(frame) }\n * );\n */\nexport function serveChannelsToWindow(channels: readonly ServedChannel[], options: ChannelHostOptions): ChannelHost {\n const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;\n const assembler = new FrameAssembler();\n /** The synthetic port each stream is served through. */\n const ports = new Map<number, StreamPort>();\n\n // `servePorts` installs its chain on this and answers handshakes\n // delivered through `onmessage`. It is the whole of the shim: a\n // worker's global scope, as far as anything downstream can tell.\n const host: PortHost = { onmessage: null };\n const stop = serveChannels(channels, host);\n\n return {\n setColorScheme(scheme: 'light' | 'dark'): void {\n options.send(frameControl('colorScheme', { scheme }));\n },\n receive(frame: GessoFrame): void {\n if (frame.kind === 'control') {\n if (frame.name === 'openUrl') {\n const payload = JSON.parse(frame.body) as { url?: string };\n if (typeof payload.url === 'string') {\n options.onOpenUrl?.(payload.url);\n }\n }\n return;\n }\n if (frame.kind === 'open') {\n const port = new StreamPort(frame.stream, options.send, chunkBytes);\n ports.set(frame.stream, port);\n host.onmessage?.({\n data: { type: 'gesso:port', key: frame.name },\n // A `StreamPort` is a `MessagePort` in the two ways anything\n // downstream uses one, and in no others. The cast is the\n // seam; widening `PortHost` to admit a structural port would\n // widen it for every worker as well.\n ports: [port as unknown as MessagePort]\n });\n return;\n }\n if (frame.kind === 'close') {\n assembler.forget(frame.stream);\n ports.delete(frame.stream);\n return;\n }\n const value = assembler.take(frame);\n if (value === undefined) {\n return;\n }\n const port = ports.get(frame.stream);\n if (port === undefined) {\n throw new Error(\n `A frame arrived for stream ${frame.stream}, which was never opened. The window and the main process disagree about what is running.`\n );\n }\n port.deliver(value);\n },\n dispose(): void {\n for (const stream of ports.keys()) {\n options.send({ kind: 'close', stream });\n }\n ports.clear();\n stop();\n host.onmessage = null;\n }\n };\n}\n\n/**\n * One channel's port, as the application layer sees it.\n *\n * `provide` sets `onmessage` and calls `postMessage`, and that is the\n * entire surface it uses, which is why a channel can be served over\n * something that is not a `MessagePort` at all.\n */\nclass StreamPort {\n onmessage: ((event: { data: unknown }) => void) | null = null;\n\n constructor(\n private readonly stream: number,\n private readonly send: (frame: GessoFrame) => void,\n private readonly chunkBytes: number\n ) {}\n\n postMessage(value: unknown): void {\n for (const frame of frameData(this.stream, value, this.chunkBytes)) {\n this.send(frame);\n }\n }\n\n deliver(value: unknown): void {\n this.onmessage?.({ data: value });\n }\n\n /** `provide` closes a port it is done with; there is nothing to close. */\n close(): void {}\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;AAwDA,SAAgB,sBAAsB,UAAoC,SAA0C;CAClH,MAAM,aAAa,QAAQ,cAAA;CAC3B,MAAM,YAAY,IAAI,eAAe;;CAErC,MAAM,wBAAQ,IAAI,IAAwB;CAK1C,MAAM,OAAiB,EAAE,WAAW,KAAK;CACzC,MAAM,OAAO,cAAc,UAAU,IAAI;CAEzC,OAAO;EACL,eAAe,QAAgC;GAC7C,QAAQ,KAAK,aAAa,eAAe,EAAE,OAAO,CAAC,CAAC;EACtD;EACA,QAAQ,OAAyB;GAC/B,IAAI,MAAM,SAAS,WAAW;IAC5B,IAAI,MAAM,SAAS,WAAW;KAC5B,MAAM,UAAU,KAAK,MAAM,MAAM,IAAI;KACrC,IAAI,OAAO,QAAQ,QAAQ,UACzB,QAAQ,YAAY,QAAQ,GAAG;IAEnC;IACA;GACF;GACA,IAAI,MAAM,SAAS,QAAQ;IACzB,MAAM,OAAO,IAAI,WAAW,MAAM,QAAQ,QAAQ,MAAM,UAAU;IAClE,MAAM,IAAI,MAAM,QAAQ,IAAI;IAC5B,KAAK,YAAY;KACf,MAAM;MAAE,MAAM;MAAc,KAAK,MAAM;KAAK;KAK5C,OAAO,CAAC,IAA8B;IACxC,CAAC;IACD;GACF;GACA,IAAI,MAAM,SAAS,SAAS;IAC1B,UAAU,OAAO,MAAM,MAAM;IAC7B,MAAM,OAAO,MAAM,MAAM;IACzB;GACF;GACA,MAAM,QAAQ,UAAU,KAAK,KAAK;GAClC,IAAI,UAAU,KAAA,GACZ;GAEF,MAAM,OAAO,MAAM,IAAI,MAAM,MAAM;GACnC,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,MACR,8BAA8B,MAAM,OAAO,0FAC7C;GAEF,KAAK,QAAQ,KAAK;EACpB;EACA,UAAgB;GACd,KAAK,MAAM,UAAU,MAAM,KAAK,GAC9B,QAAQ,KAAK;IAAE,MAAM;IAAS;GAAO,CAAC;GAExC,MAAM,MAAM;GACZ,KAAK;GACL,KAAK,YAAY;EACnB;CACF;AACF;;;;;;;;AASA,IAAM,aAAN,MAAiB;CAII;CACA;CACA;CALnB,YAAyD;CAEzD,YACE,QACA,MACA,YACA;EAHiB,KAAA,SAAA;EACA,KAAA,OAAA;EACA,KAAA,aAAA;CAChB;CAEH,YAAY,OAAsB;EAChC,KAAK,MAAM,SAAS,UAAU,KAAK,QAAQ,OAAO,KAAK,UAAU,GAC/D,KAAK,KAAK,KAAK;CAEnB;CAEA,QAAQ,OAAsB;EAC5B,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;CAClC;;CAGA,QAAc,CAAC;AACjB"}
|
package/dist/view.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { r as GessoFrame } from "./frames-BrrAFMqE.js";
|
|
2
|
+
import { AppLogicEndpoint } from "gesso-framework";
|
|
3
|
+
//#region src/view.d.ts
|
|
4
|
+
interface ElectrobunBridgeOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Sends one frame to the main process, which is
|
|
7
|
+
* `view.rpc.send.<name>` for whichever message name the application
|
|
8
|
+
* declared. A function rather than the RPC object, so this package
|
|
9
|
+
* imports nothing from Electrobun's SDK and can be specified without
|
|
10
|
+
* a window.
|
|
11
|
+
*/
|
|
12
|
+
send: (frame: GessoFrame) => void;
|
|
13
|
+
/** Overrides `DEFAULT_CHUNK_BYTES`. Only a test should need to. */
|
|
14
|
+
chunkBytes?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The appearance the platform is in, as the main process reports it.
|
|
17
|
+
*
|
|
18
|
+
* Wire it to `app.setColorScheme`. It exists because
|
|
19
|
+
* `prefers-color-scheme` is not to be trusted in every webview: on
|
|
20
|
+
* WebKitGTK it reported light on a desktop that was in dark mode
|
|
21
|
+
*, and a shell that believes
|
|
22
|
+
* it is a browser gets the appearance wrong there.
|
|
23
|
+
*/
|
|
24
|
+
onColorScheme?: (scheme: 'light' | 'dark') => void;
|
|
25
|
+
}
|
|
26
|
+
interface ElectrobunBridge {
|
|
27
|
+
/**
|
|
28
|
+
* Hand this to the shell as its application layer:
|
|
29
|
+
*
|
|
30
|
+
* createApp({ renderWorker: …, appLogicWorker: bridge.endpoint })
|
|
31
|
+
*
|
|
32
|
+
* The shell wires it exactly as it wires a worker it was handed, and
|
|
33
|
+
* never closes it.
|
|
34
|
+
*/
|
|
35
|
+
readonly endpoint: AppLogicEndpoint;
|
|
36
|
+
/** Call from the RPC handler that receives frames from the main process. */
|
|
37
|
+
receive(frame: GessoFrame): void;
|
|
38
|
+
/**
|
|
39
|
+
* Hands a url to the main process to open outside the window.
|
|
40
|
+
*
|
|
41
|
+
* Pass it as `onOpenUrl` to the shell: `window.open` in a webview
|
|
42
|
+
* opens another webview or nothing at all, and a link in a desktop
|
|
43
|
+
* application belongs in the person's browser.
|
|
44
|
+
*/
|
|
45
|
+
openUrl(url: string): void;
|
|
46
|
+
/** Closes every stream and stops pumping. */
|
|
47
|
+
dispose(): void;
|
|
48
|
+
}
|
|
49
|
+
declare function createElectrobunBridge(options: ElectrobunBridgeOptions): ElectrobunBridge;
|
|
50
|
+
//#endregion
|
|
51
|
+
export { ElectrobunBridge, ElectrobunBridgeOptions, createElectrobunBridge };
|
|
52
|
+
//# sourceMappingURL=view.d.ts.map
|
package/dist/view.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { i as frameData, n as FrameAssembler, r as frameControl } from "./frames-BQisaYy-.js";
|
|
2
|
+
import { isHubMessage, isPortHandshake } from "gesso-framework";
|
|
3
|
+
//#region src/view.ts
|
|
4
|
+
/**
|
|
5
|
+
* The webview's half of the bridge.
|
|
6
|
+
*
|
|
7
|
+
* A Gesso render worker opens a named port per channel and expects the
|
|
8
|
+
* application layer at the other end of it. In a desktop window the
|
|
9
|
+
* application layer is in another process, reachable only from this
|
|
10
|
+
* thread, so this stands in its place: it accepts the handshakes the
|
|
11
|
+
* render worker sends and pumps each one over Electrobun's RPC as a
|
|
12
|
+
* numbered stream.
|
|
13
|
+
*
|
|
14
|
+
* It is a transport and nothing else. It serializes a message it never
|
|
15
|
+
* inspects, and it holds no channel, no token and no patch. Anything
|
|
16
|
+
* that needs to understand a payload to route it belongs on the other
|
|
17
|
+
* side of the bridge, and if that ever changes here, the wrong thing
|
|
18
|
+
* is happening on the thread that must stay free for input.
|
|
19
|
+
*/
|
|
20
|
+
function createElectrobunBridge(options) {
|
|
21
|
+
const chunkBytes = options.chunkBytes ?? 1048576;
|
|
22
|
+
const assembler = new FrameAssembler();
|
|
23
|
+
/** The render worker's port for each stream this side opened. */
|
|
24
|
+
const ports = /* @__PURE__ */ new Map();
|
|
25
|
+
let hub = null;
|
|
26
|
+
let nextStream = 1;
|
|
27
|
+
let disposed = false;
|
|
28
|
+
const openStream = (name, port) => {
|
|
29
|
+
const stream = nextStream++;
|
|
30
|
+
ports.set(stream, port);
|
|
31
|
+
port.onmessage = (event) => {
|
|
32
|
+
for (const frame of frameData(stream, event.data, chunkBytes)) options.send(frame);
|
|
33
|
+
};
|
|
34
|
+
port.start?.();
|
|
35
|
+
options.send({
|
|
36
|
+
kind: "open",
|
|
37
|
+
stream,
|
|
38
|
+
name
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
endpoint: {
|
|
43
|
+
postMessage(message, transfer) {
|
|
44
|
+
if (disposed) return;
|
|
45
|
+
if (isHubMessage(message)) {
|
|
46
|
+
const port = transfer?.[0];
|
|
47
|
+
if (port === void 0) throw new Error("The shell sent a hub message with no port attached.");
|
|
48
|
+
hub = port;
|
|
49
|
+
hub.onmessage = (event) => {
|
|
50
|
+
if (!isPortHandshake(event.data)) return;
|
|
51
|
+
const handshake = event.ports?.[0];
|
|
52
|
+
if (handshake === void 0) throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);
|
|
53
|
+
openStream(event.data.key, handshake);
|
|
54
|
+
};
|
|
55
|
+
hub.start?.();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
addEventListener() {},
|
|
60
|
+
removeEventListener() {}
|
|
61
|
+
},
|
|
62
|
+
openUrl(url) {
|
|
63
|
+
if (!disposed) options.send(frameControl("openUrl", { url }));
|
|
64
|
+
},
|
|
65
|
+
receive(frame) {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
if (frame.kind === "control") {
|
|
68
|
+
if (frame.name === "colorScheme") {
|
|
69
|
+
const payload = JSON.parse(frame.body);
|
|
70
|
+
if (payload.scheme !== void 0) options.onColorScheme?.(payload.scheme);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (frame.kind === "close") {
|
|
75
|
+
assembler.forget(frame.stream);
|
|
76
|
+
ports.get(frame.stream)?.close();
|
|
77
|
+
ports.delete(frame.stream);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (frame.kind !== "data") throw new Error(`The main process opened stream ${frame.stream}, which only the window may do.`);
|
|
81
|
+
const value = assembler.take(frame);
|
|
82
|
+
if (value === void 0) return;
|
|
83
|
+
const port = ports.get(frame.stream);
|
|
84
|
+
if (port === void 0) return;
|
|
85
|
+
port.postMessage(value);
|
|
86
|
+
},
|
|
87
|
+
dispose() {
|
|
88
|
+
disposed = true;
|
|
89
|
+
for (const [stream, port] of ports) {
|
|
90
|
+
options.send({
|
|
91
|
+
kind: "close",
|
|
92
|
+
stream
|
|
93
|
+
});
|
|
94
|
+
port.close();
|
|
95
|
+
}
|
|
96
|
+
ports.clear();
|
|
97
|
+
if (hub !== null) {
|
|
98
|
+
hub.onmessage = null;
|
|
99
|
+
hub.close();
|
|
100
|
+
hub = null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
//#endregion
|
|
106
|
+
export { createElectrobunBridge };
|
|
107
|
+
|
|
108
|
+
//# sourceMappingURL=view.js.map
|
package/dist/view.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.js","names":[],"sources":["../src/view.ts"],"sourcesContent":["/**\n * The webview's half of the bridge.\n *\n * A Gesso render worker opens a named port per channel and expects the\n * application layer at the other end of it. In a desktop window the\n * application layer is in another process, reachable only from this\n * thread, so this stands in its place: it accepts the handshakes the\n * render worker sends and pumps each one over Electrobun's RPC as a\n * numbered stream.\n *\n * It is a transport and nothing else. It serializes a message it never\n * inspects, and it holds no channel, no token and no patch. Anything\n * that needs to understand a payload to route it belongs on the other\n * side of the bridge, and if that ever changes here, the wrong thing\n * is happening on the thread that must stay free for input.\n */\nimport { isHubMessage, isPortHandshake, type AppLogicEndpoint } from 'gesso-framework';\n\nimport { DEFAULT_CHUNK_BYTES, FrameAssembler, frameControl, frameData, type GessoFrame } from './frames';\n\nexport interface ElectrobunBridgeOptions {\n /**\n * Sends one frame to the main process, which is\n * `view.rpc.send.<name>` for whichever message name the application\n * declared. A function rather than the RPC object, so this package\n * imports nothing from Electrobun's SDK and can be specified without\n * a window.\n */\n send: (frame: GessoFrame) => void;\n /** Overrides `DEFAULT_CHUNK_BYTES`. Only a test should need to. */\n chunkBytes?: number;\n /**\n * The appearance the platform is in, as the main process reports it.\n *\n * Wire it to `app.setColorScheme`. It exists because\n * `prefers-color-scheme` is not to be trusted in every webview: on\n * WebKitGTK it reported light on a desktop that was in dark mode\n *, and a shell that believes\n * it is a browser gets the appearance wrong there.\n */\n onColorScheme?: (scheme: 'light' | 'dark') => void;\n}\n\nexport interface ElectrobunBridge {\n /**\n * Hand this to the shell as its application layer:\n *\n * createApp({ renderWorker: …, appLogicWorker: bridge.endpoint })\n *\n * The shell wires it exactly as it wires a worker it was handed, and\n * never closes it.\n */\n readonly endpoint: AppLogicEndpoint;\n /** Call from the RPC handler that receives frames from the main process. */\n receive(frame: GessoFrame): void;\n /**\n * Hands a url to the main process to open outside the window.\n *\n * Pass it as `onOpenUrl` to the shell: `window.open` in a webview\n * opens another webview or nothing at all, and a link in a desktop\n * application belongs in the person's browser.\n */\n openUrl(url: string): void;\n /** Closes every stream and stops pumping. */\n dispose(): void;\n}\n\nexport function createElectrobunBridge(options: ElectrobunBridgeOptions): ElectrobunBridge {\n const chunkBytes = options.chunkBytes ?? DEFAULT_CHUNK_BYTES;\n const assembler = new FrameAssembler();\n /** The render worker's port for each stream this side opened. */\n const ports = new Map<number, MessagePort>();\n let hub: MessagePort | null = null;\n let nextStream = 1;\n let disposed = false;\n\n const openStream = (name: string, port: MessagePort): void => {\n const stream = nextStream++;\n ports.set(stream, port);\n port.onmessage = event => {\n for (const frame of frameData(stream, event.data, chunkBytes)) {\n options.send(frame);\n }\n };\n port.start?.();\n options.send({ kind: 'open', stream, name });\n };\n\n const endpoint: AppLogicEndpoint = {\n postMessage(message: unknown, transfer?: Transferable[]): void {\n if (disposed) {\n return;\n }\n if (isHubMessage(message)) {\n const port = transfer?.[0] as MessagePort | undefined;\n if (port === undefined) {\n throw new Error('The shell sent a hub message with no port attached.');\n }\n hub = port;\n hub.onmessage = event => {\n if (!isPortHandshake(event.data)) {\n return;\n }\n const handshake = event.ports?.[0];\n if (handshake === undefined) {\n throw new Error(`Port handshake for '${event.data.key}' arrived with no port attached.`);\n }\n openStream(event.data.key, handshake);\n };\n hub.start?.();\n return;\n }\n // Everything else the shell sends an application worker is about\n // a worker in this page: console forwarding, today. The\n // application process's console is its own, and reaching it is\n // E1.2's business rather than the transport's.\n },\n addEventListener(): void {\n // The shell listens here for console entries from the\n // application worker. There is no worker, and nothing on the\n // other side of the bridge speaks that protocol yet, so a\n // listener would never be called and is not kept.\n },\n removeEventListener(): void {}\n };\n\n return {\n endpoint,\n openUrl(url: string): void {\n if (!disposed) {\n options.send(frameControl('openUrl', { url }));\n }\n },\n receive(frame: GessoFrame): void {\n if (disposed) {\n return;\n }\n if (frame.kind === 'control') {\n if (frame.name === 'colorScheme') {\n const payload = JSON.parse(frame.body) as { scheme?: 'light' | 'dark' };\n if (payload.scheme !== undefined) {\n options.onColorScheme?.(payload.scheme);\n }\n }\n // An unknown control name is ignored rather than thrown on: the\n // main process may be newer than the window, which is ordinary\n // during a hot reload.\n return;\n }\n if (frame.kind === 'close') {\n assembler.forget(frame.stream);\n ports.get(frame.stream)?.close();\n ports.delete(frame.stream);\n return;\n }\n if (frame.kind !== 'data') {\n // `open` is this side's word. One arriving from the main\n // process means the two ends disagree about who starts a\n // stream, which is worth hearing about rather than ignoring.\n throw new Error(`The main process opened stream ${frame.stream}, which only the window may do.`);\n }\n const value = assembler.take(frame);\n if (value === undefined) {\n return;\n }\n const port = ports.get(frame.stream);\n if (port === undefined) {\n // A frame for a stream this side has closed. Ordinary during\n // teardown, because the far end may already have sent.\n return;\n }\n port.postMessage(value);\n },\n dispose(): void {\n disposed = true;\n for (const [stream, port] of ports) {\n options.send({ kind: 'close', stream });\n port.close();\n }\n ports.clear();\n if (hub !== null) {\n hub.onmessage = null;\n hub.close();\n hub = null;\n }\n }\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmEA,SAAgB,uBAAuB,SAAoD;CACzF,MAAM,aAAa,QAAQ,cAAA;CAC3B,MAAM,YAAY,IAAI,eAAe;;CAErC,MAAM,wBAAQ,IAAI,IAAyB;CAC3C,IAAI,MAA0B;CAC9B,IAAI,aAAa;CACjB,IAAI,WAAW;CAEf,MAAM,cAAc,MAAc,SAA4B;EAC5D,MAAM,SAAS;EACf,MAAM,IAAI,QAAQ,IAAI;EACtB,KAAK,aAAY,UAAS;GACxB,KAAK,MAAM,SAAS,UAAU,QAAQ,MAAM,MAAM,UAAU,GAC1D,QAAQ,KAAK,KAAK;EAEtB;EACA,KAAK,QAAQ;EACb,QAAQ,KAAK;GAAE,MAAM;GAAQ;GAAQ;EAAK,CAAC;CAC7C;CAwCA,OAAO;EACL,UAAA;GAtCA,YAAY,SAAkB,UAAiC;IAC7D,IAAI,UACF;IAEF,IAAI,aAAa,OAAO,GAAG;KACzB,MAAM,OAAO,WAAW;KACxB,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,MAAM,qDAAqD;KAEvE,MAAM;KACN,IAAI,aAAY,UAAS;MACvB,IAAI,CAAC,gBAAgB,MAAM,IAAI,GAC7B;MAEF,MAAM,YAAY,MAAM,QAAQ;MAChC,IAAI,cAAc,KAAA,GAChB,MAAM,IAAI,MAAM,uBAAuB,MAAM,KAAK,IAAI,iCAAiC;MAEzF,WAAW,MAAM,KAAK,KAAK,SAAS;KACtC;KACA,IAAI,QAAQ;KACZ;IACF;GAKF;GACA,mBAAyB,CAKzB;GACA,sBAA4B,CAAC;EAItB;EACP,QAAQ,KAAmB;GACzB,IAAI,CAAC,UACH,QAAQ,KAAK,aAAa,WAAW,EAAE,IAAI,CAAC,CAAC;EAEjD;EACA,QAAQ,OAAyB;GAC/B,IAAI,UACF;GAEF,IAAI,MAAM,SAAS,WAAW;IAC5B,IAAI,MAAM,SAAS,eAAe;KAChC,MAAM,UAAU,KAAK,MAAM,MAAM,IAAI;KACrC,IAAI,QAAQ,WAAW,KAAA,GACrB,QAAQ,gBAAgB,QAAQ,MAAM;IAE1C;IAIA;GACF;GACA,IAAI,MAAM,SAAS,SAAS;IAC1B,UAAU,OAAO,MAAM,MAAM;IAC7B,MAAM,IAAI,MAAM,MAAM,CAAC,EAAE,MAAM;IAC/B,MAAM,OAAO,MAAM,MAAM;IACzB;GACF;GACA,IAAI,MAAM,SAAS,QAIjB,MAAM,IAAI,MAAM,kCAAkC,MAAM,OAAO,gCAAgC;GAEjG,MAAM,QAAQ,UAAU,KAAK,KAAK;GAClC,IAAI,UAAU,KAAA,GACZ;GAEF,MAAM,OAAO,MAAM,IAAI,MAAM,MAAM;GACnC,IAAI,SAAS,KAAA,GAGX;GAEF,KAAK,YAAY,KAAK;EACxB;EACA,UAAgB;GACd,WAAW;GACX,KAAK,MAAM,CAAC,QAAQ,SAAS,OAAO;IAClC,QAAQ,KAAK;KAAE,MAAM;KAAS;IAAO,CAAC;IACtC,KAAK,MAAM;GACb;GACA,MAAM,MAAM;GACZ,IAAI,QAAQ,MAAM;IAChB,IAAI,YAAY;IAChB,IAAI,MAAM;IACV,MAAM;GACR;EACF;CACF;AACF"}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gesso-electrobun",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Run a Gesso application in an Electrobun window, with its stores in the main process.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Kevin Baker",
|
|
7
|
+
"homepage": "https://github.com/kevinpbaker/gesso#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/kevinpbaker/gesso.git",
|
|
11
|
+
"directory": "packages/electrobun"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/kevinpbaker/gesso/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"gesso",
|
|
18
|
+
"canvas",
|
|
19
|
+
"ui",
|
|
20
|
+
"typescript",
|
|
21
|
+
"electrobun",
|
|
22
|
+
"desktop",
|
|
23
|
+
"webview",
|
|
24
|
+
"native",
|
|
25
|
+
"cross-platform"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./view": {
|
|
35
|
+
"types": "./dist/view.d.ts",
|
|
36
|
+
"default": "./dist/view.js"
|
|
37
|
+
},
|
|
38
|
+
"./main": {
|
|
39
|
+
"types": "./dist/main.d.ts",
|
|
40
|
+
"default": "./dist/main.js"
|
|
41
|
+
},
|
|
42
|
+
"./desktop": {
|
|
43
|
+
"types": "./dist/desktop.d.ts",
|
|
44
|
+
"default": "./dist/desktop.js"
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"README.md",
|
|
52
|
+
"CHANGELOG.md",
|
|
53
|
+
"dist",
|
|
54
|
+
"LICENSE"
|
|
55
|
+
],
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"gesso-framework": "^0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"rxjs": "^7.8.2"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"rxjs": "^7.8.2"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsdown"
|
|
67
|
+
}
|
|
68
|
+
}
|