create-bridgething 0.5.2 → 0.7.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/dist/index.mjs +73 -9
- package/package.json +5 -2
- package/template/_claude/skills/bridgething/reference/develop.md +10 -9
- package/template/_claude/skills/bridgething/reference/ship.md +7 -0
- package/template/package.json +1 -1
- package/template/scripts/push.ts +223 -122
- package/template-launcher/_claude_append.md +40 -0
- package/template-launcher/src/App.tsx +98 -0
- package/template-overlay/_claude_append.md +64 -0
- package/template-overlay/overlay/main.tsx +228 -0
- package/template-overlay/overlay/style.css +8 -0
- package/template-overlay/src/App.tsx +12 -0
- package/template-overlay/vite.overlay.config.ts +44 -0
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { dirname, extname, join, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { v7 } from "uuid";
|
|
@@ -9,13 +9,37 @@ import { v7 } from "uuid";
|
|
|
9
9
|
//! substitute the project name, print next steps. Opinionated stack:
|
|
10
10
|
//! React 19 + Vite + Tailwind v4 + TypeScript strict, plus
|
|
11
11
|
//! `@bridgething/client` preinstalled.
|
|
12
|
-
const
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
const TEMPLATE_DIR = resolve(__dirname, "..", "template");
|
|
14
|
+
const VARIANTS = {
|
|
15
|
+
app: { summary: "webapp" },
|
|
16
|
+
launcher: {
|
|
17
|
+
dir: "template-launcher",
|
|
18
|
+
manifest: {
|
|
19
|
+
role: "launcher",
|
|
20
|
+
description: "A custom home screen."
|
|
21
|
+
},
|
|
22
|
+
summary: "launcher (a replacement home screen)"
|
|
23
|
+
},
|
|
24
|
+
overlay: {
|
|
25
|
+
dir: "template-overlay",
|
|
26
|
+
manifest: {
|
|
27
|
+
overlay: "overlay.js",
|
|
28
|
+
description: "A custom system overlay."
|
|
29
|
+
},
|
|
30
|
+
scripts: { build: "vite build && vite build -c vite.settings.config.ts && vite build -c vite.overlay.config.ts" },
|
|
31
|
+
summary: "overlay (system UI drawn over every webapp)"
|
|
32
|
+
}
|
|
33
|
+
};
|
|
13
34
|
function parseArgs(argv) {
|
|
14
35
|
const positional = [];
|
|
15
36
|
let install = true;
|
|
16
37
|
let git = true;
|
|
38
|
+
let variant = "app";
|
|
17
39
|
for (const arg of argv) if (arg === "--no-install") install = false;
|
|
18
40
|
else if (arg === "--no-git") git = false;
|
|
41
|
+
else if (arg === "--launcher") variant = "launcher";
|
|
42
|
+
else if (arg === "--overlay") variant = "overlay";
|
|
19
43
|
else if (arg === "--help" || arg === "-h") {
|
|
20
44
|
printHelp();
|
|
21
45
|
process.exit(0);
|
|
@@ -31,19 +55,52 @@ function parseArgs(argv) {
|
|
|
31
55
|
return {
|
|
32
56
|
target: positional[0],
|
|
33
57
|
install,
|
|
34
|
-
git
|
|
58
|
+
git,
|
|
59
|
+
variant
|
|
35
60
|
};
|
|
36
61
|
}
|
|
37
62
|
function printHelp() {
|
|
38
|
-
console.log(`Usage: create-bridgething <target-dir> [--no-install] [--no-git]
|
|
63
|
+
console.log(`Usage: create-bridgething <target-dir> [--launcher | --overlay] [--no-install] [--no-git]
|
|
39
64
|
|
|
40
65
|
Scaffold a new bridgething webapp at <target-dir>.
|
|
41
66
|
|
|
67
|
+
Variants:
|
|
68
|
+
(default) A normal webapp.
|
|
69
|
+
--launcher A home screen. Declares 'role: launcher' and can take the
|
|
70
|
+
device's launcher slot, replacing the built-in hub.
|
|
71
|
+
--overlay A system overlay. Ships an 'overlay.js' the daemon injects
|
|
72
|
+
into every webapp, replacing the built-in one.
|
|
73
|
+
|
|
42
74
|
Options:
|
|
43
75
|
--no-install Skip 'bun install' after copying.
|
|
44
76
|
--no-git Skip 'git init' after copying.
|
|
45
77
|
`);
|
|
46
78
|
}
|
|
79
|
+
function patchJson(path, patch) {
|
|
80
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
writeFileSync(path, `${JSON.stringify({
|
|
82
|
+
...parsed,
|
|
83
|
+
...patch
|
|
84
|
+
}, null, 2)}\n`);
|
|
85
|
+
}
|
|
86
|
+
function applyVariant(target, variant, subs) {
|
|
87
|
+
const spec = VARIANTS[variant];
|
|
88
|
+
if (spec.dir) copyTemplate(resolve(__dirname, "..", spec.dir), target, subs);
|
|
89
|
+
const appendPath = join(target, "_claude_append.md");
|
|
90
|
+
if (existsSync(appendPath)) {
|
|
91
|
+
const claudePath = join(target, "CLAUDE.md");
|
|
92
|
+
writeFileSync(claudePath, readFileSync(claudePath, "utf8") + readFileSync(appendPath, "utf8"));
|
|
93
|
+
rmSync(appendPath);
|
|
94
|
+
}
|
|
95
|
+
if (spec.manifest) patchJson(join(target, "public", "manifest.json"), spec.manifest);
|
|
96
|
+
if (spec.scripts) {
|
|
97
|
+
const pkgPath = join(target, "package.json");
|
|
98
|
+
patchJson(pkgPath, { scripts: {
|
|
99
|
+
...JSON.parse(readFileSync(pkgPath, "utf8")).scripts,
|
|
100
|
+
...spec.scripts
|
|
101
|
+
} });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
47
104
|
const BINARY_EXT = new Set([
|
|
48
105
|
".ttf",
|
|
49
106
|
".otf",
|
|
@@ -108,12 +165,14 @@ function main() {
|
|
|
108
165
|
}
|
|
109
166
|
} else mkdirSync(target, { recursive: true });
|
|
110
167
|
const webappUuid = v7();
|
|
111
|
-
|
|
112
|
-
copyTemplate(TEMPLATE_DIR, target, {
|
|
168
|
+
const subs = {
|
|
113
169
|
projectName,
|
|
114
170
|
webappUuid
|
|
115
|
-
}
|
|
116
|
-
console.log(
|
|
171
|
+
};
|
|
172
|
+
console.log(`scaffolding ${projectName} (${webappUuid}) in ${target}`);
|
|
173
|
+
copyTemplate(TEMPLATE_DIR, target, subs);
|
|
174
|
+
applyVariant(target, args.variant, subs);
|
|
175
|
+
console.log(` ✓ template copied (${VARIANTS[args.variant].summary})`);
|
|
117
176
|
linkAgentAliases(target);
|
|
118
177
|
console.log(" ✓ agent guides linked (CLAUDE.md, AGENTS.md, /bridgething skill)");
|
|
119
178
|
if (args.git) if (run("git", ["init", "--quiet"], target)) console.log(" ✓ git initialized");
|
|
@@ -138,7 +197,12 @@ driving the app, and installing and sharing it.
|
|
|
138
197
|
bun run push <addr> # build + install onto a connected Car Thing (default bridgething.local)
|
|
139
198
|
bun run share # build first, then zip dist/ to hand to friends
|
|
140
199
|
bun run update # bring the connected Car Thing to the latest bridgething release
|
|
141
|
-
|
|
200
|
+
${args.variant === "app" ? "" : `
|
|
201
|
+
'bun run push' also claims the device's ${args.variant} slot, so this is live the
|
|
202
|
+
moment it lands. 'bun run push --release' hands the slot back to the built-in
|
|
203
|
+
${args.variant === "launcher" ? "hub" : "overlay"}, which is how you recover if a build of this wedges the screen.
|
|
204
|
+
The companion phone app can do the same thing.
|
|
205
|
+
`}
|
|
142
206
|
The starter App connects to ws://127.0.0.1:8891/ on the device, and to
|
|
143
207
|
ws://<device-ip>:8891/ in dev when you set VITE_BRIDGETHING_URL.
|
|
144
208
|
`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-bridgething",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Scaffold a new bridgething webapp (React + Vite + Tailwind v4)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,9 +24,12 @@
|
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
|
-
"template"
|
|
27
|
+
"template",
|
|
28
|
+
"template-launcher",
|
|
29
|
+
"template-overlay"
|
|
28
30
|
],
|
|
29
31
|
"scripts": {
|
|
32
|
+
"prebuild": "bun scripts/sync-push.ts",
|
|
30
33
|
"build": "tsdown",
|
|
31
34
|
"dev": "tsdown --watch",
|
|
32
35
|
"typecheck": "tsc --noEmit"
|
|
@@ -68,25 +68,26 @@ CDP input goes straight into the page (never through the physical buttons), so t
|
|
|
68
68
|
launcher gesture and on-device browser-nav side effects never interfere.
|
|
69
69
|
|
|
70
70
|
Install the app onto the device first so the kiosk is showing it (build + push).
|
|
71
|
-
|
|
72
|
-
chromium refuses to bind it to anything routable, so a tunnel is mandatory):
|
|
71
|
+
CDP is served at `bridgething.local:9222`, reachable directly over USB:
|
|
73
72
|
|
|
74
73
|
```bash
|
|
75
74
|
# device answers at bridgething.local over USB; root has no password
|
|
76
|
-
|
|
77
|
-
curl -s http://localhost:9223/json/version >/dev/null && echo tunnel-up
|
|
75
|
+
curl -s http://bridgething.local:9222/json/version >/dev/null && echo cdp-up
|
|
78
76
|
```
|
|
79
77
|
|
|
78
|
+
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.
|
|
80
|
+
|
|
80
81
|
**Playwright's `connectOverCDP` hangs against the device's embedded chromium** -
|
|
81
82
|
it never finishes the websocket upgrade (a quirk of the device's `cast_shell`
|
|
82
|
-
build
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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:
|
|
86
87
|
|
|
87
88
|
```ts
|
|
88
89
|
// drive.ts -> run with: bun drive.ts
|
|
89
|
-
const targets = (await fetch('http://
|
|
90
|
+
const targets = (await fetch('http://bridgething.local:9222/json').then(r => r.json())) as any[];
|
|
90
91
|
const page = targets.find(t => t.type === 'page');
|
|
91
92
|
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
|
92
93
|
await new Promise<void>((res, rej) => {
|
|
@@ -21,6 +21,13 @@ activating. Iterating on hardware, rerun `bun run push` after each change. For
|
|
|
21
21
|
the fast edit loop, drive a local browser instead (see the develop reference) and
|
|
22
22
|
save the device for final checks.
|
|
23
23
|
|
|
24
|
+
If `manifest.json` declares `role: launcher` or an `overlay`, `push` also claims
|
|
25
|
+
the matching device slot, so the bundle is live rather than merely installed. A
|
|
26
|
+
launcher is switched to; an overlay is not, since switching away from the app it
|
|
27
|
+
draws over defeats the point. `--no-slot` pushes without claiming, and
|
|
28
|
+
`--release` gives the slots back to the built-in hub and overlay - the recovery
|
|
29
|
+
path when a build wedges the screen.
|
|
30
|
+
|
|
24
31
|
## Make a shareable zip
|
|
25
32
|
|
|
26
33
|
```bash
|
package/template/package.json
CHANGED
package/template/scripts/push.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Build this webapp and install it onto a connected Car Thing
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Build this webapp and install it onto a connected Car Thing, then make it
|
|
3
|
+
// visible: a plain app becomes active, a launcher takes the home-screen slot,
|
|
4
|
+
// an overlay takes the overlay slot. Run with --help for the flags.
|
|
5
|
+
//
|
|
6
|
+
// Generated from @bridgething/webapp-shared. It is yours now; tweak it freely.
|
|
5
7
|
import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
|
|
6
8
|
import { spawn } from 'node:child_process';
|
|
7
9
|
import { randomUUID } from 'node:crypto';
|
|
8
10
|
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
-
import { resolve } from 'node:path';
|
|
11
|
+
import { dirname, resolve } from 'node:path';
|
|
10
12
|
|
|
11
13
|
function parseUuid(s: string): Uint8Array {
|
|
12
14
|
const hex = s.replace(/-/g, '').toLowerCase();
|
|
@@ -27,6 +29,12 @@ function uuidToString(bytes: Uint8Array): string {
|
|
|
27
29
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
function bundleDirName(id: string): string {
|
|
33
|
+
const hex = id.replace(/-/g, '').toLowerCase();
|
|
34
|
+
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`manifest id is not a uuid: ${id}`);
|
|
35
|
+
return hex;
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
const FRAME_HEADER_LENGTH = 16;
|
|
31
39
|
const FRAME_MAGIC = 0xdead;
|
|
32
40
|
const FRAME_VERSION = 2;
|
|
@@ -98,107 +106,82 @@ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
|
98
106
|
return diff === 0;
|
|
99
107
|
}
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
const ANNOUNCE_DATA = {
|
|
110
|
+
type: 'capabilities',
|
|
111
|
+
data: {
|
|
112
|
+
event: 'announce',
|
|
113
|
+
data: {
|
|
114
|
+
gateway: {
|
|
115
|
+
address: '',
|
|
116
|
+
name: 'bridgething-webapp-push',
|
|
117
|
+
osName: 'host',
|
|
118
|
+
appName: 'bridgething-webapp-push',
|
|
119
|
+
appVersion: '0.1.0',
|
|
120
|
+
adapterVersion: 'host',
|
|
121
|
+
libVersion: 'v0',
|
|
122
|
+
libbridgethingVersion: 'v0',
|
|
123
|
+
},
|
|
124
|
+
uriSchemes: [],
|
|
125
|
+
network: { kind: 'unknown', metered: false },
|
|
126
|
+
available: { geo: false, notifications: false, netFetch: false, netWs: false, audioTts: false },
|
|
127
|
+
audio: { earcons: [], voices: [] },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
};
|
|
104
131
|
|
|
105
|
-
|
|
132
|
+
function sendRequest(host: string, port: number, data: unknown): Promise<unknown | null> {
|
|
106
133
|
const url = `ws://${host}:${port}/`;
|
|
107
|
-
console.log(`gateway ${url}`);
|
|
108
134
|
const ws = new WebSocket(url);
|
|
109
135
|
ws.binaryType = 'arraybuffer';
|
|
110
136
|
const acc = new FrameAccumulator();
|
|
137
|
+
const requestId = freshMsgId();
|
|
138
|
+
let sent = false;
|
|
111
139
|
|
|
112
|
-
|
|
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) => {
|
|
140
|
+
return new Promise<unknown | null>((res, rej) => {
|
|
152
141
|
const overall = setTimeout(() => {
|
|
153
142
|
try {
|
|
154
143
|
ws.close();
|
|
155
144
|
} catch {}
|
|
156
|
-
rej(new Error(
|
|
145
|
+
rej(new Error(`gateway request timed out (15s) against ${url}`));
|
|
157
146
|
}, 15_000);
|
|
158
147
|
|
|
148
|
+
const finish = (fn: () => void) => {
|
|
149
|
+
clearTimeout(overall);
|
|
150
|
+
try {
|
|
151
|
+
ws.close();
|
|
152
|
+
} catch {}
|
|
153
|
+
fn();
|
|
154
|
+
};
|
|
155
|
+
|
|
159
156
|
ws.addEventListener('open', () => {
|
|
160
|
-
ws.send(frame(
|
|
161
|
-
ws.send(frame(
|
|
162
|
-
|
|
157
|
+
ws.send(frame({ id: freshMsgId(), meta: { kind: 'event' }, data: ANNOUNCE_DATA }));
|
|
158
|
+
ws.send(frame({ id: requestId, meta: { kind: 'request' }, data }));
|
|
159
|
+
sent = true;
|
|
163
160
|
});
|
|
164
161
|
|
|
165
162
|
ws.addEventListener('message', (event: MessageEvent) => {
|
|
166
|
-
const
|
|
167
|
-
const bytes =
|
|
163
|
+
const raw = event.data;
|
|
164
|
+
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
|
|
168
165
|
if (!bytes) return;
|
|
169
166
|
try {
|
|
170
167
|
acc.append(bytes);
|
|
171
|
-
let msg = acc.next();
|
|
172
|
-
while (msg !== null) {
|
|
168
|
+
for (let msg = acc.next(); msg !== null; msg = acc.next()) {
|
|
173
169
|
const meta = msg.meta as { kind?: string; data?: { requestId?: Uint8Array } };
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
try {
|
|
179
|
-
ws.close();
|
|
180
|
-
} catch {}
|
|
181
|
-
res(interpretSwitchResponse(msg.data));
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
170
|
+
const respId = meta?.kind === 'response' ? meta.data?.requestId : undefined;
|
|
171
|
+
if (respId && bytesEqual(respId, requestId)) {
|
|
172
|
+
finish(() => res(msg.data));
|
|
173
|
+
return;
|
|
184
174
|
}
|
|
185
|
-
msg = acc.next();
|
|
186
175
|
}
|
|
187
176
|
} catch (err) {
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
ws.close();
|
|
191
|
-
} catch {}
|
|
192
|
-
rej(err instanceof Error ? err : new Error(String(err)));
|
|
177
|
+
finish(() => rej(err instanceof Error ? err : new Error(String(err))));
|
|
193
178
|
}
|
|
194
179
|
});
|
|
195
180
|
|
|
196
181
|
ws.addEventListener('close', (event: CloseEvent) => {
|
|
197
182
|
clearTimeout(overall);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
if (switchSent && (event.code === 1000 || event.code === 1005)) {
|
|
201
|
-
res({ ok: true, activeId: null, activeName: null });
|
|
183
|
+
if (sent && (event.code === 1000 || event.code === 1005)) {
|
|
184
|
+
res(null);
|
|
202
185
|
return;
|
|
203
186
|
}
|
|
204
187
|
rej(new Error(`gateway ws closed before response (code ${event.code})`));
|
|
@@ -206,84 +189,202 @@ async function sendSwitchTo(host: string, port: number, manifestId: string): Pro
|
|
|
206
189
|
});
|
|
207
190
|
}
|
|
208
191
|
|
|
209
|
-
|
|
192
|
+
type Outcome<T> = { ok: true; value: T | null } | { ok: false; reason: string };
|
|
193
|
+
|
|
194
|
+
function interpret<T>(data: unknown | null, expected: string): Outcome<T> {
|
|
195
|
+
if (data === null) return { ok: true, value: null };
|
|
210
196
|
const outer = data as { type?: string; data?: unknown };
|
|
211
197
|
if (outer?.type !== 'webapp') {
|
|
212
198
|
return { ok: false, reason: `unexpected response type ${JSON.stringify(outer?.type)}` };
|
|
213
199
|
}
|
|
214
200
|
const inner = outer.data as { event?: string; data?: unknown };
|
|
215
|
-
if (inner?.event ===
|
|
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
|
-
}
|
|
201
|
+
if (inner?.event === expected) return { ok: true, value: inner.data as T };
|
|
219
202
|
if (inner?.event === 'webappError') {
|
|
220
|
-
const
|
|
221
|
-
return { ok: false, reason: `daemon refused: ${
|
|
203
|
+
const err = inner.data as { type?: string; data?: Record<string, unknown> } | undefined;
|
|
204
|
+
return { ok: false, reason: `daemon refused: ${err?.type} ${JSON.stringify(err?.data ?? {})}` };
|
|
222
205
|
}
|
|
223
206
|
return { ok: false, reason: `unexpected webapp response variant ${JSON.stringify(inner?.event)}` };
|
|
224
207
|
}
|
|
225
208
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
209
|
+
type ActiveWebapp = { id?: Uint8Array | null; name?: string | null };
|
|
210
|
+
type Slots = { launcher?: Uint8Array | null; overlay?: Uint8Array | null };
|
|
211
|
+
type Slot = 'launcher' | 'overlay';
|
|
212
|
+
|
|
213
|
+
async function switchTo(host: string, port: number, id: string): Promise<Outcome<ActiveWebapp>> {
|
|
214
|
+
const data = { type: 'webapp', data: { event: 'switchTo', data: { id: parseUuid(id) } } };
|
|
215
|
+
return interpret<ActiveWebapp>(await sendRequest(host, port, data), 'switched');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function setSlot(host: string, port: number, slot: Slot, id: string | null): Promise<Outcome<Slots>> {
|
|
219
|
+
const data = {
|
|
220
|
+
type: 'webapp',
|
|
221
|
+
data: { event: 'setSlot', data: { slot, id: id === null ? null : parseUuid(id) } },
|
|
222
|
+
};
|
|
223
|
+
return interpret<Slots>(await sendRequest(host, port, data), 'slots');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const WEBAPP_ROOT = '/var/bridgething/webapps';
|
|
227
|
+
const SSH_OPTS = ['-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=ERROR'];
|
|
228
|
+
|
|
229
|
+
function run(cmd: string, args: string[], label: string, cwd?: string): Promise<void> {
|
|
231
230
|
return new Promise<void>((res, rej) => {
|
|
232
|
-
const child = spawn(
|
|
233
|
-
child.on('exit', code => (code === 0 ? res() : rej(new Error(
|
|
231
|
+
const child = spawn(cmd, args, { stdio: 'inherit', cwd });
|
|
232
|
+
child.on('exit', code => (code === 0 ? res() : rej(new Error(`${label} exited ${code}`))));
|
|
234
233
|
child.on('error', rej);
|
|
235
234
|
});
|
|
236
235
|
}
|
|
237
236
|
|
|
237
|
+
async function pushBundle(localDir: string, host: string, dirName: string): Promise<void> {
|
|
238
|
+
const src = localDir.endsWith('/') ? localDir : `${localDir}/`;
|
|
239
|
+
const staged = `.push.${dirName}`;
|
|
240
|
+
const dest = `root@${host}:${WEBAPP_ROOT}/${staged}/`;
|
|
241
|
+
console.log(`rsync ${src} -> root@${host}:${WEBAPP_ROOT}/${dirName}/`);
|
|
242
|
+
await run('rsync', ['-avz', '--delete', '-e', ['ssh', ...SSH_OPTS].join(' '), src, dest], 'rsync');
|
|
243
|
+
const swap = [
|
|
244
|
+
'set -e',
|
|
245
|
+
`cd ${WEBAPP_ROOT}`,
|
|
246
|
+
`rm -rf .old.${dirName}`,
|
|
247
|
+
`if [ -d ${dirName} ]; then mv ${dirName} .old.${dirName}; fi`,
|
|
248
|
+
`mv ${staged} ${dirName}`,
|
|
249
|
+
`rm -rf .old.${dirName}`,
|
|
250
|
+
].join('; ');
|
|
251
|
+
await run('ssh', [...SSH_OPTS, `root@${host}`, swap], 'ssh');
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
function buildBundle(repoDir: string): Promise<void> {
|
|
239
255
|
console.log('bun run build');
|
|
240
|
-
return
|
|
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
|
-
});
|
|
256
|
+
return run('bun', ['run', 'build'], 'bun run build', repoDir);
|
|
245
257
|
}
|
|
246
258
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
259
|
+
type Manifest = { id?: string; name?: string; role?: string; overlay?: string };
|
|
260
|
+
|
|
261
|
+
function declaredSlots(manifest: Manifest): Slot[] {
|
|
262
|
+
const slots: Slot[] = [];
|
|
263
|
+
if (manifest.role === 'launcher') slots.push('launcher');
|
|
264
|
+
if (manifest.overlay) slots.push('overlay');
|
|
265
|
+
return slots;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
type Args = {
|
|
269
|
+
host: string;
|
|
270
|
+
port: number;
|
|
271
|
+
skipBuild: boolean;
|
|
272
|
+
claimSlots: boolean;
|
|
273
|
+
release: boolean;
|
|
274
|
+
switchAfter: boolean | null;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
function parseArgs(argv: string[]): Args {
|
|
278
|
+
const args: Args = {
|
|
279
|
+
host: process.env.SUPERBIRD_HOST ?? 'bridgething.local',
|
|
280
|
+
port: Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892),
|
|
281
|
+
skipBuild: process.env.SKIP_BUILD === '1',
|
|
282
|
+
claimSlots: true,
|
|
283
|
+
release: false,
|
|
284
|
+
switchAfter: null,
|
|
285
|
+
};
|
|
286
|
+
for (const arg of argv) {
|
|
287
|
+
if (arg === '--skip-build') args.skipBuild = true;
|
|
288
|
+
else if (arg === '--no-switch') args.switchAfter = false;
|
|
289
|
+
else if (arg === '--switch') args.switchAfter = true;
|
|
290
|
+
else if (arg === '--no-slot') args.claimSlots = false;
|
|
291
|
+
else if (arg === '--release') args.release = true;
|
|
292
|
+
else if (arg === '--help' || arg === '-h') {
|
|
293
|
+
printHelp();
|
|
294
|
+
process.exit(0);
|
|
295
|
+
} else if (arg.startsWith('--')) throw new Error(`unknown flag: ${arg}`);
|
|
296
|
+
else args.host = arg;
|
|
257
297
|
}
|
|
258
|
-
|
|
298
|
+
return args;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function printHelp(): void {
|
|
302
|
+
console.log(`Usage: bun run push [host] [options]
|
|
303
|
+
|
|
304
|
+
Build, rsync dist/ onto a connected Car Thing, and make it visible.
|
|
305
|
+
|
|
306
|
+
A plain webapp becomes the active app. A launcher also takes the home-screen
|
|
307
|
+
slot; an overlay takes the overlay slot and the daemon reloads the kiosk so it
|
|
308
|
+
is injected into whatever app is showing. Overlay-only bundles are not switched
|
|
309
|
+
to by default, since switching away is the opposite of what you want to test.
|
|
259
310
|
|
|
260
|
-
|
|
311
|
+
Options:
|
|
312
|
+
--release hand this bundle's slots back to the built-in ones and stop.
|
|
313
|
+
the recovery path when a build wedges the screen.
|
|
314
|
+
--no-slot push without claiming any slot.
|
|
315
|
+
--switch switch to this bundle even if it is overlay-only.
|
|
316
|
+
--no-switch push without switching.
|
|
317
|
+
--skip-build rsync whatever is already in dist/.
|
|
318
|
+
|
|
319
|
+
Env: SUPERBIRD_HOST, BRIDGETHING_GATEWAY_PORT, SKIP_BUILD=1
|
|
320
|
+
`);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type BridgethingPushOptions = {
|
|
324
|
+
scriptUrl: string;
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
export async function bridgethingPush({ scriptUrl }: BridgethingPushOptions): Promise<void> {
|
|
328
|
+
const args = parseArgs(process.argv.slice(2));
|
|
329
|
+
|
|
330
|
+
const repoDir = resolve(dirname(new URL(scriptUrl).pathname), '..');
|
|
261
331
|
const distDir = resolve(repoDir, 'dist');
|
|
262
332
|
const manifestPath = resolve(distDir, 'manifest.json');
|
|
263
333
|
|
|
264
|
-
|
|
334
|
+
const readManifest = (): Manifest => {
|
|
335
|
+
if (!existsSync(manifestPath)) {
|
|
336
|
+
throw new Error(`no manifest.json at ${manifestPath}; run 'bun run build' first or drop --skip-build`);
|
|
337
|
+
}
|
|
338
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest;
|
|
339
|
+
if (!parsed.id) throw new Error(`${manifestPath} has no 'id' field`);
|
|
340
|
+
return parsed;
|
|
341
|
+
};
|
|
265
342
|
|
|
266
|
-
if (
|
|
267
|
-
|
|
343
|
+
if (args.release) {
|
|
344
|
+
const manifest = readManifest();
|
|
345
|
+
for (const slot of declaredSlots(manifest)) {
|
|
346
|
+
const result = await setSlot(args.host, args.port, slot, null);
|
|
347
|
+
if (!result.ok) throw new Error(result.reason);
|
|
348
|
+
console.log(`${slot} slot: reverted to the built-in one`);
|
|
349
|
+
}
|
|
350
|
+
if (declaredSlots(manifest).length === 0) console.log('this bundle declares no slots; nothing to release');
|
|
351
|
+
return;
|
|
268
352
|
}
|
|
269
|
-
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { id?: string };
|
|
270
|
-
if (!manifest.id) throw new Error(`${manifestPath} has no 'id' field`);
|
|
271
353
|
|
|
272
|
-
|
|
273
|
-
|
|
354
|
+
if (!args.skipBuild) await buildBundle(repoDir);
|
|
355
|
+
const manifest = readManifest();
|
|
356
|
+
const id = manifest.id as string;
|
|
357
|
+
const slots = declaredSlots(manifest);
|
|
358
|
+
|
|
359
|
+
await pushBundle(distDir, args.host, bundleDirName(id));
|
|
274
360
|
|
|
275
|
-
if (
|
|
276
|
-
|
|
361
|
+
if (args.claimSlots) {
|
|
362
|
+
for (const slot of slots) {
|
|
363
|
+
const result = await setSlot(args.host, args.port, slot, id);
|
|
364
|
+
if (!result.ok) throw new Error(result.reason);
|
|
365
|
+
console.log(`${slot} slot: ${manifest.name ?? id}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const overlayOnly = slots.length > 0 && slots.every(s => s === 'overlay');
|
|
370
|
+
const shouldSwitch = args.switchAfter ?? !overlayOnly;
|
|
371
|
+
if (!shouldSwitch) {
|
|
372
|
+
console.log(overlayOnly ? 'overlay pushed; the kiosk reloaded with it injected' : 'skipping switch');
|
|
277
373
|
return;
|
|
278
374
|
}
|
|
279
375
|
|
|
280
|
-
const
|
|
281
|
-
if (!
|
|
282
|
-
const
|
|
283
|
-
|
|
376
|
+
const switched = await switchTo(args.host, args.port, id);
|
|
377
|
+
if (!switched.ok) throw new Error(switched.reason);
|
|
378
|
+
const active = switched.value;
|
|
379
|
+
if (!active) {
|
|
380
|
+
console.log('switched (the daemon dropped the push connection reloading the kiosk)');
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const activeStr = active.id ? uuidToString(active.id) : '(none)';
|
|
384
|
+
console.log(`active webapp: ${active.name ?? '(unnamed)'} ${activeStr}`);
|
|
284
385
|
}
|
|
285
386
|
|
|
286
|
-
|
|
387
|
+
bridgethingPush({ scriptUrl: import.meta.url }).catch(err => {
|
|
287
388
|
console.error(err instanceof Error ? err.message : err);
|
|
288
389
|
process.exit(1);
|
|
289
390
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
## This project is a launcher
|
|
3
|
+
|
|
4
|
+
`manifest.json` declares `"role": "launcher"`, which means two things:
|
|
5
|
+
|
|
6
|
+
- The daemon hides this bundle from `client.webapp.list`, so a launcher never
|
|
7
|
+
lists itself in its own grid.
|
|
8
|
+
- The bundle is eligible for the device's **launcher slot**. Holding that slot
|
|
9
|
+
makes this the home screen: the boot default, the target of the Mode-button
|
|
10
|
+
gesture, and what the back button returns to.
|
|
11
|
+
|
|
12
|
+
`bun run push` claims the slot and switches to it, so a push puts you straight
|
|
13
|
+
on the home screen. `bun run push --release` hands it back, and the companion
|
|
14
|
+
phone app can do the same. The built-in hub stays installed and can never be
|
|
15
|
+
uninstalled, so releasing the slot always gets the stock home screen back. That
|
|
16
|
+
is the recovery path if a launcher you are building wedges the device.
|
|
17
|
+
|
|
18
|
+
### What a home screen is expected to do
|
|
19
|
+
|
|
20
|
+
Nothing is enforced. The daemon does not require a launcher to implement any
|
|
21
|
+
particular surface; if yours does less than the built-in hub, that is a choice,
|
|
22
|
+
not an error, and the user can switch back. The built-in hub covers:
|
|
23
|
+
|
|
24
|
+
- the app grid (`client.webapp.list` / `.icon` / `.activate` / `.current`, plus
|
|
25
|
+
the `onWebappInstalled` / `onWebappUninstalled` events so the grid stays live)
|
|
26
|
+
- bluetooth settings: bonds, adapter alias, discoverable
|
|
27
|
+
- display brightness
|
|
28
|
+
- system info and health
|
|
29
|
+
- power: restart, shut down, factory reset
|
|
30
|
+
- OTA progress (`client.system.onOtaProgress` / `.onOtaError`)
|
|
31
|
+
|
|
32
|
+
The starter here implements only the grid. Read the built-in hub's source if you
|
|
33
|
+
want a reference for the rest; everything it uses is on the public client SDK.
|
|
34
|
+
|
|
35
|
+
### Onboarding is not your problem
|
|
36
|
+
|
|
37
|
+
The first-boot pairing wizard lives in the built-in hub. A phone must already be
|
|
38
|
+
paired before anyone can install your launcher, so by the time this runs, the
|
|
39
|
+
device is set up. A factory reset clears the launcher slot along with everything
|
|
40
|
+
else, which puts the built-in hub and its wizard back.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { BridgethingClient, type WebappInfo } from '@bridgething/client';
|
|
2
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
3
|
+
|
|
4
|
+
const wsUrl =
|
|
5
|
+
import.meta.env.VITE_BRIDGETHING_URL ??
|
|
6
|
+
(typeof window !== 'undefined' ? `ws://${window.location.host}/` : 'ws://127.0.0.1:8891/');
|
|
7
|
+
|
|
8
|
+
type Entry = {
|
|
9
|
+
info: WebappInfo;
|
|
10
|
+
iconUrl: string | null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export default function App() {
|
|
14
|
+
const client = useMemo(() => new BridgethingClient({ url: wsUrl }), []);
|
|
15
|
+
const [entries, setEntries] = useState<Entry[] | null>(null);
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [activating, setActivating] = useState<string | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
let cancelled = false;
|
|
21
|
+
const revoke: string[] = [];
|
|
22
|
+
|
|
23
|
+
const load = async () => {
|
|
24
|
+
const [list, current] = await Promise.all([client.webapp.list(), client.webapp.current()]);
|
|
25
|
+
if (!list.ok) {
|
|
26
|
+
setError('failed to list webapps');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const selfId = current.ok ? current.response.id : null;
|
|
30
|
+
const visible = list.response.webapps.filter(w => w.id !== selfId);
|
|
31
|
+
const loaded = await Promise.all(
|
|
32
|
+
visible.map(async (info): Promise<Entry> => {
|
|
33
|
+
if (!info.iconHash) return { info, iconUrl: null };
|
|
34
|
+
const icon = await client.webapp.icon({ id: info.id });
|
|
35
|
+
if (!icon.ok) return { info, iconUrl: null };
|
|
36
|
+
const bytes = new Uint8Array(icon.response.bytes as unknown as number[]);
|
|
37
|
+
const url = URL.createObjectURL(
|
|
38
|
+
new Blob([bytes], { type: icon.response.mime ?? 'application/octet-stream' }),
|
|
39
|
+
);
|
|
40
|
+
revoke.push(url);
|
|
41
|
+
return { info, iconUrl: url };
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
if (!cancelled) setEntries(loaded);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
load().catch(err => setError(err instanceof Error ? err.message : String(err)));
|
|
48
|
+
|
|
49
|
+
const offInstalled = client.webapp.onWebappInstalled(() => void load());
|
|
50
|
+
const offUninstalled = client.webapp.onWebappUninstalled(() => void load());
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
cancelled = true;
|
|
54
|
+
offInstalled();
|
|
55
|
+
offUninstalled();
|
|
56
|
+
for (const url of revoke) URL.revokeObjectURL(url);
|
|
57
|
+
};
|
|
58
|
+
}, [client]);
|
|
59
|
+
|
|
60
|
+
const activate = async (id: string) => {
|
|
61
|
+
if (activating) return;
|
|
62
|
+
setActivating(id);
|
|
63
|
+
const result = await client.webapp.activate({ id });
|
|
64
|
+
if (!result.ok) setActivating(null);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (error) return <Centered>{error}</Centered>;
|
|
68
|
+
if (!entries) return <Centered>loading</Centered>;
|
|
69
|
+
if (entries.length === 0) return <Centered>no apps installed</Centered>;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="h-full w-full overflow-y-auto p-6">
|
|
73
|
+
<div className="grid grid-cols-4 gap-4">
|
|
74
|
+
{entries.map(({ info, iconUrl }) => (
|
|
75
|
+
<button
|
|
76
|
+
key={info.id}
|
|
77
|
+
type="button"
|
|
78
|
+
disabled={activating !== null}
|
|
79
|
+
onClick={() => activate(info.id)}
|
|
80
|
+
className="flex flex-col items-center gap-2 p-3 disabled:opacity-50">
|
|
81
|
+
<div className="grid h-20 w-20 place-items-center border">
|
|
82
|
+
{iconUrl ? (
|
|
83
|
+
<img src={iconUrl} alt="" className="h-full w-full object-contain" draggable={false} />
|
|
84
|
+
) : (
|
|
85
|
+
<span className="text-2xl">{(info.name.trim().charAt(0) || '?').toUpperCase()}</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
<span className="text-center text-sm">{info.name}</span>
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function Centered({ children }: { children: React.ReactNode }) {
|
|
97
|
+
return <div className="grid h-full w-full place-items-center text-sm">{children}</div>;
|
|
98
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
|
|
2
|
+
## This project is a system overlay
|
|
3
|
+
|
|
4
|
+
`manifest.json` declares `"overlay": "overlay.js"`. When this bundle holds the
|
|
5
|
+
device's **overlay slot**, the daemon injects that file into every webapp's
|
|
6
|
+
document as it loads. The page in `src/` is incidental: it only renders if
|
|
7
|
+
someone launches this bundle from the hub.
|
|
8
|
+
|
|
9
|
+
`bun run push` claims the slot for you and reloads the kiosk, so the overlay
|
|
10
|
+
appears over whatever app is showing. It deliberately does **not** switch to
|
|
11
|
+
this bundle's own page - switching away from the app you are testing against is
|
|
12
|
+
the opposite of what you want.
|
|
13
|
+
|
|
14
|
+
`bun run push --release` hands the slot back to the daemon's built-in overlay.
|
|
15
|
+
That is the recovery path when a build of yours misbehaves; the companion phone
|
|
16
|
+
app can do the same thing.
|
|
17
|
+
|
|
18
|
+
### The contract
|
|
19
|
+
|
|
20
|
+
The daemon prepends one global before your bundle:
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
window.__bridgethingOverlay = { origin: 'http://127.0.0.1:8891', surfaces: {...} };
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
- **`surfaces`** is the active webapp's declared profile: `notifications`,
|
|
27
|
+
`call`, `pairing`, `connection`, `volume`. An app that draws its own volume
|
|
28
|
+
indicator declares `volume: false`. **Honor these.** The daemon cannot enforce
|
|
29
|
+
it, and ignoring them means the user sees two of everything on apps that draw
|
|
30
|
+
their own. If every surface is off, the daemon injects nothing at all.
|
|
31
|
+
- **`origin`** is the kiosk origin. Check `location.origin` against it before
|
|
32
|
+
mounting so the bundle never runs in a page the daemon did not serve.
|
|
33
|
+
|
|
34
|
+
Everything else comes from `@bridgething/client` over the local websocket, the
|
|
35
|
+
same SDK a normal webapp uses.
|
|
36
|
+
|
|
37
|
+
### Constraints that are not style
|
|
38
|
+
|
|
39
|
+
`overlay.js` must be **one self-contained file** under 512 KiB. It is injected as
|
|
40
|
+
a script string into another app's document, so it cannot import a module graph,
|
|
41
|
+
fetch assets, or reach a bundler at runtime. `vite.overlay.config.ts` builds it
|
|
42
|
+
as a single inlined iife; keep it that way.
|
|
43
|
+
|
|
44
|
+
That is a constraint on the *output*, not on how you write it. Style
|
|
45
|
+
`overlay/main.tsx` with tailwind classes exactly like the rest of the project.
|
|
46
|
+
`overlay/style.css` is a real stylesheet; it is imported with vite's `?inline`
|
|
47
|
+
so the compiled css lands in the bundle as a string and gets mounted into the
|
|
48
|
+
shadow root. Add `@theme` tokens or plain rules there. Because the shadow root
|
|
49
|
+
is closed, tailwind is told not to scan the project (`source(none)`) and only
|
|
50
|
+
generates what `overlay/main.tsx` uses - if you split the overlay across more
|
|
51
|
+
files, add each one with `@source`.
|
|
52
|
+
|
|
53
|
+
The starter encodes four things you should not drop:
|
|
54
|
+
|
|
55
|
+
1. **Origin guard** before mounting.
|
|
56
|
+
2. **`__bridgethingOverlayMounted`** guard, so a double injection is a no-op.
|
|
57
|
+
3. **Closed shadow root**, so your styles and the host app's cannot reach each
|
|
58
|
+
other.
|
|
59
|
+
4. **Escape-only, capture-phase key handling**, and only while something is
|
|
60
|
+
showing. Your overlay sits on top of every app on the device; swallowing keys
|
|
61
|
+
broadly breaks all of them.
|
|
62
|
+
|
|
63
|
+
You are running inside someone else's page. A crash, a fullscreen paint, or a
|
|
64
|
+
greedy key handler takes down every webapp, not just this one.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { BluetoothPin, Notification, PeerSnapshotMap, PhoneCall, VolumeChanged } from '@bridgething/client';
|
|
2
|
+
import { BridgethingClient } from '@bridgething/client';
|
|
3
|
+
import { render } from 'preact';
|
|
4
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
5
|
+
|
|
6
|
+
import css from './style.css?inline';
|
|
7
|
+
|
|
8
|
+
type OverlaySurfaces = {
|
|
9
|
+
notifications: boolean;
|
|
10
|
+
call: boolean;
|
|
11
|
+
pairing: boolean;
|
|
12
|
+
connection: boolean;
|
|
13
|
+
volume: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type OverlayConfig = { origin: string; surfaces: OverlaySurfaces };
|
|
17
|
+
|
|
18
|
+
declare global {
|
|
19
|
+
interface Window {
|
|
20
|
+
__bridgethingOverlay?: OverlayConfig;
|
|
21
|
+
__bridgethingOverlayMounted?: boolean;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const TOAST_TTL_MS = 5_000;
|
|
26
|
+
const VOLUME_TTL_MS = 1_500;
|
|
27
|
+
const CONNECTION_SHOW_DELAY_MS = 3_000;
|
|
28
|
+
|
|
29
|
+
function useLatest<T>(
|
|
30
|
+
subscribe: (emit: (value: T | null) => void) => () => void,
|
|
31
|
+
ttl?: number,
|
|
32
|
+
deps: unknown[] = [],
|
|
33
|
+
): T | null {
|
|
34
|
+
const [value, setValue] = useState<T | null>(null);
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
const off = subscribe(next => {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
setValue(next);
|
|
40
|
+
if (next !== null && ttl !== undefined) timer = setTimeout(() => setValue(null), ttl);
|
|
41
|
+
});
|
|
42
|
+
return () => {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
off();
|
|
45
|
+
};
|
|
46
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
47
|
+
}, deps);
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const CHIP = 'absolute left-1/2 -translate-x-1/2 rounded-full bg-chip px-4 py-2 text-sm text-white';
|
|
52
|
+
|
|
53
|
+
function ConnectionBanner({ client }: { client: BridgethingClient }) {
|
|
54
|
+
const [away, setAway] = useState(false);
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
57
|
+
const off = client.peer.onSnapshot((snapshot: PeerSnapshotMap) => {
|
|
58
|
+
const peers = Object.values(snapshot);
|
|
59
|
+
const paired = peers.some(p => p.paired);
|
|
60
|
+
const useful = peers.some(p => p.iap2 === 'identified' || p.companion.type === 'connected');
|
|
61
|
+
if (paired && !useful) {
|
|
62
|
+
timer ??= setTimeout(() => setAway(true), CONNECTION_SHOW_DELAY_MS);
|
|
63
|
+
} else {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
timer = undefined;
|
|
66
|
+
setAway(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
return () => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
off();
|
|
72
|
+
};
|
|
73
|
+
}, [client]);
|
|
74
|
+
|
|
75
|
+
if (!away) return null;
|
|
76
|
+
return <div class={`${CHIP} top-3`}>phone disconnected</div>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function CallBanner({ client, onDismissible }: { client: BridgethingClient; onDismissible: Dismissible }) {
|
|
80
|
+
const [call, setCall] = useState<PhoneCall | null>(null);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const offs = [
|
|
84
|
+
client.phone.onCallStarted(setCall),
|
|
85
|
+
client.phone.onCallUpdated(next => setCall(prev => (prev === null || prev.callId === next.callId ? next : prev))),
|
|
86
|
+
client.phone.onCallEnded(ended => setCall(prev => (prev?.callId === ended.callId ? null : prev))),
|
|
87
|
+
];
|
|
88
|
+
return () => offs.forEach(off => off());
|
|
89
|
+
}, [client]);
|
|
90
|
+
|
|
91
|
+
useEffect(() => (call ? onDismissible(() => setCall(null)) : undefined), [call, onDismissible]);
|
|
92
|
+
|
|
93
|
+
if (!call) return null;
|
|
94
|
+
return (
|
|
95
|
+
<div class={`${CHIP} top-3`}>
|
|
96
|
+
{call.displayName || call.remoteId || 'unknown caller'} - {call.status}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function Toasts({ client }: { client: BridgethingClient }) {
|
|
102
|
+
const [live, setLive] = useState<Notification[]>([]);
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
106
|
+
const drop = (id: string) => {
|
|
107
|
+
clearTimeout(timers.get(id));
|
|
108
|
+
timers.delete(id);
|
|
109
|
+
setLive(prev => prev.filter(n => n.id !== id));
|
|
110
|
+
};
|
|
111
|
+
const post = (n: Notification) => {
|
|
112
|
+
if (n.flags.silent) return;
|
|
113
|
+
clearTimeout(timers.get(n.id));
|
|
114
|
+
timers.set(
|
|
115
|
+
n.id,
|
|
116
|
+
setTimeout(() => drop(n.id), TOAST_TTL_MS),
|
|
117
|
+
);
|
|
118
|
+
setLive(prev => [...prev.filter(other => other.id !== n.id), n]);
|
|
119
|
+
};
|
|
120
|
+
const offs = [
|
|
121
|
+
client.notifications.onPosted(post),
|
|
122
|
+
client.notifications.onUpdated(post),
|
|
123
|
+
client.notifications.onRemoved(removed => drop(removed.id)),
|
|
124
|
+
];
|
|
125
|
+
return () => {
|
|
126
|
+
timers.forEach(clearTimeout);
|
|
127
|
+
offs.forEach(off => off());
|
|
128
|
+
};
|
|
129
|
+
}, [client]);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div class="absolute top-0 right-0 flex flex-col items-end gap-3 p-3">
|
|
133
|
+
{live.map(n => (
|
|
134
|
+
<div key={n.id} class="w-65 rounded-xl bg-card px-3 py-2.5 text-white">
|
|
135
|
+
<div class="text-[11px] opacity-60">{n.app.displayName ?? n.app.bundleId}</div>
|
|
136
|
+
<div class="text-sm font-semibold">{n.title ?? ''}</div>
|
|
137
|
+
{(n.message ?? n.subtitle) ? <div class="text-xs opacity-80">{n.message ?? n.subtitle}</div> : null}
|
|
138
|
+
</div>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function PairingModal({ client, onDismissible }: { client: BridgethingClient; onDismissible: Dismissible }) {
|
|
145
|
+
const [pin, setPin] = useState<BluetoothPin | null>(null);
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const offs = [client.bluetooth.onPin(setPin), client.bluetooth.onPairingResult(() => setPin(null))];
|
|
149
|
+
return () => offs.forEach(off => off());
|
|
150
|
+
}, [client]);
|
|
151
|
+
|
|
152
|
+
useEffect(() => (pin ? onDismissible(() => setPin(null)) : undefined), [pin, onDismissible]);
|
|
153
|
+
|
|
154
|
+
if (!pin) return null;
|
|
155
|
+
return (
|
|
156
|
+
<div class="pointer-events-auto absolute inset-0 grid place-items-center bg-scrim text-center text-white">
|
|
157
|
+
<div>
|
|
158
|
+
<div class="text-[13px] opacity-70">enter this pin on your phone</div>
|
|
159
|
+
<div class="my-3 text-5xl font-bold tracking-[0.1em]">{pin.pin}</div>
|
|
160
|
+
<div class="text-xs opacity-60">{pin.name || pin.mac}</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function VolumeChip({ client }: { client: BridgethingClient }) {
|
|
167
|
+
const volume = useLatest<VolumeChanged>(emit => client.audio.onVolumeChanged(emit), VOLUME_TTL_MS, [client]);
|
|
168
|
+
if (!volume) return null;
|
|
169
|
+
const percent = Math.round(Math.min(1, Math.max(0, volume.level)) * 100);
|
|
170
|
+
return <div class={`${CHIP} bottom-6`}>{volume.muted ? 'muted' : `${percent}%`}</div>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type Dismissible = (hide: () => void) => () => void;
|
|
174
|
+
|
|
175
|
+
function Overlay({ cfg, client }: { cfg: OverlayConfig; client: BridgethingClient }) {
|
|
176
|
+
const [stack] = useState<Array<() => void>>([]);
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const onKey = (event: KeyboardEvent) => {
|
|
179
|
+
if (event.key !== 'Escape' || stack.length === 0) return;
|
|
180
|
+
event.stopPropagation();
|
|
181
|
+
event.preventDefault();
|
|
182
|
+
stack.pop()?.();
|
|
183
|
+
};
|
|
184
|
+
document.addEventListener('keydown', onKey, { capture: true });
|
|
185
|
+
return () => document.removeEventListener('keydown', onKey, { capture: true });
|
|
186
|
+
}, [stack]);
|
|
187
|
+
|
|
188
|
+
const dismissible: Dismissible = hide => {
|
|
189
|
+
stack.push(hide);
|
|
190
|
+
return () => {
|
|
191
|
+
const at = stack.indexOf(hide);
|
|
192
|
+
if (at >= 0) stack.splice(at, 1);
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<>
|
|
198
|
+
<style>{css}</style>
|
|
199
|
+
<div class="absolute inset-0 font-sans">
|
|
200
|
+
{cfg.surfaces.connection && <ConnectionBanner client={client} />}
|
|
201
|
+
{cfg.surfaces.call && <CallBanner client={client} onDismissible={dismissible} />}
|
|
202
|
+
{cfg.surfaces.notifications && <Toasts client={client} />}
|
|
203
|
+
{cfg.surfaces.volume && <VolumeChip client={client} />}
|
|
204
|
+
{cfg.surfaces.pairing && <PairingModal client={client} onDismissible={dismissible} />}
|
|
205
|
+
</div>
|
|
206
|
+
</>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function boot() {
|
|
211
|
+
const cfg = window.__bridgethingOverlay;
|
|
212
|
+
if (!cfg || !location.origin.startsWith(cfg.origin)) return;
|
|
213
|
+
if (window.__bridgethingOverlayMounted) return;
|
|
214
|
+
window.__bridgethingOverlayMounted = true;
|
|
215
|
+
|
|
216
|
+
const mount = () => {
|
|
217
|
+
const host = document.createElement('div');
|
|
218
|
+
host.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none';
|
|
219
|
+
const shadow = host.attachShadow({ mode: 'closed' });
|
|
220
|
+
document.body.appendChild(host);
|
|
221
|
+
render(<Overlay cfg={cfg} client={new BridgethingClient({ url: `ws://${location.host}/` })} />, shadow);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (document.body) mount();
|
|
225
|
+
else document.addEventListener('DOMContentLoaded', mount, { once: true });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
boot();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export default function App() {
|
|
2
|
+
return (
|
|
3
|
+
<div className="grid h-full w-full place-items-center p-12 text-center">
|
|
4
|
+
<div className="flex flex-col gap-3">
|
|
5
|
+
<div className="text-2xl font-semibold">__PROJECT_NAME__</div>
|
|
6
|
+
<div className="text-sm opacity-70">
|
|
7
|
+
A system overlay. Assign it to the overlay slot from the companion app to see it.
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</div>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import preact from '@preact/preset-vite';
|
|
2
|
+
import tailwindcss from '@tailwindcss/vite';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { defineConfig, type Plugin } from 'vite';
|
|
6
|
+
|
|
7
|
+
const WARN_BYTES = 128 * 1024;
|
|
8
|
+
const HARD_CAP = '512 KiB';
|
|
9
|
+
|
|
10
|
+
function sizeGuard(): Plugin {
|
|
11
|
+
return {
|
|
12
|
+
name: 'overlay-size-guard',
|
|
13
|
+
closeBundle() {
|
|
14
|
+
let bytes: number;
|
|
15
|
+
try {
|
|
16
|
+
bytes = statSync(resolve(__dirname, 'dist', 'overlay.js')).size;
|
|
17
|
+
} catch {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
if (bytes > WARN_BYTES) {
|
|
21
|
+
console.warn(`overlay.js is ${(bytes / 1024).toFixed(1)} KiB; the daemon ignores overlays over ${HARD_CAP}.`);
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export default defineConfig({
|
|
28
|
+
plugins: [preact(), tailwindcss(), sizeGuard()],
|
|
29
|
+
build: {
|
|
30
|
+
target: 'es2022',
|
|
31
|
+
outDir: resolve(__dirname, 'dist'),
|
|
32
|
+
emptyOutDir: false,
|
|
33
|
+
cssCodeSplit: false,
|
|
34
|
+
lib: {
|
|
35
|
+
entry: resolve(__dirname, 'overlay', 'main.tsx'),
|
|
36
|
+
formats: ['iife'],
|
|
37
|
+
name: '__overlay',
|
|
38
|
+
fileName: () => 'overlay.js',
|
|
39
|
+
},
|
|
40
|
+
rollupOptions: {
|
|
41
|
+
output: { inlineDynamicImports: true },
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
});
|