made-bridge 0.0.0-stage → 0.2.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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -2
  3. package/builtin/plugins/README.md +22 -0
  4. package/builtin/themes/README.md +64 -0
  5. package/builtin/themes/sunset/theme.json +19 -0
  6. package/config.example.json +20 -0
  7. package/demo-project/README.md +5 -0
  8. package/demo-project/index.mjs +14 -0
  9. package/package.json +48 -4
  10. package/public/app.js +1028 -0
  11. package/public/assistants.html +67 -0
  12. package/public/bean-icon.png +0 -0
  13. package/public/i18n-messages.js +967 -0
  14. package/public/i18n-static.js +615 -0
  15. package/public/i18n.js +50 -0
  16. package/public/index.html +149 -0
  17. package/public/made-icon.png +0 -0
  18. package/public/plugins.html +64 -0
  19. package/public/plugins.js +335 -0
  20. package/public/provider-icon.js +77 -0
  21. package/public/styles.css +445 -0
  22. package/public/tasks.html +85 -0
  23. package/src/agent-icons.mjs +138 -0
  24. package/src/agents/codex.mjs +94 -0
  25. package/src/agents/cursor.mjs +31 -0
  26. package/src/agents/events.mjs +92 -0
  27. package/src/agents/index.mjs +7 -0
  28. package/src/agents/qoder.mjs +34 -0
  29. package/src/agents/shared.mjs +191 -0
  30. package/src/agents/workbuddy.mjs +121 -0
  31. package/src/board-link.mjs +266 -0
  32. package/src/config.mjs +196 -0
  33. package/src/doctor.mjs +146 -0
  34. package/src/i18n.mjs +257 -0
  35. package/src/installed.mjs +291 -0
  36. package/src/jobs.mjs +369 -0
  37. package/src/language-settings.mjs +38 -0
  38. package/src/model-settings.mjs +63 -0
  39. package/src/openai-key.mjs +47 -0
  40. package/src/oray.mjs +243 -0
  41. package/src/pairing.mjs +203 -0
  42. package/src/plugin-package.mjs +132 -0
  43. package/src/plugin-runtime.mjs +169 -0
  44. package/src/plugins/contracts.d.ts +184 -0
  45. package/src/plugins/enhance.mjs +156 -0
  46. package/src/plugins/registry.mjs +146 -0
  47. package/src/providers.mjs +75 -0
  48. package/src/server.mjs +939 -0
  49. package/src/sessions.mjs +134 -0
  50. package/src/speech/index.mjs +10 -0
  51. package/src/speech/openai.mjs +48 -0
  52. package/src/speech/whisper.mjs +59 -0
  53. package/src/speech-settings.mjs +209 -0
  54. package/src/transcribe.mjs +85 -0
  55. package/src/tunnel.mjs +531 -0
  56. package/src/usb-receiver.mjs +664 -0
  57. package/src/voice-settings.mjs +69 -0
