gesso-electrobun 0.6.3 → 0.6.5
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 +20 -0
- package/dist/desktop.d.ts +43 -4
- package/dist/desktop.js +17 -6
- package/dist/desktop.js.map +1 -1
- package/dist/frames-BQisaYy-.js.map +1 -1
- package/dist/{frames-BrrAFMqE.d.ts → frames-Bx7SnqWg.d.ts} +4 -3
- package/dist/index.d.ts +1 -1
- package/dist/main.d.ts +11 -1
- package/dist/main.js +2 -2
- package/dist/main.js.map +1 -1
- package/dist/route-B2qU38dY.js +54 -0
- package/dist/route-B2qU38dY.js.map +1 -0
- package/dist/route-CNeMnj_6.d.ts +44 -0
- package/dist/view.d.ts +14 -2
- package/dist/view.js +5 -1
- package/dist/view.js.map +1 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# gesso-electrobun
|
|
2
2
|
|
|
3
|
+
## 0.6.5
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- e93e0ee: A `Link` can now name an in-app destination with `to`, and a Cmd-click opens it somewhere new, the way a browser treats an anchor. `to` takes one of the application's own urls (`/epic/BUD-12?story=BUD-13`) or a `RouteTarget` from `to(route, params)`. A plain click, Enter or Space runs `onPress` and then navigates the router in place. A click with Command or Control held (either one, on any platform), a middle click, or Cmd-Enter / Ctrl-Enter runs `onPress` and then asks the shell to open the app at that url somewhere new, leaving the current screen where it is. An `href` link is unchanged: it opens through `openUrl` however it is clicked. When a link has both, `to` wins. `Breadcrumb` items take the same `to`.
|
|
8
|
+
|
|
9
|
+
The request is the new `ShellService.openRoute(url)`, and what "somewhere new" means is the shell's decision. `createApp` and `GessoApp` take an `onOpenRoute(url)` for a host with its own idea of a new tab, such as an app inside Jira opening one through Forge's `router.open`. Without one, a browser shell opens a tab at the app's own address for that url: the path on the same origin in `path` mode, the same page with the fragment set in `hash` mode. In `memory` mode there is no address, so the link is followed in place. A handed-in `ShellHistory` can answer the new optional `href(url)` to give the address itself; without it, such a link is also followed in place.
|
|
10
|
+
|
|
11
|
+
In `gesso-electrobun`, the view bridge has `openRoute(url)` to pass as `onOpenRoute`, and `createDesktopApp` answers it by opening a new window of the application at that route: `openWindow({ route })`, and an `onOpenRoute(url, window)` option to do something else. A window learns its route from the page it loads: `open` reads `window.route`, `withWindowRoute` puts it in the view url's fragment, and `windowRoute()` reads it back as the window's starting url. An `open` that ignores it keeps working. The Electrobun template does all three.
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [e93e0ee]
|
|
14
|
+
- gesso-framework@0.6.5
|
|
15
|
+
|
|
16
|
+
## 0.6.4
|
|
17
|
+
|
|
18
|
+
### Patch Changes
|
|
19
|
+
|
|
20
|
+
- Updated dependencies [2f6a858]
|
|
21
|
+
- gesso-framework@0.6.4
|
|
22
|
+
|
|
3
23
|
## 0.6.3
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/dist/desktop.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { r as GessoFrame } from "./frames-
|
|
1
|
+
import { r as GessoFrame } from "./frames-Bx7SnqWg.js";
|
|
2
|
+
import { n as withWindowRoute } from "./route-CNeMnj_6.js";
|
|
2
3
|
import { ChannelToken, ServedChannel } from "gesso-framework";
|
|
3
4
|
import { Observable } from "rxjs";
|
|
4
5
|
import { AgentConfirmation, AgentSurface, McpHandlerOptions } from "gesso-framework/agent";
|
|
@@ -97,9 +98,28 @@ interface DesktopWindowTransport {
|
|
|
97
98
|
interface DesktopWindowHandle {
|
|
98
99
|
/** Stable for the life of the window, and never reused. */
|
|
99
100
|
readonly id: number;
|
|
101
|
+
/**
|
|
102
|
+
* The application url the window was asked to open at, or null for
|
|
103
|
+
* wherever the application starts.
|
|
104
|
+
*
|
|
105
|
+
* Set by `openWindow({ route })`, which is what a Cmd-click on an
|
|
106
|
+
* in-app link comes to by default. The window cannot be told it
|
|
107
|
+
* through a frame in time to start there, so `open` puts it on the
|
|
108
|
+
* view's url, with `withWindowRoute`, and the window reads it back
|
|
109
|
+
* with `windowRoute` before its shell starts.
|
|
110
|
+
*/
|
|
111
|
+
readonly route: string | null;
|
|
100
112
|
/** Closes the window and disposes the channels it was served. */
|
|
101
113
|
close(): void;
|
|
102
114
|
}
|
|
115
|
+
/** What `openWindow` may be told about the window it opens. */
|
|
116
|
+
interface DesktopWindowOptions {
|
|
117
|
+
/**
|
|
118
|
+
* The application url to open at, `/epic/BUD-12?story=BUD-13`. Carried
|
|
119
|
+
* to `open` as `window.route`; see `DesktopWindowHandle.route`.
|
|
120
|
+
*/
|
|
121
|
+
readonly route?: string;
|
|
122
|
+
}
|
|
103
123
|
interface DesktopAppOptions {
|
|
104
124
|
/**
|
|
105
125
|
* The channels each window is served.
|
|
@@ -118,6 +138,11 @@ interface DesktopAppOptions {
|
|
|
118
138
|
* `receive` is what the window's frames must be fed into: wire it to
|
|
119
139
|
* the RPC message the window sends frames on, before the window
|
|
120
140
|
* opens, or the first handshake is lost.
|
|
141
|
+
*
|
|
142
|
+
* `window.route` is the application url the window should start at,
|
|
143
|
+
* or null. Put it on the view's url with `withWindowRoute`, and read
|
|
144
|
+
* it in the window with `windowRoute`; an `open` that ignores it
|
|
145
|
+
* opens every window at the application's start, as before.
|
|
121
146
|
*/
|
|
122
147
|
open: (receive: (frame: GessoFrame) => void, window: DesktopWindowHandle) => DesktopWindowTransport;
|
|
123
148
|
/**
|
|
@@ -126,6 +151,17 @@ interface DesktopAppOptions {
|
|
|
126
151
|
* application passes here.
|
|
127
152
|
*/
|
|
128
153
|
onOpenUrl?: (url: string, window: DesktopWindowHandle) => void;
|
|
154
|
+
/**
|
|
155
|
+
* One of the application's own urls a window asked to have opened
|
|
156
|
+
* somewhere new (a Cmd-click or a Ctrl-click on an in-app link), with
|
|
157
|
+
* the window that asked.
|
|
158
|
+
*
|
|
159
|
+
* Defaults to `app.openWindow({ route: url })`: a new window of this
|
|
160
|
+
* application, at that route, which is what a new tab is in an
|
|
161
|
+
* application whose windows have none. An outbound url never comes
|
|
162
|
+
* here; it is `onOpenUrl`'s, and goes to the person's browser.
|
|
163
|
+
*/
|
|
164
|
+
onOpenRoute?: (url: string, window: DesktopWindowHandle) => void;
|
|
129
165
|
/**
|
|
130
166
|
* The appearance the platform is in, pushed to every window as it
|
|
131
167
|
* changes and to a new window as it opens.
|
|
@@ -148,8 +184,11 @@ interface DesktopAppOptions {
|
|
|
148
184
|
chunkBytes?: number;
|
|
149
185
|
}
|
|
150
186
|
interface DesktopApp {
|
|
151
|
-
/**
|
|
152
|
-
|
|
187
|
+
/**
|
|
188
|
+
* Opens a window, serves it every channel, and returns its handle;
|
|
189
|
+
* at `options.route` when one is given.
|
|
190
|
+
*/
|
|
191
|
+
openWindow(options?: DesktopWindowOptions): DesktopWindowHandle;
|
|
153
192
|
/** The windows open now, in the order they were opened. */
|
|
154
193
|
readonly windows: readonly DesktopWindowHandle[];
|
|
155
194
|
/** How many windows are open, as something a channel can publish. */
|
|
@@ -183,5 +222,5 @@ declare const DesktopWindows: ChannelToken<DesktopWindowsView, DesktopWindowsCom
|
|
|
183
222
|
/** Serves `DesktopWindows` to one window. Put it in `channels`. */
|
|
184
223
|
declare function windowsChannel(app: DesktopApp, window: DesktopWindowHandle): ServedChannel;
|
|
185
224
|
//#endregion
|
|
186
|
-
export { type DesktopAgent, type DesktopAgentOptions, DesktopApp, DesktopAppOptions, type DesktopServe, DesktopWindowHandle, DesktopWindowTransport, DesktopWindows, DesktopWindowsCommands, DesktopWindowsView, type ShowMessageBox, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel };
|
|
225
|
+
export { type DesktopAgent, type DesktopAgentOptions, DesktopApp, DesktopAppOptions, type DesktopServe, DesktopWindowHandle, DesktopWindowOptions, DesktopWindowTransport, DesktopWindows, DesktopWindowsCommands, DesktopWindowsView, type ShowMessageBox, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel, withWindowRoute };
|
|
187
226
|
//# sourceMappingURL=desktop.d.ts.map
|
package/dist/desktop.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { n as withWindowRoute } from "./route-B2qU38dY.js";
|
|
1
2
|
import { serveChannelsToWindow } from "./main.js";
|
|
2
3
|
import { channel } from "gesso-framework";
|
|
3
4
|
import { BehaviorSubject } from "rxjs";
|
|
@@ -89,11 +90,15 @@ function isAddressInUse(error) {
|
|
|
89
90
|
* { token: Catalogue, source: catalogue },
|
|
90
91
|
* windowsChannel(app, window)
|
|
91
92
|
* ],
|
|
92
|
-
* open: receive => {
|
|
93
|
+
* open: (receive, handle) => {
|
|
93
94
|
* const rpc = BrowserView.defineRPC<GessoWindowRPC>({
|
|
94
95
|
* handlers: { requests: {}, messages: { gessoFrame: receive } }
|
|
95
96
|
* });
|
|
96
|
-
* const window = new BrowserWindow({
|
|
97
|
+
* const window = new BrowserWindow({
|
|
98
|
+
* title: 'Notes',
|
|
99
|
+
* url: withWindowRoute('views://mainview/index.html', handle.route),
|
|
100
|
+
* rpc
|
|
101
|
+
* });
|
|
97
102
|
* return {
|
|
98
103
|
* send: frame => window.webview.rpc.send.gessoFrame(frame),
|
|
99
104
|
* close: () => window.close()
|
|
@@ -127,19 +132,24 @@ function createDesktopApp(options) {
|
|
|
127
132
|
count.next(order.length);
|
|
128
133
|
if (order.length === 0 && !disposed) options.onLastWindowClosed?.();
|
|
129
134
|
};
|
|
130
|
-
|
|
131
|
-
openWindow() {
|
|
135
|
+
const app = {
|
|
136
|
+
openWindow(windowOptions = {}) {
|
|
132
137
|
if (disposed) throw new Error("This desktop application has been disposed; it cannot open a window.");
|
|
133
138
|
const id = nextId++;
|
|
134
139
|
const handle = {
|
|
135
140
|
id,
|
|
141
|
+
route: windowOptions.route ?? null,
|
|
136
142
|
close: () => forget(id, true)
|
|
137
143
|
};
|
|
138
144
|
let transport;
|
|
139
145
|
const host = serveChannelsToWindow(typeof options.channels === "function" ? options.channels(handle) : options.channels, {
|
|
140
146
|
send: (frame) => transport?.send(frame),
|
|
141
147
|
chunkBytes: options.chunkBytes,
|
|
142
|
-
onOpenUrl: (url) => options.onOpenUrl?.(url, handle)
|
|
148
|
+
onOpenUrl: (url) => options.onOpenUrl?.(url, handle),
|
|
149
|
+
onOpenRoute: (url) => {
|
|
150
|
+
if (options.onOpenRoute !== void 0) options.onOpenRoute(url, handle);
|
|
151
|
+
else if (!disposed) app.openWindow({ route: url });
|
|
152
|
+
}
|
|
143
153
|
});
|
|
144
154
|
entries.set(id, {
|
|
145
155
|
handle,
|
|
@@ -182,6 +192,7 @@ function createDesktopApp(options) {
|
|
|
182
192
|
count.complete();
|
|
183
193
|
}
|
|
184
194
|
};
|
|
195
|
+
return app;
|
|
185
196
|
}
|
|
186
197
|
/**
|
|
187
198
|
* The channel a window opens another window through.
|
|
@@ -218,6 +229,6 @@ function windowsChannel(app, window) {
|
|
|
218
229
|
};
|
|
219
230
|
}
|
|
220
231
|
//#endregion
|
|
221
|
-
export { DesktopWindows, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel };
|
|
232
|
+
export { DesktopWindows, createDesktopApp, messageBoxConfirm, serveDesktopAgent, windowsChannel, withWindowRoute };
|
|
222
233
|
|
|
223
234
|
//# sourceMappingURL=desktop.js.map
|
package/dist/desktop.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"desktop.js","names":[],"sources":["../src/agent.ts","../src/desktop.ts"],"sourcesContent":["import type { ServedChannel } from 'gesso-framework';\nimport {\n agentSurface,\n mcpHandler,\n type AgentConfirmation,\n type AgentSurface,\n type McpHandlerOptions\n} from 'gesso-framework/agent';\n\n/**\n * A desktop application's channels, served to AI agents over MCP.\n *\n * The main process already holds every channel's source: it is what\n * `createDesktopApp` serves to each window. So it is also where an\n * agent connects. This serves those same sources with MCP's HTTP\n * transport on the person's own machine, through `Bun.serve`, which\n * Cottontail provides as Bun does, and an agent such as Claude Code\n * connects by URL:\n *\n * const agent = serveDesktopAgent([counter], { name: 'my-app', confirm: messageBoxConfirm(Utils.showMessageBox) });\n * // claude mcp add --transport http my-app http://127.0.0.1:7310/mcp\n *\n * The channels are passed rather than read from the app, because\n * `createDesktopApp` takes them per window, and the per-window ones\n * (`windowsChannel`) are about a window an agent does not have.\n *\n * What it offers is the channel tools only. The screen is in each\n * window's render worker, out of the main process's reach, so the\n * screen tools a web app offers are not here.\n *\n * The descriptions come from `channelSchema`. A main process is bundled\n * by Electrobun's own build, which `gesso-vite-plugin` never sees, so\n * the template runs `gesso-channels` to write a module that describes\n * the contracts, and imports it.\n */\n\n/** A `Bun.serve`, as far as this uses one. */\nexport type DesktopServe = (options: {\n hostname: string;\n port: number;\n fetch: (request: Request) => Promise<Response>;\n}) => { stop(closeActiveConnections?: boolean): void };\n\nexport interface DesktopAgentOptions extends Omit<McpHandlerOptions, 'allowedOrigins'> {\n /**\n * The port to listen on (default 7310). When it is taken, the next\n * nine are tried in turn, so a second copy of the app still serves;\n * `url` says which one it got.\n */\n port?: number;\n /** Default `127.0.0.1`: reachable from this machine only. */\n hostname?: string;\n /**\n * Asks the person whether a `@confirm` command may be sent. Without\n * it such a command is refused. `messageBoxConfirm` makes one from\n * Electrobun's native dialog.\n */\n confirm?: (request: AgentConfirmation) => boolean | Promise<boolean>;\n /** The server to start. Defaults to the runtime's `Bun.serve`. */\n serve?: DesktopServe;\n}\n\nexport interface DesktopAgent {\n /** Where an agent connects: `http://127.0.0.1:<port>/mcp`. */\n readonly url: string;\n readonly surface: AgentSurface;\n /** Stops serving and stops following the channels. */\n stop(): void;\n}\n\nconst DEFAULT_PORT = 7310;\nconst PORTS_TRIED = 10;\n\nexport function serveDesktopAgent(channels: readonly ServedChannel[], options: DesktopAgentOptions = {}): DesktopAgent {\n const serve = options.serve ?? bunServe();\n const hostname = options.hostname ?? '127.0.0.1';\n const first = options.port ?? DEFAULT_PORT;\n const surface = agentSurface(channels, options.confirm === undefined ? {} : { confirm: options.confirm });\n // A desktop app has no browser pages of its own to let in, so every\n // request carrying an Origin is refused: that is a web page the person\n // has open, reaching for their machine.\n const fetch = mcpHandler(surface, { ...options, allowedOrigins: [] });\n\n let lastError: unknown;\n for (let port = first; port < first + PORTS_TRIED; port++) {\n try {\n const server = serve({ hostname, port, fetch });\n return {\n url: `http://${hostname}:${port}/mcp`,\n surface,\n stop: () => {\n server.stop(true);\n surface.dispose();\n }\n };\n } catch (error) {\n if (!isAddressInUse(error)) {\n surface.dispose();\n throw error;\n }\n lastError = error;\n }\n }\n surface.dispose();\n throw new Error(\n `Ports ${first} to ${first + PORTS_TRIED - 1} are all in use, so agents cannot be served. ` +\n `Pass another port to serveDesktopAgent. (${String(lastError)})`\n );\n}\n\n/** The options Electrobun's `Utils.showMessageBox` takes, as far as this uses them. */\nexport type ShowMessageBox = (options: {\n type?: 'info' | 'warning' | 'error' | 'question';\n title?: string;\n message?: string;\n detail?: string;\n buttons?: string[];\n defaultId?: number;\n cancelId?: number;\n}) => Promise<{ response: number }>;\n\n/**\n * A `confirm` that asks with the operating system's own dialog.\n *\n * Takes Electrobun's `Utils.showMessageBox` rather than importing it,\n * because Electrobun is a toolchain a project is projected into, not a\n * package this one can depend on. The safe answer is the default one:\n * Escape, closing the dialog, and Enter all decline.\n */\nexport function messageBoxConfirm(showMessageBox: ShowMessageBox): (request: AgentConfirmation) => Promise<boolean> {\n return async request => {\n const details = [\n request.description,\n Object.keys(request.arguments).length > 0 ? JSON.stringify(request.arguments, null, 2) : undefined\n ]\n .filter((part): part is string => part !== undefined && part !== '')\n .join('\\n\\n');\n const { response } = await showMessageBox({\n type: request.destructive ? 'warning' : 'question',\n title: 'An AI agent is asking',\n message: `An AI agent wants to ${request.command} in ${request.channel}.${request.destructive ? ' This cannot be undone.' : ''}`,\n detail: details,\n buttons: ['Allow', 'Decline'],\n defaultId: 1,\n cancelId: 1\n });\n return response === 0;\n };\n}\n\nfunction bunServe(): DesktopServe {\n const bun = (globalThis as { Bun?: { serve?: DesktopServe } }).Bun;\n if (typeof bun?.serve !== 'function') {\n throw new Error(\n 'serveDesktopAgent needs Bun.serve, which Cottontail and Bun provide. Pass serve to use another server.'\n );\n }\n return options => bun.serve!(options);\n}\n\nfunction isAddressInUse(error: unknown): boolean {\n const code = (error as { code?: unknown } | null)?.code;\n return code === 'EADDRINUSE' || /in use|EADDRINUSE/i.test(String((error as Error | null)?.message ?? error));\n}\n","/**\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\nexport {\n messageBoxConfirm,\n serveDesktopAgent,\n type DesktopAgent,\n type DesktopAgentOptions,\n type DesktopServe,\n type ShowMessageBox\n} from './agent';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"desktop.js","names":[],"sources":["../src/agent.ts","../src/desktop.ts"],"sourcesContent":["import type { ServedChannel } from 'gesso-framework';\nimport {\n agentSurface,\n mcpHandler,\n type AgentConfirmation,\n type AgentSurface,\n type McpHandlerOptions\n} from 'gesso-framework/agent';\n\n/**\n * A desktop application's channels, served to AI agents over MCP.\n *\n * The main process already holds every channel's source: it is what\n * `createDesktopApp` serves to each window. So it is also where an\n * agent connects. This serves those same sources with MCP's HTTP\n * transport on the person's own machine, through `Bun.serve`, which\n * Cottontail provides as Bun does, and an agent such as Claude Code\n * connects by URL:\n *\n * const agent = serveDesktopAgent([counter], { name: 'my-app', confirm: messageBoxConfirm(Utils.showMessageBox) });\n * // claude mcp add --transport http my-app http://127.0.0.1:7310/mcp\n *\n * The channels are passed rather than read from the app, because\n * `createDesktopApp` takes them per window, and the per-window ones\n * (`windowsChannel`) are about a window an agent does not have.\n *\n * What it offers is the channel tools only. The screen is in each\n * window's render worker, out of the main process's reach, so the\n * screen tools a web app offers are not here.\n *\n * The descriptions come from `channelSchema`. A main process is bundled\n * by Electrobun's own build, which `gesso-vite-plugin` never sees, so\n * the template runs `gesso-channels` to write a module that describes\n * the contracts, and imports it.\n */\n\n/** A `Bun.serve`, as far as this uses one. */\nexport type DesktopServe = (options: {\n hostname: string;\n port: number;\n fetch: (request: Request) => Promise<Response>;\n}) => { stop(closeActiveConnections?: boolean): void };\n\nexport interface DesktopAgentOptions extends Omit<McpHandlerOptions, 'allowedOrigins'> {\n /**\n * The port to listen on (default 7310). When it is taken, the next\n * nine are tried in turn, so a second copy of the app still serves;\n * `url` says which one it got.\n */\n port?: number;\n /** Default `127.0.0.1`: reachable from this machine only. */\n hostname?: string;\n /**\n * Asks the person whether a `@confirm` command may be sent. Without\n * it such a command is refused. `messageBoxConfirm` makes one from\n * Electrobun's native dialog.\n */\n confirm?: (request: AgentConfirmation) => boolean | Promise<boolean>;\n /** The server to start. Defaults to the runtime's `Bun.serve`. */\n serve?: DesktopServe;\n}\n\nexport interface DesktopAgent {\n /** Where an agent connects: `http://127.0.0.1:<port>/mcp`. */\n readonly url: string;\n readonly surface: AgentSurface;\n /** Stops serving and stops following the channels. */\n stop(): void;\n}\n\nconst DEFAULT_PORT = 7310;\nconst PORTS_TRIED = 10;\n\nexport function serveDesktopAgent(channels: readonly ServedChannel[], options: DesktopAgentOptions = {}): DesktopAgent {\n const serve = options.serve ?? bunServe();\n const hostname = options.hostname ?? '127.0.0.1';\n const first = options.port ?? DEFAULT_PORT;\n const surface = agentSurface(channels, options.confirm === undefined ? {} : { confirm: options.confirm });\n // A desktop app has no browser pages of its own to let in, so every\n // request carrying an Origin is refused: that is a web page the person\n // has open, reaching for their machine.\n const fetch = mcpHandler(surface, { ...options, allowedOrigins: [] });\n\n let lastError: unknown;\n for (let port = first; port < first + PORTS_TRIED; port++) {\n try {\n const server = serve({ hostname, port, fetch });\n return {\n url: `http://${hostname}:${port}/mcp`,\n surface,\n stop: () => {\n server.stop(true);\n surface.dispose();\n }\n };\n } catch (error) {\n if (!isAddressInUse(error)) {\n surface.dispose();\n throw error;\n }\n lastError = error;\n }\n }\n surface.dispose();\n throw new Error(\n `Ports ${first} to ${first + PORTS_TRIED - 1} are all in use, so agents cannot be served. ` +\n `Pass another port to serveDesktopAgent. (${String(lastError)})`\n );\n}\n\n/** The options Electrobun's `Utils.showMessageBox` takes, as far as this uses them. */\nexport type ShowMessageBox = (options: {\n type?: 'info' | 'warning' | 'error' | 'question';\n title?: string;\n message?: string;\n detail?: string;\n buttons?: string[];\n defaultId?: number;\n cancelId?: number;\n}) => Promise<{ response: number }>;\n\n/**\n * A `confirm` that asks with the operating system's own dialog.\n *\n * Takes Electrobun's `Utils.showMessageBox` rather than importing it,\n * because Electrobun is a toolchain a project is projected into, not a\n * package this one can depend on. The safe answer is the default one:\n * Escape, closing the dialog, and Enter all decline.\n */\nexport function messageBoxConfirm(showMessageBox: ShowMessageBox): (request: AgentConfirmation) => Promise<boolean> {\n return async request => {\n const details = [\n request.description,\n Object.keys(request.arguments).length > 0 ? JSON.stringify(request.arguments, null, 2) : undefined\n ]\n .filter((part): part is string => part !== undefined && part !== '')\n .join('\\n\\n');\n const { response } = await showMessageBox({\n type: request.destructive ? 'warning' : 'question',\n title: 'An AI agent is asking',\n message: `An AI agent wants to ${request.command} in ${request.channel}.${request.destructive ? ' This cannot be undone.' : ''}`,\n detail: details,\n buttons: ['Allow', 'Decline'],\n defaultId: 1,\n cancelId: 1\n });\n return response === 0;\n };\n}\n\nfunction bunServe(): DesktopServe {\n const bun = (globalThis as { Bun?: { serve?: DesktopServe } }).Bun;\n if (typeof bun?.serve !== 'function') {\n throw new Error(\n 'serveDesktopAgent needs Bun.serve, which Cottontail and Bun provide. Pass serve to use another server.'\n );\n }\n return options => bun.serve!(options);\n}\n\nfunction isAddressInUse(error: unknown): boolean {\n const code = (error as { code?: unknown } | null)?.code;\n return code === 'EADDRINUSE' || /in use|EADDRINUSE/i.test(String((error as Error | null)?.message ?? error));\n}\n","/**\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, handle) => {\n * const rpc = BrowserView.defineRPC<GessoWindowRPC>({\n * handlers: { requests: {}, messages: { gessoFrame: receive } }\n * });\n * const window = new BrowserWindow({\n * title: 'Notes',\n * url: withWindowRoute('views://mainview/index.html', handle.route),\n * rpc\n * });\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\nexport { withWindowRoute } from './route';\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 /**\n * The application url the window was asked to open at, or null for\n * wherever the application starts.\n *\n * Set by `openWindow({ route })`, which is what a Cmd-click on an\n * in-app link comes to by default. The window cannot be told it\n * through a frame in time to start there, so `open` puts it on the\n * view's url, with `withWindowRoute`, and the window reads it back\n * with `windowRoute` before its shell starts.\n */\n readonly route: string | null;\n /** Closes the window and disposes the channels it was served. */\n close(): void;\n}\n\n/** What `openWindow` may be told about the window it opens. */\nexport interface DesktopWindowOptions {\n /**\n * The application url to open at, `/epic/BUD-12?story=BUD-13`. Carried\n * to `open` as `window.route`; see `DesktopWindowHandle.route`.\n */\n readonly route?: string;\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 * `window.route` is the application url the window should start at,\n * or null. Put it on the view's url with `withWindowRoute`, and read\n * it in the window with `windowRoute`; an `open` that ignores it\n * opens every window at the application's start, as before.\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 * One of the application's own urls a window asked to have opened\n * somewhere new (a Cmd-click or a Ctrl-click on an in-app link), with\n * the window that asked.\n *\n * Defaults to `app.openWindow({ route: url })`: a new window of this\n * application, at that route, which is what a new tab is in an\n * application whose windows have none. An outbound url never comes\n * here; it is `onOpenUrl`'s, and goes to the person's browser.\n */\n onOpenRoute?: (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 /**\n * Opens a window, serves it every channel, and returns its handle;\n * at `options.route` when one is given.\n */\n openWindow(options?: DesktopWindowOptions): 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(windowOptions: DesktopWindowOptions = {}): 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 route: windowOptions.route ?? null,\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 onOpenRoute: url => {\n if (options.onOpenRoute !== undefined) {\n options.onOpenRoute(url, handle);\n } else if (!disposed) {\n app.openWindow({ route: url });\n }\n }\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\nexport {\n messageBoxConfirm,\n serveDesktopAgent,\n type DesktopAgent,\n type DesktopAgentOptions,\n type DesktopServe,\n type ShowMessageBox\n} from './agent';\n"],"mappings":";;;;;;AAsEA,MAAM,eAAe;AACrB,MAAM,cAAc;AAEpB,SAAgB,kBAAkB,UAAoC,UAA+B,CAAC,GAAiB;CACrH,MAAM,QAAQ,QAAQ,SAAS,SAAS;CACxC,MAAM,WAAW,QAAQ,YAAY;CACrC,MAAM,QAAQ,QAAQ,QAAQ;CAC9B,MAAM,UAAU,aAAa,UAAU,QAAQ,YAAY,KAAA,IAAY,CAAC,IAAI,EAAE,SAAS,QAAQ,QAAQ,CAAC;CAIxG,MAAM,QAAQ,WAAW,SAAS;EAAE,GAAG;EAAS,gBAAgB,CAAC;CAAE,CAAC;CAEpE,IAAI;CACJ,KAAK,IAAI,OAAO,OAAO,OAAO,QAAQ,aAAa,QACjD,IAAI;EACF,MAAM,SAAS,MAAM;GAAE;GAAU;GAAM;EAAM,CAAC;EAC9C,OAAO;GACL,KAAK,UAAU,SAAS,GAAG,KAAK;GAChC;GACA,YAAY;IACV,OAAO,KAAK,IAAI;IAChB,QAAQ,QAAQ;GAClB;EACF;CACF,SAAS,OAAO;EACd,IAAI,CAAC,eAAe,KAAK,GAAG;GAC1B,QAAQ,QAAQ;GAChB,MAAM;EACR;EACA,YAAY;CACd;CAEF,QAAQ,QAAQ;CAChB,MAAM,IAAI,MACR,SAAS,MAAM,MAAM,QAAQ,cAAc,EAAE,wFACC,OAAO,SAAS,EAAE,EAClE;AACF;;;;;;;;;AAqBA,SAAgB,kBAAkB,gBAAkF;CAClH,OAAO,OAAM,YAAW;EACtB,MAAM,UAAU,CACd,QAAQ,aACR,OAAO,KAAK,QAAQ,SAAS,CAAC,CAAC,SAAS,IAAI,KAAK,UAAU,QAAQ,WAAW,MAAM,CAAC,IAAI,KAAA,CAC3F,CAAC,CACE,QAAQ,SAAyB,SAAS,KAAA,KAAa,SAAS,EAAE,CAAC,CACnE,KAAK,MAAM;EACd,MAAM,EAAE,aAAa,MAAM,eAAe;GACxC,MAAM,QAAQ,cAAc,YAAY;GACxC,OAAO;GACP,SAAS,wBAAwB,QAAQ,QAAQ,MAAM,QAAQ,QAAQ,GAAG,QAAQ,cAAc,4BAA4B;GAC5H,QAAQ;GACR,SAAS,CAAC,SAAS,SAAS;GAC5B,WAAW;GACX,UAAU;EACZ,CAAC;EACD,OAAO,aAAa;CACtB;AACF;AAEA,SAAS,WAAyB;CAChC,MAAM,MAAO,WAAkD;CAC/D,IAAI,OAAO,KAAK,UAAU,YACxB,MAAM,IAAI,MACR,wGACF;CAEF,QAAO,YAAW,IAAI,MAAO,OAAO;AACtC;AAEA,SAAS,eAAe,OAAyB;CAE/C,OADc,OAAqC,SACnC,gBAAgB,qBAAqB,KAAK,OAAQ,OAAwB,WAAW,KAAK,CAAC;AAC7G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;ACHA,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;CAEA,MAAM,MAAkB;EACtB,WAAW,gBAAsC,CAAC,GAAwB;GACxE,IAAI,UACF,MAAM,IAAI,MAAM,sEAAsE;GAExF,MAAM,KAAK;GACX,MAAM,SAA8B;IAClC;IACA,OAAO,cAAc,SAAS;IAC9B,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;IACjD,cAAa,QAAO;KAClB,IAAI,QAAQ,gBAAgB,KAAA,GAC1B,QAAQ,YAAY,KAAK,MAAM;UAC1B,IAAI,CAAC,UACV,IAAI,WAAW,EAAE,OAAO,IAAI,CAAC;IAEjC;GACF,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;CACF;CAEA,OAAO;AACT;;;;;;;;;;;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"}
|
|
@@ -1 +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,
|
|
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, a url the application wants opened outside the\n * window, and one of its own routes it wants opened in a new window.\n * 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;AAwBnC,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"}
|
|
@@ -39,8 +39,9 @@ type GessoFrame = {
|
|
|
39
39
|
} |
|
|
40
40
|
/**
|
|
41
41
|
* The adapter's own traffic, which is not a channel: the appearance
|
|
42
|
-
* the platform is in,
|
|
43
|
-
*
|
|
42
|
+
* the platform is in, a url the application wants opened outside the
|
|
43
|
+
* window, and one of its own routes it wants opened in a new window.
|
|
44
|
+
* It carries a name and a serialized payload for
|
|
44
45
|
* the same reason a `data` frame carries a body, and it is a
|
|
45
46
|
* separate kind so that nothing has to reserve a stream number.
|
|
46
47
|
*/
|
|
@@ -82,4 +83,4 @@ declare class FrameAssembler {
|
|
|
82
83
|
}
|
|
83
84
|
//#endregion
|
|
84
85
|
export { isGessoFrame as a, frameData as i, FrameAssembler as n, GessoFrame as r, DEFAULT_CHUNK_BYTES as t };
|
|
85
|
-
//# sourceMappingURL=frames-
|
|
86
|
+
//# sourceMappingURL=frames-Bx7SnqWg.d.ts.map
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as isGessoFrame, i as frameData, n as FrameAssembler, r as GessoFrame, t as DEFAULT_CHUNK_BYTES } from "./frames-
|
|
1
|
+
import { a as isGessoFrame, i as frameData, n as FrameAssembler, r as GessoFrame, t as DEFAULT_CHUNK_BYTES } from "./frames-Bx7SnqWg.js";
|
|
2
2
|
export { DEFAULT_CHUNK_BYTES, FrameAssembler, type GessoFrame, frameData, isGessoFrame };
|
package/dist/main.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as GessoFrame } from "./frames-
|
|
1
|
+
import { r as GessoFrame } from "./frames-Bx7SnqWg.js";
|
|
2
2
|
import { ServedChannel } from "gesso-framework";
|
|
3
3
|
//#region src/main.d.ts
|
|
4
4
|
interface ChannelHostOptions {
|
|
@@ -21,6 +21,16 @@ interface ChannelHostOptions {
|
|
|
21
21
|
* operating system is the application's decision.
|
|
22
22
|
*/
|
|
23
23
|
onOpenUrl?: (url: string) => void;
|
|
24
|
+
/**
|
|
25
|
+
* One of the application's own urls the window asked to have opened
|
|
26
|
+
* in a new window: a Cmd-click on an in-app link, sent by the view
|
|
27
|
+
* bridge's `openRoute`.
|
|
28
|
+
*
|
|
29
|
+
* Not called for the application either. `createDesktopApp` answers
|
|
30
|
+
* it by opening a window at that route; a host built on this alone
|
|
31
|
+
* decides for itself.
|
|
32
|
+
*/
|
|
33
|
+
onOpenRoute?: (url: string) => void;
|
|
24
34
|
}
|
|
25
35
|
interface ChannelHost {
|
|
26
36
|
/** Call from the RPC handler that receives frames from the window. */
|
package/dist/main.js
CHANGED
|
@@ -35,9 +35,9 @@ function serveChannelsToWindow(channels, options) {
|
|
|
35
35
|
},
|
|
36
36
|
receive(frame) {
|
|
37
37
|
if (frame.kind === "control") {
|
|
38
|
-
if (frame.name === "openUrl") {
|
|
38
|
+
if (frame.name === "openUrl" || frame.name === "openRoute") {
|
|
39
39
|
const payload = JSON.parse(frame.body);
|
|
40
|
-
if (typeof payload.url === "string") options.onOpenUrl?.(payload.url);
|
|
40
|
+
if (typeof payload.url === "string") (frame.name === "openUrl" ? options.onOpenUrl : options.onOpenRoute)?.(payload.url);
|
|
41
41
|
}
|
|
42
42
|
return;
|
|
43
43
|
}
|
package/dist/main.js.map
CHANGED
|
@@ -1 +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":";;;;;;;;;;;;;;;;;;;;;;;;
|
|
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 * One of the application's own urls the window asked to have opened\n * in a new window: a Cmd-click on an in-app link, sent by the view\n * bridge's `openRoute`.\n *\n * Not called for the application either. `createDesktopApp` answers\n * it by opening a window at that route; a host built on this alone\n * decides for itself.\n */\n onOpenRoute?: (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' || frame.name === 'openRoute') {\n const payload = JSON.parse(frame.body) as { url?: string };\n if (typeof payload.url === 'string') {\n (frame.name === 'openUrl' ? options.onOpenUrl : options.onOpenRoute)?.(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":";;;;;;;;;;;;;;;;;;;;;;;;AAkEA,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,aAAa,MAAM,SAAS,aAAa;KAC1D,MAAM,UAAU,KAAK,MAAM,MAAM,IAAI;KACrC,IAAI,OAAO,QAAQ,QAAQ,UACzB,CAAC,MAAM,SAAS,YAAY,QAAQ,YAAY,QAAQ,YAAA,GAAe,QAAQ,GAAG;IAEtF;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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/route.ts
|
|
2
|
+
/**
|
|
3
|
+
* How a new window learns the route it opens at.
|
|
4
|
+
*
|
|
5
|
+
* A desktop window keeps its routes in memory (`history: { mode:
|
|
6
|
+
* 'memory' }`), so there is no address the main process could open
|
|
7
|
+
* it at. What it can choose is the url of the page it loads, and the
|
|
8
|
+
* page can read that url before its shell starts. So the route rides
|
|
9
|
+
* on the view's url, in the fragment, and the window starts its
|
|
10
|
+
* memory history there: `withWindowRoute` writes it on the main
|
|
11
|
+
* process's side and `windowRoute` reads it on the window's.
|
|
12
|
+
*
|
|
13
|
+
* The fragment rather than a query, because the fragment never leaves
|
|
14
|
+
* the webview: a `views://` url is served by Electrobun's own scheme
|
|
15
|
+
* handler, and a query string is one more thing that handler would
|
|
16
|
+
* have to agree to ignore when it looks the file up.
|
|
17
|
+
*
|
|
18
|
+
* Read once, at start-up, rather than sent as a frame after the window
|
|
19
|
+
* has spoken. A frame would arrive after the shell had already started
|
|
20
|
+
* at `/`, and the window would draw the root first and then jump.
|
|
21
|
+
*/
|
|
22
|
+
/** The fragment parameter the route is carried in. */
|
|
23
|
+
const ROUTE_PARAM = "gesso-route";
|
|
24
|
+
/**
|
|
25
|
+
* The view's url, carrying `route` for the window to start at.
|
|
26
|
+
*
|
|
27
|
+
* `route` null or absent leaves the url as it was, so a window opened
|
|
28
|
+
* without one loads exactly what it always did. A fragment already on
|
|
29
|
+
* the url is replaced: it is the page's address inside a window that
|
|
30
|
+
* has no address bar, and nothing else reads it.
|
|
31
|
+
*/
|
|
32
|
+
function withWindowRoute(viewUrl, route) {
|
|
33
|
+
if (route === null || route === void 0) return viewUrl;
|
|
34
|
+
const at = viewUrl.indexOf("#");
|
|
35
|
+
return `${at === -1 ? viewUrl : viewUrl.slice(0, at)}#${ROUTE_PARAM}=${encodeURIComponent(route)}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The route this window was opened at, or `/`.
|
|
39
|
+
*
|
|
40
|
+
* Pass it as the shell's starting url:
|
|
41
|
+
*
|
|
42
|
+
* createApp({ history: { mode: 'memory', initialUrl: windowRoute() }, … })
|
|
43
|
+
*
|
|
44
|
+
* `hash` defaults to the page's own; a parameter so that it can be
|
|
45
|
+
* specified without a window.
|
|
46
|
+
*/
|
|
47
|
+
function windowRoute(hash = globalThis.location?.hash ?? "") {
|
|
48
|
+
const route = new URLSearchParams(hash.replace(/^#/, "")).get(ROUTE_PARAM);
|
|
49
|
+
return route === null || route.length === 0 ? "/" : route;
|
|
50
|
+
}
|
|
51
|
+
//#endregion
|
|
52
|
+
export { withWindowRoute as n, windowRoute as t };
|
|
53
|
+
|
|
54
|
+
//# sourceMappingURL=route-B2qU38dY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"route-B2qU38dY.js","names":[],"sources":["../src/route.ts"],"sourcesContent":["/**\n * How a new window learns the route it opens at.\n *\n * A desktop window keeps its routes in memory (`history: { mode:\n * 'memory' }`), so there is no address the main process could open\n * it at. What it can choose is the url of the page it loads, and the\n * page can read that url before its shell starts. So the route rides\n * on the view's url, in the fragment, and the window starts its\n * memory history there: `withWindowRoute` writes it on the main\n * process's side and `windowRoute` reads it on the window's.\n *\n * The fragment rather than a query, because the fragment never leaves\n * the webview: a `views://` url is served by Electrobun's own scheme\n * handler, and a query string is one more thing that handler would\n * have to agree to ignore when it looks the file up.\n *\n * Read once, at start-up, rather than sent as a frame after the window\n * has spoken. A frame would arrive after the shell had already started\n * at `/`, and the window would draw the root first and then jump.\n */\n\n/** The fragment parameter the route is carried in. */\nconst ROUTE_PARAM = 'gesso-route';\n\n/**\n * The view's url, carrying `route` for the window to start at.\n *\n * `route` null or absent leaves the url as it was, so a window opened\n * without one loads exactly what it always did. A fragment already on\n * the url is replaced: it is the page's address inside a window that\n * has no address bar, and nothing else reads it.\n */\nexport function withWindowRoute(viewUrl: string, route: string | null | undefined): string {\n if (route === null || route === undefined) {\n return viewUrl;\n }\n const at = viewUrl.indexOf('#');\n const base = at === -1 ? viewUrl : viewUrl.slice(0, at);\n return `${base}#${ROUTE_PARAM}=${encodeURIComponent(route)}`;\n}\n\n/**\n * The route this window was opened at, or `/`.\n *\n * Pass it as the shell's starting url:\n *\n * createApp({ history: { mode: 'memory', initialUrl: windowRoute() }, … })\n *\n * `hash` defaults to the page's own; a parameter so that it can be\n * specified without a window.\n */\nexport function windowRoute(hash: string = globalThis.location?.hash ?? ''): string {\n const route = new URLSearchParams(hash.replace(/^#/, '')).get(ROUTE_PARAM);\n return route === null || route.length === 0 ? '/' : route;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;AAsBA,MAAM,cAAc;;;;;;;;;AAUpB,SAAgB,gBAAgB,SAAiB,OAA0C;CACzF,IAAI,UAAU,QAAQ,UAAU,KAAA,GAC9B,OAAO;CAET,MAAM,KAAK,QAAQ,QAAQ,GAAG;CAE9B,OAAO,GADM,OAAO,KAAK,UAAU,QAAQ,MAAM,GAAG,EAAE,EACvC,GAAG,YAAY,GAAG,mBAAmB,KAAK;AAC3D;;;;;;;;;;;AAYA,SAAgB,YAAY,OAAe,WAAW,UAAU,QAAQ,IAAY;CAClF,MAAM,QAAQ,IAAI,gBAAgB,KAAK,QAAQ,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,WAAW;CACzE,OAAO,UAAU,QAAQ,MAAM,WAAW,IAAI,MAAM;AACtD"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/route.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* How a new window learns the route it opens at.
|
|
4
|
+
*
|
|
5
|
+
* A desktop window keeps its routes in memory (`history: { mode:
|
|
6
|
+
* 'memory' }`), so there is no address the main process could open
|
|
7
|
+
* it at. What it can choose is the url of the page it loads, and the
|
|
8
|
+
* page can read that url before its shell starts. So the route rides
|
|
9
|
+
* on the view's url, in the fragment, and the window starts its
|
|
10
|
+
* memory history there: `withWindowRoute` writes it on the main
|
|
11
|
+
* process's side and `windowRoute` reads it on the window's.
|
|
12
|
+
*
|
|
13
|
+
* The fragment rather than a query, because the fragment never leaves
|
|
14
|
+
* the webview: a `views://` url is served by Electrobun's own scheme
|
|
15
|
+
* handler, and a query string is one more thing that handler would
|
|
16
|
+
* have to agree to ignore when it looks the file up.
|
|
17
|
+
*
|
|
18
|
+
* Read once, at start-up, rather than sent as a frame after the window
|
|
19
|
+
* has spoken. A frame would arrive after the shell had already started
|
|
20
|
+
* at `/`, and the window would draw the root first and then jump.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* The view's url, carrying `route` for the window to start at.
|
|
24
|
+
*
|
|
25
|
+
* `route` null or absent leaves the url as it was, so a window opened
|
|
26
|
+
* without one loads exactly what it always did. A fragment already on
|
|
27
|
+
* the url is replaced: it is the page's address inside a window that
|
|
28
|
+
* has no address bar, and nothing else reads it.
|
|
29
|
+
*/
|
|
30
|
+
declare function withWindowRoute(viewUrl: string, route: string | null | undefined): string;
|
|
31
|
+
/**
|
|
32
|
+
* The route this window was opened at, or `/`.
|
|
33
|
+
*
|
|
34
|
+
* Pass it as the shell's starting url:
|
|
35
|
+
*
|
|
36
|
+
* createApp({ history: { mode: 'memory', initialUrl: windowRoute() }, … })
|
|
37
|
+
*
|
|
38
|
+
* `hash` defaults to the page's own; a parameter so that it can be
|
|
39
|
+
* specified without a window.
|
|
40
|
+
*/
|
|
41
|
+
declare function windowRoute(hash?: string): string;
|
|
42
|
+
//#endregion
|
|
43
|
+
export { withWindowRoute as n, windowRoute as t };
|
|
44
|
+
//# sourceMappingURL=route-CNeMnj_6.d.ts.map
|
package/dist/view.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { r as GessoFrame } from "./frames-
|
|
1
|
+
import { r as GessoFrame } from "./frames-Bx7SnqWg.js";
|
|
2
|
+
import { t as windowRoute } from "./route-CNeMnj_6.js";
|
|
2
3
|
import { AppLogicEndpoint } from "gesso-framework";
|
|
3
4
|
//#region src/view.d.ts
|
|
4
5
|
interface ElectrobunBridgeOptions {
|
|
@@ -43,10 +44,21 @@ interface ElectrobunBridge {
|
|
|
43
44
|
* application belongs in the person's browser.
|
|
44
45
|
*/
|
|
45
46
|
openUrl(url: string): void;
|
|
47
|
+
/**
|
|
48
|
+
* Asks the main process to open this application at one of its own
|
|
49
|
+
* urls in a new window.
|
|
50
|
+
*
|
|
51
|
+
* Pass it as `onOpenRoute` to the shell. It is what a Cmd-click on an
|
|
52
|
+
* in-app link means in a desktop application: a window has no tabs,
|
|
53
|
+
* and a `window.open` here would open a bare webview the application
|
|
54
|
+
* does not serve. `createDesktopApp` opens the window, at that route,
|
|
55
|
+
* unless the application said otherwise.
|
|
56
|
+
*/
|
|
57
|
+
openRoute(url: string): void;
|
|
46
58
|
/** Closes every stream and stops pumping. */
|
|
47
59
|
dispose(): void;
|
|
48
60
|
}
|
|
49
61
|
declare function createElectrobunBridge(options: ElectrobunBridgeOptions): ElectrobunBridge;
|
|
50
62
|
//#endregion
|
|
51
|
-
export { ElectrobunBridge, ElectrobunBridgeOptions, createElectrobunBridge };
|
|
63
|
+
export { ElectrobunBridge, ElectrobunBridgeOptions, createElectrobunBridge, windowRoute };
|
|
52
64
|
//# sourceMappingURL=view.d.ts.map
|
package/dist/view.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { i as frameData, n as FrameAssembler, r as frameControl } from "./frames-BQisaYy-.js";
|
|
2
|
+
import { t as windowRoute } from "./route-B2qU38dY.js";
|
|
2
3
|
import { isHubMessage, isPortHandshake } from "gesso-framework";
|
|
3
4
|
//#region src/view.ts
|
|
4
5
|
/**
|
|
@@ -62,6 +63,9 @@ function createElectrobunBridge(options) {
|
|
|
62
63
|
openUrl(url) {
|
|
63
64
|
if (!disposed) options.send(frameControl("openUrl", { url }));
|
|
64
65
|
},
|
|
66
|
+
openRoute(url) {
|
|
67
|
+
if (!disposed) options.send(frameControl("openRoute", { url }));
|
|
68
|
+
},
|
|
65
69
|
receive(frame) {
|
|
66
70
|
if (disposed) return;
|
|
67
71
|
if (frame.kind === "control") {
|
|
@@ -103,6 +107,6 @@ function createElectrobunBridge(options) {
|
|
|
103
107
|
};
|
|
104
108
|
}
|
|
105
109
|
//#endregion
|
|
106
|
-
export { createElectrobunBridge };
|
|
110
|
+
export { createElectrobunBridge, windowRoute };
|
|
107
111
|
|
|
108
112
|
//# sourceMappingURL=view.js.map
|
package/dist/view.js.map
CHANGED
|
@@ -1 +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":"
|
|
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 { windowRoute } from './route';\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 /**\n * Asks the main process to open this application at one of its own\n * urls in a new window.\n *\n * Pass it as `onOpenRoute` to the shell. It is what a Cmd-click on an\n * in-app link means in a desktop application: a window has no tabs,\n * and a `window.open` here would open a bare webview the application\n * does not serve. `createDesktopApp` opens the window, at that route,\n * unless the application said otherwise.\n */\n openRoute(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 openRoute(url: string): void {\n if (!disposed) {\n options.send(frameControl('openRoute', { 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":";;;;;;;;;;;;;;;;;;;;AAgFA,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,UAAU,KAAmB;GAC3B,IAAI,CAAC,UACH,QAAQ,KAAK,aAAa,aAAa,EAAE,IAAI,CAAC,CAAC;EAEnD;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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gesso-electrobun",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Run a Gesso application in an Electrobun window, with its stores in the main process.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kevin Baker",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"LICENSE"
|
|
55
55
|
],
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"gesso-framework": "^0.6.
|
|
57
|
+
"gesso-framework": "^0.6.5"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"rxjs": "^7.8.2"
|