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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/create-gesso-app.mjs +400 -0
- package/dist/create-gesso-app.mjs.map +1 -0
- package/package.json +47 -0
- package/templates/electrobun/README.md +209 -0
- package/templates/electrobun/_gitignore +10 -0
- package/templates/electrobun/electrobun.config.ts +49 -0
- package/templates/electrobun/hutch.config.ts +48 -0
- package/templates/electrobun/package.json +20 -0
- package/templates/electrobun/src/main/index.ts +90 -0
- package/templates/electrobun/src/render/App.tsx +100 -0
- package/templates/electrobun/src/shared/Counter.ts +28 -0
- package/templates/electrobun/src/shared/rpc.ts +22 -0
- package/templates/electrobun/src/view/index.html +31 -0
- package/templates/electrobun/src/view/main.ts +53 -0
- package/templates/electrobun/src/view/render.worker.ts +15 -0
- package/templates/electrobun/tsconfig.json +28 -0
- package/templates/electrobun/vite.config.ts +32 -0
- package/templates/web/README.md +88 -0
- package/templates/web/_gitignore +4 -0
- package/templates/web/index.html +28 -0
- package/templates/web/package.json +24 -0
- package/templates/web/src/App.tsx +63 -0
- package/templates/web/src/main.ts +31 -0
- package/templates/web/src/worker.ts +12 -0
- package/templates/web/tsconfig.json +20 -0
- package/templates/web/vite.config.ts +23 -0
|
@@ -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,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);
|