@@ -0,0 +1,664 @@
1
+ import fs from 'node:fs';
2
+ import { translateKnownMessage } from './i18n.mjs';
3
+ import path from 'node:path';
4
+ import http from 'node:http';
5
+ import { execFile } from 'node:child_process';
6
+ import { randomBytes } from 'node:crypto';
7
+ import { promisify } from 'node:util';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+ export const USB_RECEIVER_AUTHORITY = '192.168.4.1:8788';
11
+ export const USB_DIRECT_AUTHORITY = 'usb.vibe.local:8788';
12
+
13
+ const MAX_LINE = 2048;
14
+ const MAX_CHUNK = 1024;
15
+ // 8×1KB 的 base64 行大约 11KB,小于接收器 16KB 接收环。窗口内不等确认,
16
+ // 避免 224KB 提示音被逐块往返拖过接收器 30 秒转发上限。
17
+ const ACK_WINDOW = 8;
18
+ const MAX_REQUEST = 1_100_000;
19
+ const MAX_RESPONSE = 262_144;
20
+ const REQUEST_TIMEOUT_MS = 30_000;
21
+ const VOICE_TIMEOUT_MS = 150_000;
22
+ const HELLO_TIMEOUT_MS = 7_000;
23
+ const DISCOVERY_INTERVAL_MS = 2_000;
24
+ const REJECTED_PORT_COOLDOWN_MS = 60_000;
25
+ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
26
+ // Probe only the native ESP USB device and the WCH bridge verified on the
27
+ // supported C3 receiver. A matching USB ID is a candidate, not a ready peer:
28
+ // UsbReceiverProtocol must still validate its hello before forwarding data.
29
+ const SUPPORTED_USB_IDS = new Set(['303a:1001', '1a86:55d3']);
30
+
31
+ function supportedUsbDevice(vendor, product) {
32
+ return Number.isInteger(vendor) && Number.isInteger(product) &&
33
+ SUPPORTED_USB_IDS.has(`${vendor.toString(16)}:${product.toString(16)}`);
34
+ }
35
+
36
+ function validId(id) {
37
+ return Number.isInteger(id) && id >= 0 && id <= 0xffffffff;
38
+ }
39
+
40
+ function isVoiceRequest(method, route) {
41
+ return method === 'POST' && (route === '/device/voice' || route.startsWith('/device/voice?'));
42
+ }
43
+
44
+ function errorBody(message) {
45
+ return Buffer.from(JSON.stringify({ error: message }), 'utf8');
46
+ }
47
+
48
+ function limitedText(value, limit = 128) {
49
+ return typeof value === 'string' && value.length <= limit && !/[\r\n\x00-\x1f\x7f]/.test(value);
50
+ }
51
+
52
+ export function parseMacUsbSerialPorts(output) {
53
+ const ports = new Set();
54
+ const blocks = String(output).split(/(?=^[\s|]*\+-o [^\n]*<class IOUSBHostDevice,)/m);
55
+ for (const block of blocks) {
56
+ if (!block.includes('<class IOUSBHostDevice,')) continue;
57
+ const vendor = block.match(/"idVendor"\s*=\s*(\d+)/);
58
+ const product = block.match(/"idProduct"\s*=\s*(\d+)/);
59
+ if (!supportedUsbDevice(Number(vendor?.[1]), Number(product?.[1]))) continue;
60
+ for (const match of block.matchAll(/"IOCalloutDevice"\s*=\s*"(\/dev\/cu\.[A-Za-z0-9._-]+)"/g)) {
61
+ ports.add(match[1]);
62
+ }
63
+ }
64
+ return [...ports];
65
+ }
66
+
67
+ export function linuxUsbSerialPorts({ sysTtyRoot = '/sys/class/tty', devRoot = '/dev' } = {}) {
68
+ const ports = [];
69
+ let names;
70
+ try { names = fs.readdirSync(sysTtyRoot); } catch { return ports; }
71
+ for (const name of names) {
72
+ if (!/^tty(?:ACM|USB)\d+$/.test(name)) continue;
73
+ let directory;
74
+ try { directory = fs.realpathSync(path.join(sysTtyRoot, name, 'device')); }
75
+ catch { continue; }
76
+ while (directory !== '/') {
77
+ try {
78
+ const vendor = Number.parseInt(fs.readFileSync(path.join(directory, 'idVendor'), 'utf8'), 16);
79
+ const product = Number.parseInt(fs.readFileSync(path.join(directory, 'idProduct'), 'utf8'), 16);
80
+ if (supportedUsbDevice(vendor, product)) {
81
+ const device = path.join(devRoot, name);
82
+ if (fs.existsSync(device)) ports.push(device);
83
+ break;
84
+ }
85
+ } catch { /* Walk to the USB device parent. */ }
86
+ directory = path.dirname(directory);
87
+ }
88
+ }
89
+ return ports;
90
+ }
91
+
92
+ export async function discoverUsbSerialPorts(platform = process.platform) {
93
+ if (platform === 'darwin') {
94
+ try {
95
+ const result = await execFileAsync('ioreg', ['-p', 'IOService', '-r', '-c', 'IOUSBHostDevice', '-l', '-t', '-w0'], {
96
+ encoding: 'utf8', timeout: 3000, maxBuffer: 8 * 1024 * 1024,
97
+ });
98
+ return parseMacUsbSerialPorts(result.stdout).filter((port) => fs.existsSync(port));
99
+ } catch { return []; }
100
+ }
101
+ if (platform === 'linux') return linuxUsbSerialPorts();
102
+ return [];
103
+ }
104
+
105
+ async function configureSerial(port, platform = process.platform) {
106
+ if (!['darwin', 'linux'].includes(platform)) return;
107
+ const settings = ['921600', 'raw', '-echo', 'cs8', '-parenb', '-cstopb',
108
+ '-crtscts', 'clocal', '-hupcl'];
109
+ const args = platform === 'darwin'
110
+ ? ['-f', port, ...settings]
111
+ : ['-F', port, ...settings];
112
+ try { await execFileAsync('stty', args, { encoding: 'utf8', timeout: 3000 }); }
113
+ catch { throw new Error('无法配置 USB 串口,请检查端口权限'); }
114
+ }
115
+
116
+ export function forwardUsbRequest(request, { devicePort, authority, forwardSecret, signal }) {
117
+ return new Promise((resolve, reject) => {
118
+ const headers = {
119
+ Host: authority,
120
+ 'Content-Length': String(request.body.length),
121
+ 'X-Vibe-Usb-Forward': forwardSecret,
122
+ };
123
+ if (request.authorization) headers.Authorization = request.authorization;
124
+ if (request.contentType) headers['Content-Type'] = request.contentType;
125
+ const outgoing = http.request({
126
+ hostname: '127.0.0.1', port: devicePort, method: request.method,
127
+ path: request.path, headers, signal,
128
+ }, (response) => {
129
+ const chunks = [];
130
+ let length = 0;
131
+ response.on('data', (chunk) => {
132
+ length += chunk.length;
133
+ if (length > MAX_RESPONSE) {
134
+ reject(new Error('设备接口响应过大'));
135
+ response.destroy();
136
+ outgoing.destroy();
137
+ return;
138
+ }
139
+ chunks.push(chunk);
140
+ });
141
+ response.on('end', () => {
142
+ if (length > MAX_RESPONSE) return;
143
+ resolve({
144
+ status: response.statusCode || 502,
145
+ contentType: String(response.headers['content-type'] || 'application/octet-stream').slice(0, 128),
146
+ body: Buffer.concat(chunks, length),
147
+ });
148
+ });
149
+ response.on('error', reject);
150
+ });
151
+ outgoing.on('error', reject);
152
+ outgoing.end(request.body);
153
+ });
154
+ }
155
+
156
+ export class UsbReceiverProtocol {
157
+ constructor({ sendLine, forward, onHello = () => {}, onBye = () => {}, onActivity = () => {},
158
+ timeoutMs = REQUEST_TIMEOUT_MS, voiceTimeoutMs = VOICE_TIMEOUT_MS,
159
+ refreshAuthenticatedVoice = () => false, voiceKeepaliveMs = 10_000, locale = () => 'zh-CN' }) {
160
+ this.sendLine = sendLine;
161
+ this.forward = forward;
162
+ this.onHello = onHello;
163
+ this.onBye = onBye;
164
+ this.onActivity = onActivity;
165
+ this.timeoutMs = timeoutMs;
166
+ this.voiceTimeoutMs = voiceTimeoutMs;
167
+ this.refreshAuthenticatedVoice = refreshAuthenticatedVoice;
168
+ this.voiceKeepaliveMs = voiceKeepaliveMs;
169
+ this.locale = locale;
170
+ this.line = Buffer.alloc(0);
171
+ this.discardLine = false;
172
+ this.helloReceived = false;
173
+ this.peer = null;
174
+ this.connectionId = randomBytes(16).toString('hex');
175
+ this.generation = 0;
176
+ this.current = null;
177
+ this.responding = false;
178
+ this.closed = false;
179
+ this.pendingWork = Promise.resolve();
180
+ // 响应分块的确认计数。接收器仍逐块回 ack;这里最多超前 ACK_WINDOW 块,
181
+ // 既保住 UART 接收环,又不必每一千字节停一次。
182
+ this.ackWaiters = new Map();
183
+ this.ackCredits = new Map();
184
+ }
185
+
186
+ feed(chunk) {
187
+ if (this.closed) return;
188
+ let start = 0;
189
+ for (let index = 0; index < chunk.length; index += 1) {
190
+ if (chunk[index] !== 10) continue;
191
+ const segment = chunk.subarray(start, index);
192
+ start = index + 1;
193
+ if (this.discardLine || this.line.length + segment.length + 1 > MAX_LINE) {
194
+ this.line = Buffer.alloc(0);
195
+ this.discardLine = false;
196
+ this.rejectCurrent(400, 'USB 帧过长');
197
+ continue;
198
+ }
199
+ const line = Buffer.concat([this.line, segment]).toString('utf8').replace(/\r$/, '');
200
+ this.line = Buffer.alloc(0);
201
+ this.handleLine(line);
202
+ }
203
+ const remainder = chunk.subarray(start);
204
+ if (this.discardLine) return;
205
+ if (this.line.length + remainder.length + 1 > MAX_LINE) {
206
+ this.line = Buffer.alloc(0);
207
+ this.discardLine = true;
208
+ return;
209
+ }
210
+ this.line = Buffer.concat([this.line, remainder]);
211
+ }
212
+
213
+ handleLine(line) {
214
+ let frame;
215
+ try { frame = JSON.parse(line); } catch {
216
+ console.error(`[usb-link] unparseable: ${line.slice(0, 240)}`);
217
+ this.rejectCurrent(400, 'USB 帧格式无效');
218
+ return;
219
+ }
220
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame)) {
221
+ this.rejectCurrent(400, 'USB 帧格式无效');
222
+ return;
223
+ }
224
+ if (frame.type === 'hello') {
225
+ if (frame.version !== 1 || ![undefined, 'receiver', 'direct'].includes(frame.mode)) return;
226
+ const direct = frame.mode === 'direct';
227
+ if (direct && (!/^[0-9a-f]{12}$/.test(frame.deviceId || '') ||
228
+ !limitedText(frame.deviceName, 80) || !frame.deviceName.trim())) return;
229
+ if (!direct && (!limitedText(frame.ssid, 64) ||
230
+ typeof frame.password !== 'string' || frame.password.length < 8 || frame.password.length > 63 ||
231
+ /[\r\n\x00-\x1f\x7f]/.test(frame.password))) return;
232
+ const peer = { mode: direct ? 'direct' : 'receiver',
233
+ deviceId: direct ? frame.deviceId : '', deviceName: direct ? frame.deviceName : '',
234
+ ssid: direct ? '' : frame.ssid, password: direct ? '' : frame.password };
235
+ if (this.peer && (this.peer.mode !== peer.mode || this.peer.deviceId !== peer.deviceId)) return;
236
+ if (this.onHello(peer) === false) return;
237
+ console.error(`[usb-link] hello: mode=${peer.mode} ${new Date().toISOString()}`);
238
+ this.peer = peer;
239
+ this.helloReceived = true;
240
+ this.onActivity();
241
+ // The receiver waits for this acknowledgement before forwarding HTTP.
242
+ // A USB cable being present does not mean the desktop process is ready.
243
+ const generation = this.generation;
244
+ this.pendingWork = this.pendingWork.then(async () => {
245
+ if (!this.closed && this.helloReceived && this.generation === generation) {
246
+ await this.sendLine(JSON.stringify({ type: 'ready', version: 1, connectionId: this.connectionId }));
247
+ }
248
+ }).catch(() => this.dispose());
249
+ return;
250
+ }
251
+ if (!this.helloReceived) return;
252
+ if (frame.type === 'ack' && validId(frame.id)) {
253
+ if (this.ackCredits.has(frame.id)) {
254
+ this.ackCredits.set(frame.id, this.ackCredits.get(frame.id) + 1);
255
+ }
256
+ const waiter = this.ackWaiters.get(frame.id);
257
+ if (waiter) waiter();
258
+ return;
259
+ }
260
+ if (frame.type === 'bye' && this.peer.mode === 'direct' &&
261
+ [undefined, 'direct'].includes(frame.mode)) {
262
+ this.cancelCurrent();
263
+ this.helloReceived = false;
264
+ this.generation += 1;
265
+ this.onBye(this.peer);
266
+ return;
267
+ }
268
+ if (frame.type === 'request') {
269
+ if (this.current || this.responding || !validId(frame.id)) {
270
+ if (this.current) this.rejectCurrent(409, 'USB 请求重叠');
271
+ return;
272
+ }
273
+ if (!['GET', 'POST'].includes(frame.method) ||
274
+ typeof frame.path !== 'string' || frame.path.length > 512 ||
275
+ /[\r\n\x00-\x1f\x7f#]/.test(frame.path) ||
276
+ !limitedText(frame.authorization || '', 128) ||
277
+ !limitedText(frame.contentType || '', 128) ||
278
+ !Number.isInteger(frame.length) || frame.length < 0 || frame.length > MAX_REQUEST) {
279
+ this.queueResponse(frame.id, 400, this.errorBody('USB 请求格式无效'));
280
+ return;
281
+ }
282
+ const current = {
283
+ id: frame.id, method: frame.method, path: frame.path, mode: this.peer.mode,
284
+ authorization: frame.authorization || '', contentType: frame.contentType || '',
285
+ length: frame.length, chunks: [], received: 0,
286
+ controller: new AbortController(),
287
+ };
288
+ current.timeoutMs = isVoiceRequest(frame.method, frame.path) ? this.voiceTimeoutMs : this.timeoutMs;
289
+ current.timer = setTimeout(() => this.timeout(current), current.timeoutMs);
290
+ current.timer.unref?.();
291
+ this.current = current;
292
+ if (isVoiceRequest(frame.method, frame.path) && this.refreshVoice(current)) {
293
+ current.keepaliveTimer = setInterval(() => {
294
+ if (this.current !== current || this.closed || current.controller.signal.aborted ||
295
+ !this.refreshVoice(current)) {
296
+ clearInterval(current.keepaliveTimer);
297
+ current.keepaliveTimer = null;
298
+ }
299
+ }, this.voiceKeepaliveMs);
300
+ current.keepaliveTimer.unref?.();
301
+ }
302
+ this.onActivity();
303
+ return;
304
+ }
305
+ const current = this.current;
306
+ if (!current || frame.id !== current.id) return;
307
+ if (frame.type === 'cancel') {
308
+ // Leaving Vibe disconnects the display from the receiver AP. Stop both
309
+ // the upstream operation and its temporary device-online refresh.
310
+ this.cancelCurrent();
311
+ this.onActivity();
312
+ return;
313
+ }
314
+ if (frame.type === 'data') {
315
+ if (typeof frame.chunk !== 'string' || frame.chunk.length > 1368 ||
316
+ !BASE64.test(frame.chunk)) {
317
+ this.rejectCurrent(400, 'USB 数据帧无效');
318
+ return;
319
+ }
320
+ const decoded = Buffer.from(frame.chunk, 'base64');
321
+ if (decoded.length === 0 || decoded.length > MAX_CHUNK ||
322
+ decoded.toString('base64') !== frame.chunk ||
323
+ current.received + decoded.length > current.length) {
324
+ this.rejectCurrent(400, 'USB 数据长度无效');
325
+ return;
326
+ }
327
+ current.chunks.push(decoded);
328
+ current.received += decoded.length;
329
+ this.onActivity();
330
+ return;
331
+ }
332
+ if (frame.type === 'end') {
333
+ if (current.received !== current.length) {
334
+ this.rejectCurrent(400, 'USB 请求长度不符');
335
+ return;
336
+ }
337
+ this.responding = true;
338
+ const request = { ...current, body: Buffer.concat(current.chunks, current.length) };
339
+ this.pendingWork = this.pendingWork.then(async () => {
340
+ try {
341
+ if (this.closed || this.current !== current || current.controller.signal.aborted) return;
342
+ const response = await this.forward(request, current.controller.signal);
343
+ if (this.closed || this.current !== current) return;
344
+ clearTimeout(current.timer);
345
+ await this.writeResponse(current.id, response.status, response.contentType, response.body);
346
+ } catch (error) {
347
+ if (this.closed || this.current !== current) return;
348
+ const status = current.controller.signal.aborted ? 504 : 502;
349
+ await this.writeResponse(current.id, status, 'application/json; charset=utf-8',
350
+ this.errorBody(status === 504 ? 'USB 请求超时' : '电脑设备接口暂不可用'));
351
+ } finally {
352
+ this.finish(current);
353
+ }
354
+ }).catch(() => this.finish(current));
355
+ return;
356
+ }
357
+ this.rejectCurrent(400, 'USB 帧类型无效');
358
+ }
359
+
360
+ timeout(current) {
361
+ if (this.current !== current || this.closed) return;
362
+ current.controller.abort();
363
+ this.current = null;
364
+ this.responding = true;
365
+ clearTimeout(current.timer);
366
+ clearInterval(current.keepaliveTimer);
367
+ this.queueResponse(current.id, 504, this.errorBody('USB 请求超时'));
368
+ }
369
+
370
+ rejectCurrent(status, message) {
371
+ const current = this.current;
372
+ if (!current) return;
373
+ current.controller.abort();
374
+ this.current = null;
375
+ this.responding = true;
376
+ clearTimeout(current.timer);
377
+ clearInterval(current.keepaliveTimer);
378
+ this.queueResponse(current.id, status, this.errorBody(message));
379
+ }
380
+
381
+ queueResponse(id, status, body) {
382
+ this.responding = true;
383
+ this.pendingWork = this.pendingWork.then(() =>
384
+ this.writeResponse(id, status, 'application/json; charset=utf-8', body)
385
+ ).catch(() => {}).finally(() => { if (!this.current) this.responding = false; });
386
+ }
387
+
388
+ async writeResponse(id, status, contentType, body) {
389
+ if (this.closed) return;
390
+ const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body || '');
391
+ if (bytes.length > MAX_RESPONSE) {
392
+ await this.writeResponse(id, 502, 'application/json; charset=utf-8', this.errorBody('设备接口响应过大'));
393
+ return;
394
+ }
395
+ this.ackCredits.set(id, 0);
396
+ try {
397
+ await this.sendLine(JSON.stringify({ type: 'response', id, status,
398
+ contentType: limitedText(contentType, 128) ? contentType : 'application/octet-stream',
399
+ length: bytes.length }));
400
+ let sentChunks = 0;
401
+ for (let offset = 0; offset < bytes.length; offset += MAX_CHUNK) {
402
+ while (sentChunks - (this.ackCredits.get(id) || 0) >= ACK_WINDOW) {
403
+ await this.waitForCredits(id, sentChunks - ACK_WINDOW + 1);
404
+ }
405
+ await this.sendLine(JSON.stringify({ type: 'data', id,
406
+ chunk: bytes.subarray(offset, offset + MAX_CHUNK).toString('base64') }));
407
+ sentChunks += 1;
408
+ }
409
+ if (sentChunks > (this.ackCredits.get(id) || 0)) await this.waitForCredits(id, sentChunks);
410
+ await this.sendLine(JSON.stringify({ type: 'end', id }));
411
+ console.error(`[usb-link] response done id=${id} bytes=${bytes.length} ${new Date().toISOString()}`);
412
+ this.onActivity();
413
+ } finally {
414
+ this.ackCredits.delete(id);
415
+ this.ackWaiters.delete(id);
416
+ }
417
+ }
418
+
419
+ waitForCredits(id, target) {
420
+ if ((this.ackCredits.get(id) || 0) >= target) return Promise.resolve();
421
+ return new Promise((resolve, reject) => {
422
+ const timer = setTimeout(() => {
423
+ console.error(`[usb-link] ack TIMEOUT id=${id} ${new Date().toISOString()}`);
424
+ this.ackWaiters.delete(id);
425
+ reject(new Error('USB ACK 超时'));
426
+ }, 15_000);
427
+ timer.unref?.();
428
+ const check = () => {
429
+ if ((this.ackCredits.get(id) || 0) < target) return;
430
+ clearTimeout(timer);
431
+ this.ackWaiters.delete(id);
432
+ resolve();
433
+ };
434
+ this.ackWaiters.set(id, check);
435
+ check();
436
+ });
437
+ }
438
+
439
+ finish(current) {
440
+ clearTimeout(current.timer);
441
+ clearInterval(current.keepaliveTimer);
442
+ if (this.current === current) {
443
+ this.current = null;
444
+ this.responding = false;
445
+ }
446
+ }
447
+
448
+ dispose() {
449
+ console.error(`[usb-link] dispose ${new Date().toISOString()}`);
450
+ this.closed = true;
451
+ this.cancelCurrent();
452
+ this.ackWaiters.clear();
453
+ this.ackCredits.clear();
454
+ this.line = Buffer.alloc(0);
455
+ }
456
+
457
+ cancelCurrent() {
458
+ const current = this.current;
459
+ this.current = null;
460
+ this.responding = false;
461
+ current?.controller.abort();
462
+ clearTimeout(current?.timer);
463
+ clearInterval(current?.keepaliveTimer);
464
+ }
465
+
466
+ errorBody(message) { return errorBody(translateKnownMessage(message, this.locale())); }
467
+
468
+ refreshVoice(current) {
469
+ try { return this.refreshAuthenticatedVoice(current.authorization) === true; }
470
+ catch { return false; }
471
+ }
472
+ }
473
+
474
+ export class UsbReceiver {
475
+ constructor({ devicePort, portPath = '', authority = USB_RECEIVER_AUTHORITY, forwardSecret = '',
476
+ refreshAuthenticatedVoice = () => false, locale = () => 'zh-CN' }) {
477
+ this.devicePort = devicePort;
478
+ this.portPath = portPath;
479
+ this.locale = locale;
480
+ this.authority = authority;
481
+ this.forwardSecret = forwardSecret;
482
+ this.refreshAuthenticatedVoice = refreshAuthenticatedVoice;
483
+ this.link = null;
484
+ this.links = new Map();
485
+ this.polling = false;
486
+ this.stopped = true;
487
+ this.cooldown = new Map();
488
+ this.timer = null;
489
+ this.state = {
490
+ status: process.platform === 'darwin' || process.platform === 'linux' || portPath ? 'disconnected' : 'disabled',
491
+ port: portPath, mode: '', active: false, deviceId: '', deviceName: '', ssid: '', password: '', lastSeen: '',
492
+ error: process.platform === 'darwin' || process.platform === 'linux' || portPath ? '' : '此系统需手动配置 VIBE_USB_PORT',
493
+ };
494
+ }
495
+
496
+ summary() { return { ...this.state, error: translateKnownMessage(this.state.error, this.locale()) }; }
497
+
498
+ start() {
499
+ if (!this.stopped) return;
500
+ this.stopped = false;
501
+ this.timer = setInterval(() => { void this.poll(); }, DISCOVERY_INTERVAL_MS);
502
+ this.timer.unref?.();
503
+ void this.poll();
504
+ }
505
+
506
+ async poll() {
507
+ if (this.link && this.state.status === 'connected' &&
508
+ !this.link.protocol?.current && !this.link.protocol?.responding &&
509
+ Date.now() - Date.parse(this.state.lastSeen) > 20_000) {
510
+ this.disconnect(this.link, 'USB 握手心跳超时');
511
+ }
512
+ if (this.polling || this.stopped || this.state.status === 'disabled') return;
513
+ this.polling = true;
514
+ try {
515
+ const candidates = this.portPath ? [this.portPath] : await discoverUsbSerialPorts();
516
+ if (this.stopped) return;
517
+ if (!candidates.length) {
518
+ if (!this.links.size) {
519
+ this.state.port = this.portPath;
520
+ this.state.error = this.portPath ? 'USB 设备未连接' : '未发现受支持的 USB 设备;可设置 VIBE_USB_PORT';
521
+ }
522
+ return;
523
+ }
524
+ for (const port of candidates) {
525
+ if (this.stopped) break;
526
+ if (this.links.has(port)) continue;
527
+ if (Date.now() < (this.cooldown.get(port) || 0)) continue;
528
+ let unregisteredFd = null;
529
+ let openedLink = null;
530
+ try {
531
+ // A blocking read on a serial FD can make closeSync wait forever when
532
+ // the attached ESP32 is the round display rather than a receiver.
533
+ const fd = fs.openSync(port, fs.constants.O_RDWR | fs.constants.O_NONBLOCK |
534
+ (fs.constants.O_NOCTTY || 0));
535
+ unregisteredFd = fd;
536
+ // WCH resets the baud rate when its final handle closes. Keep our
537
+ // handle open while stty opens, configures and closes its own handle.
538
+ await configureSerial(port);
539
+ if (this.stopped) break;
540
+ const link = { fd, port, helloTimer: null, inputTimer: null,
541
+ protocol: null, writeQueue: Promise.resolve() };
542
+ this.links.set(port, link);
543
+ openedLink = link;
544
+ unregisteredFd = null;
545
+ if (!this.link) {
546
+ this.link = link;
547
+ this.state.port = port;
548
+ this.state.status = 'waiting';
549
+ this.state.error = '';
550
+ }
551
+ const sendLine = (line) => {
552
+ const bytes = Buffer.from(`${line}\n`, 'utf8');
553
+ if (bytes.length > MAX_LINE) return Promise.reject(new Error('USB 响应帧过长'));
554
+ link.writeQueue = link.writeQueue.then(() => new Promise((resolve, reject) => {
555
+ const deadline = Date.now() + 5_000;
556
+ const writeRemaining = (offset) => {
557
+ if (this.links.get(port) !== link) { reject(new Error('USB 设备已断开')); return; }
558
+ fs.write(link.fd, bytes, offset, bytes.length - offset, null, (error, written) => {
559
+ if (error?.code === 'EAGAIN' || error?.code === 'EWOULDBLOCK' || (!error && !written)) {
560
+ if (Date.now() >= deadline) reject(new Error('USB 写入超时'));
561
+ else setTimeout(() => writeRemaining(offset), 10);
562
+ } else if (error) reject(error);
563
+ else if (offset + written === bytes.length) resolve();
564
+ else writeRemaining(offset + written);
565
+ });
566
+ };
567
+ writeRemaining(0);
568
+ }));
569
+ link.writeQueue.catch(() => this.disconnect(link, 'USB 设备已断开'));
570
+ return link.writeQueue;
571
+ };
572
+ link.protocol = new UsbReceiverProtocol({
573
+ sendLine, locale: this.locale,
574
+ forward: (request, signal) => forwardUsbRequest(request, {
575
+ devicePort: this.devicePort,
576
+ authority: request.mode === 'direct' ? USB_DIRECT_AUTHORITY : this.authority,
577
+ forwardSecret: this.forwardSecret, signal,
578
+ }),
579
+ onHello: (peer) => {
580
+ if (this.links.get(port) !== link || (this.state.active && this.link !== link)) return false;
581
+ this.link = link;
582
+ clearTimeout(link.helloTimer);
583
+ this.state = { status: 'connected', active: true, port, ...peer,
584
+ lastSeen: new Date().toISOString(), error: '' };
585
+ },
586
+ onBye: () => {
587
+ if (this.link !== link) return;
588
+ this.state = { ...this.state, status: 'waiting', active: false, error: '' };
589
+ },
590
+ onActivity: () => {
591
+ if (this.link === link && this.state.status === 'connected') {
592
+ this.state.lastSeen = new Date().toISOString();
593
+ }
594
+ },
595
+ refreshAuthenticatedVoice: this.refreshAuthenticatedVoice,
596
+ });
597
+ const inputBuffer = Buffer.allocUnsafe(4096);
598
+ const readAvailable = () => {
599
+ if (this.links.get(port) !== link) return;
600
+ // TTY drivers may return only 1 KiB even when more data is ready.
601
+ // Drain a bounded batch, then yield so HTTP and UI stay responsive.
602
+ for (let reads = 0; reads < 64; reads += 1) {
603
+ try {
604
+ const length = fs.readSync(fd, inputBuffer, 0, inputBuffer.length, null);
605
+ if (length > 0) link.protocol.feed(inputBuffer.subarray(0, length));
606
+ else { this.disconnect(link, 'USB 设备已断开'); return; }
607
+ } catch (error) {
608
+ if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') break;
609
+ this.disconnect(link, 'USB 设备已断开');
610
+ return;
611
+ }
612
+ }
613
+ if (this.links.get(port) !== link) return;
614
+ link.inputTimer = setTimeout(readAvailable,
615
+ link.protocol.current || link.protocol.responding ? 1 : 20);
616
+ link.inputTimer.unref?.();
617
+ };
618
+ link.inputTimer = setTimeout(readAvailable, 20);
619
+ link.inputTimer.unref?.();
620
+ link.helloTimer = setTimeout(() => {
621
+ if (this.link === link && this.state.status !== 'connected') {
622
+ // A round display only says hello while Vibe is open. Keep this
623
+ // nonblocking reader alive so opening Vibe is detected at once,
624
+ // without reopening/resetting the board or delaying other ports.
625
+ this.state.error = 'USB 已连接,等待打开码得应用的 USB 直连';
626
+ }
627
+ }, HELLO_TIMEOUT_MS);
628
+ link.helloTimer.unref?.();
629
+ } catch {
630
+ if (openedLink) this.disconnect(openedLink);
631
+ if (!this.stopped) {
632
+ if (!this.state.active) this.state.error = '无法打开 USB 串口,请检查端口权限或占用情况';
633
+ this.cooldown.set(port, Date.now() + REJECTED_PORT_COOLDOWN_MS);
634
+ }
635
+ } finally {
636
+ if (unregisteredFd !== null) {
637
+ await new Promise((resolve) => fs.close(unregisteredFd, () => resolve()));
638
+ }
639
+ }
640
+ }
641
+ } finally { this.polling = false; }
642
+ }
643
+
644
+ disconnect(link, error = '') {
645
+ if (this.links.get(link.port) !== link) return;
646
+ this.links.delete(link.port);
647
+ clearTimeout(link.helloTimer);
648
+ link.protocol?.dispose();
649
+ clearTimeout(link.inputTimer);
650
+ fs.close(link.fd, () => {});
651
+ if (this.link === link) {
652
+ this.link = this.links.values().next().value || null;
653
+ this.state = { status: this.link ? 'waiting' : 'disconnected', active: false,
654
+ port: this.link?.port || this.portPath || '', mode: '', deviceId: '', deviceName: '', ssid: '', password: '',
655
+ lastSeen: this.state.lastSeen, error };
656
+ }
657
+ }
658
+
659
+ async stop() {
660
+ this.stopped = true;
661
+ clearInterval(this.timer);
662
+ for (const link of this.links.values()) this.disconnect(link);
663
+ }
664
+ }