create-bridgething 0.11.0 → 0.12.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.
@@ -1,39 +1,46 @@
1
1
  # Running and driving the app
2
2
 
3
- The device is an 800x480 landscape screen with five physical controls plus touch.
4
- To develop effectively you (1) run the app, (2) see it at the real size, and
5
- (3) press the buttons. The fast loop drives a browser you control on your own
6
- machine; you only need the physical Car Thing for final validation.
3
+ ## Start the dev server
7
4
 
8
- ## Contents
5
+ ```bash
6
+ bun run dev
7
+ ```
9
8
 
10
- - Start the dev server
11
- - Drive it with Playwright (recommended for agents)
12
- - Alternative: your own browser + DevTools
13
- - Drive the real device over CDP
14
- - Input reference
9
+ Vite serves at `http://localhost:5173/` with hot reload. The page reaches the
10
+ daemon on the Car Thing plugged in over USB through the dev server's
11
+ `/__bridgething` proxy, so now-playing, config, and geo are the real thing rather
12
+ than fixtures. `SUPERBIRD_HOST=bridgething-<serial>.local` targets another
13
+ device, and `BRIDGETHING_DAEMON_URL=ws://host:8891` targets a daemon anywhere
14
+ else.
15
15
 
16
- ## Start the dev server
16
+ ## Show it on the Car Thing with hot reload
17
17
 
18
18
  ```bash
19
- bun run dev
19
+ bun run dev:device
20
20
  ```
21
21
 
22
- Vite serves at `http://localhost:5173/` with hot reload.
22
+ On start it installs the app once if the daemon has never seen it, makes it the
23
+ active webapp so config, permissions, and forwards are yours, then points the
24
+ kiosk at `http://<your usb address>:5173/`. Every save from then on hot-reloads
25
+ on the real display. Ctrl-C hands the screen back to the installed build.
23
26
 
24
- ## Drive it with Playwright (recommended for agents)
27
+ ## The extension half
25
28
 
26
- Playwright is **not** installed by default (a human clicking around does not need
27
- it). Add it only when you want to see and drive the app programmatically:
29
+ When `public/manifest.json` declares `extension`, both dev commands also bundle
30
+ `extension/main.ts`, run it under Deno with the manifest's permissions, and link
31
+ it to the same Car Thing. Every save rebuilds and restarts it, and `ctx.log.*`
32
+ prints in the vite terminal. The contract is in
33
+ [reference/extension.md](extension.md).
34
+
35
+ ## Drive it with Playwright
36
+
37
+ To screenshot and drive the app from a script, add Playwright:
28
38
 
29
39
  ```bash
30
40
  bun add -d playwright
31
41
  bunx playwright install chromium
32
42
  ```
33
43
 
34
- Then a short script gives you a locked 800x480 window you can screenshot and send
35
- input to:
36
-
37
44
  ```ts
38
45
  import { chromium } from 'playwright';
39
46
 
@@ -44,50 +51,46 @@ await page.goto('http://localhost:5173/');
44
51
  await page.screenshot({ path: 'screen.png' }); // look at the result
45
52
 
46
53
  // physical controls -> the exact events the device sends:
47
- await page.keyboard.press('1'); // preset 1 (also '2' '3' '4')
48
- await page.keyboard.press('m'); // Mode button
49
- await page.keyboard.press('Escape'); // Back button
50
- await page.mouse.wheel(120, 0); // rotary wheel: HORIZONTAL scroll (deltaX)
54
+ await page.keyboard.press('1'); // preset 1 (also '2' '3' '4')
55
+ await page.keyboard.press('m'); // Mode button
56
+ await page.keyboard.press('Escape'); // Back button
57
+ await page.mouse.wheel(120, 0); // rotary wheel: HORIZONTAL scroll (deltaX)
51
58
  // touch: page.tap('selector') or page.mouse.click(x, y)
52
59
  ```
53
60
 
54
- Loop: edit code -> vite hot-reloads -> screenshot -> adjust. Keep the viewport at
55
- exactly `{ width: 800, height: 480 }` so what you see matches the device.
61
+ Loop: edit code, let vite hot-reload, screenshot, adjust. Keep the viewport at
62
+ `{ width: 800, height: 480 }` so what you see matches the device.
56
63
 
