dsh-pocket-relay 1.0.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/lib/web-rpc.js ADDED
@@ -0,0 +1,622 @@
1
+ // dsh-pocket Web RPC(loopback-only):设置页 ⇄ Host 的手机访问通道
2
+ //
3
+ // 兼容两条路径:
4
+ // 1. dsh v0.1.5-alpha.1+(client-connection inject 收缩为 ['credentials'],
5
+ // rpc.handle 内部访问 owner.webServer 必抛 "cannot get property without inject")
6
+ // → 直接挂到本插件 inject 的 webServer 上,逐分支复刻 Connection /api 传输语义。
7
+ // 2. 旧版 dsh / 无 webServer 服务(headless)→ 回退 ctx.connection.rpc.handle(...)。
8
+
9
+ import { promises as fs } from 'node:fs';
10
+ import path from 'node:path';
11
+ import os from 'node:os';
12
+ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus } from '../client/api.js';
13
+ import { qrDataUrl } from './service.mjs';
14
+
15
+ /** 单次读取上限:4 MB,避免把大文件塞进剪贴板 / 内存。 */
16
+ const FILE_READ_MAX = 4 * 1024 * 1024;
17
+
18
+ /** RPC 请求体上限:8 MB。dsh-pocket 的所有 endpoint 都是小控制 JSON(status / 隧道开关 /
19
+ * 自定义密码等),不存在大载荷;413 直接拒绝避免无界缓冲。 */
20
+ const POCKET_RPC_BODY_MAX = 8 * 1024 * 1024;
21
+
22
+ /** endpoint 段字符(与 dsh-client-connection 的 ENDPOINT_SEGMENT_PATTERN 对齐)。 */
23
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
24
+
25
+ /** client-request 信封校验失败时使用的兜底 rpcId(与 dsh 内部 INVALID_REQUEST_RPC_ID 对齐)。 */
26
+ const INVALID_REQUEST_RPC_ID = 'invalid-request';
27
+
28
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
29
+
30
+ /**
31
+ * 从 `${channel}/<endpoint>` 路径里取出 endpoint,段非法时返回 undefined。
32
+ * 与 dsh-client-connection 的 endpointFromPath 字段对字段一致。
33
+ */
34
+ function endpointFromPath(channel, pathname) {
35
+ if (!pathname.startsWith(`${channel}/`)) return undefined;
36
+ const endpoint = pathname.slice(channel.length + 1);
37
+ if (endpoint.split('/').some((seg) => seg === '' || seg === '.' || seg === '..' || !ENDPOINT_SEGMENT_PATTERN.test(seg))) {
38
+ return undefined;
39
+ }
40
+ return endpoint;
41
+ }
42
+
43
+ /** 构造 server-response JSON 串(与 dsh-client-connection 的 fullResponse 字段对齐)。 */
44
+ function serverResponseJson(rpcId, result) {
45
+ return JSON.stringify({ type: 'server-response', rpcId, result });
46
+ }
47
+
48
+ /**
49
+ * 旧版 dsh / 无 connection.requestRejection 时的最小信任栅栏:仅放行 loopback Host,
50
+ * 拒 cross-site fetch 与 Origin 不匹配(与 isTrustedApiRequest 的 loopback 分支对齐)。
51
+ * 新版 dsh 走 ctx.connection.requestRejection(req)(401/403 都在那里),本函数只是兜底。
52
+ */
53
+ function isTrustedLoopbackRequest(req) {
54
+ const host = req.headers?.host;
55
+ if (!host) return false;
56
+ const hostName = host.split(':')[0];
57
+ if (!LOOPBACK_HOSTNAMES.has(hostName)) return false;
58
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
59
+ const origin = req.headers.origin;
60
+ if (origin === undefined) return true;
61
+ try { return new URL(origin).host === host; } catch { return false; }
62
+ }
63
+
64
+ /**
65
+ * 把 RPC handler 包装成 fetch-shaped handler,逐分支复刻 dsh-client-connection 的
66
+ * rpcFetchHandler:404(非 POST / 无 endpoint)、415(content-type)、400(非 JSON)、
67
+ * gateway/bad-request(信封非法 / method 与 endpoint 不匹配)、500(handler 抛错),
68
+ * 成功返回 server-response JSON(200)。wire 协议与原 /api 通道逐字节一致。
69
+ */
70
+ function pocketFetchHandler(channel, handler, log) {
71
+ return {
72
+ async fetch(request) {
73
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
74
+ if (request.method !== 'POST' || endpoint === undefined) {
75
+ return new Response('not found', { status: 404 });
76
+ }
77
+ const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
78
+ if (mediaType !== 'application/json') {
79
+ return new Response('content type must be application/json', { status: 415 });
80
+ }
81
+ let body;
82
+ try {
83
+ body = await request.json();
84
+ } catch {
85
+ return new Response('body is not JSON', { status: 400 });
86
+ }
87
+ // 最小信封校验(不依赖 @deepseek-ai/dsh-host-apiproxy 的 clientRequestSchema,
88
+ // 形状稳定:{ rpcId: string, method: string, payload?: any })。
89
+ const rpcId = body && typeof body.rpcId === 'string' ? body.rpcId : INVALID_REQUEST_RPC_ID;
90
+ const method = body && typeof body.method === 'string' ? body.method : null;
91
+ if (rpcId === INVALID_REQUEST_RPC_ID || method === null) {
92
+ return new Response(
93
+ serverResponseJson(INVALID_REQUEST_RPC_ID, {
94
+ ok: false,
95
+ error: { code: 'bad-request', message: 'invalid client-request message', details: { issues: [] } },
96
+ }),
97
+ { status: 200, headers: { 'content-type': 'application/json' } },
98
+ );
99
+ }
100
+ if (method !== endpoint) {
101
+ return new Response(
102
+ serverResponseJson(rpcId, {
103
+ ok: false,
104
+ error: {
105
+ code: 'bad-request',
106
+ message: `method ${JSON.stringify(method)} does not match endpoint ${JSON.stringify(endpoint)}`,
107
+ details: { issues: [] },
108
+ },
109
+ }),
110
+ { status: 200, headers: { 'content-type': 'application/json' } },
111
+ );
112
+ }
113
+ try {
114
+ const result = await handler(endpoint, body.payload, request.signal);
115
+ return new Response(serverResponseJson(rpcId, result), {
116
+ status: 200,
117
+ headers: { 'content-type': 'application/json' },
118
+ });
119
+ } catch (err) {
120
+ log.error?.('dsh-pocket-relay: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
121
+ return new Response(`handler failure: ${String(err)}`, { status: 500 });
122
+ }
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * node:http 请求 → fetch-shaped handler → node:http 响应的桥接。
129
+ * 与 dsh-client-connection 的 bridge 行为一致:
130
+ * - res.close 时若响应未结束 → abort(client 主动断开不再等 handler);
131
+ * - 声明 content-length 超 maxBodyBytes → 413 + 销毁 socket;
132
+ * - 流式读取超 maxBodyBytes → 413 + 销毁 socket(防无界缓冲);
133
+ * - 把请求头里 string 值原样传给 fetch handler(数组头被丢弃,与原实现一致);
134
+ * - 响应 body 流式回写,遇背压等 drain / close。
135
+ */
136
+ async function pocketHttpBridge(req, res, fetchHandler, maxBodyBytes) {
137
+ const abort = new AbortController();
138
+ res.on('close', () => { if (!res.writableEnded) abort.abort(); });
139
+
140
+ const declaredLen = req.headers['content-length'];
141
+ if (declaredLen !== undefined && Number(declaredLen) > maxBodyBytes) {
142
+ res.writeHead(413, { connection: 'close' });
143
+ res.end();
144
+ req.destroy();
145
+ return;
146
+ }
147
+ const chunks = [];
148
+ let received = 0;
149
+ let tooLarge = false;
150
+ for await (const chunk of req) {
151
+ received += chunk.length;
152
+ if (received > maxBodyBytes) { tooLarge = true; break; }
153
+ chunks.push(chunk);
154
+ }
155
+ if (tooLarge) {
156
+ res.writeHead(413, { connection: 'close' });
157
+ res.end();
158
+ req.destroy();
159
+ return;
160
+ }
161
+
162
+ const url = `http://${req.headers.host ?? '127.0.0.1'}${req.url}`;
163
+ const init = {
164
+ method: req.method ?? 'GET',
165
+ headers: Object.fromEntries(
166
+ Object.entries(req.headers).filter(([, v]) => typeof v === 'string'),
167
+ ),
168
+ signal: abort.signal,
169
+ };
170
+ if (chunks.length > 0) init.body = Buffer.concat(chunks);
171
+ const request = new Request(url, init);
172
+
173
+ const response = await fetchHandler.fetch(request);
174
+ const headers = Object.fromEntries(response.headers.entries());
175
+ // 不写 content-length(流式响应长度未知),交由 Node 自动分块。
176
+ res.writeHead(response.status, headers);
177
+ if (response.body === null) { res.end(); return; }
178
+ for await (const chunk of response.body) {
179
+ if (!res.write(chunk)) {
180
+ await new Promise((resolve) => {
181
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
182
+ res.once('drain', done);
183
+ res.once('close', done);
184
+ });
185
+ }
186
+ if (res.writableEnded) break;
187
+ }
188
+ res.end();
189
+ }
190
+
191
+ /**
192
+ * 直接把 /dsh-pocket 通道挂到插件自己 inject 的 webServer 上。
193
+ * 返回 disposer(同步或异步均可调用),返回 null 表示环境无 webServer(调用方应回退 rpc.handle)。
194
+ *
195
+ * 行为分支与 dsh-client-connection 的 register() /api 路由 1:1 对齐:
196
+ * - connection.requestRejection(req) 可用时(dsh v0.1.5-alpha.1+)→ 走它,
197
+ * 401 unauthorized / 403 forbidden 都在那里判定(浏览器 cookie + Host/Origin 栅栏);
198
+ * - 不可用时(旧版 dsh)→ 用 isTrustedLoopbackRequest 兜底,仅做 loopback 信任栅栏,
199
+ * 浏览器 cookie 认证旧版本就没有,行为与原 rpc.handle({authority:'loopback'}) 一致;
200
+ * - 之后交给 pocketHttpBridge + pocketFetchHandler,复刻 /api 的 4xx/5xx/200 分支。
201
+ */
202
+ function mountPocketWebRoute(ctx, { channel, handler, log }) {
203
+ const webServer = ctx?.webServer;
204
+ if (!webServer || typeof webServer.register !== 'function') return null;
205
+ // 必须持有 connection 本体并以**方法形式**调用 requestRejection(issue #117):
206
+ // dsh 的 HostConnectionService.requestRejection 是类方法,内部读 this.trustedHosts /
207
+ // this.browserAuth。先把方法抽成裸函数(`const fn = ctx.connection.requestRejection`)
208
+ // 再调用会丢失 this → TypeError → 被下面的 catch 兜底成 403,于是**任何**请求
209
+ // (本机、带会话 cookie 的浏览器、移动端)都被判 forbidden,设置页 status RPC 全挂
210
+ // (用户可见症状:局域网区块一直显示「代理未就绪…」,公网隧道开启报 403)。
211
+ // dsh 自己的 /api 路由也是以方法形式调用的(见 client-connection 的 register());
212
+ // 本仓库 lib/index.js 处理 authenticatedUrl 时同样用 fn.call(ctx.connection, ...) 绑定。
213
+ // 等价写法:requestRejection.call(ctx.connection, req)(issue #117 报告者的建议)。
214
+ const connection = ctx?.connection;
215
+ const fetchHandler = pocketFetchHandler(channel, handler, log);
216
+ const route = {
217
+ kind: 'prefix',
218
+ path: channel,
219
+ handler: async (req, res) => {
220
+ let rejection;
221
+ if (typeof connection?.requestRejection === 'function') {
222
+ try { rejection = connection.requestRejection(req); } catch { rejection = 403; }
223
+ } else if (!isTrustedLoopbackRequest(req)) {
224
+ rejection = 403;
225
+ }
226
+ if (rejection !== undefined) {
227
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' });
228
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
229
+ return;
230
+ }
231
+ await pocketHttpBridge(req, res, fetchHandler, POCKET_RPC_BODY_MAX);
232
+ },
233
+ };
234
+ const registered = webServer.register(route);
235
+ // register 可能返回 disposer、Promise<disposer> 或 undefined(仅写入路由表)。
236
+ // 统一规整成「可幂等调用的清理函数」。
237
+ const cleanup = typeof registered === 'function'
238
+ ? () => { try { registered(); } catch { /* 已清理 */ } }
239
+ : (registered && typeof registered.then === 'function')
240
+ ? (() => { let done = false; return async () => { if (done) return; done = true; try { const d = await registered; if (typeof d === 'function') d(); } catch { /* 已清理 */ } }; })()
241
+ : () => {};
242
+ return cleanup;
243
+ }
244
+
245
+ function ok(value) {
246
+ return { ok: true, value };
247
+ }
248
+
249
+ /**
250
+ * 构造符合 DSH rpcErrorSchema 的错误(按 code 的 discriminated union,
251
+ * details 必填且分分支定形;'internal' 不在合法 code 集合里)。
252
+ */
253
+ function fail(code, message) {
254
+ if (code === 'cancelled') return { ok: false, error: { code: 'cancelled', message, details: {} } };
255
+ // 其余一律归入 bad-request(issues 是自由数组)
256
+ return { ok: false, error: { code: 'bad-request', message, details: { issues: [{ message }] } } };
257
+ }
258
+
259
+ /** 各平台停止 dsh web 进程的命令(Windows 没有 lsof/kill)。 */
260
+ export function killHint(port) {
261
+ if (process.platform === 'win32') {
262
+ return `netstat -ano | findstr :${port}(找 LISTENING 的 PID)→ taskkill /PID <PID> /F`;
263
+ }
264
+ return `lsof -ti :${port} | xargs kill -9`;
265
+ }
266
+
267
+ /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。
268
+ * 优先直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+ 兼容路径),
269
+ * 不可用时回退 ctx.connection.rpc.handle(旧版 dsh 兼容路径)。
270
+ * wire 协议两条路径完全一致:client-request → handler → server-response。 */
271
+ export function installPocketRpc(ctx, { service, log = console, desktop = false, runUpdate = null, restart = null, restartNotice = null, getToken = null, getLanToken = null, refreshLanToken = null, getLanAuthEnabled = null, setLanAuthEnabled = null, getLanEnabled = null, setLanEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null, getRelayConfig = null, setRelayConfig = null }) {
272
+ const pocketRpcHandler = async (endpoint, payload = {}, signal) => {
273
+ if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
274
+
275
+ // status 响应:服务状态 + 重启提示 + 停止命令 + 桌面端标志 + 公网/局域网访问密码 + 局域网密码开关
276
+ const statusPayload = async () => {
277
+ let notice = null;
278
+ try { notice = (await restartNotice?.()) ?? null; } catch { notice = null; }
279
+ const s = await service.status();
280
+ return ok({
281
+ ...redactStatus(s),
282
+ desktop,
283
+ restartNotice: notice,
284
+ killHint: killHint(s.dshPort ?? 3080),
285
+ accessToken: getToken?.() ?? null,
286
+ lanToken: getLanToken?.() ?? null,
287
+ lanAuthEnabled: getLanAuthEnabled?.() ?? true,
288
+ lanEnabled: getLanEnabled?.() ?? true,
289
+ publicPinCustom: getPinCustom?.('public') ?? false,
290
+ lanPinCustom: getPinCustom?.('lan') ?? false,
291
+ tunnelConfig: getTunnelConfig?.() ?? { mode: 'quick', hostname: '', tokenSet: false },
292
+ // 中继脱敏视图:token 只写不读,只回 tokenSet 布尔(与隧道 Token 同款语义)
293
+ relay: relayView(),
294
+ // 设备计数(列表本身走 device.list,避免每次 3 秒轮询都拖着全量设备数据)
295
+ deviceStatus: deviceStore()?.status() ?? { devices: 0, pending: 0, pairings: 0, sessions: 0 },
296
+ });
297
+ };
298
+
299
+ /** 中继配置的脱敏视图(永不回显 token 原文)。 */
300
+ const relayView = () => {
301
+ const cfg = getRelayConfig?.() ?? {};
302
+ return {
303
+ enabled: cfg.enabled === true,
304
+ consent: cfg.consent === true,
305
+ host: cfg.host ?? '',
306
+ port: cfg.port ?? 0,
307
+ tls: cfg.tls === true,
308
+ allowInsecure: cfg.allowInsecure === true,
309
+ tokenSet: Boolean(cfg.token),
310
+ publicUrl: cfg.publicUrl ?? '',
311
+ // 本机在 relay 上的名字(多机共存用)。空 = 按主机名派生,界面上
312
+ // 显示运行态里的实际值即可(relayState.agentId),所以这里回空字符串。
313
+ agentId: cfg.agentId ?? '',
314
+ };
315
+ };
316
+
317
+ /** 设备存储句柄(service 暴露;测试可注入 internals.deviceAuth)。 */
318
+ const deviceStore = () => service?.deviceAuth ?? null;
319
+
320
+ /**
321
+ * 设备列表视图。**永不包含 tokenHash / password**——device-auth.mjs 的
322
+ * 公开行本身就不带这些字段,这里只是原样搬出来。
323
+ */
324
+ const deviceListPayload = () => {
325
+ const store = deviceStore();
326
+ if (!store) return { devices: [], pending: [], status: { devices: 0, pending: 0, pairings: 0, sessions: 0 } };
327
+ return {
328
+ devices: store.list().filter((d) => d.approved),
329
+ pending: store.pending(),
330
+ status: store.status(),
331
+ };
332
+ };
333
+
334
+ /** 配对二维码要指向的地址:中继的对外访问地址。 */
335
+ const pairUrlFor = (code) => {
336
+ const base = String((getRelayConfig?.() ?? {}).publicUrl ?? '').replace(/\/+$/, '');
337
+ return base ? `${base}/pocket-pair?code=${encodeURIComponent(code)}` : null;
338
+ };
339
+
340
+ /**
341
+ * 让运行态跟上配置态:enabled 且配置完整 → 重连;否则 → 停。
342
+ *
343
+ * 一律「先停再起」,不比较配置差异:中继 agent 是纯出站长连接,
344
+ * 重建成本极低(毫秒级),而漏判某个字段(比如 tls 从 false 翻成 true)
345
+ * 会留下一条用旧参数连着的连接,用户看到的是「改了没生效」。
346
+ *
347
+ * 返回 { ok, message }:失败**不抛**,由调用方转成 RPC 错误,
348
+ * 以免中继连不上把整个设置页打挂。
349
+ */
350
+ const applyRelayState = async () => {
351
+ const cfg = getRelayConfig?.() ?? {};
352
+ if (!cfg.enabled) {
353
+ service.stopRelay?.();
354
+ return { ok: true };
355
+ }
356
+ if (!cfg.host || !cfg.port || !cfg.token) {
357
+ service.stopRelay?.();
358
+ return { ok: false, message: '请先填写中继地址、端口和 Token | fill in the relay address, port and token first' };
359
+ }
360
+ try {
361
+ service.stopRelay?.(); // 先停:保证用新参数重连
362
+ await service.startRelay();
363
+ return { ok: true };
364
+ } catch (err) {
365
+ return { ok: false, message: err?.message ?? String(err) };
366
+ }
367
+ };
368
+
369
+ try {
370
+ if (endpoint === POCKET_ENDPOINTS.status) {
371
+ return await statusPayload();
372
+ }
373
+ if (endpoint === POCKET_ENDPOINTS.lanTokenRefresh) {
374
+ const fresh = refreshLanToken?.() ?? null;
375
+ if (!fresh) return fail('bad-request', '局域网密码刷新不可用 | LAN PIN refresh unavailable');
376
+ return ok({ lanToken: fresh });
377
+ }
378
+ if (endpoint === POCKET_ENDPOINTS.lanAuthSetEnabled) {
379
+ const enabled = setLanAuthEnabled?.(payload?.on === true);
380
+ if (enabled === undefined) return fail('bad-request', '局域网密码开关不可用 | LAN PIN switch unavailable');
381
+ return ok({ lanAuthEnabled: enabled });
382
+ }
383
+ if (endpoint === POCKET_ENDPOINTS.lanSetEnabled) {
384
+ const enabled = setLanEnabled?.(payload?.on === true);
385
+ if (enabled === undefined) return fail('bad-request', '局域网访问开关不可用 | LAN access switch unavailable');
386
+ return ok({ lanEnabled: enabled });
387
+ }
388
+ if (endpoint === POCKET_ENDPOINTS.lanSetOverride) {
389
+ // 返回完整 status:前端 setStatus(await call(...)) 直接替换 status 对象,
390
+ // 若只返回 { lanIpOverride } 会丢掉 accessToken/lanToken/tunnelUrl 等字段,
391
+ // 且 lanUrl/二维码不会随新 IP 刷新(PR #47 的客户端写法依赖完整 status)。
392
+ try {
393
+ const ip = setLanIpOverride?.(payload?.ip ?? '');
394
+ if (ip === undefined) return fail('bad-request', '局域网地址设置不可用 | LAN address setting unavailable');
395
+ return await statusPayload();
396
+ } catch (err) {
397
+ return fail('bad-request', err?.message ?? String(err));
398
+ }
399
+ }
400
+ if (endpoint === POCKET_ENDPOINTS.pinSetCustom) {
401
+ const which = payload?.which === 'public' || payload?.which === 'lan' ? payload.which : null;
402
+ if (!which) return fail('bad-request', '未知密码类型 | unknown PIN kind');
403
+ try {
404
+ const pin = setCustomPin?.(which, payload?.value);
405
+ if (pin === undefined) return fail('bad-request', '自定义密码不可用 | custom PIN unavailable');
406
+ return ok({ which, pin, custom: true });
407
+ } catch (err) {
408
+ return fail('bad-request', err?.message ?? String(err));
409
+ }
410
+ }
411
+ if (endpoint === POCKET_ENDPOINTS.tunnelSetConfig) {
412
+ // 命名隧道配置(issue #66):{ mode, hostname, token }——token 只写不读,
413
+ // 留空表示保持不变(undefined 不覆盖);返回完整 status 供前端直接替换。
414
+ if (!setTunnelConfig) return fail('bad-request', '隧道配置不可用 | tunnel config unavailable');
415
+ try {
416
+ setTunnelConfig({ mode: payload?.mode, hostname: payload?.hostname, token: payload?.token });
417
+ return await statusPayload();
418
+ } catch (err) {
419
+ return fail('bad-request', err?.message ?? String(err));
420
+ }
421
+ }
422
+ if (endpoint === POCKET_ENDPOINTS.relaySetConfig) {
423
+ // 中继配置:{ host, port, token, publicUrl, agentId, tls, allowInsecure, enabled, consent }
424
+ // token 留空 = 保持已存值(界面不回显)。返回完整 status 供前端直接替换。
425
+ if (!setRelayConfig) return fail('bad-request', '中继配置不可用 | relay config unavailable');
426
+ try {
427
+ setRelayConfig({
428
+ host: payload?.host,
429
+ port: payload?.port,
430
+ token: payload?.token,
431
+ publicUrl: payload?.publicUrl,
432
+ agentId: payload?.agentId,
433
+ tls: payload?.tls,
434
+ allowInsecure: payload?.allowInsecure,
435
+ enabled: payload?.enabled,
436
+ consent: payload?.consent,
437
+ });
438
+ } catch (err) {
439
+ return fail('bad-request', err?.message ?? String(err));
440
+ }
441
+ // 配置变了 → 先用新配置重连一次,保证「改完就是生效的」,
442
+ // 而不是等下次重启 dsh web 才生效(常开这个诉求下,用户不该关心重启)。
443
+ const applied = await applyRelayState();
444
+ if (!applied.ok) return fail('bad-request', applied.message);
445
+ return await statusPayload();
446
+ }
447
+ if (endpoint === POCKET_ENDPOINTS.relaySetEnabled) {
448
+ // 常开开关(「暂停远程」/「恢复」)。暂停只断中继,不影响局域网与公网。
449
+ if (!setRelayConfig) return fail('bad-request', '中继不可用 | relay unavailable');
450
+ try {
451
+ setRelayConfig({ enabled: payload?.on === true });
452
+ } catch (err) {
453
+ return fail('bad-request', err?.message ?? String(err));
454
+ }
455
+ const applied = await applyRelayState();
456
+ if (!applied.ok) return fail('bad-request', applied.message);
457
+ return await statusPayload();
458
+ }
459
+ if (endpoint === POCKET_ENDPOINTS.deviceList) {
460
+ return ok(deviceListPayload());
461
+ }
462
+ if (endpoint === POCKET_ENDPOINTS.devicePairingCreate) {
463
+ const store = deviceStore();
464
+ if (!store) return fail('bad-request', '设备认证不可用 | device auth unavailable');
465
+ // 二维码必须指向用户手机能打开的地址;没配对外地址就生成的码是废的,
466
+ // 与其让用户扫了打不开,不如在这里说清楚。
467
+ if (!pairUrlFor('x')) {
468
+ return fail('bad-request', '请先在中继设置里填写「对外访问地址」,否则配对二维码无法使用 | set the public URL first');
469
+ }
470
+ const out = store.createPairing({ name: payload?.name });
471
+ if (out.error) return fail('bad-request', out.error);
472
+ const pairUrl = pairUrlFor(out.code);
473
+ // 二维码在这里生成:手机要扫的就是它,让前端再走一次 RPC 拿图没必要
474
+ const pairQr = await qrDataUrl(pairUrl).catch(() => null);
475
+ return ok({ ...out, pairUrl, pairQr, ...deviceListPayload() });
476
+ }
477
+ if (endpoint === POCKET_ENDPOINTS.devicePairingCancel) {
478
+ const store = deviceStore();
479
+ if (!store) return fail('bad-request', '设备认证不可用 | device auth unavailable');
480
+ store.cancelPairing?.();
481
+ return ok(deviceListPayload());
482
+ }
483
+ if (endpoint === POCKET_ENDPOINTS.deviceApprove
484
+ || endpoint === POCKET_ENDPOINTS.deviceReject
485
+ || endpoint === POCKET_ENDPOINTS.deviceRevoke) {
486
+ const store = deviceStore();
487
+ if (!store) return fail('bad-request', '设备认证不可用 | device auth unavailable');
488
+ const id = String(payload?.id ?? '').trim();
489
+ if (!id) return fail('bad-request', '缺少设备 id | missing device id');
490
+ const done = endpoint === POCKET_ENDPOINTS.deviceApprove ? store.approve(id)
491
+ : endpoint === POCKET_ENDPOINTS.deviceReject ? store.reject(id)
492
+ : store.revoke(id);
493
+ if (!done) return fail('bad-request', '设备不存在或状态不匹配 | device not found or wrong state');
494
+ return ok(deviceListPayload());
495
+ }
496
+ if (endpoint === POCKET_ENDPOINTS.pocketReset) {
497
+ // 恢复出厂设置:必须显式确认(payload.confirm === true),先停隧道与中继再清空设置与密码,
498
+ // 返回完整 status 供前端直接替换(旧密码立即作废,手机需重新输入)。
499
+ if (payload?.confirm !== true) {
500
+ return fail('bad-request', '恢复出厂设置需要确认 | factory reset requires confirmation');
501
+ }
502
+ if (!resetPocket) return fail('bad-request', '恢复出厂设置不可用 | factory reset unavailable');
503
+ try {
504
+ service.stopTunnel();
505
+ service.stopRelay?.();
506
+ // 设备凭据也是本机状态的一部分:恢复出厂要一并撤销,否则「重置」之后
507
+ // 之前配对的手机还能照常进来,用户会以为重置没生效。
508
+ service.resetDevices?.();
509
+ resetPocket();
510
+ return await statusPayload();
511
+ } catch (err) {
512
+ return fail('bad-request', err?.message ?? String(err));
513
+ }
514
+ }
515
+ if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
516
+ // 安全免责声明(issue #31):每次开启公网都必须先确认(前端弹框勾选)。
517
+ // 服务端强制校验,防止绕过前端直接调 RPC。
518
+ if (payload?.disclaimer !== true) {
519
+ return fail('bad-request', '开启公网前请先阅读并勾选安全免责声明 | please accept the security disclaimer before enabling public access');
520
+ }
521
+ await service.startTunnel();
522
+ return await statusPayload();
523
+ }
524
+ if (endpoint === POCKET_ENDPOINTS.tunnelStop) {
525
+ service.stopTunnel();
526
+ return await statusPayload();
527
+ }
528
+ if (endpoint === POCKET_ENDPOINTS.version) {
529
+ return ok({ current: runUpdate?.currentVersion?.() ?? null, loaded: runUpdate?.loadedVersion?.() ?? null });
530
+ }
531
+ if (endpoint === POCKET_ENDPOINTS.fileRead) {
532
+ // 移动端「复制文件内容」(issue #17):手机点复制按钮 → 主机读文件正文返回。
533
+ // 路径三种形态:
534
+ // - 绝对路径:直接用;
535
+ // - ~/ 开头:展开为用户 HOME;
536
+ // - 相对路径:相对「客户端传入的 cwd」或「DSH 主机进程 cwd = 工作目录」
537
+ // (用户从自己项目里 `dsh web` 时,二者一致,与 dsh-web 的
538
+ // resolveWorkspacePath(cwd, path) 行为对齐)。安全边界同既有 RPC:
539
+ // 仅本机/隧道经 PIN 可达,等同于你自己操作这台机器。
540
+ const raw = String(payload?.path ?? '').trim();
541
+ if (!raw) return fail('bad-request', '缺少文件路径 | missing path');
542
+ let abs;
543
+ try {
544
+ if (/^~[/\\]?/.test(raw)) {
545
+ // 去掉 ~ 及其后的可选斜杠,再相对 HOME 解析
546
+ abs = path.resolve(os.homedir(), raw.replace(/^~[/\\]?/, ''));
547
+ } else if (path.isAbsolute(raw)) {
548
+ abs = path.resolve(raw);
549
+ } else {
550
+ const base = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
551
+ abs = path.resolve(base, raw);
552
+ }
553
+ } catch {
554
+ return fail('bad-request', '路径非法 | invalid path');
555
+ }
556
+ let stat;
557
+ try {
558
+ stat = await fs.stat(abs);
559
+ } catch {
560
+ return fail('bad-request', `文件不存在:${abs} | file not found`);
561
+ }
562
+ if (stat.isDirectory()) return fail('bad-request', '这是目录,不是文件 | it is a directory');
563
+ if (stat.size > FILE_READ_MAX) {
564
+ return fail('bad-request', `文件过大(${(stat.size / 1024 / 1024).toFixed(1)} MB),无法复制 | file too large`);
565
+ }
566
+ let buf;
567
+ try {
568
+ buf = await fs.readFile(abs);
569
+ } catch (err) {
570
+ return fail('bad-request', `读取失败:${err?.message ?? String(err)} | read failed`);
571
+ }
572
+ // 二进制检测:前 8KB 含 NUL 字节即视为二进制,文本复制无意义。
573
+ if (buf.subarray(0, 8192).includes(0)) {
574
+ return fail('bad-request', '二进制文件,无法复制文本 | binary file');
575
+ }
576
+ return ok({ content: buf.toString('utf8'), path: abs, size: stat.size });
577
+ }
578
+ if (endpoint === POCKET_ENDPOINTS.update) {
579
+ // 桌面端:更新由 DSH Desktop 管理,这里关闭(不删除,仅禁用)
580
+ if (desktop) return fail('bad-request', '桌面版更新由 DSH Desktop 管理,已在此环境停用 | updates are managed by DSH Desktop here');
581
+ if (!runUpdate) return fail('bad-request', '更新不可用 | update unavailable');
582
+ const result = await runUpdate.perform(payload?.profile ?? 'web');
583
+ // 更新成功 → 自动重启生效(用户只点一次;helper 拉起失败则保持现状,可手动重启)
584
+ if (result?.ok && restart) {
585
+ const rr = restart();
586
+ result.autoRestart = rr?.helperPid != null;
587
+ }
588
+ return ok(result);
589
+ }
590
+ if (endpoint === POCKET_ENDPOINTS.restart) {
591
+ // 桌面端:重启由 DSH Desktop 管理,这里关闭(不删除,仅禁用)
592
+ if (desktop) return fail('bad-request', '桌面版重启由 DSH Desktop 管理,已在此环境停用 | restart is managed by DSH Desktop here');
593
+ if (!restart) return fail('bad-request', '重启不可用 | restart unavailable');
594
+ const result = restart();
595
+ // 重启拉起失败(helper 都没 spawn 出来)→ 如实报错,别让 UI 误报成功
596
+ if (!result || result.helperPid == null) {
597
+ return fail('bad-request', `重启失败:${result?.error ?? '未知'} | restart failed`);
598
+ }
599
+ const dshPort = service.dshPort ?? 3080;
600
+ return ok({ ...result, hint: `重启后进程在后台运行;如需停止:${killHint(dshPort)}` });
601
+ }
602
+ return fail('bad-request', `Unknown endpoint: ${endpoint}`);
603
+ } catch (err) {
604
+ log.error?.('dsh-pocket-relay: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
605
+ return fail('bad-request', err?.message ?? String(err));
606
+ }
607
+ };
608
+
609
+ // 优先路径:直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+)。
610
+ // 该路径下 ctx.connection 仅用于 requestRejection(401/403 完整栅栏),即使
611
+ // client-connection 已不再 inject webServer,本插件自己 inject 的 webServer 也照常工作。
612
+ const directMount = mountPocketWebRoute(ctx, { channel: POCKET_RPC_CHANNEL, handler: pocketRpcHandler, log });
613
+ if (directMount) return directMount;
614
+
615
+ // 回退路径:旧版 dsh(无 webServer 服务,如 headless / 测试 fixture)走 rpc.handle。
616
+ // { authority: 'loopback' } 旧版用、新版 Connection 从未消费,保留向后兼容。
617
+ if (!ctx?.connection?.rpc?.handle) {
618
+ log.warn?.('dsh-pocket-relay: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
619
+ return () => {};
620
+ }
621
+ return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, pocketRpcHandler, { authority: 'loopback' });
622
+ }