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 CHANGED
@@ -1,13 +1,16 @@
1
1
  # create-bridgething
2
2
 
3
- Scaffold a [bridgething](https://github.com/JoeyEamigh/bridgething) webapp for
4
- the Spotify Car Thing: React, Vite, Tailwind v4, and
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-app
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 project ships `bun run dev`, `build`, `push` (install on a connected Car
22
- Thing), `share` (zip for the store), and `update` (bring the device to the
23
- latest release).
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)) move(appSkill, 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 at ${base}/catalog.v1.json, which you can submit to the bridgething store.
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.12.1",
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/extension": "0.12.1",
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
- "tsdown": "^0.21.10"
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 or packaging a shareable zip, and writing the desktop-side Deno extension a webapp can ship.
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: [reference/sdk.md](reference/sdk.md)
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, and updating the
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`, and `Escape`, and hold shift while
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
- The settings page under `settings/` reads and writes them through
113
- `@bridgething/client/settings`, a `postMessage` bridge. It runs on the phone with
114
- real internet, but loads from a `file://` origin and the webview enforces CORS on
115
- fetch and XHR. WebSocket APIs work. Plain HTTP APIs work when the server sends
116
- permissive CORS headers, since requests arrive with `Origin: null`. For a
117
- CORS-strict HTTP-only service, fetch through `client.net` instead.
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
- Write it in react like the rest of the app. `react` and `react-dom/client`
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
@@ -14,13 +14,13 @@
14
14
  "update": "bunx @bridgething/updater"
15
15
  },
16
16
  "dependencies": {
17
- "preact": "^10.29.7",
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.5",
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 as createRequire2 } from 'node:module';
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(createRequire2(join2(root, 'package.json')).resolve('deno/package.json'));
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: await daemonProxyTarget(),
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 };
@@ -360,13 +360,13 @@ async function bridgethingPush({ scriptUrl }) {
360
360
  return parsed;
361
361
  };
362
362
  if (args.release) {
363
- const manifest2 = readManifest();
364
- for (const slot of declaredSlots(manifest2)) {
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(manifest2).length === 0) console.log('this bundle declares no slots; nothing to release');
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 { settings, type ConfigField, type SettingsContext } from '@bridgething/client/settings';
2
- import { useEffect, useState, type FormEvent, type InputEvent } from 'react';
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
- (async () => {
18
- try {
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 saveConfig(event: FormEvent<HTMLFormElement>) {
30
- event.preventDefault();
31
- setStatus('saving...');
17
+ async function saveToDevice() {
18
+ setStatus('writing to device...');
32
19
  try {
33
- for (const field of fields) {
34
- const { key } = fieldMeta(field);
35
- await settings.config.set(key, values[key] ?? '');
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(err instanceof Error ? err.message : String(err));
24
+ setStatus(errText(err));
40
25
  }
41
26
  }
42
27
 
43
- async function saveToDevice() {
44
- setStatus('writing to device...');
28
+ async function close() {
45
29
  try {
46
- await settings.doc.set('preferences', JSON.stringify({ values, savedAt: Date.now() }));
47
- setStatus('synced a preferences doc to the device');
30
+ await closeSettings(form.current);
48
31
  } catch (err) {
49
- setStatus(err instanceof Error ? err.message : String(err));
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.webappId} on ${ctx.deviceId}` : 'connecting to the companion host...'}</p>
39
+ <p className="hint">{ctx ? `${ctx.name} ${ctx.version}` : 'connecting to the companion host...'}</p>
57
40
 
58
- <form onSubmit={saveConfig}>
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
- <div className="row">
89
- <button type="submit">Save settings</button>
90
- <button type="button" className="secondary" onClick={saveToDevice}>
91
- Save to device
92
- </button>
93
- <button type="button" className="secondary" onClick={() => settings.done()}>
94
- Done
95
- </button>
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: light dark;
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-size: 1.25rem;
22
- margin: 0 0 0.25rem;
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
- .field input,
44
- .field select {
45
- padding: 8px 10px;
46
- font-size: 1rem;
47
- border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
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: 10px;
36
+ gap: 12px;
56
37
  align-items: center;
57
- margin-top: 8px;
38
+ margin-top: 16px;
58
39
  }
59
40
 
60
- button {
61
- padding: 9px 16px;
62
- font-size: 0.95rem;
63
- font-weight: 600;
64
- border: none;
65
- border-radius: 8px;
66
- background: #00a8e8;
67
- color: #06232e;
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
- background: color-mix(in srgb, currentColor 12%, transparent);
73
- color: inherit;
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
- font-size: 0.85rem;
83
- opacity: 0.8;
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
  }
@@ -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(__dirname, 'dist', 'settings.html');
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(__dirname, 'dist'),
48
+ outDir: resolve(import.meta.dirname, 'dist'),
48
49
  emptyOutDir: false,
49
50
  rollupOptions: {
50
- input: resolve(__dirname, 'settings', 'settings.html'),
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 = { origin: 'http://127.0.0.1:8891', surfaces: {...} };
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
- `origin` is the kiosk origin. Compare `location.origin` against it before
26
- mounting. Everything else comes from `@bridgething/client` over the local
27
- websocket.
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 carries its own code and
33
- styles and reaches nothing at runtime. `vite.overlay.config.ts` builds a single
34
- inlined iife; keep that shape.
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 origin guard before mounting.
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 || !location.origin.startsWith(cfg.origin)) return;
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: `ws://${location.host}/` })} />, shadow);
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(__dirname, 'dist', 'overlay.js')).size;
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(__dirname, 'dist'),
31
+ outDir: resolve(import.meta.dirname, 'dist'),
32
32
  emptyOutDir: false,
33
33
  cssCodeSplit: false,
34
34
  lib: {
35
- entry: resolve(__dirname, 'overlay', 'main.tsx'),
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` says when it is behind.
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 the companion app edits and `client.config` reads.
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/`. Chromium's debugging port is 9223 and is bound to the device's loopback, so it goes through an ssh tunnel; the command opens and closes one itself. Up to six, filename order, first is the store card.
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`, which can be submitted to <bridgething.com/apps>
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 is behind
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
  },
@@ -1,6 +1,6 @@
1
1
  {
2
- "@bridgething/client": "^0.12.1",
3
- "@bridgething/extension": "^0.12.1",
4
- "@bridgething/source": "^0.12.1",
5
- "libbridgething": "0.12.1"
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
  }