57
- ## Alternative: your own browser + DevTools
64
+ ## Your own browser and DevTools
58
65
 
59
- Open `http://localhost:5173/` in your normal browser, then in DevTools' device
60
- toolbar set a custom device of **800 x 480**. You get real DevTools plus the same
61
- keys (type `1`-`4`, `m`, `Escape`; shift+wheel for the rotary's horizontal
62
- scroll). This is the recommended manual path.
66
+ 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.
63
69
 
64
70
  ## Drive the real device over CDP
65
71
 
66
- To see and drive the app on real hardware, talk CDP to the device's own chromium.
67
- CDP input goes straight into the page (never through the physical buttons), so the
68
- launcher gesture and on-device browser-nav side effects never interfere.
72
+ CDP input goes into the page, so the launcher gesture and browser-nav side
73
+ effects stay out of the way.
69
74
 
70
- Install the app onto the device first so the kiosk is showing it (build + push).
71
- CDP is served at `bridgething.local:9222`, reachable directly over USB:
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:
72
77
 
73
78
  ```bash
74
- # device answers at bridgething.local over USB; root has no password
75
79
  curl -s http://bridgething.local:9222/json/version >/dev/null && echo cdp-up
76
80
  ```
77
81
 
78
82
  Use the `webSocketDebuggerUrl` exactly as returned. It comes back as an IP rather
79
- than the hostname you asked for, and it is already correct - do not rewrite it.
83
+ than the hostname you asked for, and that address is the correct one.
80
84
 
81
- **Playwright's `connectOverCDP` hangs against the device's embedded chromium** -
82
- it never finishes the websocket upgrade (a quirk of the device's `cast_shell`
83
- build; a plain websocket connects fine). Drive it with a raw CDP websocket
84
- instead. Connect to the `page` target from `/json` (the page-level endpoint, not
85
- the browser-level one). This script screenshots the live device and presses a
86
- button; it is the whole loop:
85
+ Playwright's `connectOverCDP` never finishes the websocket upgrade against the
86
+ device's embedded chromium, so drive it with a raw CDP websocket. Connect to the
87
+ `page` target from `/json`, not the browser-level endpoint.
87
88
 
88
89
  ```ts
89
90
  // drive.ts -> run with: bun drive.ts
90
- const targets = (await fetch('http://bridgething.local:9222/json').then(r => r.json())) as any[];
91
+ const targets = (await fetch('http://bridgething.local:9222/json').then(r =>
92
+ r.json(),
93
+ )) as any[];
91
94
  const page = targets.find(t => t.type === 'page');
92
95
  const ws = new WebSocket(page.webSocketDebuggerUrl);
93
96
  await new Promise<void>((res, rej) => {
@@ -99,45 +102,61 @@ let id = 0;
99
102
  const pending = new Map<number, (v: any) => void>();
100
103
  ws.onmessage = ev => {
101
104
  const m = JSON.parse(ev.data as string);
102
- if (m.id && pending.has(m.id)) (pending.get(m.id)!(m.result), pending.delete(m.id));
105
+ if (m.id && pending.has(m.id))
106
+ (pending.get(m.id)!(m.result), pending.delete(m.id));
103
107
  };
104
108
  const send = (method: string, params: any = {}) =>
105
- new Promise<any>(res => (pending.set(++id, res), ws.send(JSON.stringify({ id, method, params }))));
109
+ new Promise<any>(
110
+ res => (
111
+ pending.set(++id, res),
112
+ ws.send(JSON.stringify({ id, method, params }))
113
+ ),
114
+ );
106
115
 
107
116
  // screenshot
108
117
  const { data } = await send('Page.captureScreenshot', { format: 'png' });
109
118
  await Bun.write('device.png', Buffer.from(data, 'base64'));
110
119
 
111
- // read what is on screen (great for asserting a change happened)
112
- const text = await send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true });
120
+ // read what is on screen, to assert a change happened
121
+ const text = await send('Runtime.evaluate', {
122
+ expression: 'document.body.innerText',
123
+ returnByValue: true,
124
+ });
113
125
  console.log(text.result.value);
114
126
 
115
127
  // press a button: keyDown + keyUp. key/code/text must match a real key.
116
128
  const key = (type: string, k: string, code: string, vk: number) =>
117
- send('Input.dispatchKeyEvent', { type, key: k, code, text: k.length === 1 ? k : '', windowsVirtualKeyCode: vk });
118
- await key('keyDown', '3', 'Digit3', 51); // preset 3
129
+ send('Input.dispatchKeyEvent', {
130
+ type,
131
+ key: k,
132
+ code,
133
+ text: k.length === 1 ? k : '',
134
+ windowsVirtualKeyCode: vk,
135
+ });
136
+ await key('keyDown', '3', 'Digit3', 51); // preset 3
119
137
  await key('keyUp', '3', 'Digit3', 51);
120
138
 
121
139
  // rotary wheel = horizontal scroll:
122
- await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: 400, y: 240, deltaX: 120, deltaY: 0 });
140
+ await send('Input.dispatchMouseEvent', {
141
+ type: 'mouseWheel',
142
+ x: 400,
143
+ y: 240,
144
+ deltaX: 120,
145
+ deltaY: 0,
146
+ });
123
147
 
124
148
  process.exit(0);
125
149
  ```
