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,38 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const DEFAULT_LOCALE = 'zh-CN';
6
+ export function validateLocale(locale) {
7
+ if (locale !== 'zh-CN' && locale !== 'en') throw new Error('语言仅支持 zh-CN 或 en');
8
+ return locale;
9
+ }
10
+ export function loadLanguageSettings(stateDir, fallback = DEFAULT_LOCALE) {
11
+ const initial = validateLocale(fallback);
12
+ if (!stateDir) return initial;
13
+ try {
14
+ const file = path.join(stateDir, 'language-settings.json');
15
+ if (!fs.lstatSync(file).isFile()) throw new Error('语言设置文件无效');
16
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ return validateLocale(saved?.locale);
18
+ } catch (error) {
19
+ if (error.code === 'ENOENT') return initial;
20
+ throw error;
21
+ }
22
+ }
23
+ export function saveLanguageSettings(config, payload) {
24
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload) ||
25
+ Object.keys(payload).some(key => key !== 'locale')) throw new Error('语言设置格式无效');
26
+ const locale = validateLocale(payload.locale);
27
+ const directory = config.stateDir;
28
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
29
+ const file = path.join(directory, 'language-settings.json');
30
+ const temporary = path.join(directory, `.language-settings-${process.pid}-${randomUUID()}`);
31
+ try {
32
+ fs.writeFileSync(temporary, JSON.stringify({ locale }), { flag: 'wx', mode: 0o600 });
33
+ fs.renameSync(temporary, file);
34
+ } finally { fs.rmSync(temporary, { force: true }); }
35
+ // Only update the live locale after the durable write succeeds.
36
+ config.locale = locale;
37
+ return { locale };
38
+ }
@@ -0,0 +1,63 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ export const DEFAULT_CODEX_MODEL = 'gpt-6-astra';
6
+ const DEFAULT_MODEL_AGENTS = [
7
+ { id: 'codex', capabilities: {model: true}, model: {required: true} },
8
+ { id: 'cursor', capabilities: {model: true} }, { id: 'qoder', capabilities: {model: true} },
9
+ ];
10
+ export const MODEL_ID = /^[A-Za-z0-9][A-Za-z0-9._:/+@\[\],=-]{0,119}$/;
11
+
12
+ export function validateModelSettings(value, registry) {
13
+ const agents = (registry?.agents() || DEFAULT_MODEL_AGENTS).filter(p => p.capabilities.model);
14
+ if (!value || typeof value !== 'object' || Array.isArray(value) ||
15
+ Object.keys(value).length !== agents.length || !agents.every(p => Object.hasOwn(value, p.id))) {
16
+ throw new Error('请填写当前已安装助手的模型设置');
17
+ }
18
+ return validateStoredModels(value, new Set(agents.filter(p => p.model?.required).map(p => p.id)));
19
+ }
20
+
21
+ export function validateStoredModels(value, required = new Set()) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('模型设置格式无效');
23
+ const models = {};
24
+ for (const [provider, model] of Object.entries(value)) {
25
+ if (!/^[a-z0-9][a-z0-9_-]{0,39}$/.test(provider) || ['constructor','prototype','__proto__'].includes(provider) ||
26
+ typeof model !== 'string' || (!model && required.has(provider)) || (model && !MODEL_ID.test(model))) {
27
+ throw new Error(`${provider} 模型名称无效`);
28
+ }
29
+ models[provider] = model;
30
+ }
31
+ return models;
32
+ }
33
+
34
+ function settingsPath(stateDir) {
35
+ return path.join(stateDir, 'models.json');
36
+ }
37
+
38
+ export function loadModelSettings(stateDir) {
39
+ const file = settingsPath(stateDir);
40
+ try {
41
+ if (!fs.lstatSync(file).isFile()) throw new Error('模型设置文件无效');
42
+ return validateStoredModels(JSON.parse(fs.readFileSync(file, 'utf8')));
43
+ } catch (error) {
44
+ if (error.code === 'ENOENT') return null;
45
+ throw error;
46
+ }
47
+ }
48
+
49
+ export function saveModelSettings(stateDir, value, registry) {
50
+ const validated = validateModelSettings(value, registry);
51
+ const models = { ...(loadModelSettings(stateDir) || {}), ...validated };
52
+ fs.mkdirSync(stateDir, { recursive: true, mode: 0o700 });
53
+ const file = settingsPath(stateDir);
54
+ const temporary = path.join(stateDir, `.models-${process.pid}-${randomUUID()}`);
55
+ try {
56
+ fs.writeFileSync(temporary, JSON.stringify(models), { flag: 'wx', mode: 0o600 });
57
+ fs.renameSync(temporary, file);
58
+ fs.chmodSync(file, 0o600);
59
+ } finally {
60
+ fs.rmSync(temporary, { force: true });
61
+ }
62
+ return models;
63
+ }
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ function secretPaths(stateDir) {
6
+ const directory = path.join(stateDir, 'secrets');
7
+ return { directory, file: path.join(directory, 'openai-api-key') };
8
+ }
9
+
10
+ export function loadOpenAIKey(stateDir) {
11
+ const { file } = secretPaths(stateDir);
12
+ try {
13
+ if (!fs.lstatSync(file).isFile()) throw new Error('密钥存储文件无效');
14
+ const key = fs.readFileSync(file, 'utf8').trim();
15
+ if (!key || key.length > 512 || /\s/.test(key)) throw new Error('本机保存的 OpenAI 密钥格式无效');
16
+ return key;
17
+ } catch (error) {
18
+ if (error.code === 'ENOENT') return '';
19
+ throw error;
20
+ }
21
+ }
22
+
23
+ export function validateOpenAIKey(value) {
24
+ if (typeof value !== 'string' || value.length > 512 || (value && (value !== value.trim() || /\s/.test(value)))) {
25
+ throw new Error('语音 API Key 格式无效');
26
+ }
27
+ return value;
28
+ }
29
+
30
+ export function saveOpenAIKey(stateDir, value) {
31
+ validateOpenAIKey(value);
32
+ const { directory, file } = secretPaths(stateDir);
33
+ if (value === '') {
34
+ fs.rmSync(file, { force: true });
35
+ return;
36
+ }
37
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
38
+ fs.chmodSync(directory, 0o700);
39
+ const temporary = path.join(directory, `.openai-api-key-${process.pid}-${randomUUID()}`);
40
+ try {
41
+ fs.writeFileSync(temporary, value, { flag: 'wx', mode: 0o600 });
42
+ fs.renameSync(temporary, file);
43
+ fs.chmodSync(file, 0o600);
44
+ } finally {
45
+ fs.rmSync(temporary, { force: true });
46
+ }
47
+ }
package/src/oray.mjs ADDED
@@ -0,0 +1,243 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { isIP } from 'node:net';
5
+
6
+ const ORAY_API = 'https://hsk-api.oray.com';
7
+ const API_TIMEOUT_MS = 8000;
8
+ const PROBE_TIMEOUT_MS = 5000;
9
+
10
+ function privateFile(file, value) {
11
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
12
+ const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
13
+ fs.writeFileSync(temporary, value, { mode: 0o600 });
14
+ fs.renameSync(temporary, file);
15
+ fs.chmodSync(file, 0o600);
16
+ }
17
+
18
+ function validateApiKey(value) {
19
+ if (typeof value !== 'string' || value.length < 16 || value.length > 2048 ||
20
+ !/^[A-Za-z0-9._-]+$/.test(value)) {
21
+ throw new Error('花生壳 API Key 格式无效');
22
+ }
23
+ return value;
24
+ }
25
+
26
+ export function normalizeOrayDomain(value) {
27
+ if (typeof value !== 'string' || value.length > 260) throw new Error('请输入花生壳 HTTPS 域名');
28
+ const trimmed = value.trim();
29
+ let parsed;
30
+ try { parsed = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`); }
31
+ catch { throw new Error('请输入花生壳 HTTPS 域名'); }
32
+ const hostname = parsed.hostname.toLowerCase();
33
+ const labels = hostname.split('.');
34
+ if (parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.port ||
35
+ parsed.pathname !== '/' || parsed.search || parsed.hash || hostname.length > 253 ||
36
+ labels.length < 2 || isIP(hostname) || labels.some((label) =>
37
+ !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) {
38
+ throw new Error('花生壳地址须为 HTTPS 域名,不含端口、路径或参数');
39
+ }
40
+ return hostname;
41
+ }
42
+
43
+ function mapRecord(record) {
44
+ if (!record || typeof record !== 'object') return null;
45
+ const domain = typeof record.domain === 'string' ? record.domain.toLowerCase() : '';
46
+ return {
47
+ domain,
48
+ port: Number(record.port),
49
+ fwtype: Number(record.fwtype),
50
+ servicehost: String(record.servicehost ?? ''),
51
+ serviceport: Number(record.serviceport),
52
+ isforbid: record.isforbid,
53
+ memo: typeof record.memo === 'string' ? record.memo : '',
54
+ };
55
+ }
56
+
57
+ function targetMatches(record, domain, devicePort) {
58
+ return record.domain === domain && record.port === 443 && record.fwtype === 3 &&
59
+ record.servicehost === '127.0.0.1' && record.serviceport === devicePort &&
60
+ typeof record.isforbid === 'boolean';
61
+ }
62
+
63
+ export class OrayManager {
64
+ constructor({ devicePort, stateDir, bridgeId, fetchImpl = fetch }) {
65
+ if (!Number.isInteger(devicePort) || devicePort < 1 || devicePort > 65535) {
66
+ throw new Error('设备接口端口无效');
67
+ }
68
+ this.devicePort = devicePort;
69
+ this.bridgeId = bridgeId;
70
+ this.fetchImpl = fetchImpl;
71
+ this.settingsFile = stateDir ? path.join(stateDir, 'oray-settings.json') : null;
72
+ this.apiKeyFile = stateDir ? path.join(stateDir, 'oray-apikey') : null;
73
+ this.domain = null;
74
+ if (this.settingsFile) {
75
+ try { this.domain = normalizeOrayDomain(JSON.parse(fs.readFileSync(this.settingsFile, 'utf8')).domain); }
76
+ catch { /* Saved settings cannot enable a public mapping on their own. */ }
77
+ }
78
+ this.status = 'off';
79
+ this.url = null;
80
+ this.error = '';
81
+ this.mappingEnabled = false;
82
+ this.busy = false;
83
+ }
84
+
85
+ get apiKeyConfigured() {
86
+ try { return Boolean(this.apiKeyFile && validateApiKey(fs.readFileSync(this.apiKeyFile, 'utf8').trim())); }
87
+ catch { return false; }
88
+ }
89
+
90
+ summary() {
91
+ return { status: this.status, url: this.url, error: this.error, domain: this.domain,
92
+ mappingEnabled: this.mappingEnabled, apiKeyConfigured: this.apiKeyConfigured };
93
+ }
94
+
95
+ configure({ domain, apiKey, clearApiKey = false } = {}) {
96
+ if (this.busy || this.mappingEnabled) throw new Error('请先停用已选花生壳映射,再修改设置');
97
+ if (typeof clearApiKey !== 'boolean' || (clearApiKey && apiKey)) throw new Error('API Key 设置冲突');
98
+ const nextDomain = domain === undefined ? this.domain : normalizeOrayDomain(domain);
99
+ const nextKey = apiKey ? validateApiKey(apiKey) : null;
100
+ if (!this.settingsFile || !this.apiKeyFile) throw new Error('桥接器未配置状态目录');
101
+ if (nextKey) privateFile(this.apiKeyFile, nextKey);
102
+ else if (clearApiKey) fs.rmSync(this.apiKeyFile, { force: true });
103
+ privateFile(this.settingsFile, JSON.stringify({ domain: nextDomain }, null, 2));
104
+ this.domain = nextDomain;
105
+ this.status = 'off';
106
+ this.url = null;
107
+ this.error = '';
108
+ return this.summary();
109
+ }
110
+
111
+ async apiRequest(method, route, data = undefined) {
112
+ if (!this.apiKeyConfigured) throw new Error('请先保存花生壳 API Key');
113
+ const secret = validateApiKey(fs.readFileSync(this.apiKeyFile, 'utf8').trim());
114
+ let response;
115
+ try {
116
+ response = await this.fetchImpl(`${ORAY_API}${route}`, {
117
+ method, redirect: 'error', signal: AbortSignal.timeout(API_TIMEOUT_MS),
118
+ headers: { Authorization: `apikey ${secret}`, ...(data === undefined ? {} : { 'content-type': 'application/json' }) },
119
+ ...(data === undefined ? {} : { body: JSON.stringify(data) }),
120
+ });
121
+ } catch { throw new Error('无法连接花生壳开放 API,请检查网络'); }
122
+ if (response.status === 401 || response.status === 403) throw new Error('花生壳 API Key 无效或无权限');
123
+ if (!response.ok) throw new Error(`花生壳开放 API 返回 ${response.status},请检查账号服务及映射配置`);
124
+ if (response.status === 204) return null;
125
+ try { return await response.json(); }
126
+ catch { throw new Error('花生壳开放 API 返回的数据无效'); }
127
+ }
128
+
129
+ async listMappings() {
130
+ const records = await this.apiRequest('GET', '/openapi/v2/mapping/list');
131
+ if (!Array.isArray(records)) throw new Error('花生壳映射列表格式无效');
132
+ return records.map(mapRecord).filter(Boolean);
133
+ }
134
+
135
+ async selectedMapping() {
136
+ if (!this.domain) throw new Error('请先选择花生壳 HTTPS 域名');
137
+ const candidates = (await this.listMappings()).filter((record) =>
138
+ record.domain === this.domain && record.port === 443 && record.fwtype === 3);
139
+ if (candidates.length !== 1) {
140
+ throw new Error('未找到唯一的 HTTPS/443 映射,请先在花生壳创建并选择该域名');
141
+ }
142
+ const mapping = candidates[0];
143
+ if (!targetMatches(mapping, this.domain, this.devicePort)) {
144
+ throw new Error(`花生壳映射须指向本机 127.0.0.1:${this.devicePort},已拒绝操作其他服务`);
145
+ }
146
+ return mapping;
147
+ }
148
+
149
+ async setEnabled(enabled) {
150
+ const service = await this.apiRequest('GET', '/openapi/api/forward/service');
151
+ const userId = Number(service?.userid);
152
+ if (!Number.isSafeInteger(userId) || userId < 1) throw new Error('无法读取花生壳账号 ID');
153
+ await this.apiRequest('POST', `/openapi/api/mapping/${userId}/forbid/${enabled ? 'off' : 'on'}`,
154
+ { domain: this.domain, port: 443, fwtype: 3 });
155
+ for (let attempt = 0; attempt < 4; attempt += 1) {
156
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 350));
157
+ const mapping = await this.selectedMapping();
158
+ this.mappingEnabled = !mapping.isforbid;
159
+ if (this.mappingEnabled === enabled) return;
160
+ }
161
+ throw new Error('花生壳映射状态尚未更新,请稍后刷新状态');
162
+ }
163
+
164
+ async publicProbe() {
165
+ if (typeof this.bridgeId !== 'string' || !this.bridgeId) throw new Error('桥接器身份尚未准备好');
166
+ const nonce = randomBytes(16).toString('hex');
167
+ let response;
168
+ try {
169
+ response = await this.fetchImpl(`https://${this.domain}/device/tunnel-probe?nonce=${nonce}`,
170
+ { redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
171
+ } catch { return false; }
172
+ if (!response.ok) return false;
173
+ try {
174
+ const proof = await response.json();
175
+ return proof?.nonce === nonce && proof.bridgeId === this.bridgeId &&
176
+ proof.authority === this.domain;
177
+ } catch { return false; }
178
+ }
179
+
180
+ fail(error) {
181
+ this.status = 'error';
182
+ this.url = null;
183
+ this.error = error instanceof Error ? error.message : String(error);
184
+ return this.summary();
185
+ }
186
+
187
+ async refresh() {
188
+ if (this.busy) return this.summary();
189
+ this.busy = true;
190
+ try {
191
+ const mapping = await this.selectedMapping();
192
+ this.mappingEnabled = !mapping.isforbid;
193
+ if (!this.mappingEnabled) {
194
+ this.status = 'off';
195
+ this.url = null;
196
+ this.error = '';
197
+ } else if (await this.publicProbe()) {
198
+ this.status = 'online';
199
+ this.url = `https://${this.domain}`;
200
+ this.error = '';
201
+ } else {
202
+ this.fail(new Error('映射已启用,但无法验证公网地址指向本桥接器;请检查花生壳客户端已登录且映射保留公网 Host'));
203
+ }
204
+ return this.summary();
205
+ } catch (error) { return this.fail(error); }
206
+ finally { this.busy = false; }
207
+ }
208
+
209
+ async start() {
210
+ if (this.busy) return this.summary();
211
+ this.busy = true;
212
+ this.status = 'starting';
213
+ this.url = null;
214
+ this.error = '';
215
+ try {
216
+ const mapping = await this.selectedMapping();
217
+ this.mappingEnabled = !mapping.isforbid;
218
+ if (!this.mappingEnabled) await this.setEnabled(true);
219
+ if (!(await this.publicProbe())) {
220
+ return this.fail(new Error('映射已启用,但无法验证公网地址指向本桥接器;请检查花生壳客户端已登录且映射保留公网 Host'));
221
+ }
222
+ this.status = 'online';
223
+ this.url = `https://${this.domain}`;
224
+ return this.summary();
225
+ } catch (error) { return this.fail(error); }
226
+ finally { this.busy = false; }
227
+ }
228
+
229
+ async stop() {
230
+ if (this.busy) return this.summary();
231
+ this.busy = true;
232
+ try {
233
+ const mapping = await this.selectedMapping();
234
+ this.mappingEnabled = !mapping.isforbid;
235
+ if (this.mappingEnabled) await this.setEnabled(false);
236
+ this.status = 'off';
237
+ this.url = null;
238
+ this.error = '';
239
+ return this.summary();
240
+ } catch (error) { return this.fail(error); }
241
+ finally { this.busy = false; }
242
+ }
243
+ }
@@ -0,0 +1,203 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
4
+ import { isIP } from 'node:net';
5
+
6
+ const ID = /^[0-9a-f]{12}$/;
7
+ const CODE = /^[0-9]{6}$/;
8
+ const NONCE = /^[0-9a-f]{32}$/;
9
+ const PENDING_MS = 120_000;
10
+ const APPROVED_RETRY_MS = 60_000;
11
+ const PAIRED_DISCOVERY = 'VIBE_BRIDGE_PAIRED_V2';
12
+ const MANUAL_VERIFICATION = 'VIBE_BRIDGE_MANUAL_V2';
13
+
14
+ function writePrivate(file, value) {
15
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
16
+ const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
17
+ fs.writeFileSync(temporary, JSON.stringify(value, null, 2), { mode: 0o600 });
18
+ fs.renameSync(temporary, file);
19
+ fs.chmodSync(file, 0o600);
20
+ }
21
+
22
+ function safeEqual(received, expected) {
23
+ if (typeof received !== 'string' || typeof expected !== 'string') return false;
24
+ const left = Buffer.from(received);
25
+ const right = Buffer.from(expected);
26
+ return left.length === right.length && timingSafeEqual(left, right);
27
+ }
28
+
29
+ export class PairingStore {
30
+ constructor(stateDir) {
31
+ this.file = path.join(stateDir, 'paired-devices.json');
32
+ this.idFile = path.join(stateDir, 'bridge-id.json');
33
+ this.pending = new Map();
34
+ this.paired = new Map();
35
+ this.persistedSeenAt = new Map();
36
+ this.requestTimes = new Map();
37
+ this.verifyTimes = new Map();
38
+ this.activeUntil = 0;
39
+ if (fs.existsSync(this.file)) {
40
+ try {
41
+ const saved = JSON.parse(fs.readFileSync(this.file, 'utf8'));
42
+ for (const item of saved) {
43
+ if (ID.test(item.deviceId) && /^[0-9a-f]{64}$/.test(item.token)) {
44
+ this.paired.set(item.deviceId, item);
45
+ this.persistedSeenAt.set(item.deviceId, Date.parse(item.lastSeen) || 0);
46
+ }
47
+ }
48
+ } catch { /* A damaged state file never authorizes a device. */ }
49
+ }
50
+ try {
51
+ const saved = JSON.parse(fs.readFileSync(this.idFile, 'utf8'));
52
+ if (/^[A-Za-z0-9_-]{1,64}$/.test(saved.bridgeId)) this.bridgeId = saved.bridgeId;
53
+ } catch { /* Create an ID for the first run. */ }
54
+ if (!this.bridgeId) {
55
+ this.bridgeId = randomUUID();
56
+ writePrivate(this.idFile, { bridgeId: this.bridgeId });
57
+ }
58
+ }
59
+
60
+ open(seconds = 120) {
61
+ this.activeUntil = Date.now() + Math.min(Math.max(seconds, 1), 300) * 1000;
62
+ return this.summary();
63
+ }
64
+
65
+ close() {
66
+ this.activeUntil = 0;
67
+ // Closing the pairing window stops new requests, but an approved device
68
+ // may still be retrying after a lost HTTP response.
69
+ for (const [id, item] of this.pending) {
70
+ if (item.status !== 'approved') this.pending.delete(id);
71
+ }
72
+ return this.summary();
73
+ }
74
+
75
+ prune() {
76
+ const now = Date.now();
77
+ for (const [id, item] of this.pending) {
78
+ if (now > item.expiresAt) this.pending.delete(id);
79
+ }
80
+ }
81
+
82
+ summary() {
83
+ this.prune();
84
+ return {
85
+ open: Date.now() < this.activeUntil,
86
+ openUntil: this.activeUntil,
87
+ pending: [...this.pending.values()].filter((item) => item.status === 'pending').map(({ deviceId, deviceName, code, nonce, expiresAt, endpoint }) => ({ deviceId, deviceName, code, nonce, expiresAt, endpoint })),
88
+ paired: [...this.paired.values()].map(({ deviceId, deviceName, pairedAt, lastSeen }) => ({
89
+ deviceId, deviceName, pairedAt, lastSeen,
90
+ online: Number.isFinite(Date.parse(lastSeen)) && Date.now() - Date.parse(lastSeen) <= 30_000,
91
+ })),
92
+ };
93
+ }
94
+
95
+ request(input, address, endpoint = '') {
96
+ const { deviceId, deviceName, code, nonce } = input || {};
97
+ if (!ID.test(deviceId) || !CODE.test(code) || !NONCE.test(nonce)) throw new Error('配对请求格式无效');
98
+ if (typeof deviceName !== 'string' || deviceName.length > 80) throw new Error('设备名称无效');
99
+ this.prune();
100
+ const prior = this.pending.get(deviceId);
101
+ if (prior && safeEqual(prior.nonce, nonce) && prior.address === address) return { status: prior.status };
102
+ if (Date.now() >= this.activeUntil) throw new Error('电脑配对窗口未开启');
103
+ if (this.pending.size >= 8) throw new Error('待配对设备过多');
104
+ const recent = (this.requestTimes.get(address) || []).filter((time) => Date.now() - time < 60_000);
105
+ if (recent.length >= 10) throw new Error('配对尝试过于频繁');
106
+ recent.push(Date.now());
107
+ this.requestTimes.set(address, recent);
108
+ this.pending.set(deviceId, {
109
+ deviceId, deviceName, code, nonce, address, endpoint,
110
+ status: 'pending', expiresAt: Math.min(Date.now() + PENDING_MS, this.activeUntil),
111
+ });
112
+ return { status: 'pending' };
113
+ }
114
+
115
+ status(deviceId, nonce, address) {
116
+ this.prune();
117
+ const item = this.pending.get(deviceId);
118
+ if (!item || !safeEqual(item.nonce, nonce) || item.address !== address) return { status: 'expired' };
119
+ if (item.status === 'approved') {
120
+ return { status: 'approved', token: item.token, bridgeId: this.bridgeId };
121
+ }
122
+ if (item.status === 'denied') {
123
+ this.pending.delete(deviceId);
124
+ return { status: 'denied' };
125
+ }
126
+ return { status: 'pending' };
127
+ }
128
+
129
+ decide(deviceId, nonce, action) {
130
+ this.prune();
131
+ const item = this.pending.get(deviceId);
132
+ if (!item || !safeEqual(item.nonce, nonce) || item.status !== 'pending') throw new Error('配对请求已失效');
133
+ if (action === 'reject') {
134
+ item.status = 'denied';
135
+ } else {
136
+ item.status = 'approved';
137
+ item.token = randomBytes(32).toString('hex');
138
+ item.expiresAt = Date.now() + APPROVED_RETRY_MS;
139
+ const now = new Date().toISOString();
140
+ this.paired.set(deviceId, { deviceId, deviceName: item.deviceName, token: item.token, pairedAt: now, lastSeen: now });
141
+ this.persistedSeenAt.set(deviceId, Date.now());
142
+ writePrivate(this.file, [...this.paired.values()]);
143
+ this.activeUntil = 0;
144
+ }
145
+ return this.summary();
146
+ }
147
+
148
+ remove(deviceId) {
149
+ if (!this.paired.delete(deviceId)) throw new Error('设备未配对');
150
+ this.persistedSeenAt.delete(deviceId);
151
+ this.pending.delete(deviceId);
152
+ writePrivate(this.file, [...this.paired.values()]);
153
+ return this.summary();
154
+ }
155
+
156
+ deviceIdForToken(token) {
157
+ if (!this.authenticate(token)) return null;
158
+ for (const item of this.paired.values()) {
159
+ if (safeEqual(item.token, token)) return item.deviceId;
160
+ }
161
+ return null;
162
+ }
163
+
164
+ authenticate(token) {
165
+ if (typeof token !== 'string' || !/^[0-9a-f]{64}$/.test(token)) return false;
166
+ for (const item of this.paired.values()) {
167
+ if (safeEqual(item.token, token)) {
168
+ const now = Date.now();
169
+ item.lastSeen = new Date(now).toISOString();
170
+ if (now - (this.persistedSeenAt.get(item.deviceId) || 0) >= 60_000) {
171
+ writePrivate(this.file, [...this.paired.values()]);
172
+ this.persistedSeenAt.set(item.deviceId, now);
173
+ }
174
+ return true;
175
+ }
176
+ }
177
+ return false;
178
+ }
179
+
180
+ signedDiscoveryReply(deviceId, nonce, port, serverIPv4) {
181
+ const paired = this.paired.get(deviceId);
182
+ if (!paired || !NONCE.test(nonce) || !Number.isInteger(port) || port < 1 || port > 65535 ||
183
+ isIP(serverIPv4) !== 4) return null;
184
+ const signed = [PAIRED_DISCOVERY, deviceId, nonce, String(port), this.bridgeId, serverIPv4].join('\n');
185
+ const mac = createHmac('sha256', Buffer.from(paired.token, 'hex')).update(signed, 'ascii').digest('hex');
186
+ return `${PAIRED_DISCOVERY} ${port} ${this.bridgeId} ${serverIPv4} ${mac}`;
187
+ }
188
+
189
+ verifyManual(deviceId, nonce, address, authority) {
190
+ if (!ID.test(deviceId || '') || !NONCE.test(nonce || '')) return null;
191
+ if (typeof authority !== 'string' || authority.length > 253 || !authority) return null;
192
+ const now = Date.now();
193
+ const recent = (this.verifyTimes.get(address) || []).filter((time) => now - time < 60_000);
194
+ if (recent.length >= 60) throw new Error('校验请求过于频繁');
195
+ recent.push(now);
196
+ this.verifyTimes.set(address, recent);
197
+ const paired = this.paired.get(deviceId);
198
+ if (!paired) return null;
199
+ const signed = [MANUAL_VERIFICATION, deviceId, nonce, this.bridgeId, authority].join('\n');
200
+ const mac = createHmac('sha256', Buffer.from(paired.token, 'hex')).update(signed, 'ascii').digest('hex');
201
+ return { bridgeId: this.bridgeId, authority, mac };
202
+ }
203
+ }