create-bridgething 0.1.2
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/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/index.mjs +147 -0
- package/package.json +44 -0
- package/template/CLAUDE.md +87 -0
- package/template/README.md +54 -0
- package/template/_claude/skills/bridgething/SKILL.md +24 -0
- package/template/_claude/skills/bridgething/reference/develop.md +142 -0
- package/template/_claude/skills/bridgething/reference/sdk.md +180 -0
- package/template/_claude/skills/bridgething/reference/ship.md +37 -0
- package/template/_gitignore +5 -0
- package/template/index.html +13 -0
- package/template/package.json +29 -0
- package/template/public/manifest.json +9 -0
- package/template/scripts/push.ts +289 -0
- package/template/scripts/share.ts +32 -0
- package/template/src/App.tsx +98 -0
- package/template/src/fonts/Inter-Variable.ttf +0 -0
- package/template/src/fonts/Outfit-Variable.ttf +0 -0
- package/template/src/index.css +52 -0
- package/template/src/main.tsx +10 -0
- package/template/src/vite-env.d.ts +9 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.config.ts +14 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# The webapp SDK (`@bridgething/client`)
|
|
2
|
+
|
|
3
|
+
A webapp reaches the daemon only through this one client: a typed facade over a
|
|
4
|
+
local WebSocket. You never open sockets or build wire messages yourself.
|
|
5
|
+
|
|
6
|
+
## Contents
|
|
7
|
+
|
|
8
|
+
- Read the types (the `.d.ts` files are the source of truth)
|
|
9
|
+
- Connect
|
|
10
|
+
- The three call shapes (events / requests / commands)
|
|
11
|
+
- Cookbook (player, asset, store, config, net, library)
|
|
12
|
+
- Full surface index (all 18)
|
|
13
|
+
- Gotchas
|
|
14
|
+
|
|
15
|
+
## Read the types
|
|
16
|
+
|
|
17
|
+
You have no editor to hover in, so read the shipped declarations directly. Every
|
|
18
|
+
method, event, and field carries a doc comment.
|
|
19
|
+
|
|
20
|
+
- `node_modules/@bridgething/client/dist/dispatch.generated.d.ts` - every
|
|
21
|
+
`client.<surface>` method and event, with argument and return types.
|
|
22
|
+
- `node_modules/@bridgething/lib/dist/bindings/*.d.ts` - the payload and reply
|
|
23
|
+
types (`PlayerState`, `MediaItem`, `NowPlayingUpdate`, `AssetGot`, ...) with
|
|
24
|
+
per-field docs. `client.d.ts` is the webapp surface; `shared.ts` holds the
|
|
25
|
+
shared data types.
|
|
26
|
+
|
|
27
|
+
Useful greps:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
grep -n 'class PlayerSurface' -A40 node_modules/@bridgething/client/dist/dispatch.generated.d.ts
|
|
31
|
+
grep -n 'export type PlayerState ' -A12 node_modules/@bridgething/lib/dist/bindings/shared.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The `.d.ts` is the source of truth; this file is the map.
|
|
35
|
+
|
|
36
|
+
## Connect
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { BridgethingClient } from '@bridgething/client';
|
|
40
|
+
|
|
41
|
+
const client = new BridgethingClient(); // on device: talks to the local daemon
|
|
42
|
+
// dev against a real device from your laptop:
|
|
43
|
+
const client = new BridgethingClient({ url: import.meta.env.VITE_BRIDGETHING_URL });
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Construct once and reuse (a `useMemo` in React). It auto-connects and
|
|
47
|
+
auto-reconnects. Watch the link with `client.on(e => ...)` (`e.type` is
|
|
48
|
+
`open` / `close` / `connecting` / `message`) and read `client.connectionState`
|
|
49
|
+
(`'connecting' | 'open' | 'closing' | 'closed'`). The daemon is always the sole
|
|
50
|
+
peer; you never address anything.
|
|
51
|
+
|
|
52
|
+
## The three call shapes
|
|
53
|
+
|
|
54
|
+
Every surface method is one of three shapes. Learn these and the whole SDK reads
|
|
55
|
+
the same way.
|
|
56
|
+
|
|
57
|
+
**1. Events** - the daemon pushes; you subscribe. `onXxx` returns an unsubscribe
|
|
58
|
+
function (call it in cleanup). Or `subscribe({...})` for several at once.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const off = client.player.onSnapshot(reply => setState(reply.state));
|
|
62
|
+
// ...later
|
|
63
|
+
off();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**2. Requests** - you ask, the daemon answers. Returns a tagged result; always
|
|
67
|
+
check `.ok`.
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
const res = await client.player.stateGet();
|
|
71
|
+
if (res.ok) console.log(res.response.state);
|
|
72
|
+
else console.warn(res.kind, res.error); // kind: 'domain' | 'protocol'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**3. Commands** - fire-and-forget. Returns `Promise<void>`; it resolves when the
|
|
76
|
+
daemon has taken the message, not when the phone finished acting.
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
await client.player.skipNext();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Cookbook
|
|
83
|
+
|
|
84
|
+
**Now playing + transport (`client.player`)**
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
client.player.onSnapshot(r => setState(r.state)); // full PlayerState on every material change
|
|
88
|
+
client.player.stateGet().then(r => r.ok && setState(r.response.state)); // prime on mount
|
|
89
|
+
|
|
90
|
+
// PlayerState: { track?: MediaItem, playback: Playback, queue, options, context? }
|
|
91
|
+
// track.title / track.artist / track.album / track.artworkId / track.durationMs
|
|
92
|
+
// playback.state: 'stopped' | 'paused' | 'playing'; playback.positionMs; playback.shuffle
|
|
93
|
+
|
|
94
|
+
client.player.play({ uri: 'spotify:track:...' });
|
|
95
|
+
client.player.pause(); client.player.resume(); client.player.skipNext();
|
|
96
|
+
client.player.skipPrev({ allowSeeking: true }); // true = restart if progressed
|
|
97
|
+
client.player.seekTo({ positionMs: 30_000 });
|
|
98
|
+
client.player.setShuffle({ on: true });
|
|
99
|
+
client.player.setRepeat({ mode: 'all' }); // 'off' | 'all' | 'one'
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Smooth progress: `onSnapshot` fires on material changes (track change, play/pause,
|
|
103
|
+
seek), NOT every second. Extrapolate the playhead locally from `playback.state`
|
|
104
|
+
and `positionMs` between snapshots.
|
|
105
|
+
|
|
106
|
+
**Artwork and images (`client.asset`)**
|
|
107
|
+
|
|
108
|
+
Player state gives opaque asset ids, never URLs. Fetch bytes and wrap a blob:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const res = await client.asset.get({ id: track.artworkId, requestId: crypto.randomUUID() });
|
|
112
|
+
if (res.ok) {
|
|
113
|
+
const bytes = new Uint8Array(res.response.bytes as unknown as number[]);
|
|
114
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: res.response.mime ?? 'image/jpeg' }));
|
|
115
|
+
// set <img src={url}>, and URL.revokeObjectURL(url) on cleanup
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Persistence (`client.store`)** - per-webapp key/value that survives restarts:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
await client.store.put({ key: 'theme', value: 'dark' });
|
|
123
|
+
const r = await client.store.get({ key: 'theme' }); // r.ok && r.response.value
|
|
124
|
+
await client.store.delete({ key: 'theme' });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**User settings (`client.config`)** - values you declare in `manifest.json`; the
|
|
128
|
+
user edits them in the companion app, your side is read-only:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
client.config.onChanged(c => applySetting(c.key, c.value));
|
|
132
|
+
const r = await client.config.get({ key: 'units' });
|
|
133
|
+
const all = await client.config.list();
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**Internet through the phone (`client.net`)** - the device has no direct network;
|
|
137
|
+
`net.fetch` / websockets / a SOCKS proxy all tunnel through the phone. Needs the
|
|
138
|
+
matching `permissions` entry in `manifest.json` (`net.fetch`, `net.ws`,
|
|
139
|
+
`net.proxy`). Read the `NetSurface` block in the `.d.ts` for the request shapes.
|
|
140
|
+
|
|
141
|
+
**Library + search (`client.library`)** - `browse`, `search`, `recommendations`,
|
|
142
|
+
`favoritesList`/`favoritesContains` (requests), `favoritesToggle`/`favoritesSet`
|
|
143
|
+
(commands), `onFavoriteChanged` (event). All Spotify-backed.
|
|
144
|
+
|
|
145
|
+
## Full surface index
|
|
146
|
+
|
|
147
|
+
18 surfaces on `client.<name>`. Read each surface's class in
|
|
148
|
+
`dispatch.generated.d.ts` for its exact methods and types.
|
|
149
|
+
|
|
150
|
+
| Surface | What it does | Notable methods |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| `player` | now-playing + transport | onSnapshot, stateGet, play, pause, resume, skipNext, skipPrev, seekTo, setShuffle, setRepeat, queueGet |
|
|
153
|
+
| `asset` | fetch blobs by opaque id | get, preload, onReady/onCleared |
|
|
154
|
+
| `store` | per-webapp key/value | get, put, delete |
|
|
155
|
+
| `config` | user settings from the manifest | get, list, onChanged |
|
|
156
|
+
| `capabilities` | which gateway features are live | get, onSnapshot |
|
|
157
|
+
| `library` | Spotify browse/search/favorites | browse, search, recommendations, favoritesList/Contains/Toggle/Set, onFavoriteChanged |
|
|
158
|
+
| `audio` | volume, mute, TTS, earcons | volumeUp/Down, setVolume, muteToggle, tts, earcon |
|
|
159
|
+
| `notifications` | phone notification actions | invokePositive/Negative, onPosted/Removed/Updated |
|
|
160
|
+
| `phone` | call control (Android; iOS uses iAP2) | stateGet, accept, end, initiate, mute, dtmf, onCallStarted/Updated/Ended |
|
|
161
|
+
| `geo` | location watches (phone-sourced) | watch, unwatch, getOnce, onPosition |
|
|
162
|
+
| `net` | HTTP / WS / SOCKS via the phone | fetch, wsOpen/wsSend/wsClose, streamOpen/streamCancel |
|
|
163
|
+
| `hardware` | backlight + ambient light sensor | displaySetMode, displaySetLevel, stateGet, onAmbientLightUpdate, onBrightnessChanged |
|
|
164
|
+
| `bluetooth` | adapter alias/bonds/discoverable | list, connect, forget, setAlias, enableDiscoverable |
|
|
165
|
+
| `system` | version, logs, power, diagnostics | versionRequest, logsTail/Subscribe, reboot, powerOff, factoryReset, onVersion, onLogEntry, onOta* |
|
|
166
|
+
| `time` | wall clock (device has no RTC) | get, onSnapshot, onChanged |
|
|
167
|
+
| `voice` | mic / push-to-talk (capability-gated) | pushToTalk, cancel, muteMic, stateGet |
|
|
168
|
+
| `webapp` | launcher: list/activate installed apps | list, current, activate, icon |
|
|
169
|
+
| `forward` | arbitrary passthrough to the companion | text, json, binary, onText/onJson/onBinary |
|
|
170
|
+
|
|
171
|
+
## Gotchas
|
|
172
|
+
|
|
173
|
+
- Always check `.ok` on request results; the daemon can answer with a domain or
|
|
174
|
+
protocol error (e.g. `player.play` with a uri no gateway claims).
|
|
175
|
+
- Assume no phone: player, library, net all depend on the connected companion.
|
|
176
|
+
Render an empty/placeholder state, do not throw.
|
|
177
|
+
- `config` is read-only from the webapp; only the companion writes it.
|
|
178
|
+
- Asset ids are opaque - never parse or construct them, never build image URLs.
|
|
179
|
+
- Some surfaces are capability-gated or platform-specific (e.g. `voice`, and
|
|
180
|
+
`phone` on iOS). Check `client.capabilities` and handle absence.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Installing and sharing the app
|
|
2
|
+
|
|
3
|
+
Two outputs: install onto your own connected device, and a zip anyone can install
|
|
4
|
+
through the bridgething companion app.
|
|
5
|
+
|
|
6
|
+
## Install onto a connected Car Thing
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun run push # targets bridgething.local (device over USB)
|
|
10
|
+
bun run push <address> # or an explicit host / IP
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`push` builds `dist/` itself, rsyncs it to the device, then tells the daemon to
|
|
14
|
+
switch the kiosk to your app - one command, no separate build step. The device
|
|
15
|
+
must be reachable: connected over USB it answers to `bridgething.local`;
|
|
16
|
+
otherwise pass its address. If multiple devices are on the network, set
|
|
17
|
+
`SUPERBIRD_HOST=bridgething-<serial>.local`.
|
|
18
|
+
|
|
19
|
+
Flags: `--skip-build` reuses the existing `dist/`; `--no-switch` copies without
|
|
20
|
+
activating. Iterating on hardware, rerun `bun run push` after each change. For
|
|
21
|
+
the fast edit loop, drive a local browser instead (see the develop reference) and
|
|
22
|
+
save the device for final checks.
|
|
23
|
+
|
|
24
|
+
## Make a shareable zip
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
bun run build # share does NOT build; produce dist/ first
|
|
28
|
+
bun run share # writes <name>-<version>.zip from dist/
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Send that zip to anyone with a bridgething-flashed Car Thing. They install it
|
|
32
|
+
from the bridgething companion app (no rebuild, no dev tools). The zip is just
|
|
33
|
+
your built bundle with `manifest.json` at its root.
|
|
34
|
+
|
|
35
|
+
Bump `version` in `public/manifest.json` before sharing an update, so people can
|
|
36
|
+
tell builds apart. Never touch `manifest.json`'s `id` - it is the webapp's
|
|
37
|
+
identity and the install path on the device.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="bg-black text-white">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=800, height=480, initial-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
|
6
|
+
<meta name="theme-color" content="#000000" />
|
|
7
|
+
<title>__PROJECT_NAME__</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body class="m-0 h-screen w-screen overflow-hidden touch-none select-none antialiased">
|
|
10
|
+
<div id="root" class="h-full w-full"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PROJECT_NAME__",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"preview": "vite preview",
|
|
10
|
+
"push": "bun scripts/push.ts",
|
|
11
|
+
"share": "bun scripts/share.ts"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@bridgething/client": "^0.1.2",
|
|
15
|
+
"react": "^19.2.0",
|
|
16
|
+
"react-dom": "^19.2.0"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@msgpack/msgpack": "^3.1.3",
|
|
20
|
+
"@tailwindcss/vite": "^4.1.16",
|
|
21
|
+
"@types/react": "^19.2.10",
|
|
22
|
+
"@types/react-dom": "^19.2.3",
|
|
23
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
24
|
+
"fflate": "^0.8.2",
|
|
25
|
+
"tailwindcss": "^4.1.16",
|
|
26
|
+
"typescript": "^6.0.3",
|
|
27
|
+
"vite": "^7.1.13"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// Build this webapp and install it onto a connected Car Thing: rsync dist/ to
|
|
3
|
+
// the device, then tell the daemon to switch the kiosk to it. You own this
|
|
4
|
+
// script; tweak it freely.
|
|
5
|
+
import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
function parseUuid(s: string): Uint8Array {
|
|
12
|
+
const hex = s.replace(/-/g, '').toLowerCase();
|
|
13
|
+
if (hex.length !== 32 || !/^[0-9a-f]+$/.test(hex)) {
|
|
14
|
+
throw new Error(`invalid uuid: ${s}`);
|
|
15
|
+
}
|
|
16
|
+
const out = new Uint8Array(16);
|
|
17
|
+
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function freshMsgId(): Uint8Array {
|
|
22
|
+
return parseUuid(randomUUID());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function uuidToString(bytes: Uint8Array): string {
|
|
26
|
+
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
27
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const FRAME_HEADER_LENGTH = 16;
|
|
31
|
+
const FRAME_MAGIC = 0xdead;
|
|
32
|
+
const FRAME_VERSION = 2;
|
|
33
|
+
const COMPRESSION_NONE = 0x00;
|
|
34
|
+
const ENCODING_MSGPACK = 0x00;
|
|
35
|
+
const PRIORITY_NORMAL = 0x00;
|
|
36
|
+
|
|
37
|
+
function writeFrameHeader(payloadLength: number): Uint8Array<ArrayBuffer> {
|
|
38
|
+
const buf = new Uint8Array(FRAME_HEADER_LENGTH);
|
|
39
|
+
const view = new DataView(buf.buffer);
|
|
40
|
+
view.setUint16(0, FRAME_MAGIC, false);
|
|
41
|
+
view.setUint8(2, FRAME_VERSION);
|
|
42
|
+
view.setUint8(3, COMPRESSION_NONE);
|
|
43
|
+
view.setUint8(4, ENCODING_MSGPACK);
|
|
44
|
+
view.setUint8(5, PRIORITY_NORMAL);
|
|
45
|
+
view.setBigUint64(8, BigInt(payloadLength), false);
|
|
46
|
+
return buf;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function frame(message: unknown): Uint8Array<ArrayBuffer> {
|
|
50
|
+
const body = msgpackEncode(message);
|
|
51
|
+
const header = writeFrameHeader(body.length);
|
|
52
|
+
const out = new Uint8Array(header.length + body.length);
|
|
53
|
+
out.set(header, 0);
|
|
54
|
+
out.set(body, header.length);
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type GatewayMsg = { id: Uint8Array; meta: unknown; data: unknown };
|
|
59
|
+
|
|
60
|
+
class FrameAccumulator {
|
|
61
|
+
private buffer = new Uint8Array(0);
|
|
62
|
+
|
|
63
|
+
append(chunk: Uint8Array): void {
|
|
64
|
+
if (chunk.length === 0) return;
|
|
65
|
+
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
|
66
|
+
merged.set(this.buffer, 0);
|
|
67
|
+
merged.set(chunk, this.buffer.length);
|
|
68
|
+
this.buffer = merged;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
next(): GatewayMsg | null {
|
|
72
|
+
if (this.buffer.length < FRAME_HEADER_LENGTH) return null;
|
|
73
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
74
|
+
const magic = view.getUint16(0, false);
|
|
75
|
+
if (magic !== FRAME_MAGIC) throw new Error(`bad framing magic 0x${magic.toString(16)}`);
|
|
76
|
+
const version = view.getUint8(2);
|
|
77
|
+
if (version !== FRAME_VERSION) throw new Error(`unsupported frame version ${version}`);
|
|
78
|
+
const compression = view.getUint8(3);
|
|
79
|
+
if (compression !== COMPRESSION_NONE) {
|
|
80
|
+
throw new Error(`unsupported inbound compression ${compression} (this script only handles uncompressed)`);
|
|
81
|
+
}
|
|
82
|
+
const encoding = view.getUint8(4);
|
|
83
|
+
if (encoding !== ENCODING_MSGPACK) throw new Error(`unsupported inbound encoding ${encoding}`);
|
|
84
|
+
const len = Number(view.getBigUint64(8, false));
|
|
85
|
+
const total = FRAME_HEADER_LENGTH + len;
|
|
86
|
+
if (this.buffer.length < total) return null;
|
|
87
|
+
const body = this.buffer.subarray(FRAME_HEADER_LENGTH, total);
|
|
88
|
+
const decoded = msgpackDecode(body) as GatewayMsg;
|
|
89
|
+
this.buffer = this.buffer.slice(total);
|
|
90
|
+
return decoded;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
95
|
+
if (a.length !== b.length) return false;
|
|
96
|
+
let diff = 0;
|
|
97
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
98
|
+
return diff === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type SwitchOutcome =
|
|
102
|
+
| { ok: true; activeId: Uint8Array | null; activeName: string | null }
|
|
103
|
+
| { ok: false; reason: string };
|
|
104
|
+
|
|
105
|
+
async function sendSwitchTo(host: string, port: number, manifestId: string): Promise<SwitchOutcome> {
|
|
106
|
+
const url = `ws://${host}:${port}/`;
|
|
107
|
+
console.log(`gateway ${url}`);
|
|
108
|
+
const ws = new WebSocket(url);
|
|
109
|
+
ws.binaryType = 'arraybuffer';
|
|
110
|
+
const acc = new FrameAccumulator();
|
|
111
|
+
|
|
112
|
+
const announce: GatewayMsg = {
|
|
113
|
+
id: freshMsgId(),
|
|
114
|
+
meta: { kind: 'event' },
|
|
115
|
+
data: {
|
|
116
|
+
type: 'capabilities',
|
|
117
|
+
data: {
|
|
118
|
+
event: 'announce',
|
|
119
|
+
data: {
|
|
120
|
+
gateway: {
|
|
121
|
+
address: '',
|
|
122
|
+
name: 'bridgething-webapp-push',
|
|
123
|
+
osName: 'host',
|
|
124
|
+
appName: 'bridgething-webapp-push',
|
|
125
|
+
appVersion: '0.1.0',
|
|
126
|
+
adapterVersion: 'host',
|
|
127
|
+
libVersion: 'v0',
|
|
128
|
+
libbridgethingVersion: 'v0',
|
|
129
|
+
},
|
|
130
|
+
uriSchemes: [],
|
|
131
|
+
network: { kind: 'unknown', metered: false },
|
|
132
|
+
available: { geo: false, notifications: false, netFetch: false, netWs: false, audioTts: false },
|
|
133
|
+
audio: { earcons: [], voices: [] },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const switchRequestId = freshMsgId();
|
|
140
|
+
const switchMsg: GatewayMsg = {
|
|
141
|
+
id: switchRequestId,
|
|
142
|
+
meta: { kind: 'request' },
|
|
143
|
+
data: {
|
|
144
|
+
type: 'webapp',
|
|
145
|
+
data: { event: 'switchTo', data: { id: parseUuid(manifestId) } },
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
let switchSent = false;
|
|
150
|
+
|
|
151
|
+
return await new Promise<SwitchOutcome>((res, rej) => {
|
|
152
|
+
const overall = setTimeout(() => {
|
|
153
|
+
try {
|
|
154
|
+
ws.close();
|
|
155
|
+
} catch {}
|
|
156
|
+
rej(new Error('gateway switch timed out (15s)'));
|
|
157
|
+
}, 15_000);
|
|
158
|
+
|
|
159
|
+
ws.addEventListener('open', () => {
|
|
160
|
+
ws.send(frame(announce));
|
|
161
|
+
ws.send(frame(switchMsg));
|
|
162
|
+
switchSent = true;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
ws.addEventListener('message', (event: MessageEvent) => {
|
|
166
|
+
const data = event.data;
|
|
167
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
|
|
168
|
+
if (!bytes) return;
|
|
169
|
+
try {
|
|
170
|
+
acc.append(bytes);
|
|
171
|
+
let msg = acc.next();
|
|
172
|
+
while (msg !== null) {
|
|
173
|
+
const meta = msg.meta as { kind?: string; data?: { requestId?: Uint8Array } };
|
|
174
|
+
if (meta?.kind === 'response') {
|
|
175
|
+
const respId = meta.data?.requestId;
|
|
176
|
+
if (respId && bytesEqual(respId, switchRequestId)) {
|
|
177
|
+
clearTimeout(overall);
|
|
178
|
+
try {
|
|
179
|
+
ws.close();
|
|
180
|
+
} catch {}
|
|
181
|
+
res(interpretSwitchResponse(msg.data));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
msg = acc.next();
|
|
186
|
+
}
|
|
187
|
+
} catch (err) {
|
|
188
|
+
clearTimeout(overall);
|
|
189
|
+
try {
|
|
190
|
+
ws.close();
|
|
191
|
+
} catch {}
|
|
192
|
+
rej(err instanceof Error ? err : new Error(String(err)));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
ws.addEventListener('close', (event: CloseEvent) => {
|
|
197
|
+
clearTimeout(overall);
|
|
198
|
+
// the daemon activates the webapp and then drops this ephemeral connection;
|
|
199
|
+
// a clean close after the switch was sent means it took, ack or not.
|
|
200
|
+
if (switchSent && (event.code === 1000 || event.code === 1005)) {
|
|
201
|
+
res({ ok: true, activeId: null, activeName: null });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
rej(new Error(`gateway ws closed before response (code ${event.code})`));
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function interpretSwitchResponse(data: unknown): SwitchOutcome {
|
|
210
|
+
const outer = data as { type?: string; data?: unknown };
|
|
211
|
+
if (outer?.type !== 'webapp') {
|
|
212
|
+
return { ok: false, reason: `unexpected response type ${JSON.stringify(outer?.type)}` };
|
|
213
|
+
}
|
|
214
|
+
const inner = outer.data as { event?: string; data?: unknown };
|
|
215
|
+
if (inner?.event === 'switched') {
|
|
216
|
+
const active = inner.data as { id?: Uint8Array | null; name?: string | null } | null;
|
|
217
|
+
return { ok: true, activeId: active?.id ?? null, activeName: active?.name ?? null };
|
|
218
|
+
}
|
|
219
|
+
if (inner?.event === 'webappError') {
|
|
220
|
+
const errVariant = inner.data as { type?: string; data?: { id?: string; reason?: string } } | undefined;
|
|
221
|
+
return { ok: false, reason: `daemon refused: ${errVariant?.type} ${JSON.stringify(errVariant?.data ?? {})}` };
|
|
222
|
+
}
|
|
223
|
+
return { ok: false, reason: `unexpected webapp response variant ${JSON.stringify(inner?.event)}` };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function rsync(localDir: string, host: string, remoteName: string): Promise<void> {
|
|
227
|
+
const sshArgs = 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=ERROR';
|
|
228
|
+
const src = localDir.endsWith('/') ? localDir : `${localDir}/`;
|
|
229
|
+
const dest = `root@${host}:/var/bridgething/webapps/${remoteName}/`;
|
|
230
|
+
console.log(`rsync ${src} -> ${dest}`);
|
|
231
|
+
return new Promise<void>((res, rej) => {
|
|
232
|
+
const child = spawn('rsync', ['-avz', '--delete', '-e', sshArgs, src, dest], { stdio: 'inherit' });
|
|
233
|
+
child.on('exit', code => (code === 0 ? res() : rej(new Error(`rsync exited ${code}`))));
|
|
234
|
+
child.on('error', rej);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildBundle(repoDir: string): Promise<void> {
|
|
239
|
+
console.log('bun run build');
|
|
240
|
+
return new Promise<void>((res, rej) => {
|
|
241
|
+
const child = spawn('bun', ['run', 'build'], { cwd: repoDir, stdio: 'inherit' });
|
|
242
|
+
child.on('exit', code => (code === 0 ? res() : rej(new Error(`bun run build exited ${code}`))));
|
|
243
|
+
child.on('error', rej);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function main(): Promise<void> {
|
|
248
|
+
const args = process.argv.slice(2);
|
|
249
|
+
let skipBuild = process.env.SKIP_BUILD === '1';
|
|
250
|
+
let switchAfter = true;
|
|
251
|
+
let host = process.env.SUPERBIRD_HOST ?? 'bridgething.local';
|
|
252
|
+
for (const arg of args) {
|
|
253
|
+
if (arg === '--skip-build') skipBuild = true;
|
|
254
|
+
else if (arg === '--no-switch') switchAfter = false;
|
|
255
|
+
else if (arg.startsWith('--')) throw new Error(`unknown flag: ${arg}`);
|
|
256
|
+
else host = arg;
|
|
257
|
+
}
|
|
258
|
+
const port = Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892);
|
|
259
|
+
|
|
260
|
+
const repoDir = resolve(import.meta.dir, '..');
|
|
261
|
+
const distDir = resolve(repoDir, 'dist');
|
|
262
|
+
const manifestPath = resolve(distDir, 'manifest.json');
|
|
263
|
+
|
|
264
|
+
if (!skipBuild) await buildBundle(repoDir);
|
|
265
|
+
|
|
266
|
+
if (!existsSync(manifestPath)) {
|
|
267
|
+
throw new Error(`no manifest.json at ${manifestPath}; run 'bun run build' first or drop --skip-build`);
|
|
268
|
+
}
|
|
269
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { id?: string };
|
|
270
|
+
if (!manifest.id) throw new Error(`${manifestPath} has no 'id' field`);
|
|
271
|
+
|
|
272
|
+
const remoteName = process.env.BRIDGETHING_BUNDLE_NAME ?? manifest.id;
|
|
273
|
+
await rsync(distDir, host, remoteName);
|
|
274
|
+
|
|
275
|
+
if (!switchAfter) {
|
|
276
|
+
console.log('skipping switch (--no-switch)');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const outcome = await sendSwitchTo(host, port, manifest.id);
|
|
281
|
+
if (!outcome.ok) throw new Error(outcome.reason);
|
|
282
|
+
const activeStr = outcome.activeId ? uuidToString(outcome.activeId) : '(none)';
|
|
283
|
+
console.log(`active webapp: ${outcome.activeName ?? '(unnamed)'} ${activeStr}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
main().catch(err => {
|
|
287
|
+
console.error(err instanceof Error ? err.message : err);
|
|
288
|
+
process.exit(1);
|
|
289
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { zipSync } from 'fflate';
|
|
3
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { join, relative, resolve } from 'node:path';
|
|
5
|
+
|
|
6
|
+
const repoDir = resolve(import.meta.dir, '..');
|
|
7
|
+
const distDir = resolve(repoDir, 'dist');
|
|
8
|
+
const manifestPath = join(distDir, 'manifest.json');
|
|
9
|
+
|
|
10
|
+
if (!existsSync(manifestPath)) {
|
|
11
|
+
console.error(`no manifest.json at ${manifestPath}; run 'bun run build' first`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { name?: string; version?: string };
|
|
16
|
+
const name = (manifest.name || 'webapp').replace(/[^a-z0-9._-]+/gi, '-');
|
|
17
|
+
const version = manifest.version || '0.0.0';
|
|
18
|
+
|
|
19
|
+
const files: Record<string, Uint8Array> = {};
|
|
20
|
+
function walk(dir: string): void {
|
|
21
|
+
for (const entry of readdirSync(dir)) {
|
|
22
|
+
const abs = join(dir, entry);
|
|
23
|
+
if (statSync(abs).isDirectory()) walk(abs);
|
|
24
|
+
else files[relative(distDir, abs)] = new Uint8Array(readFileSync(abs));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
walk(distDir);
|
|
28
|
+
|
|
29
|
+
const outPath = resolve(repoDir, `${name}-${version}.zip`);
|
|
30
|
+
writeFileSync(outPath, zipSync(files, { level: 9 }));
|
|
31
|
+
console.log(`wrote ${relative(process.cwd(), outPath)} (${Object.keys(files).length} files)`);
|
|
32
|
+
console.log('share it: anyone with a bridgething Car Thing installs it from the companion app');
|