126
150
 
127
- Read the screenshot and the `innerText` to confirm the app reacted. Prefer the
128
- local loop for fast iteration; use the device to confirm real behavior (real
129
- now-playing, real artwork, the actual screen).
151
+ Iterate in the local loop, then confirm real now-playing, real artwork, and the
152
+ actual screen on the device.
130
153
 
131
154
  ## Input reference
132
155
 
133
- The controls arrive as ordinary browser events. Listen for them with a `keydown`
134
- handler and a `wheel` handler on `window`. Make horizontal wheel scroll move
135
- through your primary list; it is the control users reach for first.
136
-
137
- | Control | Browser event | CDP `key`/`code`/vk |
138
- | ------------ | ------------------------------------ | ---------------------- |
139
- | Preset 1-4 | `keydown` key `"1"` `"2"` `"3"` `"4"` | `Digit1`-`Digit4` / 49-52 |
140
- | Mode (M) | `keydown` key `"m"` | `KeyM` / 77 |
141
- | Back | `keydown` key `"Escape"` | `Escape` / 27 |
142
- | Rotary wheel | `wheel` with `deltaX` (horizontal) | mouseWheel `deltaX` |
143
- | Touch | pointer / touch events | `Input.dispatchTouchEvent` |
156
+ | Control | Browser event | CDP `key`/`code`/vk |
157
+ | ------------ | ------------------------------------- | -------------------------- |
158
+ | Preset 1-4 | `keydown` key `"1"` `"2"` `"3"` `"4"` | `Digit1`-`Digit4` / 49-52 |
159
+ | Mode (M) | `keydown` key `"m"` | `KeyM` / 77 |
160
+ | Back | `keydown` key `"Escape"` | `Escape` / 27 |
161
+ | Rotary wheel | `wheel` with `deltaX` (horizontal) | mouseWheel `deltaX` |
162
+ | Touch | pointer / touch events | `Input.dispatchTouchEvent` |
@@ -0,0 +1,183 @@
1
+ # Extensions (`@bridgething/extension`)
2
+
3
+ An extension is the desktop-side half of a webapp: a Deno process the bridgething
4
+ desktop app spawns, one per installed and enabled app. It has whatever host
5
+ access the manifest asks for, and reaches every connected Car Thing through the
6
+ daemon's forward surface.
7
+
8
+ ## Shape of an extension
9
+
10
+ ```ts
11
+ import { asJson, defineExtension, json } from '@bridgething/extension';
12
+
13
+ let timer: ReturnType<typeof setInterval> | undefined;
14
+
15
+ defineExtension({
16
+ start(ctx) {
17
+ ctx.on('device', event => ctx.log.info(event.type, event.device.name));
18
+ ctx.on('message', (device, message) =>
19
+ device.send(json({ echo: asJson(message) })),
20
+ );
21
+ timer = setInterval(() => ctx.broadcast(json({ tick: Date.now() })), 5_000);
22
+ },
23
+ stop() {
24
+ clearInterval(timer);
25
+ },
26
+ });
27
+ ```
28
+
29
+ `defineExtension` runs `start` after the host's `hello`, sends `ready` when the
30
+ returned promise settles, and on `stop`, or when the host closes stdin, runs
31
+ `stop` and exits. The extension runs device or no device.
32
+
33
+ - Stdout is the protocol. Log with `ctx.log.*` and keep `console.error` on stderr
34
+ for scratch debugging.
35
+ - Register listeners synchronously at the top of `start`. The host replays every
36
+ live device immediately after it calls `start`, so a listener added after an
37
+ `await` misses them.
38
+
39
+ ## The `ctx` contract
40
+
41
+ Read `node_modules/@bridgething/extension/dist/*.d.ts` for the exact signatures.
42
+
43
+ Identity:
44
+
45
+ - `ctx.api` is the host protocol revision, `1`.
46
+ - `ctx.webapp` is `{ id, name, version }` from `manifest.json`.
47
+ - `ctx.dataDir` is a directory this extension owns. The kv store lives there.
48
+
49
+ Devices:
50
+
51
+ - `ctx.devices` is the connected devices, recomputed on read.
52
+ - `ctx.device(id)` returns a handle for any id, connected or not. It never
53
+ throws.
54
+ - `ctx.on('device', event => ...)` streams `event.type` of `connected`,
55
+ `disconnected`, or `active`, with `event.device` as the handle.
56
+ - A handle carries `id`, `name`, `active`, `connected`, `config`, and `send`, and
57
+ survives a disconnect with `connected` false.
58
+
59
+ Messages:
60
+
61
+ - `ctx.device(id).send(message)` and `ctx.broadcast(message)`. Broadcast reaches
62
+ every connected device where this webapp is active.
63
+ - `ctx.on('message', (device, message) => ...)` takes forwards coming back.
64
+ - Both directions are fire and forget. Build your own correlation for
65
+ request and reply.
66
+ - The daemon delivers a forward to the active webapp, so check `device.active`. A
67
+ send to a backgrounded app is dropped silently.
68
+
69
+ Config:
70
+
71
+ - `ctx.config(device)` is a snapshot of that device's settings, keyed by
72
+ `manifest.json` config key. Values are strings.
73
+ - `ctx.on('config', (device, key, value) => ...)` fires when a settings page
74
+ writes, from any companion. `value` is `null` when the user cleared the key,
75
+ and the key leaves the snapshot. An empty string is a real empty value.
76
+
77
+ Storage, auth, logging:
78
+
79
+ - `ctx.kv.get(key)`, `.set(key, value)`, `.delete(key)`, and `.list()` are a
80
+ persistent JSON store scoped to this extension. `get` resolves `undefined` for
81
+ a missing key.
82
+ - `ctx.auth.authorize(url)` opens the system browser and resolves with the full
83
+ callback URL, query string included. It rejects when the user backs out.
84
+ - `ctx.log.debug/info/warn/error(...args)` writes into the host's log: the vite
85
+ terminal in development, the desktop app's log once installed.
86
+ - `kv` and `auth` failures reject with an `ExtensionError` carrying a `kind` of
87
+ `host-error`, `disconnected`, or `write-failed`.
88
+
89
+ Persist eagerly as state changes. The host drops your stdin the moment it sends
90
+ `stop`, so a `ctx.kv` or `ctx.auth` call made from `stop` rejects with
91
+ `kind: 'disconnected'`. Use `stop` to release timers, sockets, and children.
92
+
93
+ ## Forward messages
94
+
95
+ A forward is `{ encoding, data }`, the same type the webapp SDK uses:
96
+
97
+ | encoding | reading | writing |
98
+ | -------- | ----------------------------------- | -------------------------------------- |
99
+ | `text` | `asText(message)` -> `string` | `send('hi')` or `send(text('hi'))` |
100
+ | `json` | `asJson<T>(message)` -> `T` | `send(json(value))` |
101
+ | `binary` | `asBinary(message)` -> `Uint8Array` | `send(bytes)` or `send(binary(bytes))` |
102
+
103
+ The narrowing helpers return `undefined` when the encoding does not match, so
104
+ `asJson(message) ?? fallback` is the normal shape. Wrap a plain object in
105
+ `json(...)` before sending, because `send` cannot tell one apart from a
106
+ `ForwardMessage`. Binary always arrives as a `Uint8Array`.
107
+
108
+ ## Manifest block and permissions
109
+
110
+ ```json
111
+ "extension": {
112
+ "entry": "extension/desktop.mjs",
113
+ "permissions": ["all"],
114
+ "api": 1
115
+ }
116
+ ```
117
+
118
+ - `entry` is the path inside the built bundle. It must exist, or the daemon
119
+ refuses the install.
120
+ - `api` is the host protocol revision. `1` is frozen.
121
+ - `permissions` are Deno permission descriptors, handed to the runtime as flags.
122
+
123
+ Descriptor grammar, one string per entry: `all`, `net`, `net:<host[:port]>`,
124
+ `read`, `read:<path>`, `write`, `write:<path>`, `run`, `run:<binary>`, `env`,
125
+ `env:<VAR>`, `sys`, `sys:<kind>`, `ffi`, `ffi:<path>`. A path may start with `~`.
126
+
127
+ Narrow `["all"]` before you publish. The dev loop runs the extension with exactly
128
+ the declared flags, so it fails on your machine the moment you use something you
129
+ did not declare. The store card lists the permissions and the desktop app makes
130
+ the user confirm them at install. A version declaring `extension` must publish
131
+ from a public GitHub repo.
132
+
133
+ ## The dev loop
134
+
135
+ ```bash
136
+ bun run dev # page in your browser + extension under deno, both on the connected Car Thing
137
+ bun run dev:device # the same, with the page on the Car Thing's own screen
138
+ bun run build # webapp + settings page + dist/extension/desktop.mjs
139
+ bun run share # zips dist/, extension included
140
+ ```
141
+
142
+ Both dev commands:
143
+
144
+ 1. Make this app the active webapp on the Car Thing, installing it once if the
145
+ device has never seen it, so forwards route.
146
+ 2. Bundle `extension/main.ts` with esbuild and run it under Deno with the
147
+ manifest's permissions, from the gitignored `.dev-extension/`, which also
148
+ holds the kv store. Every save rebuilds and restarts it. A build error prints
149
+ and leaves the previous build running.
150
+ 3. Link to the daemon over USB as an extension host, so the extension gets
151
+ `device.connected`, `device.active`, `config.changed`, and every forward the
152
+ page sends. With the Car Thing unplugged it links up when one appears.
153
+
154
+ `ctx.log.*` and stderr both print in the vite terminal. A crash restarts with
155
+ backoff, or immediately on the next save; a tight loop shows as `crashed` in the
156
+ desktop app's row. Ctrl-C stops the extension and, under `dev:device`, hands the
157
+ screen back. Disable this app's extension in the desktop app while you develop,
158
+ or the page hears both copies.
159
+
160
+ Under `bun run dev`, `ctx.auth.authorize(url)` ends on bridgething.com's callback
161
+ page. Copy that page's address and paste it at
162
+ `http://localhost:5173/__extension/authorize` to deliver it to the extension. The
163
+ desktop app captures the callback itself once the app is installed there.
164
+
165
+ The runtime is the `deno` npm package, pinned to the version the desktop app
166
+ downloads and fetched into `node_modules` on first run. `BRIDGETHING_DENO=/path`
167
+ overrides it.
168
+
169
+ ## Shipping
170
+
171
+ `bun run push` installs the webapp on a Car Thing, and the extension stays on the
172
+ desktop. Once someone installs the app through the desktop app, that app runs the
173
+ extension from the zip. Phones have no extension host, and the desktop app can be
174
+ closed while the device is on a phone, so make the webapp useful with nothing
175
+ attached.
176
+
177
+ `npm:`, `jsr:`, and `node:` specifiers work in the bundle, as do `Deno.Command`
178
+ and unix sockets, subject to the permissions you declared. Deno resolves them at
179
+ runtime only when it is not standing in a project: a `package.json` or
180
+ `deno.json` anywhere above the bundle switches it to node_modules resolution and
181
+ it refuses `npm:` with _"Could not find a matching package"_. The dev loop and
182
+ the desktop app run the bundle from a data directory with
183
+ `DENO_NO_PACKAGE_JSON=1`. Set that variable when you run the bundle by hand.