create-bridgething 0.10.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.
- package/README.md +16 -12
- package/dist/index.mjs +86 -21
- package/package.json +9 -3
- package/template/CLAUDE.md +60 -84
- package/template/README.md +31 -44
- package/template/_claude/skills/bridgething/SKILL.md +12 -17
- package/template/_claude/skills/bridgething/reference/develop.md +84 -65
- package/template/_claude/skills/bridgething/reference/extension.md +183 -0
- package/template/_claude/skills/bridgething/reference/sdk.md +74 -106
- package/template/_claude/skills/bridgething/reference/ship.md +19 -38
- package/template/_gitignore +1 -0
- package/template/package.json +6 -5
- package/template/scripts/bridgething.ts +1086 -0
- package/template/scripts/push.ts +212 -193
- package/template/src/App.tsx +2 -5
- package/template/src/daemon.ts +14 -0
- package/template/vite.config.ts +5 -3
- package/template-extension/_claude_append.md +23 -0
- package/template-extension/extension/main.ts +20 -0
- package/template-launcher/_claude_append.md +14 -30
- package/template-launcher/src/App.tsx +2 -5
- package/template-overlay/_claude_append.md +31 -45
package/template/scripts/push.ts
CHANGED
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// Generated from @bridgething/webapp-shared.
|
|
3
|
-
|
|
2
|
+
// Generated from @bridgething/webapp-shared.
|
|
3
|
+
// ../webapp-shared/src/push.ts
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
6
5
|
import { existsSync, readFileSync } from 'node:fs';
|
|
7
6
|
import { dirname, resolve } from 'node:path';
|
|
8
7
|
|
|
9
|
-
|
|
8
|
+
// ../webapp-shared/src/gateway.ts
|
|
9
|
+
import { decode as msgpackDecode, encode as msgpackEncode } from '@msgpack/msgpack';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
import { lookup } from 'node:dns/promises';
|
|
12
|
+
import { isIP } from 'node:net';
|
|
13
|
+
var DEFAULT_DEVICE_HOST = 'bridgething.local';
|
|
14
|
+
function deviceHostName(host) {
|
|
15
|
+
return host ?? process.env.SUPERBIRD_HOST ?? DEFAULT_DEVICE_HOST;
|
|
16
|
+
}
|
|
17
|
+
async function resolveGatewayTarget(host) {
|
|
18
|
+
const name = deviceHostName(host);
|
|
19
|
+
const port = Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892);
|
|
20
|
+
return { name, host: await resolveHost(name), port };
|
|
21
|
+
}
|
|
22
|
+
async function resolveHost(name) {
|
|
23
|
+
if (isIP(name)) return name;
|
|
24
|
+
try {
|
|
25
|
+
return (await lookup(name, { family: 4 })).address;
|
|
26
|
+
} catch {
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function parseUuid(s) {
|
|
10
31
|
const hex = s.replace(/-/g, '').toLowerCase();
|
|
11
32
|
if (hex.length !== 32 || !/^[0-9a-f]+$/.test(hex)) {
|
|
12
33
|
throw new Error(`invalid uuid: ${s}`);
|
|
@@ -15,30 +36,25 @@ function parseUuid(s: string): Uint8Array {
|
|
|
15
36
|
for (let i = 0; i < 16; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
16
37
|
return out;
|
|
17
38
|
}
|
|
18
|
-
|
|
19
|
-
function freshMsgId(): Uint8Array {
|
|
39
|
+
function freshMsgId() {
|
|
20
40
|
return parseUuid(randomUUID());
|
|
21
41
|
}
|
|
22
|
-
|
|
23
|
-
function uuidToString(bytes: Uint8Array): string {
|
|
42
|
+
function uuidToString(bytes) {
|
|
24
43
|
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
25
44
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
26
45
|
}
|
|
27
|
-
|
|
28
|
-
function bundleDirName(id: string): string {
|
|
46
|
+
function bundleDirName(id) {
|
|
29
47
|
const hex = id.replace(/-/g, '').toLowerCase();
|
|
30
48
|
if (!/^[0-9a-f]{32}$/.test(hex)) throw new Error(`manifest id is not a uuid: ${id}`);
|
|
31
49
|
return hex;
|
|
32
50
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
function writeFrameHeader(payloadLength: number): Uint8Array<ArrayBuffer> {
|
|
51
|
+
var FRAME_HEADER_LENGTH = 16;
|
|
52
|
+
var FRAME_MAGIC = 57005;
|
|
53
|
+
var FRAME_VERSION = 2;
|
|
54
|
+
var COMPRESSION_NONE = 0;
|
|
55
|
+
var ENCODING_MSGPACK = 0;
|
|
56
|
+
var PRIORITY_NORMAL = 0;
|
|
57
|
+
function writeFrameHeader(payloadLength) {
|
|
42
58
|
const buf = new Uint8Array(FRAME_HEADER_LENGTH);
|
|
43
59
|
const view = new DataView(buf.buffer);
|
|
44
60
|
view.setUint16(0, FRAME_MAGIC, false);
|
|
@@ -49,8 +65,7 @@ function writeFrameHeader(payloadLength: number): Uint8Array<ArrayBuffer> {
|
|
|
49
65
|
view.setBigUint64(8, BigInt(payloadLength), false);
|
|
50
66
|
return buf;
|
|
51
67
|
}
|
|
52
|
-
|
|
53
|
-
function frame(message: unknown): Uint8Array<ArrayBuffer> {
|
|
68
|
+
function frame(message) {
|
|
54
69
|
const body = msgpackEncode(message);
|
|
55
70
|
const header = writeFrameHeader(body.length);
|
|
56
71
|
const out = new Uint8Array(header.length + body.length);
|
|
@@ -59,20 +74,16 @@ function frame(message: unknown): Uint8Array<ArrayBuffer> {
|
|
|
59
74
|
return out;
|
|
60
75
|
}
|
|
61
76
|
|
|
62
|
-
type GatewayMsg = { id: Uint8Array; meta: unknown; data: unknown };
|
|
63
|
-
|
|
64
77
|
class FrameAccumulator {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
append(chunk: Uint8Array): void {
|
|
78
|
+
buffer = new Uint8Array(0);
|
|
79
|
+
append(chunk) {
|
|
68
80
|
if (chunk.length === 0) return;
|
|
69
81
|
const merged = new Uint8Array(this.buffer.length + chunk.length);
|
|
70
82
|
merged.set(this.buffer, 0);
|
|
71
83
|
merged.set(chunk, this.buffer.length);
|
|
72
84
|
this.buffer = merged;
|
|
73
85
|
}
|
|
74
|
-
|
|
75
|
-
next(): GatewayMsg | null {
|
|
86
|
+
next() {
|
|
76
87
|
if (this.buffer.length < FRAME_HEADER_LENGTH) return null;
|
|
77
88
|
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
78
89
|
const magic = view.getUint16(0, false);
|
|
@@ -89,191 +100,211 @@ class FrameAccumulator {
|
|
|
89
100
|
const total = FRAME_HEADER_LENGTH + len;
|
|
90
101
|
if (this.buffer.length < total) return null;
|
|
91
102
|
const body = this.buffer.subarray(FRAME_HEADER_LENGTH, total);
|
|
92
|
-
const decoded = msgpackDecode(body)
|
|
103
|
+
const decoded = msgpackDecode(body);
|
|
93
104
|
this.buffer = this.buffer.slice(total);
|
|
94
105
|
return decoded;
|
|
95
106
|
}
|
|
96
107
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
network: { kind: 'unknown', metered: false },
|
|
122
|
-
available: { geo: false, notifications: false, netFetch: false, netWs: false, audioTts: false },
|
|
123
|
-
audio: { earcons: [], voices: [] },
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
function sendRequest(host: string, port: number, data: unknown): Promise<unknown | null> {
|
|
129
|
-
const url = `ws://${host}:${port}/`;
|
|
130
|
-
const ws = new WebSocket(url);
|
|
131
|
-
ws.binaryType = 'arraybuffer';
|
|
132
|
-
const acc = new FrameAccumulator();
|
|
133
|
-
const requestId = freshMsgId();
|
|
134
|
-
let sent = false;
|
|
135
|
-
|
|
136
|
-
return new Promise<unknown | null>((res, rej) => {
|
|
137
|
-
const overall = setTimeout(() => {
|
|
138
|
-
try {
|
|
139
|
-
ws.close();
|
|
140
|
-
} catch {}
|
|
141
|
-
rej(new Error(`gateway request timed out (15s) against ${url}`));
|
|
142
|
-
}, 15_000);
|
|
143
|
-
|
|
144
|
-
const finish = (fn: () => void) => {
|
|
145
|
-
clearTimeout(overall);
|
|
146
|
-
try {
|
|
108
|
+
var OPEN_TIMEOUT_MS = 15000;
|
|
109
|
+
var REQUEST_TIMEOUT_MS = 15000;
|
|
110
|
+
|
|
111
|
+
class GatewayLink {
|
|
112
|
+
ws;
|
|
113
|
+
url;
|
|
114
|
+
acc = new FrameAccumulator();
|
|
115
|
+
pending = new Map();
|
|
116
|
+
handlers = new Set();
|
|
117
|
+
closers = new Set();
|
|
118
|
+
closed = null;
|
|
119
|
+
constructor(ws, url) {
|
|
120
|
+
this.ws = ws;
|
|
121
|
+
this.url = url;
|
|
122
|
+
ws.addEventListener('message', event => this.receive(event.data));
|
|
123
|
+
ws.addEventListener('close', event => this.finish(`gateway closed (code ${event.code})`));
|
|
124
|
+
ws.addEventListener('error', () => this.finish('gateway socket error'));
|
|
125
|
+
}
|
|
126
|
+
static open(target) {
|
|
127
|
+
const url = `ws://${target.host}:${target.port}/`;
|
|
128
|
+
return new Promise((res, rej) => {
|
|
129
|
+
const ws = new WebSocket(url);
|
|
130
|
+
ws.binaryType = 'arraybuffer';
|
|
131
|
+
const timer = setTimeout(() => {
|
|
147
132
|
ws.close();
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
ws.
|
|
155
|
-
|
|
133
|
+
rej(new Error(`gateway connect timed out (${OPEN_TIMEOUT_MS / 1000}s) against ${url}`));
|
|
134
|
+
}, OPEN_TIMEOUT_MS);
|
|
135
|
+
ws.addEventListener('open', () => {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
res(new GatewayLink(ws, url));
|
|
138
|
+
});
|
|
139
|
+
ws.addEventListener('error', () => {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
rej(new Error(`could not reach the gateway at ${url}`));
|
|
142
|
+
});
|
|
143
|
+
ws.addEventListener('close', event => {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
rej(new Error(`gateway closed before opening (code ${event.code})`));
|
|
146
|
+
});
|
|
156
147
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
148
|
+
}
|
|
149
|
+
get isOpen() {
|
|
150
|
+
return this.closed === null;
|
|
151
|
+
}
|
|
152
|
+
onMessage(handler) {
|
|
153
|
+
this.handlers.add(handler);
|
|
154
|
+
return () => this.handlers.delete(handler);
|
|
155
|
+
}
|
|
156
|
+
onClose(handler) {
|
|
157
|
+
this.closers.add(handler);
|
|
158
|
+
return () => this.closers.delete(handler);
|
|
159
|
+
}
|
|
160
|
+
event(data) {
|
|
161
|
+
this.write({ id: freshMsgId(), meta: { kind: 'event' }, data });
|
|
162
|
+
}
|
|
163
|
+
request(kind, data) {
|
|
164
|
+
if (this.closed !== null) return Promise.reject(new Error(this.closed));
|
|
165
|
+
const id = freshMsgId();
|
|
166
|
+
const key = uuidToString(id);
|
|
167
|
+
return new Promise((resolve, reject) => {
|
|
168
|
+
const timer = setTimeout(() => {
|
|
169
|
+
this.pending.delete(key);
|
|
170
|
+
reject(new Error(`gateway ${kind} timed out (${REQUEST_TIMEOUT_MS / 1000}s) against ${this.url}`));
|
|
171
|
+
}, REQUEST_TIMEOUT_MS);
|
|
172
|
+
this.pending.set(key, { resolve, reject, timer });
|
|
173
|
+
this.write({ id, meta: { kind }, data });
|
|
175
174
|
});
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
175
|
+
}
|
|
176
|
+
close() {
|
|
177
|
+
try {
|
|
178
|
+
this.ws.close();
|
|
179
|
+
} catch {}
|
|
180
|
+
this.finish('gateway link closed');
|
|
181
|
+
}
|
|
182
|
+
write(message) {
|
|
183
|
+
if (this.closed !== null) return;
|
|
184
|
+
this.ws.send(frame(message));
|
|
185
|
+
}
|
|
186
|
+
receive(raw) {
|
|
187
|
+
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : raw instanceof Uint8Array ? raw : null;
|
|
188
|
+
if (!bytes) return;
|
|
189
|
+
try {
|
|
190
|
+
this.acc.append(bytes);
|
|
191
|
+
for (let msg = this.acc.next(); msg !== null; msg = this.acc.next()) this.dispatch(msg);
|
|
192
|
+
} catch (err) {
|
|
193
|
+
this.finish(err instanceof Error ? err.message : String(err));
|
|
194
|
+
this.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
dispatch(msg) {
|
|
198
|
+
if (msg.meta?.kind === 'response') {
|
|
199
|
+
const waiting = this.pending.get(uuidToString(msg.meta.data.requestId));
|
|
200
|
+
if (waiting) {
|
|
201
|
+
this.pending.delete(uuidToString(msg.meta.data.requestId));
|
|
202
|
+
clearTimeout(waiting.timer);
|
|
203
|
+
waiting.resolve(msg.data);
|
|
181
204
|
return;
|
|
182
205
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
206
|
+
}
|
|
207
|
+
for (const handler of [...this.handlers]) handler(msg.data);
|
|
208
|
+
}
|
|
209
|
+
finish(reason) {
|
|
210
|
+
if (this.closed !== null) return;
|
|
211
|
+
this.closed = reason;
|
|
212
|
+
for (const waiting of this.pending.values()) {
|
|
213
|
+
clearTimeout(waiting.timer);
|
|
214
|
+
waiting.reject(new Error(reason));
|
|
215
|
+
}
|
|
216
|
+
this.pending.clear();
|
|
217
|
+
for (const closer of [...this.closers]) closer(reason);
|
|
218
|
+
}
|
|
186
219
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
220
|
+
async function exchange(target, kind, data) {
|
|
221
|
+
const link = await GatewayLink.open(target);
|
|
222
|
+
try {
|
|
223
|
+
return await link.request(kind, data);
|
|
224
|
+
} finally {
|
|
225
|
+
link.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function interpret(data, expected) {
|
|
229
|
+
const outer = data;
|
|
193
230
|
if (outer?.type !== 'webapp') {
|
|
194
231
|
return { ok: false, reason: `unexpected response type ${JSON.stringify(outer?.type)}` };
|
|
195
232
|
}
|
|
196
|
-
const inner = outer.data
|
|
197
|
-
if (inner?.event === expected) return { ok: true, value: inner.data
|
|
233
|
+
const inner = outer.data;
|
|
234
|
+
if (inner?.event === expected) return { ok: true, value: inner.data };
|
|
198
235
|
if (inner?.event === 'webappError') {
|
|
199
|
-
const err = inner.data
|
|
236
|
+
const err = inner.data;
|
|
200
237
|
return { ok: false, reason: `daemon refused: ${err?.type} ${JSON.stringify(err?.data ?? {})}` };
|
|
201
238
|
}
|
|
202
239
|
return { ok: false, reason: `unexpected webapp response variant ${JSON.stringify(inner?.event)}` };
|
|
203
240
|
}
|
|
204
|
-
|
|
205
|
-
type ActiveWebapp = { id?: Uint8Array | null; name?: string | null };
|
|
206
|
-
type Slots = { launcher?: Uint8Array | null; overlay?: Uint8Array | null };
|
|
207
|
-
type Slot = 'launcher' | 'overlay';
|
|
208
|
-
|
|
209
|
-
async function switchTo(host: string, port: number, id: string): Promise<Outcome<ActiveWebapp>> {
|
|
241
|
+
async function switchTo(target, id) {
|
|
210
242
|
const data = { type: 'webapp', data: { event: 'switchTo', data: { id: parseUuid(id) } } };
|
|
211
|
-
return interpret
|
|
243
|
+
return interpret(await exchange(target, 'request', data), 'switched');
|
|
212
244
|
}
|
|
213
|
-
|
|
214
|
-
async function setSlot(host: string, port: number, slot: Slot, id: string | null): Promise<Outcome<Slots>> {
|
|
245
|
+
async function setSlot(target, slot, id) {
|
|
215
246
|
const data = {
|
|
216
247
|
type: 'webapp',
|
|
217
248
|
data: { event: 'setSlot', data: { slot, id: id === null ? null : parseUuid(id) } },
|
|
218
249
|
};
|
|
219
|
-
return interpret
|
|
250
|
+
return interpret(await exchange(target, 'request', data), 'slots');
|
|
220
251
|
}
|
|
221
252
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
function run(cmd
|
|
226
|
-
return new Promise
|
|
253
|
+
// ../webapp-shared/src/push.ts
|
|
254
|
+
var WEBAPP_ROOT = '/var/bridgething/webapps';
|
|
255
|
+
var SSH_OPTS = ['-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=ERROR'];
|
|
256
|
+
function run(cmd, args, label, cwd) {
|
|
257
|
+
return new Promise((res, rej) => {
|
|
227
258
|
const child = spawn(cmd, args, { stdio: 'inherit', cwd });
|
|
228
259
|
child.on('exit', code => (code === 0 ? res() : rej(new Error(`${label} exited ${code}`))));
|
|
229
260
|
child.on('error', rej);
|
|
230
261
|
});
|
|
231
262
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
263
|
+
function pipeThrough(producer, consumer, label) {
|
|
264
|
+
return new Promise((res, rej) => {
|
|
265
|
+
const source = spawn(producer.cmd, producer.args, { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
266
|
+
const sink = spawn(consumer.cmd, consumer.args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
267
|
+
source.stdout.pipe(sink.stdin);
|
|
268
|
+
source.on('error', rej);
|
|
269
|
+
sink.on('error', rej);
|
|
270
|
+
source.on('exit', code => {
|
|
271
|
+
if (code !== 0) rej(new Error(`${producer.cmd} exited ${code}`));
|
|
272
|
+
});
|
|
273
|
+
sink.on('exit', code => (code === 0 ? res() : rej(new Error(`${label} exited ${code}`))));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async function pushBundle(localDir, host, dirName) {
|
|
235
277
|
const staged = `.push.${dirName}`;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
await run('rsync', ['-avz', '--delete', '-e', ['ssh', ...SSH_OPTS].join(' '), src, dest], 'rsync');
|
|
239
|
-
const swap = [
|
|
278
|
+
console.log(`copying ${localDir} -> root@${host}:${WEBAPP_ROOT}/${dirName}/`);
|
|
279
|
+
const receive = [
|
|
240
280
|
'set -e',
|
|
241
281
|
`cd ${WEBAPP_ROOT}`,
|
|
242
|
-
`rm -rf .old.${dirName}`,
|
|
282
|
+
`rm -rf ${staged} .old.${dirName}`,
|
|
283
|
+
`mkdir -p ${staged}`,
|
|
284
|
+
`tar -xzf - -C ${staged}`,
|
|
243
285
|
`if [ -d ${dirName} ]; then mv ${dirName} .old.${dirName}; fi`,
|
|
244
286
|
`mv ${staged} ${dirName}`,
|
|
245
287
|
`rm -rf .old.${dirName}`,
|
|
246
288
|
].join('; ');
|
|
247
|
-
await
|
|
289
|
+
await pipeThrough(
|
|
290
|
+
{ cmd: 'tar', args: ['-czf', '-', '-C', localDir, '.'] },
|
|
291
|
+
{ cmd: 'ssh', args: [...SSH_OPTS, `root@${host}`, receive] },
|
|
292
|
+
'ssh',
|
|
293
|
+
);
|
|
248
294
|
}
|
|
249
|
-
|
|
250
|
-
function buildBundle(repoDir: string): Promise<void> {
|
|
295
|
+
function buildBundle(repoDir) {
|
|
251
296
|
console.log('bun run build');
|
|
252
297
|
return run('bun', ['run', 'build'], 'bun run build', repoDir);
|
|
253
298
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
function declaredSlots(manifest: Manifest): Slot[] {
|
|
258
|
-
const slots: Slot[] = [];
|
|
299
|
+
function declaredSlots(manifest) {
|
|
300
|
+
const slots = [];
|
|
259
301
|
if (manifest.role === 'launcher') slots.push('launcher');
|
|
260
302
|
if (manifest.overlay) slots.push('overlay');
|
|
261
303
|
return slots;
|
|
262
304
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
host: string;
|
|
266
|
-
port: number;
|
|
267
|
-
skipBuild: boolean;
|
|
268
|
-
claimSlots: boolean;
|
|
269
|
-
release: boolean;
|
|
270
|
-
switchAfter: boolean | null;
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
function parseArgs(argv: string[]): Args {
|
|
274
|
-
const args: Args = {
|
|
305
|
+
function parseArgs(argv) {
|
|
306
|
+
const args = {
|
|
275
307
|
host: process.env.SUPERBIRD_HOST ?? 'bridgething.local',
|
|
276
|
-
port: Number(process.env.BRIDGETHING_GATEWAY_PORT ?? 8892),
|
|
277
308
|
skipBuild: process.env.SKIP_BUILD === '1',
|
|
278
309
|
claimSlots: true,
|
|
279
310
|
release: false,
|
|
@@ -293,11 +324,10 @@ function parseArgs(argv: string[]): Args {
|
|
|
293
324
|
}
|
|
294
325
|
return args;
|
|
295
326
|
}
|
|
296
|
-
|
|
297
|
-
function printHelp(): void {
|
|
327
|
+
function printHelp() {
|
|
298
328
|
console.log(`Usage: bun run push [host] [options]
|
|
299
329
|
|
|
300
|
-
Build,
|
|
330
|
+
Build, copy dist/ onto a connected Car Thing, and make it visible.
|
|
301
331
|
|
|
302
332
|
A plain webapp becomes the active app. A launcher also takes the home-screen
|
|
303
333
|
slot; an overlay takes the overlay slot and the daemon reloads the kiosk so it
|
|
@@ -310,66 +340,54 @@ Options:
|
|
|
310
340
|
--no-slot push without claiming any slot.
|
|
311
341
|
--switch switch to this bundle even if it is overlay-only.
|
|
312
342
|
--no-switch push without switching.
|
|
313
|
-
--skip-build
|
|
343
|
+
--skip-build copy whatever is already in dist/.
|
|
314
344
|
|
|
315
345
|
Env: SUPERBIRD_HOST, BRIDGETHING_GATEWAY_PORT, SKIP_BUILD=1
|
|
316
346
|
`);
|
|
317
347
|
}
|
|
318
|
-
|
|
319
|
-
export type BridgethingPushOptions = {
|
|
320
|
-
scriptUrl: string;
|
|
321
|
-
};
|
|
322
|
-
|
|
323
|
-
export async function bridgethingPush({ scriptUrl }: BridgethingPushOptions): Promise<void> {
|
|
348
|
+
async function bridgethingPush({ scriptUrl }) {
|
|
324
349
|
const args = parseArgs(process.argv.slice(2));
|
|
325
|
-
|
|
350
|
+
const target = await resolveGatewayTarget(args.host);
|
|
326
351
|
const repoDir = resolve(dirname(new URL(scriptUrl).pathname), '..');
|
|
327
352
|
const distDir = resolve(repoDir, 'dist');
|
|
328
353
|
const manifestPath = resolve(distDir, 'manifest.json');
|
|
329
|
-
|
|
330
|
-
const readManifest = (): Manifest => {
|
|
354
|
+
const readManifest = () => {
|
|
331
355
|
if (!existsSync(manifestPath)) {
|
|
332
356
|
throw new Error(`no manifest.json at ${manifestPath}; run 'bun run build' first or drop --skip-build`);
|
|
333
357
|
}
|
|
334
|
-
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
358
|
+
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
335
359
|
if (!parsed.id) throw new Error(`${manifestPath} has no 'id' field`);
|
|
336
360
|
return parsed;
|
|
337
361
|
};
|
|
338
|
-
|
|
339
362
|
if (args.release) {
|
|
340
|
-
const
|
|
341
|
-
for (const slot of declaredSlots(
|
|
342
|
-
const result = await setSlot(
|
|
363
|
+
const manifest2 = readManifest();
|
|
364
|
+
for (const slot of declaredSlots(manifest2)) {
|
|
365
|
+
const result = await setSlot(target, slot, null);
|
|
343
366
|
if (!result.ok) throw new Error(result.reason);
|
|
344
367
|
console.log(`${slot} slot: reverted to the built-in one`);
|
|
345
368
|
}
|
|
346
|
-
if (declaredSlots(
|
|
369
|
+
if (declaredSlots(manifest2).length === 0) console.log('this bundle declares no slots; nothing to release');
|
|
347
370
|
return;
|
|
348
371
|
}
|
|
349
|
-
|
|
350
372
|
if (!args.skipBuild) await buildBundle(repoDir);
|
|
351
373
|
const manifest = readManifest();
|
|
352
|
-
const id = manifest.id
|
|
374
|
+
const id = manifest.id;
|
|
353
375
|
const slots = declaredSlots(manifest);
|
|
354
|
-
|
|
355
376
|
await pushBundle(distDir, args.host, bundleDirName(id));
|
|
356
|
-
|
|
357
377
|
if (args.claimSlots) {
|
|
358
378
|
for (const slot of slots) {
|
|
359
|
-
const result = await setSlot(
|
|
379
|
+
const result = await setSlot(target, slot, id);
|
|
360
380
|
if (!result.ok) throw new Error(result.reason);
|
|
361
381
|
console.log(`${slot} slot: ${manifest.name ?? id}`);
|
|
362
382
|
}
|
|
363
383
|
}
|
|
364
|
-
|
|
365
384
|
const overlayOnly = slots.length > 0 && slots.every(s => s === 'overlay');
|
|
366
385
|
const shouldSwitch = args.switchAfter ?? !overlayOnly;
|
|
367
386
|
if (!shouldSwitch) {
|
|
368
387
|
console.log(overlayOnly ? 'overlay pushed; the kiosk reloaded with it injected' : 'skipping switch');
|
|
369
388
|
return;
|
|
370
389
|
}
|
|
371
|
-
|
|
372
|
-
const switched = await switchTo(args.host, args.port, id);
|
|
390
|
+
const switched = await switchTo(target, id);
|
|
373
391
|
if (!switched.ok) throw new Error(switched.reason);
|
|
374
392
|
const active = switched.value;
|
|
375
393
|
if (!active) {
|
|
@@ -379,6 +397,7 @@ export async function bridgethingPush({ scriptUrl }: BridgethingPushOptions): Pr
|
|
|
379
397
|
const activeStr = active.id ? uuidToString(active.id) : '(none)';
|
|
380
398
|
console.log(`active webapp: ${active.name ?? '(unnamed)'} ${activeStr}`);
|
|
381
399
|
}
|
|
400
|
+
export { bridgethingPush };
|
|
382
401
|
|
|
383
402
|
bridgethingPush({ scriptUrl: import.meta.url }).catch(err => {
|
|
384
403
|
console.error(err instanceof Error ? err.message : err);
|
package/template/src/App.tsx
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { BridgethingClient, type ConnectionState, type PlayerState } from '@bridgething/client';
|
|
2
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/');
|
|
3
|
+
import { daemonUrl } from './daemon';
|
|
7
4
|
|
|
8
5
|
export default function App() {
|
|
9
|
-
const client = useMemo(() => new BridgethingClient({ url:
|
|
6
|
+
const client = useMemo(() => new BridgethingClient({ url: daemonUrl() }), []);
|
|
10
7
|
const [conn, setConn] = useState<ConnectionState>(client.connectionState);
|
|
11
8
|
const [state, setState] = useState<PlayerState | null>(null);
|
|
12
9
|
const [artUrl, setArtUrl] = useState<string | null>(null);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Generated from @bridgething/webapp-shared.
|
|
2
|
+
/// <reference types="vite/client" />
|
|
3
|
+
|
|
4
|
+
const OFF_DEVICE_URL = 'ws://127.0.0.1:8891/';
|
|
5
|
+
|
|
6
|
+
export const DAEMON_PROXY_PATH = '/__bridgething';
|
|
7
|
+
|
|
8
|
+
export function daemonUrl(): string {
|
|
9
|
+
const override = import.meta.env.VITE_BRIDGETHING_URL;
|
|
10
|
+
if (override) return override;
|
|
11
|
+
if (typeof window === 'undefined') return OFF_DEVICE_URL;
|
|
12
|
+
const { host } = window.location;
|
|
13
|
+
return import.meta.env.DEV ? `ws://${host}${DAEMON_PROXY_PATH}/` : `ws://${host}/`;
|
|
14
|
+
}
|
package/template/vite.config.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import tailwindcss from '@tailwindcss/vite';
|
|
2
2
|
import react from '@vitejs/plugin-react';
|
|
3
3
|
import { defineConfig } from 'vite';
|
|
4
|
+
import { bridgething, daemonProxy } from './scripts/bridgething';
|
|
4
5
|
|
|
5
|
-
export default defineConfig({
|
|
6
|
-
plugins: [react(), tailwindcss()],
|
|
6
|
+
export default defineConfig(async () => ({
|
|
7
|
+
plugins: [react(), tailwindcss(), bridgething()],
|
|
7
8
|
build: {
|
|
8
9
|
target: 'es2022',
|
|
9
10
|
sourcemap: true,
|
|
10
11
|
},
|
|
11
12
|
server: {
|
|
12
13
|
host: true,
|
|
14
|
+
proxy: await daemonProxy(),
|
|
13
15
|
},
|
|
14
|
-
});
|
|
16
|
+
}));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
## This project ships a native extension
|
|
2
|
+
|
|
3
|
+
`manifest.json` declares an `extension` block, so this bundle has two halves.
|
|
4
|
+
|
|
5
|
+
- `src/` is the webapp, running in the device's chromium kiosk.
|
|
6
|
+
- `extension/` is a Deno process on the desktop. The bridgething desktop app
|
|
7
|
+
spawns it while the app is installed and enabled, with or without a Car Thing
|
|
8
|
+
connected.
|
|
9
|
+
|
|
10
|
+
The two halves talk over the daemon's forward surface: `client.forward` in the
|
|
11
|
+
webapp, `device.send` and `ctx.on('message', ...)` in the extension. The daemon
|
|
12
|
+
delivers a forward while this webapp is the active one on that device.
|
|
13
|
+
|
|
14
|
+
Host access belongs in the extension, an ordinary Deno program with `npm:`,
|
|
15
|
+
`jsr:`, and `node:` available. Keep `src/` a view over what it sends. The webapp
|
|
16
|
+
also runs with nothing attached, so check `capabilities.available.forward` and
|
|
17
|
+
give the user something useful when it is false.
|
|
18
|
+
|
|
19
|
+
`manifest.json` starts with `"permissions": ["all"]`. Narrow it to the Deno
|
|
20
|
+
permissions you use before you publish.
|
|
21
|
+
|
|
22
|
+
The `ctx` contract, the permission grammar, the dev loop, and the gotchas:
|
|
23
|
+
`.claude/skills/bridgething/reference/extension.md`.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { asJson, defineExtension, json } from '@bridgething/extension';
|
|
2
|
+
|
|
3
|
+
defineExtension({
|
|
4
|
+
start(ctx) {
|
|
5
|
+
ctx.log.info(`__PROJECT_NAME__ extension up, data in ${ctx.dataDir}`);
|
|
6
|
+
|
|
7
|
+
ctx.on('device', event => {
|
|
8
|
+
if (event.type !== 'connected') return;
|
|
9
|
+
const greeting = ctx.config(event.device).greeting ?? 'hello';
|
|
10
|
+
ctx.log.info(`${event.device.name} connected; greeting is "${greeting}"`);
|
|
11
|
+
event.device.send(json({ type: 'greeting', text: greeting }));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
ctx.on('message', (device, message) => {
|
|
15
|
+
const payload = asJson<Record<string, unknown>>(message);
|
|
16
|
+
if (!payload) return;
|
|
17
|
+
device.send(json({ type: 'echo', payload }));
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
});
|