dsh-pocket 2.10.2 → 2.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -133,7 +133,7 @@ Note: in named-tunnel mode the public PIN is **not auto-rotated** (the address i
133
133
  | Nothing changed after install/update | **You must restart `dsh web`**; the running process still loads the old code |
134
134
  | `listen EADDRINUSE ... :3081` | A stale dsh-pocket process holds the port: macOS/Linux `lsof -ti :3081 \| xargs kill -9`; Windows `netstat -ano \| findstr :3081` (find the LISTENING PID) → `taskkill /PID <PID> /F`, then retry |
135
135
  | Want a different port (issue #70) | Plugin mode: write `"proxyPort": 3082` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. CLI mode: `dsh-pocket --port 3082`. If the port is taken you'll get `EADDRINUSE` — kill the old process or pick another one |
136
- | Issue a temporary PIN to a guest (issue #69) | Settings "Temporary access PINs" pick public/LAN + duration (1h/24h/7d) + note Create. Share the 8-char PIN + the access URL with the guest; it auto-expires and can be revoked manually. Temp PINs share the main PIN rate limit |
136
+ | Issue a temporary PIN to a guest | Not available: the temporary access PIN feature (issue #69) was removed in 2.6.x (it crashed on revoke). To share access, send the main PIN or a `?token=<main PIN>` link, then hit "Refresh" in Settings once the guest is done |
137
137
  | cloudflared install fails on a remote Linux server (issue #45) | If all CDN sources (GitHub / ghproxy / gh.ddlc / gh-proxy) are unreachable on a remote Linux host, install `cloudflared` yourself (e.g. `apt install cloudflared`, `dnf install cloudflared`, or download the tgz and unpack it), then add `"cloudflaredPath": "/path/to/cloudflared"` into `$DSH_HOME/dsh-pocket/settings.json` and restart `dsh web`. The plugin will then use that binary directly and skip the auto-download |
138
138
  | Version stuck below 1.x | `^0.x` ranges never jump to 1.x: update with `--latest` (`dsh plugin --profile web update dsh-pocket --latest -w`) |
139
139
  | Public `error 1033` | See "Public tunnel troubleshooting" below — usually a local proxy/VPN (Clash etc. TUN mode) killing the tunnel |
package/README.md CHANGED
@@ -118,6 +118,7 @@ npx @deepseek-ai/dsh web
118
118
  - **公网判定是 fail closed**(issue #66):除本机(loopback)和局域网私网地址外,**一切陌生域名(包括你自建隧道/反向代理指向本机端口的固定域名)一律按公网处理、强制公网密码**——不存在「换域名绕过密码」的口子
119
119
  - 局域网模式不暴露公网,只有同一网络内的设备能访问
120
120
  - 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
121
+ - **CLI 模式(命令行直跑 `dsh-pocket`)也有密码**(issue #90 修复前这条路是无认证的):默认随机生成 8 位密码,打印在终端、并已内嵌进二维码(**扫码体验不变**),手动敲地址时需要填写,本机访问免密。`--pin <值>` 或 `DSH_POCKET_PIN=<值>` 自定义(至少 6 位);`--no-auth` 可关闭,**不推荐**——那等于把能执行代码的 DSH 裸暴露给任何能连上该端口的人
121
122
 
122
123
  ## 💻 DSH Desktop(桌面版)
123
124
 
@@ -133,7 +134,7 @@ npx @deepseek-ai/dsh web
133
134
  | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
134
135
  | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
135
136
  | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
136
- | 给访客一个临时 PIN(issue #69 | 设置页「临时访问 PIN」区块 选公网/局域网 + 时长(1h/24h/7d) + 备注 → 生成。把 8 位 PIN + 入口 URL 一起发给对方;过期自动作废,也能手动撤销。临时 PIN 与主 PIN 共用速率限制 |
137
+ | 想给访客一个临时密码 | 暂不支持:issue #69 的「临时访问 PIN」功能已在 2.6.x 移除(撤销时会崩)。现在分享访问:把主密码或 `?token=<主密码>` 链接发给对方,用完在设置页点「刷新」换掉即可 |
137
138
  | Linux 服务器装不上 cloudflared(issue #45) | 远程 Linux 国内/企业网下所有 CDN 源(GitHub/ghproxy/gh.ddlc/gh-proxy)都连不上时:在服务器上手动装 `cloudflared`(如 `apt install cloudflared`、`dnf install cloudflared`、或下载 tgz 解压到任意目录),然后在 `$DSH_HOME/dsh-pocket/settings.json` 加 `"cloudflaredPath": "/path/to/cloudflared"`,重启 `dsh web` 后插件直接调用它,**不再走自动下载** |
138
139
  | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket --latest -w`) |
139
140
  | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
@@ -10,24 +10,93 @@
10
10
  // 手机看到的界面 = 电脑上的界面,实时同步(WebSocket 流式透传)。
11
11
 
12
12
  import { networkInterfaces } from 'node:os';
13
+ import { realpathSync } from 'node:fs';
14
+ import { pathToFileURL } from 'node:url';
13
15
  import { createRequire } from 'node:module';
14
- import { createPocketProxy } from '../lib/proxy.mjs';
16
+ import { randomBytes, randomInt } from 'node:crypto';
17
+ import { createPocketProxy, classifyHost } from '../lib/proxy.mjs';
15
18
  import { startQuickTunnel } from '../lib/tunnel.mjs';
16
19
 
17
20
  const require = createRequire(import.meta.url);
18
21
 
19
- function parseArgs(argv) {
20
- const args = { port: 3081, host: '0.0.0.0', public: false, upstream: { host: '127.0.0.1', port: 3080 } };
22
+ /** 自定义密码的最短长度(issue #40:别让人设 `1234`)。 */
23
+ export const MIN_PIN_LENGTH = 6;
24
+
25
+ export function parseArgs(argv) {
26
+ const args = {
27
+ port: 3081,
28
+ host: '0.0.0.0',
29
+ public: false,
30
+ upstream: { host: '127.0.0.1', port: 3080 },
31
+ pin: null,
32
+ noAuth: false,
33
+ };
21
34
  for (let i = 0; i < argv.length; i++) {
22
35
  const a = argv[i];
23
36
  if (a === '--public') args.public = true;
24
37
  else if (a === '--port') args.port = Number(argv[++i]) || 3081;
25
38
  else if (a === '--host') args.host = argv[++i] ?? '0.0.0.0';
39
+ else if (a === '--pin') args.pin = String(argv[++i] ?? '');
40
+ else if (a === '--no-auth') args.noAuth = true;
26
41
  else if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
27
42
  }
28
43
  return args;
29
44
  }
30
45
 
46
+ /**
47
+ * 决定本次运行用哪个访问密码(issue #90 第 8 条)。
48
+ *
49
+ * CLI 此前**完全不构造 auth**,且默认监听 `0.0.0.0`——任何能连到这个端口的人都能
50
+ * 直接操作 dsh web,而 dsh web 能在宿主机上执行任意代码。插件版一直有 PIN,
51
+ * 只有 CLI 这条路是裸的,属实是缺陷而非取舍。
52
+ *
53
+ * 修法上刻意**不改默认监听地址**:CLI 的主用法就是「手机连同一 WiFi 扫码」,
54
+ * 默认绑 loopback 等于把这个功能废掉。改成默认生成 PIN,并把它内嵌进二维码的
55
+ * `?token=` —— 扫码体验完全不变,手动敲地址的人才会看到登录页。
56
+ *
57
+ * 优先级:`--pin` > `DSH_POCKET_PIN` > CSPRNG 随机生成。
58
+ * @returns {{ pin: string|null, source: 'flag'|'env'|'generated'|'disabled', error?: string }}
59
+ */
60
+ export function resolvePin({ pin = null, noAuth = false } = {}, env = {}) {
61
+ if (noAuth) return { pin: null, source: 'disabled' };
62
+ const explicit = pin != null && pin !== '' ? { value: String(pin), source: 'flag' }
63
+ : (env.DSH_POCKET_PIN ? { value: String(env.DSH_POCKET_PIN), source: 'env' } : null);
64
+ if (explicit) {
65
+ if (explicit.value.length < MIN_PIN_LENGTH) {
66
+ return {
67
+ pin: null,
68
+ source: explicit.source,
69
+ error: `访问密码至少 ${MIN_PIN_LENGTH} 位(当前 ${explicit.value.length} 位)。`
70
+ + `弱口令在公网上撑不过几分钟 | Access password must be at least ${MIN_PIN_LENGTH} characters.`,
71
+ };
72
+ }
73
+ return { pin: explicit.value, source: explicit.source };
74
+ }
75
+ return { pin: String(randomInt(10_000_000, 100_000_000)), source: 'generated' };
76
+ }
77
+
78
+ /**
79
+ * 构造给 createPocketProxy 的 auth(issue #90 第 8 条)。
80
+ * `isProtected` 与插件版语义保持一致:本机免密(能在本机直连本来就说明已经上了机器),
81
+ * 局域网与公网一律要密码。返回 null 表示不启用认证(`--no-auth`)。
82
+ */
83
+ export function buildAuth(pin) {
84
+ if (!pin) return null;
85
+ return {
86
+ sessionKey: randomBytes(32).toString('hex'),
87
+ isProtected: (host) => classifyHost(host) !== 'loopback',
88
+ getToken: () => pin,
89
+ };
90
+ }
91
+
92
+ /** 把访问密码拼进入口 URL,让二维码扫了就能直达(与插件版一致)。 */
93
+ export function entryUrl(base, pin) {
94
+ if (!pin) return base;
95
+ const u = new URL(base);
96
+ u.searchParams.set('token', pin);
97
+ return u.toString();
98
+ }
99
+
31
100
  function printHelp() {
32
101
  console.log(`dsh-pocket — 手机访问电脑上的 DeepSeek Harness
33
102
 
@@ -35,11 +104,16 @@ function printHelp() {
35
104
  dsh-pocket 局域网模式(手机同一 WiFi)
36
105
  dsh-pocket --public 公网模式(cloudflared 隧道,人在外面)
37
106
  dsh-pocket --port 3081 自定义代理端口
107
+ dsh-pocket --host 自定义监听地址(默认 0.0.0.0)
108
+ dsh-pocket --pin <值> 自定义访问密码(至少 ${MIN_PIN_LENGTH} 位;也可用环境变量 DSH_POCKET_PIN)
109
+ dsh-pocket --no-auth 关闭访问密码(不推荐,见下)
38
110
  dsh-pocket --help 帮助
39
111
 
40
112
  前提:dsh web 已在 127.0.0.1:3080 运行(npx @deepseek-ai/dsh web)。
41
113
 
42
- 安全提醒:dsh web 能执行代码。二维码/URL 就是钥匙,请勿发给别人。
114
+ 安全提醒:dsh web 能在这台机器上执行代码。默认会随机生成一个 8 位访问密码,
115
+ 二维码里已内嵌该密码(扫码直达),手动输入地址时需要填写。本机访问免密。
116
+ --no-auth 会让任何能连到监听端口的人直接控制 dsh web,仅在完全可信的网络里用。
43
117
  `);
44
118
  }
45
119
 
@@ -63,12 +137,32 @@ function printQr(url, label) {
63
137
  async function main() {
64
138
  const args = parseArgs(process.argv.slice(2));
65
139
 
140
+ const resolved = resolvePin(args, process.env);
141
+ if (resolved.error) {
142
+ console.error(`❌ ${resolved.error}`);
143
+ process.exit(1);
144
+ }
145
+ const pin = resolved.pin;
146
+ const auth = buildAuth(pin);
147
+
66
148
  console.log('🚀 dsh-pocket 启动中…');
67
- const { port, close } = await createPocketProxy(args);
149
+ const { port, close } = await createPocketProxy({ ...args, auth });
150
+
151
+ if (pin) {
152
+ console.log(`\n🔐 访问密码:${pin}${resolved.source === 'generated' ? '(本次随机生成)' : ''}`);
153
+ console.log(' 二维码已内嵌密码,扫码直达;手动输入地址时需要填写。本机访问免密。');
154
+ if (resolved.source === 'generated') {
155
+ console.log(` 固定密码:--pin <值> 或 DSH_POCKET_PIN=<值>`);
156
+ }
157
+ } else {
158
+ console.log('\n⚠️ 已用 --no-auth 关闭访问密码。');
159
+ console.log(' dsh web 能在这台机器上执行任意代码——现在任何能连到');
160
+ console.log(` ${args.host}:${args.port} 的人都可以直接控制它。请仅在完全可信的网络里这样用。`);
161
+ }
68
162
 
69
163
  const lan = lanIPv4();
70
164
  if (lan) {
71
- printQr(`http://${lan}:${port}`, '📶 局域网访问(手机连同一 WiFi):');
165
+ printQr(entryUrl(`http://${lan}:${port}`, pin), '📶 局域网访问(手机连同一 WiFi):');
72
166
  } else {
73
167
  console.log('⚠️ 未检测到局域网 IP,跳过局域网二维码');
74
168
  }
@@ -90,7 +184,7 @@ async function main() {
90
184
  console.log('🌐 正在建立公网隧道(cloudflared)…');
91
185
  try {
92
186
  tunnel = await startQuickTunnel({ port, signal: controller.signal });
93
- printQr(tunnel.url, '🌐 公网访问(人在外面也能用):');
187
+ printQr(entryUrl(tunnel.url, pin), '🌐 公网访问(人在外面也能用):');
94
188
  console.log(' 隧道会持续运行;Ctrl+C 退出(下次启动会换新 URL)');
95
189
  } catch (err) {
96
190
  console.error(`❌ 公网隧道失败:${err.message}(局域网二维码仍可用)`);
@@ -103,7 +197,20 @@ async function main() {
103
197
  await new Promise(() => {});
104
198
  }
105
199
 
106
- main().catch((err) => {
107
- console.error(`❌ dsh-pocket: ${err?.message ?? err}`);
108
- process.exit(1);
109
- });
200
+ // 只有被直接执行时才启动(测试里 import 本文件拿纯函数时不能把服务跑起来)。
201
+ // npm 会把 bin 装成 symlink,argv[1] 是 symlink 路径而 import.meta.url 是真实路径,
202
+ // 所以必须先 realpath 再比较,否则装完的 CLI 会变成什么都不做。
203
+ const isDirectRun = (() => {
204
+ try {
205
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1] ?? '')).href;
206
+ } catch {
207
+ return false;
208
+ }
209
+ })();
210
+
211
+ if (isDirectRun) {
212
+ main().catch((err) => {
213
+ console.error(`❌ dsh-pocket: ${err?.message ?? err}`);
214
+ process.exit(1);
215
+ });
216
+ }
package/client/client.js CHANGED
@@ -1530,6 +1530,19 @@ var MOBILE_CSS = `
1530
1530
  }
1531
1531
  }
1532
1532
 
1533
+ /* ---------- mobile: stop iOS Safari forced zoom on input focus ----------
1534
+ * Inputs are rendered with inline fontSize 13-14px, below the 16px threshold
1535
+ * that makes iOS Safari zoom the whole page on focus (and never recover).
1536
+ * Force the safe 16px minimum on narrow viewports only, so desktop keeps its
1537
+ * tighter metrics. !important is required to beat the inline styles. */
1538
+ @media (max-width: 1024px) {
1539
+ input,
1540
+ textarea,
1541
+ [contenteditable="true"] {
1542
+ font-size: 16px !important;
1543
+ }
1544
+ }
1545
+
1533
1546
  /* ---------- desktop: the mobile controls must never appear ---------- */
1534
1547
 
1535
1548
  @media (min-width: 1024px) {
package/client/index.jsx CHANGED
@@ -625,10 +625,10 @@ function PocketSettingsTab({ rpcCall, t }) {
625
625
  }
626
626
 
627
627
  export function apply(ctx) {
628
- // 双保险:确保 connection.isLoopback 为 true(issue #58)。
629
- // 主修复在代理注入的 loopback 补丁(proxy.mjs LOOPBACK_ENV_PATCH)——它在
630
- // connection 模块 provide 时就改写句柄,早于 ui-settings 选择镜像模式;
631
- // 这里兜底覆盖时序差异(若本插件 apply 晚于 ui-settings,则只能影响后续读者)。
628
+ // 兜底:确保 connection.isLoopback 为 true(issue #58)。
629
+ // 注:代理注入的 loopback 补丁(proxy.mjs LOOPBACK_ENV_PATCH)已在 #105 移除——
630
+ // 它与 DSH Desktop 2.0.4+ 客户端运行时不兼容,会令 BootHandoff 阶段白屏。
631
+ // #58「远程浏览器开设置页」需上游提供官方信任来源机制才能正经解决;此处仅保留兜底。
632
632
  if (ctx?.connection) {
633
633
  try {
634
634
  Object.defineProperty(ctx.connection, 'isLoopback', { value: true, writable: true, configurable: true });
@@ -975,6 +975,19 @@ export const MOBILE_CSS = `
975
975
  }
976
976
  }
977
977
 
978
+ /* ---------- mobile: stop iOS Safari forced zoom on input focus ----------
979
+ * Inputs are rendered with inline fontSize 13-14px, below the 16px threshold
980
+ * that makes iOS Safari zoom the whole page on focus (and never recover).
981
+ * Force the safe 16px minimum on narrow viewports only, so desktop keeps its
982
+ * tighter metrics. !important is required to beat the inline styles. */
983
+ @media (max-width: 1024px) {
984
+ input,
985
+ textarea,
986
+ [contenteditable="true"] {
987
+ font-size: 16px !important;
988
+ }
989
+ }
990
+
978
991
  /* ---------- desktop: the mobile controls must never appear ---------- */
979
992
 
980
993
  @media (min-width: 1024px) {
package/lib/index.js CHANGED
@@ -210,7 +210,8 @@ async function consumeRestartNotice() {
210
210
  function pocketRestart(service) {
211
211
  const result = restartHost();
212
212
  if (!result || result.helperPid == null) return result; // helper 都没 spawn 出来 → 失败
213
- try { service?.stopTunnel(); } catch { /* 忽略 */ }
213
+ // 停隧道是为了杀孤儿 cloudflared;保留自动恢复标记——重启后隧道要自动拉起(issue #11)
214
+ try { service?.stopTunnel({ keepAutoMarker: true }); } catch { /* 忽略 */ }
214
215
  writeRestartNotice().catch(() => {});
215
216
  return result;
216
217
  }
package/lib/proxy.mjs CHANGED
@@ -35,6 +35,22 @@ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!funct
35
35
  导致会话列表不显示(实测 PAGEERROR: Identifier 'location' has already been declared)。
36
36
  已回退;该问题属 DSH 客户端限制(location.hostname 是 unforgeable 属性)无法安全绕过。*/</script>`;
37
37
 
38
+ /**
39
+ * issue #96:dsh 0.1.1-rc.2 起,`@deepseek-ai/dsh-client-connection` 的连接层改成
40
+ * const api = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient()
41
+ * 其中 transport = globalThis.__DSH_TRANSPORT__(由 DSH 宿主 web shell 注入,本仓库
42
+ * 全程不创建、不引用这个全局)。桌面直连 127.0.0.1:3080 时宿主给的 transport 带
43
+ * createApiClient;经本代理(局域网 / 隧道域名)访问时宿主给的 transport 不带该方法
44
+ * → 手机上一执行就 TypeError: transport?.createApiClient is not a function,整页崩。
45
+ *
46
+ * 兜底策略:只在该方法缺失时补一个返回 null 的实现。null 会触发宿主自己那条
47
+ * `?? new WebApiClient()` 兜底分支,等于让连接层回到旧版本(0.1.1-rc.2 之前)的
48
+ * 行为。宿主自己有实现时一律不覆盖。
49
+ *
50
+ * 这是 stopgap 不是根治:真正的契约缺口在 DSH 宿主,需上游修复。
51
+ */
52
+ export const TRANSPORT_API_CLIENT_SHIM = `<script data-dsh-pocket-transport-shim="1">!function(){try{var K='__DSH_TRANSPORT__',cur=globalThis[K];function patch(t){try{if(t&&typeof t==='object'&&typeof t.createApiClient!=='function'){try{Object.defineProperty(t,'createApiClient',{value:function(){return null;},writable:true,configurable:true});}catch(e){try{t.createApiClient=function(){return null;};}catch(e2){}}}}catch(e){}return t;}if(cur)patch(cur);Object.defineProperty(globalThis,K,{configurable:true,enumerable:true,get:function(){return cur;},set:function(t){cur=patch(t);}});}catch(e){}}();</script>`;
53
+
38
54
  const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
39
55
 
40
56
  /**
@@ -67,74 +83,10 @@ function isCompressed(headers) {
67
83
  }
68
84
 
69
85
  /**
70
- * DSH 客户端信任环境补丁(issue #58 的可行修法):
71
- * 手机/局域网浏览器访问时 location.hostname 不是回环地址,dsh-client-connection 据此
72
- * 把 ctx.connection.isLoopback 判定为 false,dsh-client-ui-settings 便把 settings 镜像
73
- * 置为 memory 模式(不拉取 settings.describe),模型/通用设置页于是报
74
- * "settings are unavailable in this browser"(issue #58)。
75
- *
76
- * 与已回退的「全局 let location + Proxy」伪装方案不同,本补丁**不碰 location**——
77
- * 它包装 window.__ModuleLoader__,在 dsh-client-connection 模块向 Cordis 容器
78
- * provide('connection') 时把服务句柄的 isLoopback 强制为 true。经本代理(带 PIN 鉴权、
79
- * 请求头已回环化)的远程访问由此获得与本机一致的完整设置能力,会话列表回归不存在。
80
- *
81
- * 关键细节:HTML 只预加载 client-modules/client-runtime 两个 bundle(队列模式注册);
82
- * connection 等插件 bundle 是 create() 启动后由加载器**动态**加载的,而 create() 会把
83
- * facade.load 整体替换为 live 注册函数。因此不能只包一次 load 函数——必须在 facade
84
- * 对象上把 load 装成访问器(getter/setter),每次赋值(队列→live 切换)都重新包装。
86
+ * 默认注入到经代理的 HTML 文档里:crypto.randomUUID / AbortSignal.any polyfill
87
+ * (非安全上下文必需)+ issue #96 的 transport.createApiClient 兜底。
85
88
  */
86
- const LOOPBACK_ENV_PATCH = `<script data-dsh-pocket-loopback-patch="1">!function(){try{
87
- var ml=window.__ModuleLoader__;
88
- function wrapFactory(h){
89
- var of=h.factory;
90
- h.factory=function(r){
91
- var exp=of.apply(this,arguments);
92
- if(exp&&typeof exp.apply==='function'){
93
- var oa=exp.apply;
94
- exp.apply=function(ctx){
95
- var op=ctx&&ctx.provide;
96
- if(typeof op==='function'){
97
- ctx.provide=function(n,v){
98
- if(n==='connection'&&v&&typeof v==='object'){
99
- try{Object.defineProperty(v,'isLoopback',{value:true,writable:true,configurable:true});}catch(e){v.isLoopback=true;}
100
- }
101
- return op.apply(this,arguments);
102
- };
103
- }
104
- return oa.apply(this,arguments);
105
- };
106
- }
107
- return exp;
108
- };
109
- }
110
- function wrapLoad(fn){
111
- return function(h){
112
- if(h&&h.id&&String(h.id).indexOf('connection')!==-1&&typeof h.factory==='function'){wrapFactory(h);}
113
- return fn.call(this,h);
114
- };
115
- }
116
- function wrapFacade(facade){
117
- if(!facade||facade._dsh_p_w)return facade;
118
- var current=wrapLoad(facade.load);
119
- try{
120
- Object.defineProperty(facade,'load',{
121
- configurable:true,
122
- get:function(){return current;},
123
- set:function(fn){current=wrapLoad(fn);}
124
- });
125
- }catch(e){facade.load=current;}
126
- facade._dsh_p_w=true;
127
- return facade;
128
- }
129
- if(ml){wrapFacade(ml);}
130
- else{
131
- var cur=undefined;
132
- Object.defineProperty(window,'__ModuleLoader__',{configurable:true,enumerable:true,get:function(){return cur;},set:function(v){cur=wrapFacade(v);}});
133
- }
134
- }catch(e){}}();</script>`;
135
-
136
- /** 默认注入到经代理的 HTML 文档里:polyfill + loopback 信任环境补丁(issue #58)。 */
137
- export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL + LOOPBACK_ENV_PATCH;
89
+ export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL + TRANSPORT_API_CLIENT_SHIM;
138
90
 
139
91
  /**
140
92
  * DSH Desktop advanced 模式不支持的提示覆盖层(issue #19)。
@@ -216,11 +168,22 @@ function createRateLimiter(cfg = {}) {
216
168
  },
217
169
  };
218
170
  }
219
- /** 客户端真实 IP:cf-connecting-ip(隧道,可信)优先,否则 socket 地址;不信 XFF。 */
220
- function clientIp(req) {
221
- const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
222
- if (cf) return cf;
223
- return String(req.socket?.remoteAddress ?? 'unknown');
171
+ /**
172
+ * 客户端真实 IP(限速与握手计数的身份键):
173
+ * - 来自本机(cloudflared 隧道回连,源地址 loopback)→ 认 cf-connecting-ip(Cloudflare 边缘写的真实客户端 IP);
174
+ * - 其余一律用 socket 源地址。
175
+ *
176
+ * 为什么不能无条件信任 cf-connecting-ip:那是**请求头**,能直连代理端口的人可以随手
177
+ * 伪造。隧道流量必经本机 cloudflared(源地址必为 loopback),所以把它限定在 loopback
178
+ * 来源既不影响隧道场景,又让「换头即换身份」的限速绕过失效。XFF 同理,始终不认。
179
+ */
180
+ export function clientIp(req) {
181
+ const addr = String(req.socket?.remoteAddress ?? '');
182
+ if (classifySource(addr) === 'loopback') {
183
+ const cf = String(req.headers['cf-connecting-ip'] ?? '').trim();
184
+ if (cf) return cf;
185
+ }
186
+ return addr || 'unknown';
224
187
  }
225
188
 
226
189
  function parseCookies(header) {
@@ -795,13 +758,17 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
795
758
  req.on('data', (c) => { body += c; if (body.length > 1024) req.destroy(); });
796
759
  req.on('end', () => {
797
760
  const submitted = String(new URLSearchParams(body).get('token') ?? '');
798
- if (submitted === token) {
761
+ // cookie / ?token= 两条通道保持一致:常量时间比较 + 主 PIN 与替代令牌
762
+ // (临时 PIN,issue #69 钩子)任一命中即放行。原先这里是 `submitted === token`
763
+ // ——既会提前返回(计时侧信道可逐字节还原 PIN),又把替代令牌挡在门外。
764
+ const matched = acceptedTokens.find((candidate) => safeEqual(submitted, candidate));
765
+ if (matched !== undefined) {
799
766
  limiter?.clear(ip);
800
767
  res.writeHead(302, {
801
768
  // 带上 dsh-pocket-auth=1:登录成功后强制重做一次浏览器会话握手,
802
769
  // 换掉可能已过期/被撤销的 dsh web 会话 cookie(issue #77)
803
770
  location: '/?dsh-pocket-auth=1',
804
- 'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
771
+ 'set-cookie': `${TOKEN_COOKIE}=${cookieFor(matched, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
805
772
  'cache-control': 'no-store',
806
773
  });
807
774
  res.end();
package/lib/service.mjs CHANGED
@@ -187,7 +187,14 @@ export function createPocketService({
187
187
  getTunnelConfig,
188
188
  /** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
189
189
  onTunnelReady,
190
+ /** 日志(DSH 宿主 ctx.logger 有 info/warn/error;默认 console 无 info,自动退回 log)。
191
+ * 隧道自动恢复的成败必须走这里:console 输出在 DSH Desktop 里不进日志文件,
192
+ * 出问题时无法排查(2026-09-07 实测教训)。 */
193
+ log = console,
190
194
  } = {}) {
195
+ // ctx.logger 形状是 info/warn/error;console 只有 log/warn/error——info 做兜底兼容
196
+ const logInfo = (...args) => (log.info ?? log.log).call(log, ...args);
197
+ const logWarn = (...args) => log.warn(...args);
191
198
  const createProxy = internals.createProxy ?? createPocketProxy;
192
199
  const startTunnel = internals.startTunnel ?? startQuickTunnel;
193
200
  const startNamed = internals.startNamedTunnel ?? startNamedTunnel;
@@ -232,16 +239,25 @@ export function createPocketService({
232
239
  // 公网隧道自动恢复(issue #11):DSH 重启后 cloudflared 子进程被杀、隧道消失,
233
240
  // 插件无从知晓。启动时检查持久化的「隧道开启中」标记,自动重新拉起。
234
241
  const autoStatePath = home ? join(home, 'dsh-pocket', 'tunnel-auto.json') : null;
235
- async function persistAutoTunnel() {
242
+ // 写入与删除必须串行:隧道刚就绪就立即关闭时,异步写不能晚于删除重新落盘,
243
+ // 否则标记被晚到的写操作复活,下次启动误恢复(writeFile 与 rm 的完成顺序不保证)。
244
+ let autoStateQueue = Promise.resolve();
245
+ function persistAutoTunnel() {
236
246
  if (!autoStatePath) return;
237
- try {
238
- await mkdir(dirname(autoStatePath), { recursive: true });
239
- await writeFile(autoStatePath, JSON.stringify({ at: Date.now() }), 'utf8');
240
- } catch { /* 忽略 */ }
247
+ autoStateQueue = autoStateQueue.then(async () => {
248
+ try {
249
+ await mkdir(dirname(autoStatePath), { recursive: true });
250
+ await writeFile(autoStatePath, JSON.stringify({ at: Date.now() }), 'utf8');
251
+ } catch { /* 忽略 */ }
252
+ });
253
+ return autoStateQueue;
241
254
  }
242
- async function clearAutoTunnel() {
255
+ function clearAutoTunnel() {
243
256
  if (!autoStatePath) return;
244
- try { await rm(autoStatePath, { force: true }); } catch { /* 忽略 */ }
257
+ autoStateQueue = autoStateQueue.then(async () => {
258
+ try { await rm(autoStatePath, { force: true }); } catch { /* 忽略 */ }
259
+ });
260
+ return autoStateQueue;
245
261
  }
246
262
 
247
263
  return {
@@ -264,7 +280,7 @@ export function createPocketService({
264
280
  ...(launchToken ? { launchToken } : {}),
265
281
  });
266
282
  if (p !== port) {
267
- console.log(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
283
+ logInfo(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
268
284
  }
269
285
  break;
270
286
  } catch (err) {
@@ -291,7 +307,12 @@ export function createPocketService({
291
307
  else if (phase === 'registering') tunnelState.detail = '连接 Cloudflare 边缘(通常 5-30 秒)| connecting to Cloudflare edge (usually 5-30s)';
292
308
  else if (phase === 'ready') tunnelState.detail = '隧道就绪 | ready';
293
309
  };
294
- tunnelPromise = (async () => {
310
+ // `await null` 让函数体从微任务开始执行:否则在 `p` 初始化之前(getTunnelConfig
311
+ // 之类的同步抛错)就会走到 finally,引用未初始化的 const 触发 TDZ 报错,
312
+ // tunnelPromise 会永久停在 rejected —— 此后 startTunnel 一直复用这个失败的
313
+ // promise,隧道再也起不来。
314
+ const p = (async () => {
315
+ await null;
295
316
  try {
296
317
  // 命名隧道(issue #66):固定域名模式——URL 由设置里的域名拼出(cloudflared 不打印)
297
318
  const cfg = typeof getTunnelConfig === 'function' ? (getTunnelConfig() ?? {}) : {};
@@ -335,12 +356,18 @@ export function createPocketService({
335
356
  if (tunnelPromise === p) tunnelPromise = null;
336
357
  }
337
358
  })();
338
- const p = tunnelPromise;
339
- return tunnelPromise;
359
+ tunnelPromise = p;
360
+ return p;
340
361
  },
341
362
 
342
- /** 停止公网隧道(代理保持)。 */
343
- stopTunnel() {
363
+ /**
364
+ * 停止公网隧道(代理保持)。
365
+ * @param {{ keepAutoMarker?: boolean }} [opts] - keepAutoMarker=true 用于进程退出/
366
+ * 自重启(dispose / pocketRestart):隧道是随进程被动消失的,不是用户主动关闭,
367
+ * 必须保留 tunnel-auto.json,下次启动才能自动恢复(issue #11 的本意)。
368
+ * 默认(设置页手动关闭、恢复出厂)删除标记。
369
+ */
370
+ stopTunnel({ keepAutoMarker = false } = {}) {
344
371
  tunnelAbort?.abort();
345
372
  tunnelAbort = null;
346
373
  tunnelPromise = null; // 丢弃已 abort 的 in-flight(其 finally 会再清一次,无害)
@@ -349,11 +376,12 @@ export function createPocketService({
349
376
  tunnelState.phase = 'idle';
350
377
  tunnelState.detail = '';
351
378
  tunnelState.startedAt = null;
352
- void clearAutoTunnel(); // 手动关闭后不再自动恢复
379
+ if (!keepAutoMarker) void clearAutoTunnel(); // 手动关闭后不再自动恢复
353
380
  },
354
381
 
355
382
  /** 启动时自动恢复上次开启的公网隧道(DSH 重启后 cloudflared 子进程被杀,issue #11)。 */
356
383
  async restoreTunnelIfNeeded() {
384
+ logInfo('dsh-pocket: auto-restore check | 自动恢复检查:读取上次隧道状态');
357
385
  if (!autoStatePath || tunnel || tunnelPromise) return;
358
386
  let has = false;
359
387
  try {
@@ -361,12 +389,13 @@ export function createPocketService({
361
389
  has = /"at"\s*:/.test(raw);
362
390
  } catch { return; } // 无标记 → 不恢复
363
391
  if (!has) return;
392
+ logInfo('dsh-pocket: auto-restore: marker found, starting tunnel | 发现上次开启标记,尝试自动恢复');
364
393
  try {
365
394
  await this.startTunnel();
366
- console.log('dsh-pocket: public tunnel auto-restored | 已自动恢复公网隧道');
395
+ logInfo('dsh-pocket: public tunnel auto-restored | 已自动恢复公网隧道');
367
396
  } catch (err) {
368
397
  // 恢复失败保留标记(下次启动再试);网络问题见 README 排障
369
- console.warn('dsh-pocket: tunnel auto-restore failed | 自动恢复隧道失败: %s', err?.message ?? err);
398
+ logWarn('dsh-pocket: tunnel auto-restore failed | 自动恢复隧道失败: %s', err?.message ?? err);
370
399
  }
371
400
  },
372
401
 
@@ -403,8 +432,9 @@ export function createPocketService({
403
432
  },
404
433
 
405
434
  /** 停止一切(插件卸载时)。 */
435
+ /** 停止一切(插件卸载时)。进程退出不是用户手动关闭:保留自动恢复标记。 */
406
436
  async dispose() {
407
- this.stopTunnel();
437
+ this.stopTunnel({ keepAutoMarker: true });
408
438
  if (proxy) {
409
439
  const p = proxy;
410
440
  proxy = null;
package/lib/web-rpc.js CHANGED
@@ -1,4 +1,10 @@
1
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(...)。
2
8
 
3
9
  import { promises as fs } from 'node:fs';
4
10
  import path from 'node:path';
@@ -8,6 +14,224 @@ import { POCKET_RPC_CHANNEL, POCKET_ENDPOINTS, redactStatus } from '../client/ap
8
14
  /** 单次读取上限:4 MB,避免把大文件塞进剪贴板 / 内存。 */
9
15
  const FILE_READ_MAX = 4 * 1024 * 1024;
10
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
+ const requestRejection = ctx?.connection?.requestRejection;
205
+ const fetchHandler = pocketFetchHandler(channel, handler, log);
206
+ const route = {
207
+ kind: 'prefix',
208
+ path: channel,
209
+ handler: async (req, res) => {
210
+ let rejection;
211
+ if (typeof requestRejection === 'function') {
212
+ try { rejection = requestRejection(req); } catch { rejection = 403; }
213
+ } else if (!isTrustedLoopbackRequest(req)) {
214
+ rejection = 403;
215
+ }
216
+ if (rejection !== undefined) {
217
+ res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' });
218
+ res.end(rejection === 401 ? 'unauthorized' : 'forbidden');
219
+ return;
220
+ }
221
+ await pocketHttpBridge(req, res, fetchHandler, POCKET_RPC_BODY_MAX);
222
+ },
223
+ };
224
+ const registered = webServer.register(route);
225
+ // register 可能返回 disposer、Promise<disposer> 或 undefined(仅写入路由表)。
226
+ // 统一规整成「可幂等调用的清理函数」。
227
+ const cleanup = typeof registered === 'function'
228
+ ? () => { try { registered(); } catch { /* 已清理 */ } }
229
+ : (registered && typeof registered.then === 'function')
230
+ ? (() => { let done = false; return async () => { if (done) return; done = true; try { const d = await registered; if (typeof d === 'function') d(); } catch { /* 已清理 */ } }; })()
231
+ : () => {};
232
+ return cleanup;
233
+ }
234
+
11
235
  function ok(value) {
12
236
  return { ok: true, value };
13
237
  }
@@ -30,13 +254,12 @@ export function killHint(port) {
30
254
  return `lsof -ti :${port} | xargs kill -9`;
31
255
  }
32
256
 
33
- /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。 */
257
+ /** 注册 /dsh-pocket 逻辑通道(仅本机 loopback 可调)。
258
+ * 优先直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+ 兼容路径),
259
+ * 不可用时回退 ctx.connection.rpc.handle(旧版 dsh 兼容路径)。
260
+ * wire 协议两条路径完全一致:client-request → handler → server-response。 */
34
261
  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 }) {
35
- if (!ctx?.connection?.rpc?.handle) {
36
- log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
37
- return () => {};
38
- }
39
- return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, async (endpoint, payload = {}, signal) => {
262
+ const pocketRpcHandler = async (endpoint, payload = {}, signal) => {
40
263
  if (signal?.aborted) return fail('cancelled', 'The request was cancelled.');
41
264
 
42
265
  // status 响应:服务状态 + 重启提示 + 停止命令 + 桌面端标志 + 公网/局域网访问密码 + 局域网密码开关
@@ -219,5 +442,19 @@ export function installPocketRpc(ctx, { service, log = console, desktop = false,
219
442
  log.error?.('dsh-pocket: rpc %s failed | RPC 失败: %s', endpoint, err?.message ?? err);
220
443
  return fail('bad-request', err?.message ?? String(err));
221
444
  }
222
- }, { authority: 'loopback' });
445
+ };
446
+
447
+ // 优先路径:直接挂到本插件 inject 的 webServer 上(dsh v0.1.5-alpha.1+)。
448
+ // 该路径下 ctx.connection 仅用于 requestRejection(401/403 完整栅栏),即使
449
+ // client-connection 已不再 inject webServer,本插件自己 inject 的 webServer 也照常工作。
450
+ const directMount = mountPocketWebRoute(ctx, { channel: POCKET_RPC_CHANNEL, handler: pocketRpcHandler, log });
451
+ if (directMount) return directMount;
452
+
453
+ // 回退路径:旧版 dsh(无 webServer 服务,如 headless / 测试 fixture)走 rpc.handle。
454
+ // { authority: 'loopback' } 旧版用、新版 Connection 从未消费,保留向后兼容。
455
+ if (!ctx?.connection?.rpc?.handle) {
456
+ log.warn?.('dsh-pocket: DSH Host Connection RPC unavailable — settings tab disabled | 无 Connection RPC,设置页不可用');
457
+ return () => {};
458
+ }
459
+ return ctx.connection.rpc.handle(POCKET_RPC_CHANNEL, pocketRpcHandler, { authority: 'loopback' });
223
460
  }
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.10.2"
83
+ "version": "2.10.4"
84
84
  }