dsh-pocket 2.1.0 → 2.1.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/index.js CHANGED
@@ -20,7 +20,7 @@ import { randomBytes } from 'node:crypto';
20
20
  import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
22
22
  import { restartHost } from './restart.js';
23
- import { desktopEnvPatchScript, advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
23
+ import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
24
24
  import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings } from './settings.mjs';
25
25
 
26
26
  const name = 'dsh-pocket';
@@ -251,12 +251,18 @@ export function apply(ctx, config = {}, internals = {}) {
251
251
  internals,
252
252
  getLanIpOverride: () => lanIpOverride(),
253
253
  getLanEnabled: () => lanEnabled(),
254
- // 桌面端:手机扫码访问的页面缺 dsh-desktop-mode/platform 参数会让 dsh-plugin-desktop
255
- // client 崩溃(issue #3/#4)。给代理注入「桌面参数补丁」(history.replaceState 补齐参数,
256
- // 无跳转;compatibility 模式不套桌面布局,避免移动端按钮叠加)。保留默认 polyfill。
257
- // advanced 模式再加警告覆盖层(issue #19)。
254
+ // 桌面端**不再**注入 dsh-desktop-* 标记(issue #76):
255
+ // - 旧版 dsh-plugin-desktop 缺 mode/platform 会抛错,当初正是为此加了补丁(issue #3/#4);
256
+ // - 2.0.3 起,mode 与 platform **同时缺失**时 parseDesktopClientEnvironment 直接返回
257
+ // undefined(视作非桌面外壳,跳过全部桌面逻辑),正是手机页需要的效果;
258
+ // - 反之只要 URL 上出现任一 dsh-desktop-* 标记,客户端就会强制校验整组
259
+ // (material + semver version + mica),只补两个必然抛 "invalid or missing
260
+ // dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
261
+ // - 更糟的是 decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
262
+ // 普通浏览器判为 denied(403),刷新后直接打不开。
263
+ // 结论:手机/浏览器访问一律不带这些标记,advanced 模式另加警告覆盖层(issue #19)。
258
264
  injectHtml: isDesktop
259
- ? DEFAULT_INJECT + desktopEnvPatchScript(process.platform) + (desktopAdvanced ? advancedNoticeScript() : '')
265
+ ? DEFAULT_INJECT + (desktopAdvanced ? advancedNoticeScript() : '')
260
266
  : undefined,
261
267
  // 访问密码(issue #13 + #18 + #24 + #33 + #66):公网永远要密码(默认每次开启变新,
262
268
  // 用户自定义后不再轮换);局域网按开关(默认开启;关闭后局域网扫码直连)。
@@ -269,6 +275,19 @@ export function apply(ctx, config = {}, internals = {}) {
269
275
  getToken: (host) => tokenForHost(host),
270
276
  isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
271
277
  },
278
+ // dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
279
+ // 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
280
+ // 所以每次请求实时从 connection 服务取;老版本没有这个方法 → 返回空,行为不变。
281
+ launchToken: () => {
282
+ try {
283
+ const fn = ctx.connection?.authenticatedUrl;
284
+ if (typeof fn !== 'function') return '';
285
+ const url = new URL(fn.call(ctx.connection, `http://127.0.0.1:${dshPort}`));
286
+ return url.searchParams.get('token') ?? '';
287
+ } catch {
288
+ return '';
289
+ }
290
+ },
272
291
  // 隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
273
292
  getTunnelConfig: () => ({ mode: tunnelMode(), token: tunnelToken(), hostname: tunnelHostname() }),
274
293
  // 隧道就绪回调:快速模式每次开启轮换 8 位密码(旧链接作废);命名模式地址固定,
package/lib/proxy.mjs CHANGED
@@ -38,15 +38,23 @@ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!funct
38
38
  const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
39
39
 
40
40
  /**
41
- * DSH Desktop(桌面版)渲染进程兼容补丁。
41
+ * DSH Desktop(桌面版)渲染进程兼容补丁(issue #3/#4,已于 issue #76 停用)。
42
42
  *
43
- * 桌面版 profile 里的 dsh-plugin-desktop client 会在页面加载时从 URL query 读
43
+ * 历史:旧版 dsh-plugin-desktop client 在页面加载时从 URL query 读
44
44
  * `dsh-desktop-mode` 与 `dsh-desktop-platform`,缺失即抛
45
- * "invalid or missing dsh-desktop-mode null" → 页面崩(手机扫码访问桌面版时正是如此,
46
- * issue #3/#4)。本脚本在页面加载前用 history.replaceState 把这两个参数补上
47
- * (无跳转、不重载),取最轻的 `compatibility` 模式——不激活桌面布局,避免与
48
- * 移动端适配叠加。
49
- * 仅宿主在桌面版(isDesktop)时由 lib/index.js 追加进 injectHtml。
45
+ * "invalid or missing dsh-desktop-mode null" → 页面崩(手机扫码访问桌面版时正是如此)。
46
+ * 本脚本用 history.replaceState 补上这两个参数(无跳转、不重载),取最轻的
47
+ * `compatibility` 模式——不激活桌面布局,避免与移动端适配叠加。
48
+ *
49
+ * @deprecated 不要再注入(issue #76,DSH Desktop 2.0.3 起):
50
+ * ① mode 与 platform **同时缺失**时,parseDesktopClientEnvironment 直接返回 undefined
51
+ * (视作非桌面外壳,跳过全部桌面逻辑),正是手机/浏览器页面需要的效果;
52
+ * ② 只要 URL 上出现任一 dsh-desktop-* 标记,客户端就强制校验整组(material +
53
+ * semver version + mica),只补两个必然抛 "invalid or missing
54
+ * dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
55
+ * ③ 更糟:decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
56
+ * 普通浏览器判为 denied(403),刷新后直接打不开。
57
+ * lib/index.js 已不再注入本脚本;保留导出仅为兼容旧版本桌面端与既有测试。
50
58
  */
51
59
  export function desktopEnvPatchScript(platform) {
52
60
  const p = ['darwin', 'win32', 'linux'].includes(platform) ? platform : 'linux';
@@ -279,6 +287,28 @@ function loopbackAuthority(headers, upstream) {
279
287
  return headers;
280
288
  }
281
289
 
290
+ // ---------- dsh web 浏览器会话 token(issue #77) ----------
291
+ // 新版 dsh web(>= 0.1.2-alpha.1)给浏览器会话加了启动 token:根路径 `GET /` 必须带一次
292
+ // `?token=<启动 token>` 换一个绑定 authority 的 cookie,之后 /api 与 WebSocket 才放行;
293
+ // 否则一律 401("dsh web authentication required")。手机扫码进来的 URL 天然没有这个
294
+ // token,所以代理要在转发时补一次。
295
+ //
296
+ // 只在 `GET /` 且请求还没带 dsh-auth-* cookie 时注入:上游拿到 token 会 303 回干净的根
297
+ // 路径,若每次都注入就会 303 循环(浏览器很快报"重定向次数过多")。
298
+ const DSH_AUTH_COOKIE = 'dsh-auth-';
299
+ export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launchToken) {
300
+ if (method !== 'GET') return reqUrl;
301
+ let u;
302
+ try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
303
+ if (u.pathname !== '/') return reqUrl;
304
+ // 登录成功后跳回的 `/?dsh-pocket-auth=1`:强制重做一次握手(旧 cookie 可能已过期/被撤销)
305
+ const force = u.searchParams.has('dsh-pocket-auth');
306
+ if (!force && String(cookieHeader ?? '').includes(DSH_AUTH_COOKIE)) return reqUrl;
307
+ if (!launchToken) return reqUrl;
308
+ u.searchParams.set('token', launchToken);
309
+ return `${u.pathname}${u.search}`;
310
+ }
311
+
282
312
  // ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
283
313
  // DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
284
314
  // 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
@@ -348,7 +378,7 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
348
378
  * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
349
379
  * @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
350
380
  */
351
- export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true } = {}) {
381
+ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '' } = {}) {
352
382
  const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
353
383
  const server = createServer((req, res) => {
354
384
  const host = String(req.headers.host ?? '');
@@ -392,7 +422,9 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
392
422
  if (submitted === token) {
393
423
  limiter?.clear(ip);
394
424
  res.writeHead(302, {
395
- location: '/',
425
+ // 带上 dsh-pocket-auth=1:登录成功后强制重做一次浏览器会话握手,
426
+ // 换掉可能已过期/被撤销的 dsh web 会话 cookie(issue #77)
427
+ location: '/?dsh-pocket-auth=1',
396
428
  'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
397
429
  'cache-control': 'no-store',
398
430
  });
@@ -421,8 +453,11 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
421
453
  }
422
454
  }
423
455
  const headers = loopbackAuthority({ ...req.headers }, upstream);
456
+ // dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
457
+ const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
458
+ const upstreamPath = upstreamPathWithLaunchToken(req.url, req.method, req.headers.cookie, launchTok);
424
459
  const proxyReq = httpRequest(
425
- { host: upstream.host, port: upstream.port, method: req.method, path: req.url, headers, agent: false },
460
+ { host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
426
461
  (proxyRes) => {
427
462
  log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
428
463
  const contentType = String(proxyRes.headers['content-type'] ?? '');
package/lib/service.mjs CHANGED
@@ -180,6 +180,8 @@ export function createPocketService({
180
180
  injectHtml,
181
181
  /** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
182
182
  auth,
183
+ /** @type {() => string} dsh web 浏览器会话启动 token(issue #77;老版本返回空字符串) */
184
+ launchToken = () => '',
183
185
  /** 隧道配置(issue #66):() => ({ mode:'quick'|'named', token, hostname });named 用固定域名 */
184
186
  getTunnelConfig,
185
187
  /** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
@@ -257,6 +259,8 @@ export function createPocketService({
257
259
  ...(auth ? { auth } : {}),
258
260
  // 每次请求实时读开关:设置页切换后立即生效,无需重启代理
259
261
  lanAccessEnabled: () => getLanEnabled(),
262
+ // dsh web 浏览器会话启动 token(issue #77):实时取,新版 dsh 才有
263
+ ...(launchToken ? { launchToken } : {}),
260
264
  });
261
265
  if (p !== port) {
262
266
  console.log(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.1.0"
83
+ "version": "2.1.2"
84
84
  }