create-gesso-app 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,209 @@
1
+ # {{name}}
2
+
3
+ A Gesso application in a native window. The interface is built, laid
4
+ out, painted and hit-tested in a render worker; the window's own main
5
+ thread carries frames between that worker and the main process and does
6
+ nothing else. The state lives in the main process, so every window is a
7
+ replica of one source and two windows agree without anything
8
+ synchronising them.
9
+
10
+ ```
11
+ main process (Cottontail) window 1 window 2
12
+ count: BehaviorSubject shell + worker shell + worker
13
+ Counter channel ------> replica -> screen replica -> screen
14
+ DesktopWindows channel <------ commands commands
15
+ ```
16
+
17
+ ## Getting the toolchain
18
+
19
+ Electrobun 2.x is delivered by a launcher called **Hutch**, not by a
20
+ package manager. `hutch electrobun prepare` projects the SDK into
21
+ `.hutch/devkit` inside this project, which is why there is no
22
+ `electrobun` entry in `package.json` and why adding one would not help:
23
+ the npm package of that name is only a bootstrap that downloads the
24
+ toolchain.
25
+
26
+ If `hutch` is not on your path yet, the bootstrap is what fetches it.
27
+ Any command will do; this one only prints Hutch's help:
28
+
29
+ ```sh
30
+ npx electrobun@2.0.1 --help
31
+ ```
32
+
33
+ That leaves the launcher at
34
+ `~/.hutch/npm/electrobun/2.0.1/<platform>/bin/hutch` (for example
35
+ `linux-x64` or `darwin-arm64`). Put that directory on your path, or call
36
+ the binary by its full path. `HUTCH_HOME` can point somewhere disposable
37
+ to keep the downloaded toolchain out of `~/.hutch`; the launcher then
38
+ lands under that directory instead.
39
+
40
+ ## Running it
41
+
42
+ ```sh
43
+ hutch install # dependencies, including the vendored Gesso packages
44
+ hutch run dev # prepare, build the window's assets, open the window
45
+ ```
46
+
47
+ `hutch install` runs npm underneath, because `hutch.config.ts` says
48
+ `packageManager: 'npm'`: Hutch's own resolver reads a relative `file:`
49
+ override against the package that asked for it rather than against this
50
+ directory, and stops with `FileNotFound` on the second vendored package.
51
+ npm reads `overrides` from here, which is what the tarballs in `vendor/`
52
+ need. You will see a `package-lock.json`, and no `hutch.lock`.
53
+
54
+ `hutch run build` makes a distributable build under `build/stable-*`,
55
+ and `hutch run typecheck` checks the types without building. Every
56
+ script runs `hutch electrobun prepare` first, because both
57
+ `vite.config.ts` and `tsconfig.json` read the SDK out of `.hutch/devkit`
58
+ and neither can be loaded before it exists.
59
+
60
+ The first line of `hutch.config.ts` pins the Hutch that runs these
61
+ scripts to 0.24.3, the one the bootstrap above installs. The comment
62
+ under it says why: the newer launcher fails the distributable build.
63
+ Leave the line first in the file.
64
+
65
+ In a development build, what the window logs to its console is printed
66
+ by the main process, prefixed `[webview:1]`, so `console.log` in a
67
+ component or in `src/view/main.ts` lands in the terminal you ran
68
+ `hutch run dev` from.
69
+
70
+ ### Linux, on a Wayland desktop
71
+
72
+ Electrobun opens its window as an X11 client, so under a Wayland
73
+ compositor it runs through XWayland. On the machine this template was
74
+ checked on (Hyprland, an NVIDIA GPU, WebKitGTK 2.52) the window came up
75
+ as a blank surface: the log said `Failed to create GBM buffer` twice,
76
+ the page had loaded, and nothing was ever composited. Putting
77
+
78
+ ```sh
79
+ WEBKIT_DISABLE_DMABUF_RENDERER=1 hutch run dev
80
+ ```
81
+
82
+ in the environment made it paint. It has to be in the environment of
83
+ the launch; setting it from `src/main/index.ts` is too late, because
84
+ the webview reads it as it starts. Nothing about this is Gesso's, and a
85
+ desktop where the log has no GBM line does not need it.
86
+
87
+ ## What has been checked
88
+
89
+ On 2026-09-16, on Linux x64 with WebKitGTK 2.52.6 and Electrobun 2.0.1,
90
+ a project written by this template was installed by `hutch install`,
91
+ typechecked by `hutch run typecheck`, built by `hutch run build` to
92
+ `build/stable-linux-x64`, and opened by `hutch run start` with the
93
+ variable above set. The window painted in the dark appearance with the
94
+ counter at `0`. A press on **Count** reached the main process, which
95
+ logged the increment, and the window painted `1`. A press on **Dark**
96
+ turned the window light. A press on **New window** opened a second
97
+ window already reading `1`, `2 windows, one source`, and light. The
98
+ presses were dispatched to the canvas from inside the page, because
99
+ that desktop had no way to inject a pointer into an X11 window; the
100
+ path from the canvas inward is the one a real pointer takes, and the
101
+ path from a real pointer to the canvas is the platform's.
102
+
103
+ Nothing has been run on macOS or Windows, so on WKWebView and WebView2
104
+ this project is a build and a first window away from being known to
105
+ work. `hutch run dev` was not used for the check, only `hutch run
106
+ start`, which is the same command without `--watch`.
107
+
108
+ Press **Count** and the number changes. Press **New window** and a
109
+ second window opens already showing the current value, because it
110
+ replicates the same source rather than being handed a copy. Flip
111
+ **Dark** and every open window changes appearance, because the
112
+ appearance is the main process's setting and not the window's, and
113
+ because `App.tsx` binds its theme to what the shell reports.
114
+
115
+ ## What is where
116
+
117
+ | Path | What it is |
118
+ | --------------------------- | ----------------------------------------------------------------------- |
119
+ | `src/shared/Counter.ts` | The channel both processes import. The whole of what they share. |
120
+ | `src/shared/rpc.ts` | The one message a window and the main process exchange. |
121
+ | `src/main/index.ts` | The main process: the state, the windows, and what a link outside does. |
122
+ | `src/view/main.ts` | A window's main thread: build the bridge, mount, and nothing else. |
123
+ | `src/view/render.worker.ts` | The render worker: the root component and the channels it attaches. |
124
+ | `src/render/App.tsx` | The screen. |
125
+
126
+ Three lines carry weight:
127
+
128
+ ```ts
129
+ renderWorker: () => new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' });
130
+ ```
131
+
132
+ That expression has to stay written out. A bundler emits a chunk for a
133
+ worker it can see constructed, and it cannot see through a variable
134
+ holding the URL. `electrobun.config.ts` copies the chunk it emits into
135
+ the bundle, which is how the worker loads from a `views://` url.
136
+
137
+ ```json
138
+ "jsx": "react-jsx",
139
+ "jsxImportSource": "gesso-framework"
140
+ ```
141
+
142
+ Those two lines in `tsconfig.json` are the whole of what makes `<row>`
143
+ and `<text>` work, and `vite.config.ts` repeats them for esbuild. JSX
144
+ here is a spelling rather than a runtime: it compiles onto the element
145
+ factories and produces the identical tree.
146
+
147
+ ## The one rule about the window's main thread
148
+
149
+ A patch goes from the main process into the render worker as a body the
150
+ window's main thread carries and never reads. It multiplexes streams
151
+ and knows nothing about channels, components or patches. Keep it that
152
+ way: the moment that thread has to understand a payload in order to
153
+ route it, work that belongs in a worker has moved onto the thread that
154
+ draws, and a frame can be late because of it.
155
+
156
+ ## Why the main process is not pre-bundled
157
+
158
+ `electrobun.config.ts` points Cottontail at `src/main/index.ts` and lets
159
+ Electrobun bundle it, which is the ordinary arrangement. Gesso's own
160
+ Electrobun applications do not do this: they pre-bundle their main
161
+ process with esbuild first, because they import the framework out of a
162
+ workspace rather than out of `node_modules`, and a bundler cannot
163
+ resolve a package that was never installed. Here the packages are
164
+ installed, in `vendor/`, so the ordinary arrangement is the right one.
165
+
166
+ If a future toolchain ever fails to resolve `gesso-electrobun` from the
167
+ main process, that is the escape hatch: bundle `src/main/index.ts` to a
168
+ plain `.js` file with `electrobun/main` left external, and point the
169
+ `cottontail.entrypoint` at the bundle instead.
170
+
171
+ ## Why `vendor/` exists, and how to remove it
172
+
173
+ Gesso is not published to a registry yet. A `package.json` naming a
174
+ version of `gesso-core` would produce a project that cannot install, so
175
+ `create-gesso-app` packed the packages out of its own workspace, put the
176
+ tarballs in `vendor/` and pointed `dependencies` and `overrides` at
177
+ them:
178
+
179
+ ```json
180
+ "gesso-core": "file:vendor/gesso-core-0.1.0.tgz"
181
+ ```
182
+
183
+ `overrides` is there because the packages declare each other by version
184
+ range, and without it an installer is free to go looking for
185
+ `gesso-core@^0.1.0` on a registry that has never heard of it.
186
+
187
+ To pick up a change made in the Gesso workspace, run `create-gesso-app`
188
+ again over this directory with `--force`, or repack by hand:
189
+
190
+ ```sh
191
+ cd path/to/gesso && pnpm --filter './packages/*' build
192
+ cd packages/core && pnpm pack --pack-destination path/to/this/project/vendor
193
+ ```
194
+
195
+ When the packages are published this all goes away: delete `vendor/`,
196
+ delete `overrides`, and put version ranges back in `dependencies`.
197
+
198
+ ## Where to go next
199
+
200
+ - `src/render/App.tsx` is commented with what each part of it is doing.
201
+ - A channel is the barrier: view keys out, typed commands in, plain
202
+ data only. Add a key to `CounterView`, serve it in `src/main`, read it
203
+ in a component, and nothing in between has to change.
204
+ - A command crossing to the main process costs about a frame and a
205
+ half, because every message is serialised, encrypted and sent over a
206
+ socket. Patches are one way and pipelined, so they do not pay it;
207
+ anything that waits for an answer does.
208
+ - `gesso-components` has the controls: inputs, overlays, structure,
209
+ data and media. `Switch` in `App.tsx` is one of them.
@@ -0,0 +1,10 @@
1
+ node_modules
2
+ dist
3
+ build
4
+ .cottontail-tmp
5
+ *.local
6
+ .DS_Store
7
+
8
+ # Written by `hutch electrobun prepare`, and rewritten whenever the
9
+ # Electrobun version in hutch.config.ts changes.
10
+ .hutch
@@ -0,0 +1,49 @@
1
+ import type { ElectrobunConfig } from 'electrobun';
2
+
3
+ /**
4
+ * What Electrobun builds, and where it looks for it.
5
+ *
6
+ * `electrobun` resolves through `.hutch/devkit`, which
7
+ * `hutch electrobun prepare` writes. There is no `electrobun` package
8
+ * in `node_modules` and there is not meant to be: the SDK is projected
9
+ * into the project by the toolchain rather than installed from a
10
+ * registry.
11
+ */
12
+ export default {
13
+ app: {
14
+ name: '{{name}}',
15
+ // Reverse DNS, and yours rather than this one. The operating
16
+ // system uses it to tell one application from another, so two
17
+ // applications sharing an identifier share a great deal else.
18
+ identifier: 'com.example.{{name}}',
19
+ version: '0.0.1'
20
+ },
21
+ build: {
22
+ mainProcess: 'cottontail',
23
+ cottontail: {
24
+ // A TypeScript entrypoint, which Electrobun bundles for itself.
25
+ // It reaches `gesso-electrobun` through `node_modules` like any
26
+ // other dependency, which is why this project vendors the
27
+ // packages rather than linking them: the Gesso repository's own
28
+ // Electrobun applications have to pre-bundle their main process
29
+ // with esbuild, because a workspace package is not installed
30
+ // anywhere the bundler can see it.
31
+ entrypoint: 'src/main/index.ts'
32
+ },
33
+ copy: {
34
+ // What Vite wrote, put where a `views://` url will look for it.
35
+ // The render worker is one of the chunks under `assets`, so the
36
+ // second line is what lets a window start a worker at all.
37
+ 'dist/index.html': 'views/mainview/index.html',
38
+ 'dist/assets': 'views/mainview/assets'
39
+ },
40
+ watchIgnore: ['dist/**'],
41
+ // Gesso paints through a 2D canvas, so the webview each platform
42
+ // already ships is enough. Bundling CEF would put Chromium, and
43
+ // therefore WebGPU, in the application on every platform, at a
44
+ // considerable size.
45
+ mac: { bundleCEF: false },
46
+ linux: { bundleCEF: false },
47
+ win: { bundleCEF: false }
48
+ }
49
+ } satisfies ElectrobunConfig;
@@ -0,0 +1,48 @@
1
+ // @hutch cli=0.24.3
2
+ /**
3
+ * What `hutch run <script>` does, and which Electrobun to fetch.
4
+ *
5
+ * Every script begins with `hutch electrobun prepare`, because that is
6
+ * the step that writes `.hutch/devkit`, and both `vite.config.ts` and
7
+ * `tsconfig.json` read the SDK from there. Electrobun is a toolchain
8
+ * Hutch downloads rather than a package a registry serves, so a
9
+ * project that skipped this would have nothing to build against.
10
+ *
11
+ * The first line of this file is a pragma Hutch reads, not a comment
12
+ * for you, and it has to stay the first line. It pins the Hutch that
13
+ * runs these scripts to the one the Electrobun 2.0.1 bootstrap installs
14
+ * and the one this template was checked with. Without it `hutch run`
15
+ * fetches the newest launcher, and on 2026-09-15 and again on
16
+ * 2026-09-16 that launcher (0.26.0) failed this project's distributable
17
+ * build: it expected a `cottontail-core` binary in the Cottontail
18
+ * release Electrobun 2.0.1 bundles into an application (0.5.0), which
19
+ * does not ship one, and stopped with `CopySourceMissing`. The dev
20
+ * build was unaffected. Move the pin and `electrobun.version` together,
21
+ * and check `hutch run build` when you do.
22
+ */
23
+ export default {
24
+ // npm installs this project, not Hutch's own resolver. The vendored
25
+ // Gesso packages ask each other for version ranges no registry can
26
+ // answer, and `overrides` in package.json is what points those
27
+ // requests at the tarballs in vendor/. Hutch's built-in resolver
28
+ // resolves a relative `file:` override against the package that asked
29
+ // rather than against this directory, and fails with `FileNotFound`;
30
+ // npm resolves it from here. When the packages are published and
31
+ // vendor/ goes, this line can go with it.
32
+ packageManager: 'npm',
33
+ scripts: {
34
+ install: ['hutch', 'install'],
35
+ start: 'hutch electrobun prepare && hutch pm exec -- vite build && hutch electrobun dev',
36
+ dev: 'hutch electrobun prepare && hutch pm exec -- vite build && hutch electrobun dev --watch',
37
+ // The window's assets on Vite's dev server, and the application
38
+ // around them, so a change to a component reloads the webview
39
+ // without rebuilding the native side.
40
+ 'dev:hmr': ['hutch', 'pm', 'exec', '--', 'concurrently', 'hutch run hmr', 'hutch run start'],
41
+ hmr: 'hutch electrobun prepare && hutch pm exec -- vite --port 5173',
42
+ build: 'hutch electrobun prepare && hutch pm exec -- vite build && hutch electrobun build --env=stable',
43
+ typecheck: 'hutch electrobun prepare && hutch pm exec -- tsc --noEmit'
44
+ },
45
+ electrobun: {
46
+ version: '2.0.1'
47
+ }
48
+ };
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "gesso-electrobun-app",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "A Gesso application in a native Electrobun window.",
7
+ "dependencies": {
8
+ "gesso-components": "^0.1.0",
9
+ "gesso-core": "^0.1.0",
10
+ "gesso-electrobun": "^0.1.0",
11
+ "gesso-framework": "^0.1.0",
12
+ "rxjs": "^7.8.2"
13
+ },
14
+ "devDependencies": {
15
+ "@types/bun": "latest",
16
+ "concurrently": "^9.1.0",
17
+ "typescript": "^5.7.2",
18
+ "vite": "^6.0.3"
19
+ }
20
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The main process: the application's state, and the windows that
3
+ * replicate it.
4
+ *
5
+ * Nothing about a component, a layout or a pixel appears in this file,
6
+ * and nothing about a window appears in the render worker. What the
7
+ * two sides share is the channel token in `src/shared/Counter.ts`.
8
+ *
9
+ * A second window is free: `createDesktopApp` serves the same
10
+ * observables to every window it opens, each with its own record of
11
+ * what that window has already seen, so two windows agree by
12
+ * construction rather than by synchronising.
13
+ */
14
+ import { BrowserView, BrowserWindow, Utils } from 'electrobun/main';
15
+ import { BehaviorSubject, map } from 'rxjs';
16
+
17
+ import type { GessoFrame } from 'gesso-electrobun';
18
+ import { createDesktopApp, windowsChannel } from 'gesso-electrobun/desktop';
19
+
20
+ import { Counter } from '../shared/Counter';
21
+ import type { GessoWindowRPC } from '../shared/rpc';
22
+
23
+ /** The application's whole state, in the process that owns it. */
24
+ const count = new BehaviorSubject(0);
25
+
26
+ /**
27
+ * The appearance every window is told about.
28
+ *
29
+ * The application owns it because Electrobun has no appearance API,
30
+ * and because the webview's own `prefers-color-scheme` is wrong on
31
+ * WebKitGTK. On a platform that has a signal this is where it would be
32
+ * fed from; here it is a setting, which is a perfectly good source.
33
+ */
34
+ const dark = new BehaviorSubject(true);
35
+
36
+ const app = createDesktopApp({
37
+ channels: window => [
38
+ {
39
+ token: Counter,
40
+ source: {
41
+ view: { count, dark },
42
+ commands: {
43
+ increment: (by: number) => count.next(count.value + by),
44
+ setDark: (next: boolean) => dark.next(next)
45
+ }
46
+ }
47
+ },
48
+ // What a window opens another window through. Without it a screen
49
+ // would have to import this adapter to do it.
50
+ windowsChannel(app, window)
51
+ ],
52
+ open: (receive, handle) => {
53
+ const rpc = BrowserView.defineRPC<GessoWindowRPC>({
54
+ maxRequestTime: 30_000,
55
+ handlers: {
56
+ requests: {},
57
+ // Wired before the window exists, because the first handshake
58
+ // arrives as soon as the page loads and a lost handshake is a
59
+ // window that never replicates anything.
60
+ messages: { gessoFrame: (frame: GessoFrame) => receive(frame) }
61
+ }
62
+ });
63
+ const window = new BrowserWindow({
64
+ title: '{{name}}',
65
+ url: 'views://mainview/index.html',
66
+ frame: { width: 460, height: 360, x: 100 + handle.id * 60, y: 120 + handle.id * 40 },
67
+ rpc
68
+ });
69
+ return {
70
+ send: (frame: GessoFrame) => window.webview.rpc?.send.gessoFrame(frame),
71
+ close: () => window.close()
72
+ };
73
+ },
74
+ colorScheme: dark.pipe(map(on => (on ? 'dark' : 'light'))),
75
+ onOpenUrl: url => {
76
+ // The application decides what it is willing to hand to the
77
+ // operating system; the adapter only carries the request.
78
+ if (url.startsWith('https://')) {
79
+ Utils.openExternal(url);
80
+ }
81
+ },
82
+ onLastWindowClosed: () => {
83
+ // A desktop application usually stops here. One with a tray or a
84
+ // menu bar would not, which is why this is a callback and not an
85
+ // exit the adapter takes on its own.
86
+ process.exit(0);
87
+ }
88
+ });
89
+
90
+ app.openWindow();
@@ -0,0 +1,100 @@
1
+ import { map } from 'rxjs/operators';
2
+
3
+ import { Switch } from 'gesso-components';
4
+ import { darkTheme, lightTheme, percent } from 'gesso-core';
5
+ import { DesktopWindows } from 'gesso-electrobun/desktop';
6
+ import { ShellService, type ComponentContext, type Inputs } from 'gesso-framework';
7
+
8
+ import { Counter } from '../shared/Counter';
9
+
10
+ /**
11
+ * The screen, and it is an ordinary Gesso component.
12
+ *
13
+ * Nothing here knows it is in a native window. `ctx.channel` gives a
14
+ * replica of something the main process serves: `view` is a set of
15
+ * observables that arrive as the state changes, and `send` is the
16
+ * typed commands going the other way. Behind them is a process
17
+ * boundary, and the only reason to know that is latency, because a
18
+ * command crossing it costs about a frame and a half.
19
+ *
20
+ * Three things are worth knowing before you change them.
21
+ *
22
+ * - The component body runs once. Nothing re-runs when the count
23
+ * changes: `text` is bound to an observable, so one property on
24
+ * one node is written and the next frame is drawn from it.
25
+ * - No colour here is a hex value. `background`, `primary` and
26
+ * `textMuted` are names looked up on whichever theme the node
27
+ * inherits, and the root below provides that theme from the
28
+ * appearance the shell reports. The framework carries the
29
+ * appearance and has no opinion about what dark looks like, so
30
+ * without the `theme` line the switch would replicate a value that
31
+ * changed nothing on screen. `lightTheme` and `darkTheme` are a
32
+ * starting point; a palette of your own answers the same names.
33
+ * - The elements are lowercase because they are intrinsic, resolved
34
+ * by `jsxImportSource` in `tsconfig.json` the way `<div>` needs no
35
+ * import in React. `Switch` is capitalised because it is a
36
+ * component from `gesso-components`, imported like any value.
37
+ */
38
+ export function App(_inputs: Inputs<{}>, ctx: ComponentContext) {
39
+ const counter = ctx.channel(Counter);
40
+ const windows = ctx.channel(DesktopWindows);
41
+ // The scheme is what the main process told this window (see
42
+ // `src/view/main.ts`), so every window is in the same appearance.
43
+ const theme = ctx.inject(ShellService).colorScheme.pipe(map(scheme => (scheme === 'dark' ? darkTheme : lightTheme)));
44
+
45
+ return (
46
+ <column
47
+ theme={theme}
48
+ textStyle={theme.pipe(map(value => value.typography.body))}
49
+ gap={16}
50
+ x="center"
51
+ y="center"
52
+ width={percent(100)}
53
+ height={percent(100)}
54
+ backgroundColor="background">
55
+ <text text={counter.view.count.pipe(map(String))} fontSize={56} />
56
+
57
+ <row gap={10} y="center">
58
+ <button
59
+ label="Count"
60
+ onClick={() => counter.send.increment(1)}
61
+ padding={10}
62
+ borderRadius={8}
63
+ backgroundColor="primary"
64
+ cursor="pointer">
65
+ <text text="Count" color="background" fontSize={14} />
66
+ </button>
67
+
68
+ {/*
69
+ A window opening a window, over a channel rather than an
70
+ import: the screen asks for one and the main process makes
71
+ it. The new window shows the current count immediately,
72
+ because it replicates the same source rather than being
73
+ handed a copy.
74
+ */}
75
+ <button
76
+ label="New window"
77
+ onClick={() => windows.send.open()}
78
+ padding={10}
79
+ borderRadius={8}
80
+ backgroundColor="surface"
81
+ cursor="pointer">
82
+ <text text="New window" fontSize={14} />
83
+ </button>
84
+ </row>
85
+
86
+ <text
87
+ text={windows.view.count.pipe(map(open => (open === 1 ? 'One window' : `${open} windows, one source`)))}
88
+ fontSize={12}
89
+ color="textMuted"
90
+ />
91
+
92
+ {/*
93
+ The appearance is the main process's, not this window's, so
94
+ flipping it here changes every window that is open. That is the
95
+ desktop shape of `setColorScheme`: one setting, replicated.
96
+ */}
97
+ <Switch label="Dark" checked={counter.view.dark} onChange={next => counter.send.setDark(next)} />
98
+ </column>
99
+ );
100
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The channel the main process serves and every window replicates.
3
+ *
4
+ * Declared once and imported by both sides, which is the whole of what
5
+ * they share: a token, the keys a window can see, the commands it can
6
+ * send, and no framework type above it. Nothing in here is anything
7
+ * but plain data, because plain data is all that crosses a channel.
8
+ */
9
+ import { channel } from 'gesso-framework';
10
+
11
+ export interface CounterView {
12
+ /** The application's whole state, and it lives in the main process. */
13
+ count: number;
14
+ /** Which appearance every window is in. One setting, all windows. */
15
+ dark: boolean;
16
+ }
17
+
18
+ export interface CounterCommands {
19
+ increment: (by: number) => void;
20
+ setDark: (dark: boolean) => void;
21
+ }
22
+
23
+ /**
24
+ * The name is the application's own, and both sides have to agree on
25
+ * it. The second argument is what a window shows before the main
26
+ * process has answered, which on a desktop is a few milliseconds.
27
+ */
28
+ export const Counter = channel<CounterView, CounterCommands>('counter', { count: 0, dark: true });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one message a window and the main process exchange.
3
+ *
4
+ * Every channel, every patch and every command is carried inside
5
+ * `gessoFrame`, so this type does not grow as the application does. It
6
+ * is a `message` rather than a `request` on purpose: a patch expects no
7
+ * answer and a command expects no answer, and a request would put a
8
+ * timeout on a stream that runs for the life of the window.
9
+ */
10
+ import type { GessoFrame } from 'gesso-electrobun';
11
+ import type { RPCSchema } from 'electrobun/view';
12
+
13
+ export type GessoWindowRPC = {
14
+ bun: RPCSchema<{
15
+ requests: Record<string, never>;
16
+ messages: { gessoFrame: GessoFrame };
17
+ }>;
18
+ webview: RPCSchema<{
19
+ requests: Record<string, never>;
20
+ messages: { gessoFrame: GessoFrame };
21
+ }>;
22
+ };
@@ -0,0 +1,31 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{{name}}</title>
7
+ <style>
8
+ /*
9
+ The canvas is sized to its host, so the host needs a size. This
10
+ is the whole of the CSS a Gesso window needs; everything else
11
+ in it is painted, not styled. The background is here so that
12
+ the moment before the first frame is not a white flash on a
13
+ dark desktop.
14
+ */
15
+ html,
16
+ body {
17
+ margin: 0;
18
+ height: 100%;
19
+ background: #14161a;
20
+ }
21
+ #app {
22
+ width: 100vw;
23
+ height: 100vh;
24
+ }
25
+ </style>
26
+ </head>
27
+ <body>
28
+ <div id="app"></div>
29
+ <script type="module" src="./main.ts"></script>
30
+ </body>
31
+ </html>
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The window's main thread, and its whole job.
3
+ *
4
+ * It builds the bridge to the main process, hands it to the shell as
5
+ * the application layer, and mounts. Every patch goes from the main
6
+ * process straight into the render worker as a body this thread
7
+ * carries and never reads, which is what keeps a webview's main thread
8
+ * a transport rather than a router.
9
+ */
10
+ import type { GessoFrame } from 'gesso-electrobun';
11
+ import { createElectrobunBridge } from 'gesso-electrobun/view';
12
+ import { createApp } from 'gesso-framework';
13
+ import { Electroview } from 'electrobun/view';
14
+
15
+ import type { GessoWindowRPC } from '../shared/rpc';
16
+
17
+ const bridge = createElectrobunBridge({
18
+ send: frame => view.rpc?.send.gessoFrame(frame),
19
+ // The main process is the only side that knows, so the shell is told
20
+ // rather than left to ask `matchMedia`, which lies in this webview.
21
+ onColorScheme: scheme => shell.setColorScheme(scheme)
22
+ });
23
+
24
+ const rpc = Electroview.defineRPC<GessoWindowRPC>({
25
+ maxRequestTime: 30_000,
26
+ handlers: {
27
+ requests: {},
28
+ messages: { gessoFrame: (frame: GessoFrame) => bridge.receive(frame) }
29
+ }
30
+ });
31
+ const view = new Electroview({ rpc });
32
+
33
+ const host = document.getElementById('app');
34
+ if (host === null) {
35
+ throw new Error('index.html has no #app element to mount into.');
36
+ }
37
+
38
+ const shell = createApp({
39
+ // Written out literally, and it has to be: a bundler emits a chunk
40
+ // for a worker it can see constructed, and cannot see through a
41
+ // variable holding the URL.
42
+ renderWorker: () => new Worker(new URL('./render.worker.ts', import.meta.url), { type: 'module' }),
43
+ // The application layer is in another process, so this is the bridge
44
+ // rather than a data worker. The render worker cannot tell.
45
+ appLogicWorker: bridge.endpoint,
46
+ // A window has no address bar, so its routes are its own.
47
+ history: { mode: 'memory' },
48
+ // A link in a desktop application belongs in the person's browser,
49
+ // which only the process outside this window can reach.
50
+ onOpenUrl: url => bridge.openUrl(url),
51
+ onError: (message, stack, source) => console.error(`[gesso ${source}] ${message}`, stack)
52
+ });
53
+ shell.mount(host);