@guowenzhang/dsh-pocket 0.0.0-stage → 2.12.2

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,475 @@
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
+
14
+ /** 单次读取上限:4 MB,避免把大文件塞进剪贴板 / 内存。 */
15
+ const FILE_READ_MAX = 4 * 1024 * 1024;
16
+
17
+ /** RPC 请求体上限:8 MB。dsh-pocket 的所有 endpoint 都是小控制 JSON(status / 隧道开关 /
18
+ * 自定义密码等),不存在大载荷;413 直接拒绝避免无界缓冲。 */
19
+ const POCKET_RPC_BODY_MAX = 8 * 1024 * 1024;
20
+
21
+ /** endpoint 段字符(与 dsh-client-connection 的 ENDPOINT_SEGMENT_PATTERN 对齐)。 */
22
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
23
+
24
+ /** client-request 信封校验失败时使用的兜底 rpcId(与 dsh 内部 INVALID_REQUEST_RPC_ID 对齐)。 */
25
+ const INVALID_REQUEST_RPC_ID = 'invalid-request';
26
+
27
+ const LOOPBACK_HOSTNAMES = new Set(['127.0.0.1', 'localhost', '::1']);
28
+
29
+ /**
30
+ * 从 `${channel}/<endpoint>` 路径里取出 endpoint,段非法时返回 undefined。
31
+ * 与 dsh-client-connection 的 endpointFromPath 字段对字段一致。
32
+ */
33
+ function endpointFromPath(channel, pathname) {
34
+ if (!pathname.startsWith(`${channel}/`)) return undefined;
35
+ const endpoint = pathname.slice(channel.length + 1);
36
+ if (endpoint.split('/').some((seg) => seg === '' || seg === '.' || seg === '..' || !ENDPOINT_SEGMENT_PATTERN.test(seg))) {
37
+ return undefined;
38
+ }
39
+ return endpoint;
40
+ }
41
+
42
+ /** 构造 server-response JSON 串(与 dsh-client-connection 的 fullResponse 字段对齐)。 */
43
+ function serverResponseJson(rpcId, result) {
44
+ return JSON.stringify({ type: 'server-response', rpcId, result });
45
+ }
46
+
47
+ /**
48
+ * 旧版 dsh / 无 connection.requestRejection 时的最小信任栅栏:仅放行 loopback Host,
49
+ * 拒 cross-site fetch 与 Origin 不匹配(与 isTrustedApiRequest 的 loopback 分支对齐)。
50
+ * 新版 dsh 走 ctx.connection.requestRejection(req)(401/403 都在那里),本函数只是兜底。
51
+ */
52
+ function isTrustedLoopbackRequest(req) {
53
+ const host = req.headers?.host;
54
+ if (!host) return false;
55
+ const hostName = host.split(':')[0];
56
+ if (!LOOPBACK_HOSTNAMES.has(hostName)) return false;
57
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false;
58
+ const origin = req.headers.origin;
59
+ if (origin === undefined) return true;
60
+ try { return new URL(origin).host === host; } catch { return false; }
61
+ }
62
+
63
+ /**
64
+ * 把 RPC handler 包装成 fetch-shaped handler,逐分支复刻 dsh-client-connection 的
65
+ * rpcFetchHandler:404(非 POST / 无 endpoint)、415(content-type)、400(非 JSON)、
66
+ * gateway/bad-request(信封非法 / method 与 endpoint 不匹配)、500(handler 抛错),
67
+ * 成功返回 server-response JSON(200)。wire 协议与原 /api 通道逐字节一致。
68
+ */
69
+ function pocketFetchHandler(channel, handler, log) {
70
+ return {
71
+ async fetch(request) {
72
+ const endpoint = endpointFromPath(channel, new URL(request.url).pathname);
73
+ if (request.method !== 'POST' || endpoint === undefined) {
74
+ return new Response('not found', { status: 404 });
75
+ }
76
+ const mediaType = request.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
77
+ if (mediaType !== 'application/json') {
78
+ return new Response('content type must be application/json', { status: 415 });
79
+ }
80
+ let body;
81
+ try {
82
+ body = await request.json();
83
+ } catch {
84
+ return new Response('body is not JSON', { status: 400 });
85
+ }
86
+ // 最小信封校验(不依赖 @deepseek-ai/dsh-host-apiproxy 的 clientRequestSchema,
87
+ // 形状稳定:{ rpcId: string, method: string, payload?: any })。
88
+ const rpcId = body && typeof body.rpcId === 'string' ? body.rpcId : INVALID_REQUEST_RPC_ID;
89
+ const method = body && typeof body.method === 'string' ? body.method : null;
90
+ if (rpcId === INVALID_REQUEST_RPC_ID || method === null) {
91
+ return new Response(
92
+ serverResponseJson(INVALID_REQUEST_RPC_ID, {
93
+ ok: false,
94
+ error: { code: 'bad-request', message: 'invalid client-request message', details: { issues: [] } },
95
+ }),
96
+ { status: 200, headers: { 'content-type': 'application/json' } },
97
+ );
98
+ }
99
+ if (method !== endpoint) {
100
+ return new Response(
101
+ serverResponseJson(rpcId, {
102
+ ok: false,
103
+ error: {
104
+ code: 'bad-request',
105
+ message: `method ${JSON.stringify(method)} does not match endpoint ${JSON.stringify(endpoint)}`,
106
+ details: { issues: [] },
107
+ },
108
+ }),
109
+ { status: 200, headers: { 'content-type': 'application/json' } },
110
+ );
111
+ }
112
+ try {
113
+ const result = await handler(endpoint, body.payload, request.signal);
114
+ return new Response(serverResponseJson(rpcId, result), {
115
+ status: 200,
116
+ headers: { 'content-type': 'application/json' },
117
+ });
118
+ } catch (err) {
119
+ log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
120
+ return new Response(`handler failure: ${String(err)}`, { status: 500 });
121
+ }
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * node:http 请求 → fetch-shaped handler → node:http 响应的桥接。
128
+ * 与 dsh-client-connection 的 bridge 行为一致:
129
+ * - res.close 时若响应未结束 → abort(client 主动断开不再等 handler);
130
+ * - 声明 content-length 超 maxBodyBytes → 413 + 销毁 socket;
131
+ * - 流式读取超 maxBodyBytes → 413 + 销毁 socket(防无界缓冲);
132
+ * - 把请求头里 string 值原样传给 fetch handler(数组头被丢弃,与原实现一致);
133
+ * - 响应 body 流式回写,遇背压等 drain / close。
134
+ */
135
+ async function pocketHttpBridge(req, res, fetchHandler, maxBodyBytes) {
136
+ const abort = new AbortController();
137
+ res.on('close', () => { if (!res.writableEnded) abort.abort(); });
138
+
139
+ const declaredLen = req.headers['content-length'];
140
+ if (declaredLen !== undefined && Number(declaredLen) > maxBodyBytes) {
141
+ res.writeHead(413, { connection: 'close' });
142
+ res.end();
143
+ req.destroy();
144
+ return;
145
+ }
146
+ const chunks = [];
147
+ let received = 0;
148
+ let tooLarge = false;
149
+ for await (const chunk of req) {
150
+ received += chunk.length;
151
+ if (received > maxBodyBytes) { tooLarge = true; break; }
152
+ chunks.push(chunk);
153
+ }
154
+ if (tooLarge) {
155
+ res.writeHead(413, { connection: 'close' });
156
+ res.end();
157
+ req.destroy();
158
+ return;
159
+ }
160
+
161
+ const url = `http://${req.headers.host ?? '127.0.0.1'}${req.url}`;
162
+ const init = {
163
+ method: req.method ?? 'GET',
164
+ headers: Object.fromEntries(
165
+ Object.entries(req.headers).filter(([, v]) => typeof v === 'string'),
166
+ ),
167
+ signal: abort.signal,
168
+ };
169
+ if (chunks.length > 0) init.body = Buffer.concat(chunks);
170
+ const request = new Request(url, init);
171
+
172
+ const response = await fetchHandler.fetch(request);
173
+ const headers = Object.fromEntries(response.headers.entries());
174
+ // 不写 content-length(流式响应长度未知),交由 Node 自动分块。
175
+ res.writeHead(response.status, headers);
176
+ if (response.body === null) { res.end(); return; }
177
+ for await (const chunk of response.body) {
178
+ if (!res.write(chunk)) {
179
+ await new Promise((resolve) => {
180
+ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
181
+ res.once('drain', done);
182
+ res.once('close', done);
183
+ });
184
+ }
185
+ if (res.writableEnded) break;
186
+ }
187
+ res.end();
188
+ }
189
+
190
+ /**
191
+ * 直接把 /dsh-pocket 通道挂到插件自己 inject 的 webServer 上。
192
+ * 返回 disposer(同步或异步均可调用),返回 null 表示环境无 webServer(调用方应回退 rpc.handle)。
193
+ *
194
+ * 行为分支与 dsh-client-connection 的 register() /api 路由 1:1 对齐:
195
+ * - connection.requestRejection(req) 可用时(dsh v0.1.5-alpha.1+)→ 走它,
196
+ * 401 unauthorized / 403 forbidden 都在那里判定(浏览器 cookie + Host/Origin 栅栏);
197
+ * - 不可用时(旧版 dsh)→ 用 isTrustedLoopbackRequest 兜底,仅做 loopback 信任栅栏,
198
+ * 浏览器 cookie 认证旧版本就没有,行为与原 rpc.handle({authority:'loopback'}) 一致;
199
+ * - 之后交给 pocketHttpBridge + pocketFetchHandler,复刻 /api 的 4xx/5xx/200 分支。
200
+ */
201
+ function mountPocketWebRoute(ctx, { channel, handler, log }) {
202
+ const webServer = ctx?.webServer;
203
+ if (!webServer || typeof webServer.register !== 'function') return null;
204
+ // 必须持有 connection 本体并以**方法形式**调用 requestRejection(issue #117):
205
+ // dsh 的 HostConnectionService.requestRejection 是类方法,内部读 this.trustedHosts /
206
+ // this.browserAuth。先把方法抽成裸函数(`const fn = ctx.connection.requestRejection`)
207
+ // 再调用会丢失 this → TypeError → 被下面的 catch 兜底成 403,于是**任何**请求
208
+ // (本机、带会话 cookie 的浏览器、移动端)都被判 forbidden,设置页 status RPC 全挂
209
+ // (用户可见症状:局域网区块一直显示「代理未就绪…」,公网隧道开启报 403)。
210
+ // dsh 自己的 /api 路由也是以方法形式调用的(见 client-connection 的 register());
211
+ // 本仓库 lib/index.js 处理 authenticatedUrl 时同样用 fn.call(ctx.connection, ...) 绑定。
212
+ // 等价写法:requestRejection.call(ctx.connection, req)(issue #117 报告者的建议)。
213
+ const connection = ctx?.connection;
214
+ const fetchHandler = pocketFetchHandler(channel, handler, log);
215
+ const route = {
216
+ kind: 'prefix',
217
+ path: channel,
218
+ handler: async (req, res) => {
219
+ let rejection;
220
+ if (typeof connection?.requestRejection === 'function') {
221
+ try { rejection = connection.requestRejection(req); } catch { rejection = 403; }
222
+ } else if (!isTrustedLoopbackRequest(req)) {
223
+ rejection = 403;
224
+ }
225
+ if (rejection !== undefined) {
226
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' });
227
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
228
+ return;
229
+ }
230
+ await pocketHttpBridge(req, res, fetchHandler, POCKET_RPC_BODY_MAX);
231
+ },
232
+ };
233
+ const registered = webServer.register(route);
234
+ // register 可能返回 disposer、Promise<disposer> 或 undefined(仅写入路由表)。
235
+ // 统一规整成「可幂等调用的清理函数」。
236
+ const cleanup = typeof registered === 'function'
237
+ ? () => { try { registered(); } catch { /* 已清理 */ } }
238
+ : (registered && typeof registered.then === 'function')
239
+ ? (() => { let done = false; return async () => { if (done) return; done = true; try { const d = await registered; if (typeof d === 'function') d(); } catch { /* 已清理 */ } }; })()
240
+ : () => {};
241
+ return cleanup;
242
+ }
243
+
244
+ function ok(value) {
245
+ return { ok: true, value };
246
+ }
247
+
248
+ /**
249
+ * 构造符合 DSH rpcErrorSchema 的错误(按 code 的 discriminated union,
250
+ * details 必填且分分支定形;'internal' 不在合法 code 集合里)。
251
+ */
252
+ function fail(code, message) {
253
+ if (code === 'cancelled') return { ok: false, error: { code: 'cancelled', message, details: {} } };
254
+ // 其余一律归入 bad-request(issues 是自由数组)
255
+ return { ok: false, error: { code: 'bad-request', message, details: { issues: [{ message }] } } };
256
+ }
257
+
258
+ /** 各平台停止 dsh web 进程的命令(Windows 没有 lsof/kill)。 */
259
+ export function killHint(port) {
260
+ if (process.platform === 'win32') {
261
+ return `netstat -ano | findstr :${port}(找 LISTENING 的 PID)→ taskkill /PID <PID> /F`;
262
+ }
263
+ return `lsof -ti :${port} | xargs kill -9`;
264
+ }
265
+
266
+ /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。
267
+ * 优先直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+ 兼容路径),
268
+ * 不可用时回退 ctx.connection.rpc.handle(旧版 dsh 兼容路径)。
269
+ * wire 协议两条路径完全一致:client-request → handler → server-response。 */
270
+ 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, getMobileRightbarEnabled = null, setMobileRightbarEnabled = null, getLanIpOverride = null, setLanIpOverride = null, getPinCustom = null, setCustomPin = null, getTunnelConfig = null, setTunnelConfig = null, resetPocket = null }) {
271
+ const pocketRpcHandler = async (endpoint, payload = {}, signal) => {
272
+ if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
273
+
274
+ // status 响应:服务状态 + 重启提示 + 停止命令 + 桌面端标志 + 公网/局域网访问密码 + 局域网密码开关
275
+ const statusPayload = async () => {
276
+ let notice = null;
277
+ try { notice = (await restartNotice?.()) ?? null; } catch { notice = null; }
278
+ const s = await service.status();
279
+ return ok({
280
+ ...redactStatus(s),
281
+ desktop,
282
+ restartNotice: notice,
283
+ killHint: killHint(s.dshPort ?? 3080),
284
+ accessToken: getToken?.() ?? null,
285
+ lanToken: getLanToken?.() ?? null,
286
+ lanAuthEnabled: getLanAuthEnabled?.() ?? true,
287
+ lanEnabled: getLanEnabled?.() ?? true,
288
+ mobileRightbarEnabled: getMobileRightbarEnabled?.() ?? true,
289
+ publicPinCustom: getPinCustom?.('public') ?? false,
290
+ lanPinCustom: getPinCustom?.('lan') ?? false,
291
+ tunnelConfig: getTunnelConfig?.() ?? { mode: 'quick', hostname: '', tokenSet: false },
292
+ });
293
+ };
294
+
295
+ try {
296
+ if (endpoint === POCKET_ENDPOINTS.status) {
297
+ return await statusPayload();
298
+ }
299
+ if (endpoint === POCKET_ENDPOINTS.lanTokenRefresh) {
300
+ const fresh = refreshLanToken?.() ?? null;
301
+ if (!fresh) return fail('bad-request', '局域网密码刷新不可用 | LAN PIN refresh unavailable');
302
+ return ok({ lanToken: fresh });
303
+ }
304
+ if (endpoint === POCKET_ENDPOINTS.lanAuthSetEnabled) {
305
+ const enabled = setLanAuthEnabled?.(payload?.on === true);
306
+ if (enabled === undefined) return fail('bad-request', '局域网密码开关不可用 | LAN PIN switch unavailable');
307
+ return ok({ lanAuthEnabled: enabled });
308
+ }
309
+ if (endpoint === POCKET_ENDPOINTS.lanSetEnabled) {
310
+ const enabled = setLanEnabled?.(payload?.on === true);
311
+ if (enabled === undefined) return fail('bad-request', '局域网访问开关不可用 | LAN access switch unavailable');
312
+ return ok({ lanEnabled: enabled });
313
+ }
314
+ if (endpoint === POCKET_ENDPOINTS.mobileRightbarSetEnabled) {
315
+ const enabled = setMobileRightbarEnabled?.(payload?.on === true);
316
+ if (enabled === undefined) return fail('bad-request', '手机端右边栏开关不可用 | mobile right sidebar switch unavailable');
317
+ return ok({ mobileRightbarEnabled: enabled });
318
+ }
319
+ if (endpoint === POCKET_ENDPOINTS.lanSetOverride) {
320
+ // 返回完整 status:前端 setStatus(await call(...)) 直接替换 status 对象,
321
+ // 若只返回 { lanIpOverride } 会丢掉 accessToken/lanToken/tunnelUrl 等字段,
322
+ // 且 lanUrl/二维码不会随新 IP 刷新(PR #47 的客户端写法依赖完整 status)。
323
+ try {
324
+ const ip = setLanIpOverride?.(payload?.ip ?? '');
325
+ if (ip === undefined) return fail('bad-request', '局域网地址设置不可用 | LAN address setting unavailable');
326
+ return await statusPayload();
327
+ } catch (err) {
328
+ return fail('bad-request', err?.message ?? String(err));
329
+ }
330
+ }
331
+ if (endpoint === POCKET_ENDPOINTS.pinSetCustom) {
332
+ const which = payload?.which === 'public' || payload?.which === 'lan' ? payload.which : null;
333
+ if (!which) return fail('bad-request', '未知密码类型 | unknown PIN kind');
334
+ try {
335
+ const pin = setCustomPin?.(which, payload?.value);
336
+ if (pin === undefined) return fail('bad-request', '自定义密码不可用 | custom PIN unavailable');
337
+ return ok({ which, pin, custom: true });
338
+ } catch (err) {
339
+ return fail('bad-request', err?.message ?? String(err));
340
+ }
341
+ }
342
+ if (endpoint === POCKET_ENDPOINTS.tunnelSetConfig) {
343
+ // 命名隧道配置(issue #66):{ mode, hostname, token }——token 只写不读,
344
+ // 留空表示保持不变(undefined 不覆盖);返回完整 status 供前端直接替换。
345
+ if (!setTunnelConfig) return fail('bad-request', '隧道配置不可用 | tunnel config unavailable');
346
+ try {
347
+ setTunnelConfig({ mode: payload?.mode, hostname: payload?.hostname, token: payload?.token });
348
+ return await statusPayload();
349
+ } catch (err) {
350
+ return fail('bad-request', err?.message ?? String(err));
351
+ }
352
+ }
353
+ if (endpoint === POCKET_ENDPOINTS.pocketReset) {
354
+ // 恢复出厂设置:必须显式确认(payload.confirm === true),先停隧道再清空设置与密码,
355
+ // 返回完整 status 供前端直接替换(旧密码立即作废,手机需重新输入)。
356
+ if (payload?.confirm !== true) {
357
+ return fail('bad-request', '恢复出厂设置需要确认 | factory reset requires confirmation');
358
+ }
359
+ if (!resetPocket) return fail('bad-request', '恢复出厂设置不可用 | factory reset unavailable');
360
+ try {
361
+ service.stopTunnel();
362
+ resetPocket();
363
+ return await statusPayload();
364
+ } catch (err) {
365
+ return fail('bad-request', err?.message ?? String(err));
366
+ }
367
+ }
368
+ if (endpoint === POCKET_ENDPOINTS.tunnelStart) {
369
+ // 安全免责声明(issue #31):每次开启公网都必须先确认(前端弹框勾选)。
370
+ // 服务端强制校验,防止绕过前端直接调 RPC。
371
+ if (payload?.disclaimer !== true) {
372
+ return fail('bad-request', '开启公网前请先阅读并勾选安全免责声明 | please accept the security disclaimer before enabling public access');
373
+ }
374
+ await service.startTunnel();
375
+ return await statusPayload();
376
+ }
377
+ if (endpoint === POCKET_ENDPOINTS.tunnelStop) {
378
+ service.stopTunnel();
379
+ return await statusPayload();
380
+ }
381
+ if (endpoint === POCKET_ENDPOINTS.version) {
382
+ return ok({ current: runUpdate?.currentVersion?.() ?? null, loaded: runUpdate?.loadedVersion?.() ?? null });
383
+ }
384
+ if (endpoint === POCKET_ENDPOINTS.fileRead) {
385
+ // 移动端「复制文件内容」(issue #17):手机点复制按钮 → 主机读文件正文返回。
386
+ // 路径三种形态:
387
+ // - 绝对路径:直接用;
388
+ // - ~/ 开头:展开为用户 HOME;
389
+ // - 相对路径:相对「客户端传入的 cwd」或「DSH 主机进程 cwd = 工作目录」
390
+ // (用户从自己项目里 `dsh web` 时,二者一致,与 dsh-web 的
391
+ // resolveWorkspacePath(cwd, path) 行为对齐)。安全边界同既有 RPC:
392
+ // 仅本机/隧道经 PIN 可达,等同于你自己操作这台机器。
393
+ const raw = String(payload?.path ?? '').trim();
394
+ if (!raw) return fail('bad-request', '缺少文件路径 | missing path');
395
+ let abs;
396
+ try {
397
+ if (/^~[/\\]?/.test(raw)) {
398
+ // 去掉 ~ 及其后的可选斜杠,再相对 HOME 解析
399
+ abs = path.resolve(os.homedir(), raw.replace(/^~[/\\]?/, ''));
400
+ } else if (path.isAbsolute(raw)) {
401
+ abs = path.resolve(raw);
402
+ } else {
403
+ const base = typeof payload?.cwd === 'string' && payload.cwd ? payload.cwd : process.cwd();
404
+ abs = path.resolve(base, raw);
405
+ }
406
+ } catch {
407
+ return fail('bad-request', '路径非法 | invalid path');
408
+ }
409
+ let stat;
410
+ try {
411
+ stat = await fs.stat(abs);
412
+ } catch {
413
+ return fail('bad-request', `文件不存在:${abs} | file not found`);
414
+ }
415
+ if (stat.isDirectory()) return fail('bad-request', '这是目录,不是文件 | it is a directory');
416
+ if (stat.size > FILE_READ_MAX) {
417
+ return fail('bad-request', `文件过大(${(stat.size / 1024 / 1024).toFixed(1)} MB),无法复制 | file too large`);
418
+ }
419
+ let buf;
420
+ try {
421
+ buf = await fs.readFile(abs);
422
+ } catch (err) {
423
+ return fail('bad-request', `读取失败:${err?.message ?? String(err)} | read failed`);
424
+ }
425
+ // 二进制检测:前 8KB 含 NUL 字节即视为二进制,文本复制无意义。
426
+ if (buf.subarray(0, 8192).includes(0)) {
427
+ return fail('bad-request', '二进制文件,无法复制文本 | binary file');
428
+ }
429
+ return ok({ content: buf.toString('utf8'), path: abs, size: stat.size });
430
+ }
431
+ if (endpoint === POCKET_ENDPOINTS.update) {
432
+ // 桌面端:更新由 DSH Desktop 管理,这里关闭(不删除,仅禁用)
433
+ if (desktop) return fail('bad-request', '桌面版更新由 DSH Desktop 管理,已在此环境停用 | updates are managed by DSH Desktop here');
434
+ if (!runUpdate) return fail('bad-request', '更新不可用 | update unavailable');
435
+ const result = await runUpdate.perform(payload?.profile ?? 'web');
436
+ // 更新成功 → 自动重启生效(用户只点一次;helper 拉起失败则保持现状,可手动重启)
437
+ if (result?.ok && restart) {
438
+ const rr = restart();
439
+ result.autoRestart = rr?.helperPid != null;
440
+ }
441
+ return ok(result);
442
+ }
443
+ if (endpoint === POCKET_ENDPOINTS.restart) {
444
+ // 桌面端:重启由 DSH Desktop 管理,这里关闭(不删除,仅禁用)
445
+ if (desktop) return fail('bad-request', '桌面版重启由 DSH Desktop 管理,已在此环境停用 | restart is managed by DSH Desktop here');
446
+ if (!restart) return fail('bad-request', '重启不可用 | restart unavailable');
447
+ const result = restart();
448
+ // 重启拉起失败(helper 都没 spawn 出来)→ 如实报错,别让 UI 误报成功
449
+ if (!result || result.helperPid == null) {
450
+ return fail('bad-request', `重启失败:${result?.error ?? '未知'} | restart failed`);
451
+ }
452
+ const dshPort = service.dshPort ?? 3080;
453
+ return ok({ ...result, hint: `重启后进程在后台运行;如需停止:${killHint(dshPort)}` });
454
+ }
455
+ return fail('bad-request', `Unknown endpoint: ${endpoint}`);
456
+ } catch (err) {
457
+ log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
458
+ return fail('bad-request', err?.message ?? String(err));
459
+ }
460
+ };
461
+
462
+ // 优先路径:直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+)。
463
+ // 该路径下 ctx.connection 仅用于 requestRejection(401/403 完整栅栏),即使
464
+ // client-connection 已不再 inject webServer,本插件自己 inject 的 webServer 也照常工作。
465
+ const directMount = mountPocketWebRoute(ctx, { channel: POCKET_RPC_CHANNEL, handler: pocketRpcHandler, log });
466
+ if (directMount) return directMount;
467
+
468
+ // 回退路径:旧版 dsh(无 webServer 服务,如 headless / 测试 fixture)走 rpc.handle。
469
+ // { authority: 'loopback' } 旧版用、新版 Connection 从未消费,保留向后兼容。
470
+ if (!ctx?.connection?.rpc?.handle) {
471
+ log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
472
+ return () => {};
473
+ }
474
+ return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, pocketRpcHandler, { authority: 'loopback' });
475
+ }
package/package.json CHANGED
@@ -1,6 +1,87 @@
1
1
  {
2
2
  "name": "@guowenzhang/dsh-pocket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "packageManager": "pnpm@11.28.9",
4
+ "description": "把 DeepSeek Harness 装进你的口袋:一个包、一个设置页,手机扫码即同步访问电脑上的 DSH(局域网 + 公网,实时同屏)。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "bin": {
8
+ "dsh-pocket": "bin/dsh-pocket.mjs"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "default": "./lib/index.js"
13
+ },
14
+ "./client": "./client/client.js",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "lib",
20
+ "client",
21
+ "cordis.patch.yml",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "scripts": {
26
+ "build:client": "node client/build.mjs",
27
+ "release": "semantic-release",
28
+ "test": "node --test --test-timeout=30000 \"test/*.test.js\"",
29
+ "test:local": "node --test --test-timeout=300000 \"test/local/*.test.js\""
30
+ },
31
+ "dependencies": {
32
+ "qrcode": "^1.5.4",
33
+ "qrcode-terminal": "^0.12.0"
34
+ },
35
+ "devDependencies": {
36
+ "@semantic-release/changelog": "^6.0.3",
37
+ "@semantic-release/git": "^10.0.1",
38
+ "esbuild": "^0.25.9",
39
+ "semantic-release": "^24.2.9",
40
+ "ws": "^8.18.0"
41
+ },
42
+ "peerDependencies": {
43
+ "@deepseek-ai/cordis": "^4.0.1"
44
+ },
45
+ "engines": {
46
+ "node": ">=22",
47
+ "dsh": ">=0.2.0-rc.1 <0.3.0-0"
48
+ },
49
+ "license": "GPL-2.0",
50
+ "keywords": [
51
+ "deepseek-harness",
52
+ "dsh",
53
+ "dsh-plugin",
54
+ "mobile",
55
+ "remote",
56
+ "qr",
57
+ "tunnel"
58
+ ],
59
+ "dsh": {
60
+ "bundle": {
61
+ "patch": "./cordis.patch.yml"
62
+ },
63
+ "client": {
64
+ "inject": [
65
+ "@deepseek-ai/dsh-client-connection",
66
+ "@deepseek-ai/dsh-client-ui-slots",
67
+ "@deepseek-ai/dsh-client-ui-layout",
68
+ "@deepseek-ai/dsh-client-locale",
69
+ "@deepseek-ai/dsh-client-store"
70
+ ],
71
+ "platform": "web"
72
+ }
73
+ },
74
+ "repository": {
75
+ "type": "git",
76
+ "url": "git+https://github.com/zhang-guo-wen/dsh-pocket.git"
77
+ },
78
+ "bugs": {
79
+ "url": "https://github.com/zhang-guo-wen/dsh-pocket/issues"
80
+ },
81
+ "homepage": "https://github.com/zhang-guo-wen/dsh-pocket#readme",
82
+ "publishConfig": {
83
+ "access": "public",
84
+ "registry": "https://registry.npmjs.org/"
85
+ },
86
+ "version": "2.12.2"
87
+ }