create-bridgething 0.12.1 → 0.15.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/README.md +10 -7
- package/dist/index.mjs +10 -2
- package/package.json +17 -3
- package/template/_claude/skills/bridgething/SKILL.md +5 -4
- package/template/_claude/skills/bridgething/reference/develop.md +12 -3
- package/template/_claude/skills/bridgething/reference/sdk.md +295 -8
- package/template/_claude/skills/bridgething/reference/ship.md +20 -0
- package/template/package.json +2 -2
- package/template/scripts/bridgething.ts +10 -7
- package/template/scripts/push.ts +3 -3
- package/template/settings/main.tsx +29 -72
- package/template/settings/style.css +30 -48
- package/template/vite.config.ts +2 -5
- package/template/vite.settings.config.ts +4 -3
- package/template-overlay/_claude_append.md +18 -8
- package/template-overlay/overlay/main.tsx +3 -4
- package/template-overlay/vite.overlay.config.ts +3 -3
- package/template-source/CLAUDE.md +10 -7
- package/template-source/README.md +3 -3
- package/template-source/package.json +1 -0
- package/template-versions.json +4 -4
package/README.md
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
# create-bridgething
|
|
2
2
|
|
|
3
|
-
Scaffold a [bridgething](https://github.com/JoeyEamigh/bridgething)
|
|
4
|
-
the Spotify Car Thing: React, Vite, Tailwind v4,
|
|
5
|
-
[`@bridgething/client`](https://www.npmjs.com/package/@bridgething/client)
|
|
3
|
+
Scaffold a [bridgething](https://github.com/JoeyEamigh/bridgething) app source
|
|
4
|
+
for the Spotify Car Thing: a GitHub repo of webapps (React, Vite, Tailwind v4,
|
|
5
|
+
[`@bridgething/client`](https://www.npmjs.com/package/@bridgething/client)) that
|
|
6
|
+
publishes a store catalog to GitHub Pages.
|
|
6
7
|
|
|
7
8
|
```sh
|
|
8
|
-
bun create bridgething my-
|
|
9
|
+
bun create bridgething my-apps
|
|
9
10
|
```
|
|
10
11
|
|
|
12
|
+
Run it again inside the source to add another app.
|
|
13
|
+
|
|
11
14
|
| flag | what you get |
|
|
12
15
|
| -------------- | --------------------------------------------- |
|
|
13
16
|
| `--launcher` | A replacement home screen |
|
|
@@ -18,9 +21,9 @@ bun create bridgething my-app
|
|
|
18
21
|
|
|
19
22
|
`--extension` combines with `--launcher`, `--overlay`, or a plain webapp.
|
|
20
23
|
|
|
21
|
-
The
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
The source ships `bun run dev`, `push` (install on a connected Car Thing),
|
|
25
|
+
`share` (zip to hand to someone directly), `bump`, `check`, and `submit` (list
|
|
26
|
+
the source in the bridgething store). Pushing to main publishes the catalog.
|
|
24
27
|
|
|
25
28
|
- Docs: <https://bridgething.com/docs>
|
|
26
29
|
- Source: <https://github.com/JoeyEamigh/bridgething>
|
package/dist/index.mjs
CHANGED
|
@@ -133,6 +133,7 @@ const TEMPLATE_DIR = join(PACKAGE_ROOT, "template");
|
|
|
133
133
|
const SOURCE_TEMPLATE_DIR = join(PACKAGE_ROOT, "template-source");
|
|
134
134
|
const SKILL_IN_APP = join(".claude", "skills", "bridgething");
|
|
135
135
|
const SDK_VERSIONS = readJson(join(PACKAGE_ROOT, "template-versions.json"));
|
|
136
|
+
const OWN = readJson(join(PACKAGE_ROOT, "package.json"));
|
|
136
137
|
const EXTENSION = {
|
|
137
138
|
dir: "template-extension",
|
|
138
139
|
manifest: {
|
|
@@ -366,7 +367,14 @@ function scaffoldApp(root, spec) {
|
|
|
366
367
|
function linkAgentGuides(root, appDir) {
|
|
367
368
|
const rootSkill = join(root, SKILL_IN_APP);
|
|
368
369
|
const appSkill = join(appDir, SKILL_IN_APP);
|
|
369
|
-
if (!existsSync(rootSkill))
|
|
370
|
+
if (!existsSync(rootSkill)) {
|
|
371
|
+
move(appSkill, rootSkill);
|
|
372
|
+
writeJson(join(rootSkill, ".source.json"), {
|
|
373
|
+
package: OWN.name,
|
|
374
|
+
version: OWN.version,
|
|
375
|
+
fetched_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
376
|
+
});
|
|
377
|
+
}
|
|
370
378
|
linkOrCopy(relative(dirname(appSkill), rootSkill), appSkill, rootSkill);
|
|
371
379
|
for (const link of [join(root, "AGENTS.md"), join(appDir, "AGENTS.md")]) {
|
|
372
380
|
if (existsSync(link) || !existsSync(join(dirname(link), "CLAUDE.md"))) continue;
|
|
@@ -479,7 +487,7 @@ Done. ${target}
|
|
|
479
487
|
Fill in the description in apps/${details.app}/public/manifest.json.
|
|
480
488
|
|
|
481
489
|
Push to ${repoUrl}, then in Settings > Pages set the branch to gh-pages, folder / (root).
|
|
482
|
-
Your catalog ends up at
|
|
490
|
+
Your catalog ends up at ${base}/catalog.v1.json. Once it loads, "bun run submit" lists it in the bridgething store.
|
|
483
491
|
|
|
484
492
|
bun run new <slug> add another app to this source
|
|
485
493
|
bun run bump ${details.app} [version]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-bridgething",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a bridgething app source: webapps for the Spotify Car Thing, published as a catalog.v1 on GitHub Pages.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -46,13 +46,27 @@
|
|
|
46
46
|
"test": "bun test"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
|
-
"@bridgething/
|
|
49
|
+
"@bridgething/client": "0.14.0",
|
|
50
|
+
"@bridgething/extension": "0.14.0",
|
|
50
51
|
"@msgpack/msgpack": "^3.1.3",
|
|
52
|
+
"@preact/preset-vite": "^2.10.6",
|
|
53
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
51
54
|
"@types/bun": "^1.4.0",
|
|
52
55
|
"@types/node": "^26.1.1",
|
|
56
|
+
"@types/react": "^19.3.0",
|
|
57
|
+
"@types/react-dom": "^19.3.0",
|
|
53
58
|
"@types/uuid": "^11.0.0",
|
|
59
|
+
"@vitejs/plugin-react": "^6.1.2",
|
|
54
60
|
"esbuild": "^0.28.2",
|
|
55
|
-
"
|
|
61
|
+
"fflate": "^0.8.3",
|
|
62
|
+
"happy-dom": "^21.0.3",
|
|
63
|
+
"preact": "^11.0.1",
|
|
64
|
+
"react": "^19.2.3",
|
|
65
|
+
"react-dom": "^19.2.3",
|
|
66
|
+
"tailwindcss": "^4.3.3",
|
|
67
|
+
"tsdown": "^0.21.10",
|
|
68
|
+
"vite": "^8.3.4",
|
|
69
|
+
"vite-plugin-singlefile": "^2.3.4"
|
|
56
70
|
},
|
|
57
71
|
"peerDependencies": {
|
|
58
72
|
"typescript": "^6.0.3"
|
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: bridgething
|
|
3
|
-
description: Build, run, and ship a bridgething webapp for the Spotify Car Thing. Use for @bridgething/client calls (now-playing, transport, artwork, storage, user settings, networking, every surface), running the dev server and driving the 800x480 screen and its physical controls, pushing the built app to a device
|
|
3
|
+
description: Build, run, and ship a bridgething webapp for the Spotify Car Thing. Use for @bridgething/client calls (now-playing, transport, artwork, storage, user settings, networking, every surface), running the dev server and driving the 800x480 screen and its physical controls, pushing the built app to a device, packaging a shareable zip, or publishing and listing it in the bridgething store, and writing the desktop-side Deno extension a webapp can ship.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Building a bridgething webapp
|
|
7
7
|
|
|
8
8
|
- Calling the daemon, from now-playing to networking, plus every surface the SDK
|
|
9
|
-
ships:
|
|
9
|
+
ships, the companion settings page, its config form, and files the user picks:
|
|
10
|
+
[reference/sdk.md](reference/sdk.md)
|
|
10
11
|
- Running the app, screenshotting the 800x480 screen, and pressing the physical
|
|
11
12
|
controls locally and over CDP on the device:
|
|
12
13
|
[reference/develop.md](reference/develop.md)
|
|
13
|
-
- Installing onto a connected Car Thing, packaging a zip,
|
|
14
|
-
device: [reference/ship.md](reference/ship.md)
|
|
14
|
+
- Installing onto a connected Car Thing, packaging a zip, publishing to the
|
|
15
|
+
store, and updating the device: [reference/ship.md](reference/ship.md)
|
|
15
16
|
- The desktop-side Deno extension, its `ctx` contract, and its permission model,
|
|
16
17
|
when the project has an `extension/` directory:
|
|
17
18
|
[reference/extension.md](reference/extension.md)
|
|
@@ -13,6 +13,11 @@ than fixtures. `SUPERBIRD_HOST=bridgething-<serial>.local` targets another
|
|
|
13
13
|
device, and `BRIDGETHING_DAEMON_URL=ws://host:8891` targets a daemon anywhere
|
|
14
14
|
else.
|
|
15
15
|
|
|
16
|
+
Blob urls from `client.blob` (`/.bridgething/blob/...`) go through the dev
|
|
17
|
+
server to that same daemon. The daemon serves blobs for its active webapp only,
|
|
18
|
+
so they load once this app is the active one on the device. `bun run dev:device`
|
|
19
|
+
and `bun run push` both make it active.
|
|
20
|
+
|
|
16
21
|
## Show it on the Car Thing with hot reload
|
|
17
22
|
|
|
18
23
|
```bash
|
|
@@ -55,6 +60,7 @@ await page.keyboard.press('1'); // preset 1 (also '2' '3' '4')
|
|
|
55
60
|
await page.keyboard.press('m'); // Mode button
|
|
56
61
|
await page.keyboard.press('Escape'); // Back button
|
|
57
62
|
await page.mouse.wheel(120, 0); // rotary wheel: HORIZONTAL scroll (deltaX)
|
|
63
|
+
await page.keyboard.press('Enter'); // wheel click
|
|
58
64
|
// touch: page.tap('selector') or page.mouse.click(x, y)
|
|
59
65
|
```
|
|
60
66
|
|
|
@@ -64,8 +70,8 @@ Loop: edit code, let vite hot-reload, screenshot, adjust. Keep the viewport at
|
|
|
64
70
|
## Your own browser and DevTools
|
|
65
71
|
|
|
66
72
|
Open `http://localhost:5173/`, then set a custom device of 800 x 480 in DevTools'
|
|
67
|
-
device toolbar. Type `1` through `4`, `m`,
|
|
68
|
-
scrolling for the rotary's horizontal wheel.
|
|
73
|
+
device toolbar. Type `1` through `4`, `m`, `Escape`, and `Enter` for the wheel click, and hold
|
|
74
|
+
shift while scrolling for the rotary's horizontal wheel.
|
|
69
75
|
|
|
70
76
|
## Drive the real device over CDP
|
|
71
77
|
|
|
@@ -131,11 +137,13 @@ const key = (type: string, k: string, code: string, vk: number) =>
|
|
|
131
137
|
type,
|
|
132
138
|
key: k,
|
|
133
139
|
code,
|
|
134
|
-
text: k.length === 1 ? k : '',
|
|
140
|
+
text: k === 'Enter' ? '\r' : k.length === 1 ? k : '',
|
|
135
141
|
windowsVirtualKeyCode: vk,
|
|
136
142
|
});
|
|
137
143
|
await key('keyDown', '3', 'Digit3', 51); // preset 3
|
|
138
144
|
await key('keyUp', '3', 'Digit3', 51);
|
|
145
|
+
await key('keyDown', 'Enter', 'Enter', 13); // wheel click
|
|
146
|
+
await key('keyUp', 'Enter', 'Enter', 13);
|
|
139
147
|
|
|
140
148
|
// rotary wheel = horizontal scroll:
|
|
141
149
|
await send('Input.dispatchMouseEvent', {
|
|
@@ -160,4 +168,5 @@ actual screen on the device.
|
|
|
160
168
|
| Mode (M) | `keydown` key `"m"` | `KeyM` / 77 |
|
|
161
169
|
| Back | `keydown` key `"Escape"` | `Escape` / 27 |
|
|
162
170
|
| Rotary wheel | `wheel` with `deltaX` (horizontal) | mouseWheel `deltaX` |
|
|
171
|
+
| Wheel click | `keydown` key `"Enter"` | `Enter` / 13 |
|
|
163
172
|
| Touch | pointer / touch events | `Input.dispatchTouchEvent` |
|
|
@@ -68,6 +68,7 @@ client.player.stateGet().then(r => r.ok && setState(r.response.state)); // prime
|
|
|
68
68
|
// playback.state: 'stopped' | 'paused' | 'playing'; playback.positionMs; playback.shuffle
|
|
69
69
|
|
|
70
70
|
client.player.play({ uri: 'spotify:track:...' });
|
|
71
|
+
client.player.play({ uri: 'https://stream.example/live.mp3' }); // any http(s) media url plays on the phone
|
|
71
72
|
client.player.pause();
|
|
72
73
|
client.player.resume();
|
|
73
74
|
client.player.skipNext();
|
|
@@ -81,6 +82,14 @@ client.player.setRepeat({ mode: 'all' }); // 'off' | 'all' | 'one'
|
|
|
81
82
|
seek. For a smooth progress bar, extrapolate the playhead between snapshots from
|
|
82
83
|
`playback.state` and `playback.positionMs`.
|
|
83
84
|
|
|
85
|
+
An `http`/`https` uri is a raw media stream (internet radio, a podcast episode,
|
|
86
|
+
an HLS playlist) played by the phone's native player. Its snapshot carries the
|
|
87
|
+
stream's live metadata: ICY titles land in `track.title`, station artwork in
|
|
88
|
+
`track.artworkId`, and `playback.setElapsedTimeAvailable` is true only for
|
|
89
|
+
finite media that can seek. Live streams have no duration. A stream that fails
|
|
90
|
+
arrives on `player.onErrorEvent` as `playFailed`, and the stream stops when the
|
|
91
|
+
last Car Thing disconnects.
|
|
92
|
+
|
|
84
93
|
`client.asset`. Player state carries opaque asset ids:
|
|
85
94
|
|
|
86
95
|
```ts
|
|
@@ -109,14 +118,151 @@ const r = await client.config.get({ key: 'units' });
|
|
|
109
118
|
const all = await client.config.list();
|
|
110
119
|
```
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
Each field in the manifest's `config` array is `string`, `secret`, `number`,
|
|
122
|
+
`boolean` or `enum`. Keep `label` short; help text goes in `description`, which
|
|
123
|
+
the settings form shows under the label. An enum stores its `choices` values and
|
|
124
|
+
shows `choiceLabels` in their place:
|
|
125
|
+
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"type": "enum",
|
|
129
|
+
"data": {
|
|
130
|
+
"key": "frost",
|
|
131
|
+
"label": "Overlay frostiness",
|
|
132
|
+
"description": "How frosted the overlay surfaces look.",
|
|
133
|
+
"choices": ["0", "1", "2"],
|
|
134
|
+
"choiceLabels": { "0": "clear", "1": "light", "2": "frosted" },
|
|
135
|
+
"default": "2"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## The settings page: `@bridgething/client/settings`
|
|
141
|
+
|
|
142
|
+
The page under `settings/` runs in the companion: the phone app, the desktop
|
|
143
|
+
app, or the device page at bridgething.com. It reads and writes the app's
|
|
144
|
+
config and docs through `@bridgething/client/settings`, a `postMessage` bridge.
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { settings } from '@bridgething/client/settings';
|
|
148
|
+
|
|
149
|
+
const { webappId, name, version, deviceId } = await settings.context();
|
|
150
|
+
await settings.config.set('units', 'metric'); // also fields, list, delete
|
|
151
|
+
await settings.doc.set('layout', JSON.stringify(layout)); // also get, list, delete
|
|
152
|
+
const off = settings.onDocChanged((key, value) => sync(key, value));
|
|
153
|
+
settings.done(); // closes the settings screen
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The page has real internet but renders from a null origin, so the webview's
|
|
157
|
+
own `fetch` and XHR are blocked by CORS against any server without permissive
|
|
158
|
+
headers. WebSockets work from the page directly. For HTTP,
|
|
159
|
+
`settings.fetch(url, init)` has the host make the request natively and hands
|
|
160
|
+
back a real `Response`: 1 MiB each way, buffered whole, with an optional
|
|
161
|
+
`timeoutMs`. It rejects with a `SettingsFetchError` whose `kind` is `network`,
|
|
162
|
+
`timeout`, or `invalid_url`. `settings.installFetch()` swaps the global `fetch`
|
|
163
|
+
for third-party SDKs and returns a function that restores it.
|
|
164
|
+
`settings.auth.authorize(url)` opens an OAuth provider in the real browser and
|
|
165
|
+
resolves with the callback URL; register
|
|
166
|
+
`https://bridgething.com/oauth/callback` as the redirect URI, do PKCE in the
|
|
167
|
+
page, and exchange the code over `settings.fetch`. It rejects with an
|
|
168
|
+
`AuthorizeError` whose `kind` is `cancelled`, `busy` (one authorize at a time),
|
|
169
|
+
or `unsupported`. The device page has neither: there `settings.fetch` rejects
|
|
170
|
+
with a plain `Error` and `authorize` with `unsupported`.
|
|
171
|
+
|
|
172
|
+
The companion keeps the page loaded after the user leaves it and shows the
|
|
173
|
+
same document the next time they open the app's settings. Three rules follow:
|
|
174
|
+
|
|
175
|
+
- `settings.onRefresh(context => ...)` fires each time the page comes back on
|
|
176
|
+
screen, and whenever the app's name, version, or config fields change. Reread
|
|
177
|
+
anything the page read from the device, such as a doc it cached.
|
|
178
|
+
- `settings.done()` works only while the page is on screen. A `done()` from a
|
|
179
|
+
page the user already left does nothing.
|
|
180
|
+
- A page that navigates or reloads itself comes back as a fresh document.
|
|
181
|
+
Calls the old one had in flight are never answered.
|
|
182
|
+
- A page that crashes the webview before it finishes loading, or twice in a
|
|
183
|
+
row while on screen, shows a failure with a retry instead of loading again.
|
|
184
|
+
|
|
185
|
+
When the phone's settings screen hides, the phone app blurs the focused field.
|
|
186
|
+
|
|
187
|
+
## The config form: `@bridgething/client/form`
|
|
188
|
+
|
|
189
|
+
`<ConfigForm/>` renders every `config` field from the manifest and writes each
|
|
190
|
+
one to the device as the user commits it. An app with `config` and no
|
|
191
|
+
`settings` page still gets a settings screen: the companion shows a default
|
|
192
|
+
page that is just `<ConfigForm/>` and a Done button. A custom page embeds the
|
|
193
|
+
form and adds its own controls around it, so never hand-roll inputs for
|
|
194
|
+
declared fields.
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
import {
|
|
198
|
+
ConfigForm,
|
|
199
|
+
ConfigFormError,
|
|
200
|
+
closeSettings,
|
|
201
|
+
type ConfigFormHandle,
|
|
202
|
+
} from '@bridgething/client/form';
|
|
203
|
+
|
|
204
|
+
const form = useRef<ConfigFormHandle>(null);
|
|
205
|
+
|
|
206
|
+
async function connect() {
|
|
207
|
+
try {
|
|
208
|
+
const values = await form.current!.flush();
|
|
209
|
+
await signIn(values.base_url, values.token);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (!(err instanceof ConfigFormError)) throw err;
|
|
212
|
+
setStatus(err.message);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function close() {
|
|
217
|
+
try {
|
|
218
|
+
await closeSettings(form.current);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
setStatus(err instanceof Error ? err.message : String(err));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
<ConfigForm ref={form} />;
|
|
225
|
+
```
|
|
118
226
|
|
|
119
|
-
|
|
227
|
+
A toggle or a choice commits on tap. A text or number field commits when it
|
|
228
|
+
loses focus, or on Enter. A touch webview does not always take focus off a
|
|
229
|
+
field when a button is tapped, so any button that acts on the values calls
|
|
230
|
+
`flush()` first. A Done button calls `closeSettings(form.current)`, which
|
|
231
|
+
flushes and then closes the page unless an edit was refused, rejecting with
|
|
232
|
+
that error so the page can show it. `flush()` writes every field still being
|
|
233
|
+
typed, waits for the device, and resolves with the values the device holds.
|
|
234
|
+
It rejects with a `ConfigFormError`:
|
|
235
|
+
|
|
236
|
+
- `kind: 'refused'`: a typed value broke its field's rules, or the device
|
|
237
|
+
refused the write. `fields` maps each config key to its message and `message`
|
|
238
|
+
is `<label>: <reason>`. The form shows the same message under the field.
|
|
239
|
+
- `kind: 'unavailable'`: the form never loaded. `message` is the load failure,
|
|
240
|
+
and the form shows its own retry.
|
|
241
|
+
- `kind: 'replaced'`: the host pointed the page at another app while `flush()`
|
|
242
|
+
waited. The edits still land on the app they were made for.
|
|
243
|
+
|
|
244
|
+
`onChange` reports written values only, never text still being typed. It fires
|
|
245
|
+
the moment a field commits, and again with the device's value if the write is
|
|
246
|
+
refused. Take values for an action from `flush()`, not from `onChange` state.
|
|
247
|
+
The form rereads its fields and values on every refresh.
|
|
248
|
+
|
|
249
|
+
The form styles off `--bt-*` CSS variables (`--bt-color-bg`, `--bt-color-fg`,
|
|
250
|
+
`--bt-color-accent`, `--bt-font-body`, `--bt-text-row`, and the rest of the
|
|
251
|
+
names in `@bridgething/client/theme`).
|
|
252
|
+
|
|
253
|
+
The host app's theme arrives on its own. `settings.context()` carries
|
|
254
|
+
`theme: { scheme, colors, text, fonts }`, and importing
|
|
255
|
+
`@bridgething/client/settings` paints it as those `--bt-*` variables plus
|
|
256
|
+
`--bt-scheme` (`light` or `dark`), repainting when the phone or desktop flips
|
|
257
|
+
scheme. Use the variables in your own CSS and the page matches the app for
|
|
258
|
+
free; `color-scheme: var(--bt-scheme)` on `html` gets native controls right. The
|
|
259
|
+
painted values have zero specificity, so any `:root` rule of yours wins: set
|
|
260
|
+
the variables there to opt out. `settings.onThemeChanged(theme => ...)` hears
|
|
261
|
+
flips when a canvas or chart needs the raw tokens. An older companion sends no
|
|
262
|
+
theme and the variables fall back to the defaults, following
|
|
263
|
+
`prefers-color-scheme`.
|
|
264
|
+
|
|
265
|
+
Write the page in react like the rest of the app. `react` and `react-dom/client`
|
|
120
266
|
imports, `className`, `htmlFor`, react's event types. The build runs it on
|
|
121
267
|
preact through `@preact/preset-vite`, which is most of what keeps the page
|
|
122
268
|
small, so keep the react-flavoured source and leave the preset alone. One
|
|
@@ -129,6 +275,146 @@ the whole install with an invalid-manifest error. The build warns past 200 KiB
|
|
|
129
275
|
and again past 500 KiB. A heavy dependency in `settings/` is the usual way
|
|
130
276
|
there.
|
|
131
277
|
|
|
278
|
+
## Files the user picks: `client.blob`
|
|
279
|
+
|
|
280
|
+
A webapp can show files the user hands it from the companion, like a GIF or
|
|
281
|
+
video background. The settings page picks and uploads them; the webapp only
|
|
282
|
+
reads them.
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
import type { BlobEntry } from '@bridgething/lib/client';
|
|
286
|
+
|
|
287
|
+
const [bg, setBg] = useState<BlobEntry | null>(null);
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
const load = async () => {
|
|
291
|
+
const r = await client.blob.list();
|
|
292
|
+
setBg(
|
|
293
|
+
r.ok
|
|
294
|
+
? (r.response.entries.find(e => e.key === 'background') ?? null)
|
|
295
|
+
: null,
|
|
296
|
+
);
|
|
297
|
+
};
|
|
298
|
+
load();
|
|
299
|
+
return client.blob.onChanged(c => c.key === 'background' && load());
|
|
300
|
+
}, [client]);
|
|
301
|
+
|
|
302
|
+
if (!bg) return null;
|
|
303
|
+
if (bg.mime.startsWith('video/'))
|
|
304
|
+
return <video src={bg.url} autoPlay loop muted playsInline />;
|
|
305
|
+
return <img src={bg.url} />;
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`url` is `/.bridgething/blob/<key>?v=<sha256>`, served by the daemon from the
|
|
309
|
+
page's own origin with the stored mime, an ETag, and Range support. Use it
|
|
310
|
+
as-is. The `?v=` changes whenever the content does, so caching never shows a
|
|
311
|
+
stale file. Only the active webapp is served, and `onChanged` reaches only the
|
|
312
|
+
active webapp, never an overlay.
|
|
313
|
+
|
|
314
|
+
On the settings page, `pick` opens the phone's photo picker or the desktop's
|
|
315
|
+
file dialog and hands back a handle, never the bytes. `blob.put` streams the
|
|
316
|
+
file to the device under a key:
|
|
317
|
+
|
|
318
|
+
```ts
|
|
319
|
+
import { BlobError, settings } from '@bridgething/client/settings';
|
|
320
|
+
|
|
321
|
+
const picked = await settings.pick({ kind: 'video' }); // null when the user backs out
|
|
322
|
+
if (picked) {
|
|
323
|
+
thumb.src = picked.preview ?? ''; // small jpeg data url; a video's is its first frame
|
|
324
|
+
try {
|
|
325
|
+
await settings.blob.put('background', picked.handle, {
|
|
326
|
+
onProgress: (sent, total) => (bar.value = sent / total),
|
|
327
|
+
});
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err instanceof BlobError && err.kind === 'too_large') showTooBig();
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`kind` is `'image'`, `'video'`, or `'any'`. The companion makes picks fit the
|
|
335
|
+
device before upload, and there are no options beyond `cover`:
|
|
336
|
+
|
|
337
|
+
- Animated GIF, APNG, and WebP pass through byte for byte. Crop and placement
|
|
338
|
+
are yours to do in the webapp.
|
|
339
|
+
- Stills larger than the `cover` box scale down until they just cover it,
|
|
340
|
+
keeping their format. HEIC becomes JPEG.
|
|
341
|
+
- Video always becomes H.264 mp4, no audio, at most 30 fps, scaled to cover the
|
|
342
|
+
box without upscaling.
|
|
343
|
+
- `any` is untouched.
|
|
344
|
+
- On the device page at bridgething.com, the browser's own file picker is used
|
|
345
|
+
and nothing is converted or previewed, since a browser has no transcoder.
|
|
346
|
+
|
|
347
|
+
`cover: [w, h]` is the one override. It defaults to `[800, 800]` for stills,
|
|
348
|
+
which covers the screen in either orientation with zoom headroom, and
|
|
349
|
+
`[800, 480]` for video. Each edge is a whole number from 1 to 4294967295;
|
|
350
|
+
anything else rejects. The result is
|
|
351
|
+
`{ handle, mime, size, width?, height?, durationMs?, preview? }`. One picker is
|
|
352
|
+
open at a time: a `pick` while another is still open rejects with `busy`.
|
|
353
|
+
|
|
354
|
+
Bytes the page makes itself, like a canvas export, go through a handle too:
|
|
355
|
+
|
|
356
|
+
```ts
|
|
357
|
+
const png = await new Promise<Blob>(done =>
|
|
358
|
+
canvas.toBlob(b => done(b!), 'image/png'),
|
|
359
|
+
);
|
|
360
|
+
const handle = await settings.blob.create('image/png');
|
|
361
|
+
await settings.blob.append(handle, png); // Blob, ArrayBuffer, or a typed array
|
|
362
|
+
await settings.blob.put('cropped', handle);
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
`settings.blob.append` returns the handle's size so far.
|
|
366
|
+
`settings.blob.list()` returns `{ key, sha256, mime, size }` for every key, and
|
|
367
|
+
`settings.blob.delete(key)` removes one.
|
|
368
|
+
|
|
369
|
+
`put` resolves once the device has the file, however long the link takes: it
|
|
370
|
+
streams on the background lane so now-playing stays smooth, which means a
|
|
371
|
+
large video over a car's Bluetooth link takes minutes. `onProgress` reports the
|
|
372
|
+
bytes the device has confirmed a few times a second and ends on the total, so
|
|
373
|
+
show it. A `put` that picks up an interrupted upload starts at the bytes the
|
|
374
|
+
device already has, not at 0. Putting bytes the device already holds finishes
|
|
375
|
+
at once, and a second `put` of the same key replaces one still in flight.
|
|
376
|
+
|
|
377
|
+
A handle lives in the companion, not on the device:
|
|
378
|
+
|
|
379
|
+
- A `put` the device stores uses the handle up. To store the same bytes under
|
|
380
|
+
a second key, pick or create again.
|
|
381
|
+
- A failed `put` keeps the handle, so the upload can be retried with it.
|
|
382
|
+
- A `put` keeps its handle alive for as long as it streams.
|
|
383
|
+
- A handle nothing is uploading expires after 30 minutes unused.
|
|
384
|
+
- The companion holds at most 128 MiB of handles. A pick or append past that
|
|
385
|
+
rejects with `busy` until a `put` lands or a handle expires. Nothing already
|
|
386
|
+
held is dropped.
|
|
387
|
+
- A handle that expired or was used up rejects with `unknown_handle`. Pick
|
|
388
|
+
again.
|
|
389
|
+
|
|
390
|
+
`pick` and every `settings.blob` call reject with a `BlobError`. Its `kind`:
|
|
391
|
+
|
|
392
|
+
- `unsupported`: the companion or the Car Thing predates these calls, or the
|
|
393
|
+
host cannot do the work.
|
|
394
|
+
- `disconnected`: no Car Thing is linked, or the link dropped mid-upload.
|
|
395
|
+
- `too_large`: the file is over 32 MiB.
|
|
396
|
+
- `quota_exceeded`: the put would take the app past 64 MiB.
|
|
397
|
+
- `storage_full`: the Car Thing has no room left.
|
|
398
|
+
- `not_media`: the picked file is not the kind asked for.
|
|
399
|
+
- `unknown_handle`: the handle is not live. It was never made, it expired, or
|
|
400
|
+
a stored `put` used it up.
|
|
401
|
+
- `busy`: a picker is already open, or held handles fill the companion's whole
|
|
402
|
+
handle budget.
|
|
403
|
+
- `no_gesture`: the pick did not start from a tap or click, so the browser
|
|
404
|
+
opened no picker. Call `pick` from the tap handler itself.
|
|
405
|
+
- `failed`: anything else. Read `message`.
|
|
406
|
+
|
|
407
|
+
The footguns:
|
|
408
|
+
|
|
409
|
+
- 32 MiB per file and 64 MiB per webapp of distinct content. The device refuses
|
|
410
|
+
past either and never evicts to make room, so delete what you replace.
|
|
411
|
+
- A key is 1 to 256 bytes with no control characters. Do not use `.` or `..`:
|
|
412
|
+
a browser folds them out of the url, so the file never loads.
|
|
413
|
+
- A GIF is decoded on the device's CPU every frame, on a Cortex-A53 that also
|
|
414
|
+
runs everything else. A big or long GIF starves the now-playing UI. Prefer
|
|
415
|
+
video for animated backgrounds: it decodes in hardware. Pick with
|
|
416
|
+
`kind: 'video'`, or tell users to keep GIFs small.
|
|
417
|
+
|
|
132
418
|
`client.net` tunnels HTTP, websockets, and a SOCKS proxy through the phone. The
|
|
133
419
|
SOCKS proxy needs `"net.proxy"` in the manifest's `permissions`. Read the
|
|
134
420
|
`NetSurface` block in the `.d.ts` for the request shapes.
|
|
@@ -156,12 +442,13 @@ Read a surface's class in `dispatch.generated.d.ts` for its exact methods.
|
|
|
156
442
|
| `net` | HTTP, WS, and SOCKS through the phone | fetch, wsOpen, wsSend, wsClose, streamOpen, streamCancel |
|
|
157
443
|
| `hardware` | backlight and ambient light sensor | displaySetMode, displaySetLevel, stateGet, onAmbientLightUpdate, onBrightnessChanged |
|
|
158
444
|
| `bluetooth` | adapter alias, bonds, discoverable | list, connect, forget, setAlias, enableDiscoverable |
|
|
159
|
-
| `system` | version, logs, power, diagnostics | versionRequest, logsTail, logsSubscribe, reboot, powerOff, factoryReset, onVersion, onLogEntry, onOtaProgress |
|
|
445
|
+
| `system` | version, logs, power, diagnostics, launcher gesture, companion launch | versionRequest, logsTail, logsSubscribe, reboot, powerOff, factoryReset, launcherGestureGet, launcherGestureSet, companionLaunchGet, companionLaunchSet, onVersion, onLogEntry, onOtaProgress, onLauncherGestureChanged, onCompanionLaunchChanged |
|
|
160
446
|
| `time` | wall clock | get, onSnapshot, onChanged |
|
|
161
447
|
| `voice` | mic and push-to-talk, capability-gated | pushToTalk, cancel, muteMic, stateGet |
|
|
162
448
|
| `webapp` | list and activate installed webapps | list, current, activate, icon |
|
|
163
449
|
| `forward` | passthrough to the desktop-side extension | text, json, binary, onText, onJson, onBinary |
|
|
164
450
|
| `doc` | per-webapp documents the companion also writes | get, list, set, delete, onChanged |
|
|
451
|
+
| `blob` | files the user picked in the companion, served by url | list, onChanged |
|
|
165
452
|
| `lyrics` | timed lyrics for the current track | get |
|
|
166
453
|
| `peer` | every known peer and its link state | onSnapshot |
|
|
167
454
|
|
|
@@ -31,6 +31,26 @@ Anyone with a bridgething Car Thing installs that zip from the companion app.
|
|
|
31
31
|
|
|
32
32
|
Raise `version` in `public/manifest.json` before you share an update. Keep `id`.
|
|
33
33
|
|
|
34
|
+
## Publish to the store
|
|
35
|
+
|
|
36
|
+
From the repo root, `bun run bump <slug> <major | minor | patch>` moves the
|
|
37
|
+
version, then a push to main runs the publish workflow. It builds every new
|
|
38
|
+
version into `catalog.v1.json` on the `gh-pages` branch. GitHub Pages has to
|
|
39
|
+
serve that branch from `/ (root)` (repo Settings > Pages), and the catalog lands
|
|
40
|
+
at `base_url` from `source.json` plus `/catalog.v1.json`.
|
|
41
|
+
|
|
42
|
+
Until the source is submitted, the store does not show it, and only people who
|
|
43
|
+
paste that URL into the companion app can install from it. Once the first
|
|
44
|
+
publish is live and the catalog URL loads, ask the user whether they want it in
|
|
45
|
+
the bridgething store. A listing is public, so submit only on a yes:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bun run submit # lists the source, or refreshes an existing listing
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The store rechecks every listed source on its own, so later versions appear
|
|
52
|
+
without another submit. Run it again to pick one up right away.
|
|
53
|
+
|
|
34
54
|
## Keep the device on the latest bridgething
|
|
35
55
|
|
|
36
56
|
```bash
|
package/template/package.json
CHANGED
|
@@ -14,13 +14,13 @@
|
|
|
14
14
|
"update": "bunx @bridgething/updater"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"preact": "^
|
|
17
|
+
"preact": "^11.0.1",
|
|
18
18
|
"react": "^19.2.0",
|
|
19
19
|
"react-dom": "^19.2.0"
|
|
20
20
|
},
|
|
21
21
|
"devDependencies": {
|
|
22
22
|
"@msgpack/msgpack": "^3.1.3",
|
|
23
|
-
"@preact/preset-vite": "^2.10.
|
|
23
|
+
"@preact/preset-vite": "^2.10.6",
|
|
24
24
|
"@tailwindcss/vite": "^4.3.3",
|
|
25
25
|
"@types/react": "^19.2.10",
|
|
26
26
|
"@types/react-dom": "^19.2.3",
|
|
@@ -1,7 +1,4 @@
|
|
|
1
1
|
// Generated from @bridgething/webapp-shared.
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
3
|
-
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
|
-
|
|
5
2
|
// ../webapp-shared/src/dev.ts
|
|
6
3
|
import { spawn as spawn2 } from 'node:child_process';
|
|
7
4
|
import { isIPv4 } from 'node:net';
|
|
@@ -14,7 +11,7 @@ var DAEMON_PROXY_PATH = '/__bridgething';
|
|
|
14
11
|
// ../webapp-shared/src/extension.ts
|
|
15
12
|
import { spawn } from 'node:child_process';
|
|
16
13
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
17
|
-
import { createRequire
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
18
15
|
import { homedir, platform } from 'node:os';
|
|
19
16
|
import { delimiter, dirname, join as join2, resolve } from 'node:path';
|
|
20
17
|
import { createInterface } from 'node:readline';
|
|
@@ -383,7 +380,7 @@ async function resolveDeno(root) {
|
|
|
383
380
|
}
|
|
384
381
|
function denoPackageDir(root) {
|
|
385
382
|
try {
|
|
386
|
-
return dirname(
|
|
383
|
+
return dirname(createRequire(join2(root, 'package.json')).resolve('deno/package.json'));
|
|
387
384
|
} catch {
|
|
388
385
|
return null;
|
|
389
386
|
}
|
|
@@ -824,6 +821,7 @@ ${lines.join(`
|
|
|
824
821
|
// ../webapp-shared/src/dev.ts
|
|
825
822
|
var DEVICE_MODE = 'device';
|
|
826
823
|
var AUTHORIZE_PATH = '/__extension/authorize';
|
|
824
|
+
var BLOB_URL_PREFIX = '/.bridgething/blob/';
|
|
827
825
|
var DAEMON_PORT = 8891;
|
|
828
826
|
var SWITCH_SETTLE_MS = 1000;
|
|
829
827
|
async function daemonProxyTarget() {
|
|
@@ -832,13 +830,18 @@ async function daemonProxyTarget() {
|
|
|
832
830
|
return `ws://${await resolveHost(deviceHostName())}:${DAEMON_PORT}`;
|
|
833
831
|
}
|
|
834
832
|
async function daemonProxy() {
|
|
833
|
+
const target = await daemonProxyTarget();
|
|
835
834
|
return {
|
|
836
835
|
[DAEMON_PROXY_PATH]: {
|
|
837
|
-
target
|
|
836
|
+
target,
|
|
838
837
|
ws: true,
|
|
839
838
|
changeOrigin: true,
|
|
840
839
|
rewrite: path => path.slice(DAEMON_PROXY_PATH.length) || '/',
|
|
841
840
|
},
|
|
841
|
+
[BLOB_URL_PREFIX]: {
|
|
842
|
+
target: target.replace(/^ws/, 'http'),
|
|
843
|
+
changeOrigin: true,
|
|
844
|
+
},
|
|
842
845
|
};
|
|
843
846
|
}
|
|
844
847
|
function bridgething() {
|
|
@@ -1083,4 +1086,4 @@ ${note ? `<p class="note">${escapeHtml(note)}</p>` : ''}
|
|
|
1083
1086
|
${status}
|
|
1084
1087
|
`;
|
|
1085
1088
|
}
|
|
1086
|
-
export { AUTHORIZE_PATH, bridgething, buildExtension, daemonProxy, daemonProxyTarget, DEVICE_MODE };
|
|
1089
|
+
export { AUTHORIZE_PATH, BLOB_URL_PREFIX, bridgething, buildExtension, daemonProxy, daemonProxyTarget, DEVICE_MODE };
|
package/template/scripts/push.ts
CHANGED
|
@@ -360,13 +360,13 @@ async function bridgethingPush({ scriptUrl }) {
|
|
|
360
360
|
return parsed;
|
|
361
361
|
};
|
|
362
362
|
if (args.release) {
|
|
363
|
-
const
|
|
364
|
-
for (const slot of declaredSlots(
|
|
363
|
+
const manifest = readManifest();
|
|
364
|
+
for (const slot of declaredSlots(manifest)) {
|
|
365
365
|
const result = await setSlot(target, slot, null);
|
|
366
366
|
if (!result.ok) throw new Error(result.reason);
|
|
367
367
|
console.log(`${slot} slot: reverted to the built-in one`);
|
|
368
368
|
}
|
|
369
|
-
if (declaredSlots(
|
|
369
|
+
if (declaredSlots(manifest).length === 0) console.log('this bundle declares no slots; nothing to release');
|
|
370
370
|
return;
|
|
371
371
|
}
|
|
372
372
|
if (!args.skipBuild) await buildBundle(repoDir);
|
|
@@ -1,104 +1,61 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { ConfigForm, closeSettings, type ConfigFormHandle } from '@bridgething/client/form';
|
|
2
|
+
import { settings, type SettingsContext } from '@bridgething/client/settings';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
4
|
import { createRoot } from 'react-dom/client';
|
|
4
5
|
import './style.css';
|
|
5
6
|
|
|
6
|
-
function fieldMeta(field: ConfigField): { key: string; label: string } {
|
|
7
|
-
return { key: field.data.key, label: field.data.label };
|
|
8
|
-
}
|
|
9
|
-
|
|
10
7
|
function Settings() {
|
|
8
|
+
const form = useRef<ConfigFormHandle>(null);
|
|
11
9
|
const [ctx, setCtx] = useState<SettingsContext | null>(null);
|
|
12
|
-
const [fields, setFields] = useState<ConfigField[]>([]);
|
|
13
|
-
const [values, setValues] = useState<Record<string, string>>({});
|
|
14
10
|
const [status, setStatus] = useState('');
|
|
15
11
|
|
|
16
12
|
useEffect(() => {
|
|
17
|
-
(
|
|
18
|
-
|
|
19
|
-
setCtx(await settings.context());
|
|
20
|
-
const [schema, entries] = await Promise.all([settings.config.fields(), settings.config.list()]);
|
|
21
|
-
setFields(schema);
|
|
22
|
-
setValues(Object.fromEntries(entries.map(e => [e.key, e.value])));
|
|
23
|
-
} catch (err) {
|
|
24
|
-
setStatus(err instanceof Error ? err.message : String(err));
|
|
25
|
-
}
|
|
26
|
-
})();
|
|
13
|
+
settings.context().then(setCtx, (err: unknown) => setStatus(errText(err)));
|
|
14
|
+
return settings.onRefresh(setCtx);
|
|
27
15
|
}, []);
|
|
28
16
|
|
|
29
|
-
async function
|
|
30
|
-
|
|
31
|
-
setStatus('saving...');
|
|
17
|
+
async function saveToDevice() {
|
|
18
|
+
setStatus('writing to device...');
|
|
32
19
|
try {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
setStatus('settings saved');
|
|
20
|
+
const values = await form.current?.flush();
|
|
21
|
+
await settings.doc.set('preferences', JSON.stringify({ values, savedAt: Date.now() }));
|
|
22
|
+
setStatus('synced a preferences doc to the device');
|
|
38
23
|
} catch (err) {
|
|
39
|
-
setStatus(
|
|
24
|
+
setStatus(errText(err));
|
|
40
25
|
}
|
|
41
26
|
}
|
|
42
27
|
|
|
43
|
-
async function
|
|
44
|
-
setStatus('writing to device...');
|
|
28
|
+
async function close() {
|
|
45
29
|
try {
|
|
46
|
-
await
|
|
47
|
-
setStatus('synced a preferences doc to the device');
|
|
30
|
+
await closeSettings(form.current);
|
|
48
31
|
} catch (err) {
|
|
49
|
-
setStatus(
|
|
32
|
+
setStatus(errText(err));
|
|
50
33
|
}
|
|
51
34
|
}
|
|
52
35
|
|
|
53
36
|
return (
|
|
54
37
|
<main>
|
|
55
38
|
<h1>{ctx?.name ?? '__PROJECT_NAME__'} settings</h1>
|
|
56
|
-
<p className="hint">{ctx ? `${ctx.
|
|
39
|
+
<p className="hint">{ctx ? `${ctx.name} ${ctx.version}` : 'connecting to the companion host...'}</p>
|
|
57
40
|
|
|
58
|
-
<
|
|
59
|
-
{fields.length === 0 && <p className="hint">this webapp declares no config fields yet.</p>}
|
|
60
|
-
{fields.map(field => {
|
|
61
|
-
const { key, label } = fieldMeta(field);
|
|
62
|
-
const value = values[key] ?? '';
|
|
63
|
-
const onInput = (e: InputEvent<HTMLInputElement | HTMLSelectElement>) =>
|
|
64
|
-
setValues({ ...values, [key]: (e.target as HTMLInputElement).value });
|
|
65
|
-
return (
|
|
66
|
-
<div className="field" key={key}>
|
|
67
|
-
<label htmlFor={key}>{label}</label>
|
|
68
|
-
{field.type === 'enum' ? (
|
|
69
|
-
<select id={key} value={value} onInput={onInput}>
|
|
70
|
-
{field.data.choices.map(choice => (
|
|
71
|
-
<option value={choice} key={choice}>
|
|
72
|
-
{choice}
|
|
73
|
-
</option>
|
|
74
|
-
))}
|
|
75
|
-
</select>
|
|
76
|
-
) : (
|
|
77
|
-
<input
|
|
78
|
-
id={key}
|
|
79
|
-
type={field.type === 'number' ? 'number' : field.type === 'secret' ? 'password' : 'text'}
|
|
80
|
-
value={value}
|
|
81
|
-
onInput={onInput}
|
|
82
|
-
/>
|
|
83
|
-
)}
|
|
84
|
-
</div>
|
|
85
|
-
);
|
|
86
|
-
})}
|
|
41
|
+
<ConfigForm ref={form} />
|
|
87
42
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
</div>
|
|
97
|
-
</form>
|
|
43
|
+
<div className="row">
|
|
44
|
+
<button type="button" onClick={saveToDevice}>
|
|
45
|
+
Save to device
|
|
46
|
+
</button>
|
|
47
|
+
<button type="button" className="secondary" onClick={close}>
|
|
48
|
+
Done
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
98
51
|
|
|
99
52
|
<p className="status">{status}</p>
|
|
100
53
|
</main>
|
|
101
54
|
);
|
|
102
55
|
}
|
|
103
56
|
|
|
57
|
+
function errText(err: unknown): string {
|
|
58
|
+
return err instanceof Error ? err.message : String(err);
|
|
59
|
+
}
|
|
60
|
+
|
|
104
61
|
createRoot(document.getElementById('root')!).render(<Settings />);
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
:root {
|
|
2
|
-
color-scheme:
|
|
3
|
-
font-family:
|
|
4
|
-
system-ui,
|
|
5
|
-
-apple-system,
|
|
6
|
-
sans-serif;
|
|
2
|
+
color-scheme: var(--bt-scheme);
|
|
7
3
|
}
|
|
8
4
|
|
|
9
5
|
body {
|
|
10
6
|
margin: 0;
|
|
11
7
|
padding: 20px;
|
|
8
|
+
background: var(--bt-color-bg);
|
|
9
|
+
color: var(--bt-color-fg);
|
|
10
|
+
font-family: var(--bt-font-body);
|
|
11
|
+
font-size: var(--bt-text-body);
|
|
12
12
|
line-height: 1.5;
|
|
13
13
|
}
|
|
14
14
|
|
|
@@ -18,68 +18,50 @@ main {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
h1 {
|
|
21
|
-
font-
|
|
22
|
-
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
.hint {
|
|
26
|
-
opacity: 0.7;
|
|
27
|
-
font-size: 0.85rem;
|
|
28
|
-
margin: 0 0 1.25rem;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
.field {
|
|
32
|
-
display: flex;
|
|
33
|
-
flex-direction: column;
|
|
34
|
-
gap: 4px;
|
|
35
|
-
margin-bottom: 14px;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
.field label {
|
|
39
|
-
font-size: 0.85rem;
|
|
21
|
+
font-family: var(--bt-font-display);
|
|
22
|
+
font-size: var(--bt-text-title);
|
|
40
23
|
font-weight: 600;
|
|
24
|
+
margin: 0 0 4px;
|
|
41
25
|
}
|
|
42
26
|
|
|
43
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
font-size:
|
|
47
|
-
|
|
48
|
-
border-radius: 8px;
|
|
49
|
-
background: transparent;
|
|
50
|
-
color: inherit;
|
|
27
|
+
.hint {
|
|
28
|
+
color: var(--bt-color-muted);
|
|
29
|
+
font-family: var(--bt-font-mono);
|
|
30
|
+
font-size: var(--bt-text-hint);
|
|
31
|
+
margin: 0 0 20px;
|
|
51
32
|
}
|
|
52
33
|
|
|
53
34
|
.row {
|
|
54
35
|
display: flex;
|
|
55
|
-
gap:
|
|
36
|
+
gap: 12px;
|
|
56
37
|
align-items: center;
|
|
57
|
-
margin-top:
|
|
38
|
+
margin-top: 16px;
|
|
58
39
|
}
|
|
59
40
|
|
|
60
|
-
button {
|
|
61
|
-
padding:
|
|
62
|
-
font-
|
|
63
|
-
font-
|
|
64
|
-
border:
|
|
65
|
-
border-radius:
|
|
66
|
-
background:
|
|
67
|
-
color:
|
|
41
|
+
.row button {
|
|
42
|
+
padding: 10px 16px;
|
|
43
|
+
font-family: var(--bt-font-mono);
|
|
44
|
+
font-size: var(--bt-text-body);
|
|
45
|
+
border: 1px solid var(--bt-color-accent);
|
|
46
|
+
border-radius: 0;
|
|
47
|
+
background: var(--bt-color-accent-soft);
|
|
48
|
+
color: var(--bt-color-fg);
|
|
68
49
|
cursor: pointer;
|
|
69
50
|
}
|
|
70
51
|
|
|
71
|
-
button.secondary {
|
|
72
|
-
|
|
73
|
-
|
|
52
|
+
.row button.secondary {
|
|
53
|
+
border-color: var(--bt-color-edge);
|
|
54
|
+
background: none;
|
|
74
55
|
}
|
|
75
56
|
|
|
76
|
-
button:disabled {
|
|
57
|
+
.row button:disabled {
|
|
77
58
|
opacity: 0.5;
|
|
78
59
|
cursor: default;
|
|
79
60
|
}
|
|
80
61
|
|
|
81
62
|
.status {
|
|
82
|
-
|
|
83
|
-
|
|
63
|
+
color: var(--bt-color-muted);
|
|
64
|
+
font-family: var(--bt-font-mono);
|
|
65
|
+
font-size: var(--bt-text-hint);
|
|
84
66
|
min-height: 1.2em;
|
|
85
67
|
}
|
package/template/vite.config.ts
CHANGED
|
@@ -3,14 +3,11 @@ import react from '@vitejs/plugin-react';
|
|
|
3
3
|
import { defineConfig } from 'vite';
|
|
4
4
|
import { bridgething, daemonProxy } from './scripts/bridgething';
|
|
5
5
|
|
|
6
|
-
export default defineConfig(async () => ({
|
|
6
|
+
export default defineConfig(async ({ command }) => ({
|
|
7
7
|
plugins: [react(), tailwindcss(), bridgething()],
|
|
8
8
|
build: {
|
|
9
9
|
target: 'es2022',
|
|
10
10
|
sourcemap: true,
|
|
11
11
|
},
|
|
12
|
-
server: {
|
|
13
|
-
host: true,
|
|
14
|
-
proxy: await daemonProxy(),
|
|
15
|
-
},
|
|
12
|
+
server: command === 'serve' ? { host: true, proxy: await daemonProxy() } : {},
|
|
16
13
|
}));
|
|
@@ -12,7 +12,7 @@ function sizeGuard(): Plugin {
|
|
|
12
12
|
return {
|
|
13
13
|
name: 'settings-size-guard',
|
|
14
14
|
closeBundle() {
|
|
15
|
-
const out = resolve(
|
|
15
|
+
const out = resolve(import.meta.dirname, 'dist', 'settings.html');
|
|
16
16
|
let bytes: number;
|
|
17
17
|
try {
|
|
18
18
|
bytes = statSync(out).size;
|
|
@@ -42,12 +42,13 @@ function sizeGuard(): Plugin {
|
|
|
42
42
|
export default defineConfig({
|
|
43
43
|
root: 'settings',
|
|
44
44
|
plugins: [preact(), viteSingleFile(), sizeGuard()],
|
|
45
|
+
resolve: { dedupe: ['preact'] },
|
|
45
46
|
build: {
|
|
46
47
|
target: 'es2022',
|
|
47
|
-
outDir: resolve(
|
|
48
|
+
outDir: resolve(import.meta.dirname, 'dist'),
|
|
48
49
|
emptyOutDir: false,
|
|
49
50
|
rollupOptions: {
|
|
50
|
-
input: resolve(
|
|
51
|
+
input: resolve(import.meta.dirname, 'settings', 'settings.html'),
|
|
51
52
|
},
|
|
52
53
|
},
|
|
53
54
|
});
|
|
@@ -14,7 +14,11 @@ back to the built-in overlay, the recovery path when a build misbehaves.
|
|
|
14
14
|
The daemon prepends one global before your bundle:
|
|
15
15
|
|
|
16
16
|
```js
|
|
17
|
-
window.__bridgethingOverlay = {
|
|
17
|
+
window.__bridgethingOverlay = {
|
|
18
|
+
origin: 'http://127.0.0.1:8891',
|
|
19
|
+
url: 'ws://127.0.0.1:8891/?scope=overlay',
|
|
20
|
+
surfaces: {...},
|
|
21
|
+
};
|
|
18
22
|
```
|
|
19
23
|
|
|
20
24
|
`surfaces` is the active webapp's declared profile, a boolean each for
|
|
@@ -22,16 +26,22 @@ window.__bridgethingOverlay = { origin: 'http://127.0.0.1:8891', surfaces: {...}
|
|
|
22
26
|
only the surfaces set to true; an app that draws its own volume indicator
|
|
23
27
|
declares `volume: false`. When every surface is false the daemon injects nothing.
|
|
24
28
|
|
|
25
|
-
`
|
|
26
|
-
|
|
27
|
-
|
|
29
|
+
`url` is the socket to hand `@bridgething/client`. Always use it rather than
|
|
30
|
+
building one from `location`, because the kiosk can be showing a dev server or
|
|
31
|
+
an external page whose host is not the daemon. That socket is scoped to your
|
|
32
|
+
bundle, so `client.config` and `client.doc` read your own settings and storage
|
|
33
|
+
rather than the foreground webapp's, and `client.config.onChanged` fires when a
|
|
34
|
+
companion changes one of your settings.
|
|
35
|
+
|
|
36
|
+
`origin` is the kiosk origin, for telling a kiosk page apart from a dev server.
|
|
28
37
|
|
|
29
38
|
### Output constraints
|
|
30
39
|
|
|
31
40
|
`overlay.js` must be one self-contained file under 512 KiB. The daemon injects it
|
|
32
|
-
as a script string into another app's document, so it
|
|
33
|
-
|
|
34
|
-
inlined iife; keep that
|
|
41
|
+
as a script string into an isolated world over another app's document, so it
|
|
42
|
+
shares the DOM with that app but none of its javascript, and it carries its own
|
|
43
|
+
code and styles. `vite.overlay.config.ts` builds a single inlined iife; keep that
|
|
44
|
+
shape.
|
|
35
45
|
|
|
36
46
|
Style `overlay/main.tsx` with tailwind classes like the rest of the project.
|
|
37
47
|
`overlay/style.css` is imported with vite's `?inline`, so the compiled css mounts
|
|
@@ -40,7 +50,7 @@ into the shadow root as a string. Tailwind scans only `overlay/main.tsx`
|
|
|
40
50
|
|
|
41
51
|
### Keep these four from the starter
|
|
42
52
|
|
|
43
|
-
1. The
|
|
53
|
+
1. The socket url from the prelude, never one built from `location`.
|
|
44
54
|
2. The `__bridgethingOverlayMounted` guard, so a second injection is a no-op.
|
|
45
55
|
3. The closed shadow root, which keeps your styles and the host app's apart.
|
|
46
56
|
4. Escape-only key handling on the capture phase, active only while something is
|
|
@@ -13,7 +13,7 @@ type OverlaySurfaces = {
|
|
|
13
13
|
volume: boolean;
|
|
14
14
|
};
|
|
15
15
|
|
|
16
|
-
type OverlayConfig = { origin: string; surfaces: OverlaySurfaces };
|
|
16
|
+
type OverlayConfig = { origin: string; url: string; surfaces: OverlaySurfaces };
|
|
17
17
|
|
|
18
18
|
declare global {
|
|
19
19
|
interface Window {
|
|
@@ -209,8 +209,7 @@ function Overlay({ cfg, client }: { cfg: OverlayConfig; client: BridgethingClien
|
|
|
209
209
|
|
|
210
210
|
function boot() {
|
|
211
211
|
const cfg = window.__bridgethingOverlay;
|
|
212
|
-
if (!cfg ||
|
|
213
|
-
if (window.__bridgethingOverlayMounted) return;
|
|
212
|
+
if (!cfg || window.__bridgethingOverlayMounted) return;
|
|
214
213
|
window.__bridgethingOverlayMounted = true;
|
|
215
214
|
|
|
216
215
|
const mount = () => {
|
|
@@ -218,7 +217,7 @@ function boot() {
|
|
|
218
217
|
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none';
|
|
219
218
|
const shadow = host.attachShadow({ mode: 'closed' });
|
|
220
219
|
document.body.appendChild(host);
|
|
221
|
-
render(<Overlay cfg={cfg} client={new BridgethingClient({ url:
|
|
220
|
+
render(<Overlay cfg={cfg} client={new BridgethingClient({ url: cfg.url })} />, shadow);
|
|
222
221
|
};
|
|
223
222
|
|
|
224
223
|
if (document.body) mount();
|
|
@@ -13,7 +13,7 @@ function sizeGuard(): Plugin {
|
|
|
13
13
|
closeBundle() {
|
|
14
14
|
let bytes: number;
|
|
15
15
|
try {
|
|
16
|
-
bytes = statSync(resolve(
|
|
16
|
+
bytes = statSync(resolve(import.meta.dirname, 'dist', 'overlay.js')).size;
|
|
17
17
|
} catch {
|
|
18
18
|
return;
|
|
19
19
|
}
|
|
@@ -28,11 +28,11 @@ export default defineConfig({
|
|
|
28
28
|
plugins: [preact(), tailwindcss(), sizeGuard()],
|
|
29
29
|
build: {
|
|
30
30
|
target: 'es2022',
|
|
31
|
-
outDir: resolve(
|
|
31
|
+
outDir: resolve(import.meta.dirname, 'dist'),
|
|
32
32
|
emptyOutDir: false,
|
|
33
33
|
cssCodeSplit: false,
|
|
34
34
|
lib: {
|
|
35
|
-
entry: resolve(
|
|
35
|
+
entry: resolve(import.meta.dirname, 'overlay', 'main.tsx'),
|
|
36
36
|
formats: ['iife'],
|
|
37
37
|
name: '__overlay',
|
|
38
38
|
fileName: () => 'overlay.js',
|
|
@@ -14,9 +14,10 @@ Listen with a `keydown` handler and a `wheel` handler on `window`.
|
|
|
14
14
|
| Mode | `keydown` key `"m"` |
|
|
15
15
|
| Back | `keydown` key `"Escape"` |
|
|
16
16
|
| Rotary wheel | `wheel` with horizontal `deltaX` |
|
|
17
|
+
| Wheel click | `keydown` key `"Enter"` |
|
|
17
18
|
| Touch | pointer and touch events |
|
|
18
19
|
|
|
19
|
-
Make horizontal wheel scroll move through the main list. Five fast presses of Mode returns to the launcher.
|
|
20
|
+
Make horizontal wheel scroll move through the main list and wheel click activate the selected item. Five fast presses of Mode returns to the launcher.
|
|
20
21
|
|
|
21
22
|
## The client
|
|
22
23
|
|
|
@@ -33,9 +34,9 @@ Construct it once and reuse it. It connects and reconnects on its own. Call `dae
|
|
|
33
34
|
|
|
34
35
|
Now-playing and library data come from the phone's Spotify, so render a placeholder when no phone is connected. Fetch artwork with `client.asset.get` using the opaque id on the track.
|
|
35
36
|
|
|
36
|
-
Every surface: `player asset config store doc capabilities library audio notifications phone peer geo net hardware bluetooth system time voice lyrics webapp forward`. Each method is an event, a request, or a command.
|
|
37
|
+
Every surface: `player asset config store doc blob capabilities library audio notifications phone peer geo net hardware bluetooth system time voice lyrics webapp forward`. Each method is an event, a request, or a command.
|
|
37
38
|
|
|
38
|
-
Methods, types, and examples: `.claude/skills/bridgething/reference/sdk.md`. That skill lives once at the repo root and every app symlinks it, so it is one copy for the whole workspace. `bun run skills` refreshes it from the published `create-bridgething`; `bun run check`
|
|
39
|
+
Methods, types, and examples: `.claude/skills/bridgething/reference/sdk.md`. That skill lives once at the repo root and every app symlinks it, so it is one copy for the whole workspace. `bun run skills` refreshes it from the published `create-bridgething`; it and `bun run check` say when the skill is behind, when `@bridgething/source` is older than the skill expects, and when `package.json` lacks a script the skill uses.
|
|
39
40
|
|
|
40
41
|
## Working on one app
|
|
41
42
|
|
|
@@ -53,7 +54,7 @@ bun run --cwd apps/<slug> update # brings the device to the latest bridget
|
|
|
53
54
|
|
|
54
55
|
An app with a variant or an extension carries its own `apps/<slug>/CLAUDE.md` with the parts specific to it.
|
|
55
56
|
|
|
56
|
-
Running and driving the app: `.claude/skills/bridgething/reference/develop.md`. Push, zip, update: `.claude/skills/bridgething/reference/ship.md`. The desktop-side Deno extension and its permission model, for an app with an `extension/` directory: `.claude/skills/bridgething/reference/extension.md`.
|
|
57
|
+
Running and driving the app: `.claude/skills/bridgething/reference/develop.md`. Push, zip, publish to the store, update: `.claude/skills/bridgething/reference/ship.md`. The desktop-side Deno extension and its permission model, for an app with an `extension/` directory: `.claude/skills/bridgething/reference/extension.md`.
|
|
57
58
|
|
|
58
59
|
## The three files that describe an app
|
|
59
60
|
|
|
@@ -62,10 +63,10 @@ Running and driving the app: `.claude/skills/bridgething/reference/develop.md`.
|
|
|
62
63
|
- `id` identifies this webapp on the device. It is a uuidv7 generated once at scaffold time. **Never change it**: the device keys upgrade-in-place and the app's key-value namespace on it, so a new uuid orphans everyone's installed copy and its data.
|
|
63
64
|
- `version` is what the store publishes against. Move it with `bun run bump`, never by hand.
|
|
64
65
|
- `description` is the store tagline and must not be empty.
|
|
65
|
-
- `config` declares the settings
|
|
66
|
+
- `config` declares the settings `client.config` reads. The companion app edits them through `<ConfigForm/>` from `@bridgething/client/form`, on a default page when the app has no `settings` page.
|
|
66
67
|
- `permissions` grants `geo` and `net.proxy`.
|
|
67
68
|
- `art.heroPx` and `art.thumbPx` are the sizes artwork arrives at.
|
|
68
|
-
- `settings` names the page built from `settings/`, capped at 1 MiB. It talks to the companion app through `@bridgething/client/settings`.
|
|
69
|
+
- `settings` names the page built from `settings/`, capped at 1 MiB. It talks to the companion app through `@bridgething/client/settings` and embeds `<ConfigForm/>` for the declared fields. A button on it that acts on those fields awaits the form's `flush()` first. The companion keeps the page loaded between visits, so it rereads device state in `settings.onRefresh`. It is also where a user picks a file for the app (`settings.pick` + `settings.blob.put`), which the webapp then shows through `client.blob`.
|
|
69
70
|
- `extension` declares the desktop-side Deno process and the host permissions it gets.
|
|
70
71
|
|
|
71
72
|
**`apps/<slug>/catalog.json`** is the store listing and nothing else: `author`, `homepage`, `source`, an `icon` override, `screenshots`, and `min_libbridgething_version` (the oldest daemon the app works against). Never restate anything the manifest already says. An app that ships an extension must carry its `github.com` repo url in `source`, because the store shows that link next to the host permissions it is asking for.
|
|
@@ -83,7 +84,7 @@ bun run check
|
|
|
83
84
|
|
|
84
85
|
`new` scaffolds into `apps/<slug>`. Never add an app by hand: the scaffold generates the uuid, writes the store listing, wires the dev server to a connected device, and lands the tsconfig and scripts CI expects.
|
|
85
86
|
|
|
86
|
-
`shot` captures the kiosk over CDP into `apps/<slug>/screenshots/`.
|
|
87
|
+
`shot` captures the kiosk over CDP into `apps/<slug>/screenshots/`. The image fronts chromium's debugging port on `bridgething.local:9222`, so it needs nothing but the device plugged in. Up to six, filename order, first is the store card.
|
|
87
88
|
|
|
88
89
|
`bump` writes `public/manifest.json` and `package.json` together and opens the changelog section. Editing one without the other fails the build.
|
|
89
90
|
|
|
@@ -91,6 +92,8 @@ bun run check
|
|
|
91
92
|
|
|
92
93
|
Pushing to main publishes. `bun run publish --dry-run` assembles the exact bytes that would be pushed, into `site/`, without pushing.
|
|
93
94
|
|
|
95
|
+
The store only shows a source that has been submitted to it. Once the first publish is live, ask the user whether they want it in the bridgething store, and run `bun run submit` only on a yes, because a listing is public. The store rechecks listed sources on its own, so a later version needs no second submit.
|
|
96
|
+
|
|
94
97
|
## Publishing rules that are not negotiable
|
|
95
98
|
|
|
96
99
|
- **A published version is immutable.** `published.json` on the `gh-pages` branch records the sha256 clients verify against. Changing an app means a new version, always. `check` fails a pull request that changed an app without bumping it.
|
|
@@ -9,7 +9,7 @@ Webapps for the Spotify Car Thing running [bridgething](https://bridgething.com)
|
|
|
9
9
|
1. Push this repo to `https://github.com/__REPO_OWNER__/__REPO_NAME__`.
|
|
10
10
|
2. In **Settings > Pages**, set the source to **Deploy from a branch**, branch `gh-pages`, folder `/ (root)`.
|
|
11
11
|
|
|
12
|
-
The catalog is published to `__BASE_URL__/catalog.v1.json
|
|
12
|
+
The catalog is published to `__BASE_URL__/catalog.v1.json`. Once it loads, `bun run submit` lists it in the [bridgething store](https://bridgething.com/apps).
|
|
13
13
|
|
|
14
14
|
## Develop
|
|
15
15
|
|
|
@@ -45,7 +45,7 @@ bun run bump __APP_SLUG__ patch -m "Fix the wind direction arrow"
|
|
|
45
45
|
git commit -am "__APP_SLUG__: fix the wind direction arrow" && git push
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Pushing to main builds the apps and regenerates the catalog.
|
|
48
|
+
Pushing to main builds the apps and regenerates the catalog. The store rechecks listed sources on its own; `bun run submit` refreshes right away.
|
|
49
49
|
|
|
50
50
|
## Agent skill
|
|
51
51
|
|
|
@@ -53,5 +53,5 @@ Pushing to main builds the apps and regenerates the catalog.
|
|
|
53
53
|
|
|
54
54
|
```sh
|
|
55
55
|
bun run skills # refresh it from the published create-bridgething
|
|
56
|
-
bun run skills --check # check whether it
|
|
56
|
+
bun run skills --check # check whether it, the toolkit, or the scripts it uses are behind
|
|
57
57
|
```
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"shot": "bridgething-source shot",
|
|
18
18
|
"catalog": "bridgething-source catalog",
|
|
19
19
|
"publish": "bridgething-source publish",
|
|
20
|
+
"submit": "bridgething-source submit",
|
|
20
21
|
"skills": "bridgething-source skills",
|
|
21
22
|
"format": "prettier --write \"*.json\" \"*.js\""
|
|
22
23
|
},
|
package/template-versions.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"@bridgething/client": "^0.
|
|
3
|
-
"@bridgething/extension": "^0.
|
|
4
|
-
"@bridgething/source": "^0.
|
|
5
|
-
"libbridgething": "0.
|
|
2
|
+
"@bridgething/client": "^0.14.0",
|
|
3
|
+
"@bridgething/extension": "^0.14.0",
|
|
4
|
+
"@bridgething/source": "^0.15.0",
|
|
5
|
+
"libbridgething": "0.14.1"
|
|
6
6
|
}
|