create-bridgething 0.12.0 → 0.14.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/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "create-bridgething",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
- "description": "Scaffold a new bridgething webapp (React + Vite + Tailwind v4)",
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",
7
7
  "author": "Joey Eamigh <55670930+JoeyEamigh@users.noreply.github.com> (https://github.com/JoeyEamigh)",
8
8
  "homepage": "https://bridgething.com",
@@ -14,7 +14,14 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/JoeyEamigh/bridgething/issues"
16
16
  },
17
- "keywords": ["bridgething", "spotify", "car-thing", "superbird", "create", "scaffold"],
17
+ "keywords": [
18
+ "bridgething",
19
+ "spotify",
20
+ "car-thing",
21
+ "superbird",
22
+ "create",
23
+ "scaffold"
24
+ ],
18
25
  "publishConfig": {
19
26
  "access": "public",
20
27
  "registry": "https://registry.npmjs.org/"
@@ -24,7 +31,9 @@
24
31
  },
25
32
  "files": [
26
33
  "dist",
34
+ "template-versions.json",
27
35
  "template",
36
+ "template-source",
28
37
  "template-extension",
29
38
  "template-launcher",
30
39
  "template-overlay"
@@ -37,13 +46,27 @@
37
46
  "test": "bun test"
38
47
  },
39
48
  "devDependencies": {
40
- "@bridgething/extension": "0.12.0",
49
+ "@bridgething/client": "0.14.0",
50
+ "@bridgething/extension": "0.14.0",
41
51
  "@msgpack/msgpack": "^3.1.3",
52
+ "@preact/preset-vite": "^2.10.6",
53
+ "@tailwindcss/vite": "^4.3.3",
42
54
  "@types/bun": "^1.4.0",
43
55
  "@types/node": "^26.1.1",
56
+ "@types/react": "^19.3.0",
57
+ "@types/react-dom": "^19.3.0",
44
58
  "@types/uuid": "^11.0.0",
59
+ "@vitejs/plugin-react": "^6.1.2",
45
60
  "esbuild": "^0.28.2",
46
- "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"
47
70
  },
48
71
  "peerDependencies": {
49
72
  "typescript": "^6.0.3"
@@ -6,7 +6,8 @@ description: Build, run, and ship a bridgething webapp for the Spotify Car Thing
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,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,23 +70,24 @@ 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
 
72
78
  CDP input goes into the page, so the launcher gesture and browser-nav side
73
79
  effects stay out of the way.
74
80
 
75
- Get the app on the kiosk first with `bun run dev:device` or `bun run push`. CDP
76
- is served at `bridgething.local:9222`, reachable over USB:
81
+ Get the app on the kiosk first with `bun run dev:device` or `bun run push`.
82
+ Chromium listens on loopback, but the image proxies it, so CDP answers on
83
+ `bridgething.local:9222` over USB:
77
84
 
78
85
  ```bash
79
86
  curl -s http://bridgething.local:9222/json/version >/dev/null && echo cdp-up
80
87
  ```
81
88
 
82
- Use the `webSocketDebuggerUrl` exactly as returned. It comes back as an IP rather
83
- than the hostname you asked for, and that address is the correct one.
89
+ Use the `webSocketDebuggerUrl` exactly as returned. It comes back as an IP
90
+ rather than the hostname you asked for, and that address is the correct one.
84
91
 
85
92
  Playwright's `connectOverCDP` never finishes the websocket upgrade against the
86
93
  device's embedded chromium, so drive it with a raw CDP websocket. Connect to the
@@ -130,11 +137,13 @@ const key = (type: string, k: string, code: string, vk: number) =>
130
137
  type,
131
138
  key: k,
132
139
  code,
133
- text: k.length === 1 ? k : '',
140
+ text: k === 'Enter' ? '\r' : k.length === 1 ? k : '',
134
141
  windowsVirtualKeyCode: vk,
135
142
  });
136
143
  await key('keyDown', '3', 'Digit3', 51); // preset 3
137
144
  await key('keyUp', '3', 'Digit3', 51);
145
+ await key('keyDown', 'Enter', 'Enter', 13); // wheel click
146
+ await key('keyUp', 'Enter', 'Enter', 13);
138
147
 
139
148
  // rotary wheel = horizontal scroll:
140
149
  await send('Input.dispatchMouseEvent', {
@@ -159,4 +168,5 @@ actual screen on the device.
159
168
  | Mode (M) | `keydown` key `"m"` | `KeyM` / 77 |
160
169
  | Back | `keydown` key `"Escape"` | `Escape` / 27 |
161
170
  | Rotary wheel | `wheel` with `deltaX` (horizontal) | mouseWheel `deltaX` |
171
+ | Wheel click | `keydown` key `"Enter"` | `Enter` / 13 |
162
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,12 +118,302 @@ 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
+ ```
226
+
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`
266
+ imports, `className`, `htmlFor`, react's event types. The build runs it on
267
+ preact through `@preact/preset-vite`, which is most of what keeps the page
268
+ small, so keep the react-flavoured source and leave the preset alone. One
269
+ tsconfig covers `src`, `settings` and `extension`, so `bun run typecheck`
270
+ checks all three.
271
+
272
+ Keep the page small. It builds to one self-contained HTML file, and the device
273
+ refuses to install a bundle whose declared settings page is over 1 MiB, failing
274
+ the whole install with an invalid-manifest error. The build warns past 200 KiB
275
+ and again past 500 KiB. A heavy dependency in `settings/` is the usual way
276
+ there.
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.
118
417
 
119
418
  `client.net` tunnels HTTP, websockets, and a SOCKS proxy through the phone. The
120
419
  SOCKS proxy needs `"net.proxy"` in the manifest's `permissions`. Read the
@@ -143,12 +442,13 @@ Read a surface's class in `dispatch.generated.d.ts` for its exact methods.
143
442
  | `net` | HTTP, WS, and SOCKS through the phone | fetch, wsOpen, wsSend, wsClose, streamOpen, streamCancel |
144
443
  | `hardware` | backlight and ambient light sensor | displaySetMode, displaySetLevel, stateGet, onAmbientLightUpdate, onBrightnessChanged |
145
444
  | `bluetooth` | adapter alias, bonds, discoverable | list, connect, forget, setAlias, enableDiscoverable |
146
- | `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 |
147
446
  | `time` | wall clock | get, onSnapshot, onChanged |
148
447
  | `voice` | mic and push-to-talk, capability-gated | pushToTalk, cancel, muteMic, stateGet |
149
448
  | `webapp` | list and activate installed webapps | list, current, activate, icon |
150
449
  | `forward` | passthrough to the desktop-side extension | text, json, binary, onText, onJson, onBinary |
151
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 |
152
452
  | `lyrics` | timed lyrics for the current track | get |
153
453
  | `peer` | every known peer and its link state | onSnapshot |
154
454
 
@@ -7,20 +7,20 @@
7
7
  "dev": "vite",
8
8
  "dev:device": "vite --mode device",
9
9
  "build": "vite build && vite build -c vite.settings.config.ts",
10
+ "typecheck": "tsc --noEmit",
10
11
  "preview": "vite preview",
11
12
  "push": "bun scripts/push.ts",
12
13
  "share": "bun scripts/share.ts",
13
14
  "update": "bunx @bridgething/updater"
14
15
  },
15
16
  "dependencies": {
16
- "@bridgething/client": "^0.12.0",
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);