dsh-bridge-gateway 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/README.md +17 -3
  3. package/README.zh.md +18 -2
  4. package/assets/apple-touch-icon-180.png +0 -0
  5. package/assets/icon-192.png +0 -0
  6. package/assets/icon-512.png +0 -0
  7. package/assets/icon-maskable-192.png +0 -0
  8. package/assets/icon-maskable-512.png +0 -0
  9. package/client/client.js +302 -41
  10. package/client/index.js +438 -42
  11. package/docs/banner.jpg +0 -0
  12. package/docs/custom-tunnel.md +5 -5
  13. package/docs/platform-abstraction-design.md +1 -1
  14. package/docs/qq-usage.md +6 -6
  15. package/docs/screenshots/remote-auth-login-light.jpg +0 -0
  16. package/docs/screenshots/remote-auth-login.jpg +0 -0
  17. package/docs/wechat-usage.md +1 -1
  18. package/lib/auth/brand-assets.js +131 -0
  19. package/lib/auth/login-template.js +503 -361
  20. package/lib/auth/manager.js +47 -12
  21. package/lib/auth/theme-tokens.js +142 -0
  22. package/lib/bridge-rpc.js +2 -2
  23. package/lib/cloudflared-manager.mjs +1 -1
  24. package/lib/feishu/gateway.js +17 -17
  25. package/lib/feishu/index.js +3 -3
  26. package/lib/feishu/lark-bundled.mjs +288 -469
  27. package/lib/feishu/node.js +7 -7
  28. package/lib/gateway/cert.js +1 -1
  29. package/lib/index.js +210 -80
  30. package/lib/paths.js +43 -14
  31. package/lib/platform/base.js +2 -2
  32. package/lib/platform/conversation-bridge.js +17 -17
  33. package/lib/platform/index.js +1 -1
  34. package/lib/platform/manager.js +4 -4
  35. package/lib/qq/gateway.js +10 -10
  36. package/lib/qq/index.js +11 -11
  37. package/lib/qq/node.js +17 -17
  38. package/lib/telegram/gateway.js +10 -10
  39. package/lib/telegram/index.js +3 -3
  40. package/lib/telegram/node.js +5 -5
  41. package/lib/wechat/gateway.js +6 -6
  42. package/lib/wechat/index.js +4 -4
  43. package/lib/wechat/media.js +1 -1
  44. package/lib/wechat/node.js +13 -13
  45. package/package.json +6 -3
@@ -1,4 +1,4 @@
1
- // dsh-bridge Feishu / Lark conversation node
1
+ // dsh-bridge-gateway Feishu / Lark conversation node
2
2
  // 把飞书 OpenAPI / WebSocket 长连接入站事件解析后交给平台无关的
3
3
  // ConversationBridge 处理,出站通过 FeishuGateway 发送文本 / 卡片。
4
4
 
@@ -117,7 +117,7 @@ export class FeishuConversationNode extends ConversationBridge {
117
117
  }
118
118
 
119
119
  if (!text && mediaFiles.length === 0) {
120
- this.logger.debug?.(`[dsh-bridge feishu] ignore empty message from ${peerId}`)
120
+ this.logger.debug?.(`[dsh-bridge-gateway feishu] ignore empty message from ${peerId}`)
121
121
  return
122
122
  }
123
123
 
@@ -127,7 +127,7 @@ export class FeishuConversationNode extends ConversationBridge {
127
127
  fullText = fullText ? `${fullText}\n\n${mediaDesc}` : mediaDesc
128
128
  }
129
129
 
130
- this.logger.debug?.(`[dsh-bridge feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
130
+ this.logger.debug?.(`[dsh-bridge-gateway feishu] handling inbound message from ${peerId} (group=${isGroup}): ${fullText}`)
131
131
 
132
132
  // 如果还没有活动会话,且是首条消息,自动尝试选择最新已有会话
133
133
  if (!this.activeSessionId) {
@@ -162,10 +162,10 @@ export class FeishuConversationNode extends ConversationBridge {
162
162
  const filePath = path.join(mediaDir, safeName)
163
163
  await fs.promises.writeFile(filePath, buf)
164
164
  downloaded.push({ filename: safeName, path: filePath, size: buf.length })
165
- this.logger.info?.(`[dsh-bridge feishu] downloaded media ${safeName} (${buf.length} bytes)`)
165
+ this.logger.info?.(`[dsh-bridge-gateway feishu] downloaded media ${safeName} (${buf.length} bytes)`)
166
166
  }
167
167
  } catch (err) {
168
- this.logger.warn?.(`[dsh-bridge feishu] download media failed: ${err.message}`)
168
+ this.logger.warn?.(`[dsh-bridge-gateway feishu] download media failed: ${err.message}`)
169
169
  }
170
170
  }
171
171
  return downloaded
@@ -207,7 +207,7 @@ export class FeishuConversationNode extends ConversationBridge {
207
207
  this._streamCardId = res?.message_id || null
208
208
  this._streamContent = firstSlice
209
209
  } catch (err) {
210
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
210
+ this.logger.warn?.('[dsh-bridge-gateway feishu] sendMarkdownCard failed, fallback to sendText:', err?.message ?? err)
211
211
  await this.gateway.sendText(peerId, content, { isGroup })
212
212
  return
213
213
  }
@@ -239,7 +239,7 @@ export class FeishuConversationNode extends ConversationBridge {
239
239
  }
240
240
  }
241
241
  } catch (err) {
242
- this.logger.warn?.('[dsh-bridge feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
242
+ this.logger.warn?.('[dsh-bridge-gateway feishu] sendMarkdownCard failed, fallback to plain text:', err?.message ?? err)
243
243
  await this.gateway.sendText(peerId, content, { isGroup })
244
244
  }
245
245
  }
@@ -208,7 +208,7 @@ export async function getOrCreateSelfSignedCert({ certFile, keyFile, logger }) {
208
208
  writeFile(keyFile, key, 'utf8'),
209
209
  ]);
210
210
  } catch (err) {
211
- logger?.warn?.('dsh-bridge: 无法持久化自签证书: %s', err?.message ?? err);
211
+ logger?.warn?.('dsh-bridge-gateway: 无法持久化自签证书: %s', err?.message ?? err);
212
212
  }
213
213
  return { cert, key };
214
214
  }
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-bridge 主插件(Host)
1
+ // dsh-bridge-gateway 主插件(Host)
2
2
  //
3
3
  // 多渠道访问桥:
4
4
  // 1. 局域网访问代理(自动启动,零配置)
@@ -25,6 +25,7 @@ import { FeishuService } from './feishu/index.js';
25
25
  import { TelegramService } from './telegram/index.js';
26
26
  import { AuthManager } from './auth/manager.js';
27
27
  import { renderLoginPage } from './auth/login-template.js';
28
+ import { pwaIconSvg } from './auth/brand-assets.js';
28
29
  import { isSafeWorkspacePath, isSensitiveFolderName } from './security/path-validator.js';
29
30
  import { applyRestoredPlatformConfig, RESTORED_STRING_FIELDS, PLATFORM_TIMING_FIELDS } from './platform/config-restore.js';
30
31
  import {
@@ -34,6 +35,7 @@ import {
34
35
  certDir as certDirFor,
35
36
  migrateLegacyDataDir,
36
37
  migrateLegacyCloudflaredDir,
38
+ cloudflaredBinDirs,
37
39
  } from './paths.js';
38
40
  import { getOrCreateSelfSignedCert } from './gateway/cert.js';
39
41
 
@@ -235,6 +237,11 @@ class QrCache {
235
237
 
236
238
  /**
237
239
  * PWA Web App Manifest 与 App 启动图标
240
+ *
241
+ * 图标与配色统一到 DSH 本体:此前这里是一个蓝色机器人脸(#4f6ef7),而登录页是
242
+ * 绿色盾牌、DSH 本体是鲸鱼 —— 手机用户把网关加到主屏后,会经历「机器人图标 →
243
+ * 绿色盾牌页 → 鲸鱼 DSH」三方品牌跳变。现在三处同源同一枚鲸鱼(brand-assets.js),
244
+ * 底色用 DSH 暗色画布 --dsw-alias-bg-base = #151517。
238
245
  */
239
246
  const PWA_MANIFEST = JSON.stringify({
240
247
  name: 'DeepSeek Harness',
@@ -242,34 +249,52 @@ const PWA_MANIFEST = JSON.stringify({
242
249
  description: 'DeepSeek Harness Remote & Mobile Workspace',
243
250
  start_url: '/',
244
251
  display: 'standalone',
245
- background_color: '#181825',
246
- theme_color: '#1e1e2e',
252
+ background_color: '#151517',
253
+ theme_color: '#151517',
247
254
  orientation: 'any',
248
255
  icons: [
249
- {
250
- src: '/__dsh_bridge__/pwa-icon.svg',
251
- sizes: 'any',
252
- type: 'image/svg+xml',
253
- purpose: 'any maskable'
254
- }
256
+ // PNG 是主屏图标的主力:HarmonyOS 与多数 Android 启动器**不支持 SVG 图标**,
257
+ // 只声明 SVG 会导致"添加到桌面"后图标一片空白(2026-10-16 华为 Mate 80 实测)。
258
+ // any 与 maskable 分开声明:maskable 必须满幅,启动器会按自己的形状裁切。
259
+ { src: '/__dsh_bridge__/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
260
+ { src: '/__dsh_bridge__/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
261
+ { src: '/__dsh_bridge__/icon-maskable-192.png', sizes: '192x192', type: 'image/png', purpose: 'maskable' },
262
+ { src: '/__dsh_bridge__/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
263
+ { src: '/__dsh_bridge__/pwa-icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }
255
264
  ]
256
265
  }, null, 2);
257
266
 
258
- const PWA_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
259
- <defs>
260
- <linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
261
- <stop offset="0%" stop-color="#4f6ef7"/>
262
- <stop offset="100%" stop-color="#24388a"/>
263
- </linearGradient>
264
- </defs>
265
- <rect width="512" height="512" rx="128" fill="url(#g)"/>
266
- <path d="M150 170 C150 140, 362 140, 362 170 L362 330 C362 360, 150 360, 150 330 Z" fill="#ffffff" fill-opacity="0.12"/>
267
- <circle cx="206" cy="220" r="28" fill="#ffffff"/>
268
- <circle cx="306" cy="220" r="28" fill="#ffffff"/>
269
- <path d="M200 290 Q256 340 312 290" stroke="#ffffff" stroke-width="24" stroke-linecap="round" fill="none"/>
270
- <rect x="236" y="90" width="40" height="60" rx="10" fill="#ffffff"/>
271
- <circle cx="256" cy="80" r="16" fill="#4f6ef7"/>
272
- </svg>`;
267
+ const PWA_ICON_SVG = pwaIconSvg();
268
+
269
+ /**
270
+ * 主屏图标的 PNG 路由表。文件由 scripts/generate-icons.mjs 生成并入库(assets/)。
271
+ * 运行期只读文件、不做图形转换。
272
+ */
273
+ const PWA_ICON_ROUTES = {
274
+ '/__dsh_bridge__/icon-192.png': 'icon-192.png',
275
+ '/__dsh_bridge__/icon-512.png': 'icon-512.png',
276
+ '/__dsh_bridge__/icon-maskable-192.png': 'icon-maskable-192.png',
277
+ '/__dsh_bridge__/icon-maskable-512.png': 'icon-maskable-512.png',
278
+ // iOS 的约定路径:必须是 PNG(iOS 自己加圆角,所以用圆角版即可)
279
+ '/apple-touch-icon.png': 'apple-touch-icon-180.png',
280
+ '/apple-touch-icon-precomposed.png': 'apple-touch-icon-180.png',
281
+ };
282
+
283
+ /** assets 目录(图标 PNG 所在)。 */
284
+ const PWA_ASSETS_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'assets');
285
+
286
+ /**
287
+ * 读取一个图标 PNG。缺失时返回 null(说明没跑过 generate-icons),由调用方降级。
288
+ * @param {string} fileName assets 下的文件名
289
+ * @returns {Buffer|null}
290
+ */
291
+ function readPwaIconPng(fileName) {
292
+ try {
293
+ return readFileSync(join(PWA_ASSETS_DIR, fileName));
294
+ } catch {
295
+ return null;
296
+ }
297
+ }
273
298
 
274
299
 
275
300
  function encodeBase64Url(value) {
@@ -349,6 +374,58 @@ function normalizeClientIp(raw) {
349
374
  return ip.startsWith('::ffff:') ? ip.slice(7) : ip;
350
375
  }
351
376
 
377
+ /**
378
+ * 判定 TCP 对端是否属于私网地址段(**不含环回**)。
379
+ *
380
+ * 用途:与 `auth.genuineLocal` 组合,决定登录页是否展示「忘记密码」救急指引
381
+ * (其中含宿主内部路径 ~/.dsh/dsh-bridge-gateway/reset-auth)。该指引对能登上
382
+ * 宿主 shell 的人有用,对匿名公网访客只是不必要的信息披露。
383
+ *
384
+ * 为什么环回不在这里判:TCP 对端是 127.0.0.1 有两种截然不同的情形 ——
385
+ * ① 真的在宿主机上打开 127.0.0.1(应展示)
386
+ * ② 经隧道或同机反向代理转发的公网流量(**绝不能展示**)
387
+ * 二者只能靠 Host 头与隧道标头区分,那段判定在 AuthManager.verifyRequest 里
388
+ * 已经做过(`genuineLocal`),这里只负责"非环回的私网直连"这一种情形。
389
+ *
390
+ * 注意:这是**展示用**判定,不参与任何鉴权决策。
391
+ *
392
+ * @param {string} rawIp 原始 remoteAddress(可能带 ::ffff: 前缀)
393
+ * @returns {boolean}
394
+ */
395
+ function isPrivateLanAddress(rawIp) {
396
+ const ip = normalizeClientIp(rawIp);
397
+ if (!ip) return false;
398
+ // IPv6 唯一本地地址 fc00::/7 与链路本地 fe80::/10
399
+ if (/^f[cd][0-9a-f]{2}:/i.test(ip) || /^fe[89ab][0-9a-f]:/i.test(ip)) return true;
400
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip);
401
+ if (!m) return false;
402
+ const [a, b] = [Number(m[1]), Number(m[2])];
403
+ if (a === 10) return true; // 10.0.0.0/8
404
+ if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
405
+ if (a === 192 && b === 168) return true; // 192.168.0.0/16
406
+ if (a === 169 && b === 254) return true; // 169.254.0.0/16 链路本地
407
+ return false;
408
+ }
409
+
410
+ /**
411
+ * 组合出登录页的 `isLocal`(是否本机/局域网来源)—— **纯展示用,不参与鉴权**。
412
+ *
413
+ * 两个来源缺一不可:
414
+ * - `auth.genuineLocal`:AuthManager 判定的"真·宿主机物理访问"
415
+ * (环回 + 非隧道 + Host 指向本机)。隧道与同机反向代理虽然 TCP 对端也是
416
+ * 127.0.0.1,但这里会判为 false —— 这正是不能只用 IP 判断的原因。
417
+ * - `isPrivateLanAddress`:非环回的私网直连(192.168/10/172.16-31)。
418
+ *
419
+ * 单独成函数是为了可单测:这里判错会导致向匿名公网访客泄露宿主内部路径。
420
+ *
421
+ * @param {{genuineLocal?: boolean}} auth verifyRequest 的返回值
422
+ * @param {string} remoteAddress TCP 对端地址
423
+ * @returns {boolean}
424
+ */
425
+ function resolveLoginPageIsLocal(auth, remoteAddress) {
426
+ return Boolean(auth?.genuineLocal) || isPrivateLanAddress(remoteAddress);
427
+ }
428
+
352
429
  /** 网关数据目录(与 config.json 同处) */
353
430
  function gatewayDataDir() {
354
431
  return dataDir();
@@ -430,11 +507,26 @@ class ProxyServer {
430
507
  res.end(PWA_MANIFEST);
431
508
  return;
432
509
  }
433
- if (pathname === '/__dsh_bridge__/pwa-icon.svg' || pathname === '/apple-touch-icon.png') {
434
- res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
435
- res.end(PWA_ICON_SVG);
436
- return;
437
- }
510
+ // 主屏图标:PNG 优先(HarmonyOS / Android / iOS 都只认 PNG),
511
+ // 资源缺失时降级到内联 SVG,至少不是 404。
512
+ if (PWA_ICON_ROUTES[pathname]) {
513
+ const png = readPwaIconPng(PWA_ICON_ROUTES[pathname]);
514
+ if (png) {
515
+ res.writeHead(200, {
516
+ 'Content-Type': 'image/png',
517
+ 'Content-Length': png.length,
518
+ 'Cache-Control': 'public, max-age=86400',
519
+ 'Access-Control-Allow-Origin': '*',
520
+ });
521
+ res.end(png);
522
+ return;
523
+ }
524
+ }
525
+ if (pathname === '/__dsh_bridge__/pwa-icon.svg') {
526
+ res.writeHead(200, { 'Content-Type': 'image/svg+xml; charset=utf-8', 'Access-Control-Allow-Origin': '*' });
527
+ res.end(PWA_ICON_SVG);
528
+ return;
529
+ }
438
530
 
439
531
  // 1. 处理登录 API: POST /__dsh_bridge__/login
440
532
  if (pathname === '/__dsh_bridge__/login' && req.method === 'POST') {
@@ -558,13 +650,47 @@ class ProxyServer {
558
650
  const isHtml = accept.includes('text/html') || (!req.url.startsWith('/api/') && !req.url.includes('.'));
559
651
  if (isHtml) {
560
652
  const clientIp = req.socket?.remoteAddress || '';
561
- const isLocked = this.authManager?.isIpBlocked(clientIp);
653
+ // 锁定状态只查一次:isIpBlocked() 与 lockRemainingMs() 语义等价
654
+ // (后者顺带清理已过期记录),分两次调用会在"锁定刚过期"的瞬间出现竞态
655
+ // —— 拿到 locked=true 却拿到剩余 0,页面会显示一个凭空的倒计时。
656
+ const lockRemainingMs = this.authManager?.lockRemainingMs(clientIp) ?? 0;
657
+ const isLocked = lockRemainingMs > 0;
658
+ // 认证模式与防护范围透传给模板:token_only 下不再渲染必然失败的密码框,
659
+ // 副标题也能说清"这次为什么要求认证"。
660
+ const publicStatus = this.authManager?.getPublicStatus() ?? {};
562
661
  const html = renderLoginPage({
563
- hasPassword: this.authManager?.hasPassword,
662
+ mode: publicStatus.mode,
663
+ scope: publicStatus.scope,
664
+ hasPassword: publicStatus.hasPassword,
564
665
  locked: isLocked,
565
- error: isLocked ? '尝试次数过多,请 60 秒后再试' : '',
666
+ lockRemainingMs,
667
+ isLocal: resolveLoginPageIsLocal(auth, clientIp),
668
+ version: VERSION,
669
+ });
670
+ // 登录页内嵌错误文案、锁定态与认证模式,必须禁缓存 ——
671
+ // 与本仓库对代理 DSH HTML 的处理保持一致(见下方 index.html 注入处)。
672
+ //
673
+ // CSP 是纵深防御:本页全部资源都是内联的(零外部请求,有测试钉死),
674
+ // 因此可以收到极紧。**connect-src 'self' 不能漏** —— 登录靠
675
+ // fetch('/__dsh_bridge__/login') 提交,default-src 'none' 下漏掉它会
676
+ // 直接让认证失效。form-action 'none' 兜住"JS 不可用时原生提交"这条路径。
677
+ res.writeHead(401, {
678
+ 'Content-Type': 'text/html; charset=utf-8',
679
+ 'Cache-Control': 'no-store, must-revalidate',
680
+ 'Pragma': 'no-cache',
681
+ 'X-Content-Type-Options': 'nosniff',
682
+ 'Referrer-Policy': 'no-referrer',
683
+ 'Content-Security-Policy': [
684
+ "default-src 'none'",
685
+ "style-src 'unsafe-inline'",
686
+ "script-src 'unsafe-inline'",
687
+ "connect-src 'self'",
688
+ "img-src 'self' data:",
689
+ "form-action 'none'",
690
+ "base-uri 'none'",
691
+ "frame-ancestors 'none'",
692
+ ].join('; '),
566
693
  });
567
- res.writeHead(401, { 'Content-Type': 'text/html; charset=utf-8' });
568
694
  res.end(html);
569
695
  return;
570
696
  } else {
@@ -612,7 +738,7 @@ class ProxyServer {
612
738
  if (!res.headersSent) {
613
739
  res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
614
740
  res.end(
615
- `dsh-bridge: 无法连接插件 host 服务 (127.0.0.1:${extra.targetPort}) — ${err.message}`
741
+ `dsh-bridge-gateway: 无法连接插件 host 服务 (127.0.0.1:${extra.targetPort}) — ${err.message}`
616
742
  );
617
743
  } else {
618
744
  res.destroy();
@@ -650,7 +776,7 @@ class ProxyServer {
650
776
  proxyReq.on('error', (err) => {
651
777
  this.logger.error('代理请求失败: %s', err.message);
652
778
  if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
653
- res.end(`dsh-bridge: 无法连接 dsh web (127.0.0.1:${this.targetPort}) — ${err.message}`);
779
+ res.end(`dsh-bridge-gateway: 无法连接 dsh web (127.0.0.1:${this.targetPort}) — ${err.message}`);
654
780
  });
655
781
  req.pipe(proxyReq);
656
782
  };
@@ -778,7 +904,7 @@ class ProxyServer {
778
904
  this.server.once('error', reject);
779
905
  this.server.listen(this.localPort, '0.0.0.0', () => {
780
906
  const proto = this.tls ? 'https' : 'http';
781
- this.logger.info('dsh-bridge: %s代理已启动 %s://0.0.0.0:%d -> 127.0.0.1:%d', this.tag, proto, this.localPort, this.targetPort);
907
+ this.logger.info('dsh-bridge-gateway: %s代理已启动 %s://0.0.0.0:%d -> 127.0.0.1:%d', this.tag, proto, this.localPort, this.targetPort);
782
908
  resolve();
783
909
  });
784
910
  });
@@ -823,7 +949,7 @@ class ProxyServer {
823
949
  const conn = this.wsSockets.get(socket);
824
950
  if (conn) conn.closeReason = reason;
825
951
  this.logger?.warn?.(
826
- 'dsh-bridge: %s回收外网长连接 %s(%s,静默 %ds,鉴权方式 %s)',
952
+ 'dsh-bridge-gateway: %s回收外网长连接 %s(%s,静默 %ds,鉴权方式 %s)',
827
953
  this.tag, conn?.ip ?? '未知', reason, Math.round((idleMs ?? 0) / 1000), conn?.via ?? '未知',
828
954
  );
829
955
  try { socket.destroy(); } catch {}
@@ -877,7 +1003,7 @@ class ProxyServer {
877
1003
  kicked += 1;
878
1004
  }
879
1005
  }
880
- this.logger?.warn?.(`dsh-bridge: 管理员强制断开 ${target} 的 ${kicked} 条连接`);
1006
+ this.logger?.warn?.(`dsh-bridge-gateway: 管理员强制断开 ${target} 的 ${kicked} 条连接`);
881
1007
  return { kicked };
882
1008
  }
883
1009
 
@@ -1214,14 +1340,18 @@ class BridgeService {
1214
1340
  await this.onPersist?.({ cloudflared: this.cloudflaredConfig });
1215
1341
  }
1216
1342
 
1217
- // 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制
1343
+ // 重置 Cloudflare 隧道:关闭隧道 + 删除已下载的 cloudflared 二进制。
1344
+ // 当前目录与全部历史目录都要删 —— 迁移失败时二进制可能留在旧目录里,
1345
+ // 只删当前目录会让"重置"看起来没生效(下次仍从旧目录捡到那个二进制)。
1346
+ // 目录一律取自 lib/paths.js,不在这里硬编码字面量(这正是 paths.js 存在的理由)。
1218
1347
  async resetCloudflared() {
1219
1348
  await this.stopCloudflared();
1220
- const binDir = join(this.home ?? join(homedir(), '.dsh-bridge'), 'bin');
1349
+ const binDirs = this.home ? [join(this.home, 'bin')] : cloudflaredBinDirs();
1221
1350
  const candidates = ['cloudflared.exe', 'cloudflared'];
1222
- for (const name of candidates) {
1223
- const p = join(binDir, name);
1224
- try { await unlink(p); } catch {}
1351
+ for (const binDir of binDirs) {
1352
+ for (const binName of candidates) {
1353
+ try { await unlink(join(binDir, binName)); } catch {}
1354
+ }
1225
1355
  }
1226
1356
  this.cloudflaredState = { phase: 'idle', detail: '' };
1227
1357
  }
@@ -1436,7 +1566,7 @@ class BridgeService {
1436
1566
  });
1437
1567
 
1438
1568
  await this.gateway.start();
1439
- this.logger?.info?.('dsh-bridge: 公网直连网关已启用 0.0.0.0:%d (HTTPS + 强制登录门禁)', port);
1569
+ this.logger?.info?.('dsh-bridge-gateway: 公网直连网关已启用 0.0.0.0:%d (HTTPS + 强制登录门禁)', port);
1440
1570
  return this.gateway;
1441
1571
  }
1442
1572
 
@@ -2147,7 +2277,7 @@ function apply(ctx, config = {}) {
2147
2277
  '<meta name="theme-color" content="#1e1e2e">',
2148
2278
  '<link rel="manifest" href="/manifest.webmanifest">',
2149
2279
  '<link rel="icon" type="image/svg+xml" href="/__dsh_bridge__/pwa-icon.svg">',
2150
- '<link rel="apple-touch-icon" href="/__dsh_bridge__/pwa-icon.svg">',
2280
+ '<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">',
2151
2281
  ].join(''),
2152
2282
  },
2153
2283
  {
@@ -2180,24 +2310,24 @@ function apply(ctx, config = {}) {
2180
2310
  try {
2181
2311
  await ctx.loader.remove(id);
2182
2312
  } catch (err) {
2183
- logger.warn('dsh-bridge: unmount %s failed: %s', id, err?.message ?? err);
2313
+ logger.warn('dsh-bridge-gateway: unmount %s failed: %s', id, err?.message ?? err);
2184
2314
  }
2185
2315
  }
2186
2316
  };
2187
2317
  try {
2188
- logger.info('dsh-bridge: mounting directory picker (browse backend + web surface)');
2318
+ logger.info('dsh-bridge-gateway: mounting directory picker (browse backend + web surface)');
2189
2319
  for (const name of [DIRECTORY_PICKER_BACKEND, DIRECTORY_PICKER_SURFACE]) {
2190
2320
  ids.push(await ctx.loader.create({ name }));
2191
2321
  }
2192
2322
  } catch (err) {
2193
- logger.error('dsh-bridge: directory picker mount failed: %s', err?.message ?? err);
2323
+ logger.error('dsh-bridge-gateway: directory picker mount failed: %s', err?.message ?? err);
2194
2324
  await unmount();
2195
2325
  return () => {};
2196
2326
  }
2197
2327
  return unmount;
2198
- }, 'dsh-bridge: directory picker (browse backend + surface)');
2328
+ }, 'dsh-bridge-gateway: directory picker (browse backend + surface)');
2199
2329
  } else {
2200
- logger.warn('dsh-bridge: loader unavailable, directory picker left to DSH native composition');
2330
+ logger.warn('dsh-bridge-gateway: loader unavailable, directory picker left to DSH native composition');
2201
2331
  }
2202
2332
 
2203
2333
  const proxyPort = config.port ?? 3082;
@@ -2262,7 +2392,7 @@ function apply(ctx, config = {}) {
2262
2392
  const stored = await loadConfig();
2263
2393
  delete stored.auth;
2264
2394
  await saveConfig(stored);
2265
- logger.warn('dsh-bridge: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
2395
+ logger.warn('dsh-bridge-gateway: [保命救急] 检测到 reset-auth 标记文件,已成功重置所有访问密码与安全策略!');
2266
2396
  } catch {}
2267
2397
  }
2268
2398
 
@@ -2279,7 +2409,7 @@ function apply(ctx, config = {}) {
2279
2409
  if (stored.auth.adminPasswordSalt) authManager.adminPasswordSalt = stored.auth.adminPasswordSalt;
2280
2410
  if (stored.auth.secretToken) authManager.secretToken = stored.auth.secretToken;
2281
2411
  if (stored.auth.allowLoopback != null) authManager.allowLoopback = Boolean(stored.auth.allowLoopback);
2282
- logger.info('dsh-bridge: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
2412
+ logger.info('dsh-bridge-gateway: loaded saved auth config (enabled=%s, mode=%s, adminPolicy=%s)', authManager.enabled, authManager.mode, authManager.adminPolicy);
2283
2413
  }
2284
2414
  }).catch(() => {});
2285
2415
 
@@ -2305,43 +2435,43 @@ function apply(ctx, config = {}) {
2305
2435
  loadConfig().then(async (stored) => {
2306
2436
  if (stored?.lan?.selectedIp) {
2307
2437
  service.selectedLanIp = stored.lan.selectedIp;
2308
- logger.info('dsh-bridge: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
2438
+ logger.info('dsh-bridge-gateway: loaded saved lan config (selectedIp=%s)', service.selectedLanIp);
2309
2439
  }
2310
2440
  if (stored?.gateway) {
2311
2441
  service.gatewayConfig = stored.gateway;
2312
- logger.info('dsh-bridge: loaded saved gateway config (port=%s, autoStart=%s)', service.gatewayConfig.port ?? 7443, Boolean(service.gatewayConfig.autoStart));
2442
+ logger.info('dsh-bridge-gateway: loaded saved gateway config (port=%s, autoStart=%s)', service.gatewayConfig.port ?? 7443, Boolean(service.gatewayConfig.autoStart));
2313
2443
  if (service.gatewayConfig.autoStart) {
2314
- logger.info('dsh-bridge: auto-starting public direct gateway...');
2444
+ logger.info('dsh-bridge-gateway: auto-starting public direct gateway...');
2315
2445
  service.startGateway().catch((err) => {
2316
- logger.error('dsh-bridge: gateway auto-start failed: %s', err?.message ?? err);
2446
+ logger.error('dsh-bridge-gateway: gateway auto-start failed: %s', err?.message ?? err);
2317
2447
  });
2318
2448
  }
2319
2449
  }
2320
2450
  if (stored?.cloudflared) {
2321
2451
  service.cloudflaredConfig = stored.cloudflared;
2322
- logger.info('dsh-bridge: loaded saved cloudflared config (autoStart=%s, tokenConfigured=%s)', Boolean(service.cloudflaredConfig.autoStart), Boolean(service.cloudflaredConfig.token));
2452
+ logger.info('dsh-bridge-gateway: loaded saved cloudflared config (autoStart=%s, tokenConfigured=%s)', Boolean(service.cloudflaredConfig.autoStart), Boolean(service.cloudflaredConfig.token));
2323
2453
  if (service.cloudflaredConfig.autoStart) {
2324
- logger.info('dsh-bridge: auto-starting cloudflared tunnel...');
2454
+ logger.info('dsh-bridge-gateway: auto-starting cloudflared tunnel...');
2325
2455
  service.startCloudflared({ autoStart: true }).catch((err) => {
2326
- logger.error('dsh-bridge: cloudflared auto-start failed: %s', err?.message ?? err);
2456
+ logger.error('dsh-bridge-gateway: cloudflared auto-start failed: %s', err?.message ?? err);
2327
2457
  });
2328
2458
  }
2329
2459
  }
2330
2460
 
2331
2461
  if (stored?.customTunnel) {
2332
2462
  service.customTunnelConfig = stored.customTunnel;
2333
- logger.info('dsh-bridge: loaded saved custom tunnel config (autoStart=%s)', Boolean(service.customTunnelConfig.autoStart));
2463
+ logger.info('dsh-bridge-gateway: loaded saved custom tunnel config (autoStart=%s)', Boolean(service.customTunnelConfig.autoStart));
2334
2464
  if (service.customTunnelConfig.autoStart && service.customTunnelConfig.serverUrl) {
2335
- logger.info('dsh-bridge: auto-starting custom tunnel...');
2465
+ logger.info('dsh-bridge-gateway: auto-starting custom tunnel...');
2336
2466
  service.startCustomTunnel({ autoStart: true }).catch((err) => {
2337
- logger.error('dsh-bridge: custom tunnel auto-start failed: %s', err?.message ?? err);
2467
+ logger.error('dsh-bridge-gateway: custom tunnel auto-start failed: %s', err?.message ?? err);
2338
2468
  });
2339
2469
  }
2340
2470
  }
2341
2471
 
2342
2472
  if (stored?.tailscale?.url) {
2343
2473
  service.tailscaleConfig = stored.tailscale;
2344
- logger.info('dsh-bridge: loaded saved tailscale url (%s)', service.tailscaleConfig.url);
2474
+ logger.info('dsh-bridge-gateway: loaded saved tailscale url (%s)', service.tailscaleConfig.url);
2345
2475
  }
2346
2476
  }).catch(() => {});
2347
2477
 
@@ -2353,7 +2483,7 @@ function apply(ctx, config = {}) {
2353
2483
  const timer = setInterval(() => { void service.refreshTailnet(); }, TAILNET_TTL_MS);
2354
2484
  if (timer.unref) timer.unref();
2355
2485
  return () => clearInterval(timer);
2356
- }, 'dsh-bridge: tailnet probe refresh');
2486
+ }, 'dsh-bridge-gateway: tailnet probe refresh');
2357
2487
 
2358
2488
  // 平台管理器:注册/协调所有 IM 平台适配器
2359
2489
  const platformManager = new PlatformManager({ logger });
@@ -2426,7 +2556,7 @@ function apply(ctx, config = {}) {
2426
2556
  try {
2427
2557
  if (cfg.activeSessionId) {
2428
2558
  wechat.node.activeSessionId = cfg.activeSessionId;
2429
- logger.info('dsh-bridge: restored wechat active session: %s', cfg.activeSessionId);
2559
+ logger.info('dsh-bridge-gateway: restored wechat active session: %s', cfg.activeSessionId);
2430
2560
  } else {
2431
2561
  await wechat.node._pickDefaultSession().catch(() => {});
2432
2562
  }
@@ -2443,9 +2573,9 @@ function apply(ctx, config = {}) {
2443
2573
  accountId: cfg.accountId,
2444
2574
  baseUrl: cfg.baseUrl,
2445
2575
  });
2446
- logger.info('dsh-bridge: loaded saved wechat bot config, starting gateway');
2576
+ logger.info('dsh-bridge-gateway: loaded saved wechat bot config, starting gateway');
2447
2577
  await wechat.start().catch((err) => {
2448
- logger.error('dsh-bridge: wechat auto-start failed: %s', err?.message ?? err);
2578
+ logger.error('dsh-bridge-gateway: wechat auto-start failed: %s', err?.message ?? err);
2449
2579
  });
2450
2580
  }
2451
2581
  }
@@ -2465,7 +2595,7 @@ function apply(ctx, config = {}) {
2465
2595
  try {
2466
2596
  if (cfg.activeSessionId) {
2467
2597
  qq.node.activeSessionId = cfg.activeSessionId;
2468
- logger.info('dsh-bridge: restored qq active session: %s', cfg.activeSessionId);
2598
+ logger.info('dsh-bridge-gateway: restored qq active session: %s', cfg.activeSessionId);
2469
2599
  } else {
2470
2600
  await qq.node._pickDefaultSession().catch(() => {});
2471
2601
  }
@@ -2485,9 +2615,9 @@ function apply(ctx, config = {}) {
2485
2615
  gatewayUrl: cfg.gatewayUrl,
2486
2616
  accountId: cfg.accountId,
2487
2617
  });
2488
- logger.info('dsh-bridge: loaded saved qq bot config, starting gateway');
2618
+ logger.info('dsh-bridge-gateway: loaded saved qq bot config, starting gateway');
2489
2619
  await qq.start().catch((err) => {
2490
- logger.error('dsh-bridge: qq auto-start failed: %s', err?.message ?? err);
2620
+ logger.error('dsh-bridge-gateway: qq auto-start failed: %s', err?.message ?? err);
2491
2621
  });
2492
2622
  }
2493
2623
  }
@@ -2507,7 +2637,7 @@ function apply(ctx, config = {}) {
2507
2637
  try {
2508
2638
  if (cfg.activeSessionId) {
2509
2639
  feishu.node.activeSessionId = cfg.activeSessionId;
2510
- logger.info('dsh-bridge: restored feishu active session: %s', cfg.activeSessionId);
2640
+ logger.info('dsh-bridge-gateway: restored feishu active session: %s', cfg.activeSessionId);
2511
2641
  } else {
2512
2642
  await feishu.node._pickDefaultSession().catch(() => {});
2513
2643
  }
@@ -2524,9 +2654,9 @@ function apply(ctx, config = {}) {
2524
2654
  appSecret: cfg.appSecret,
2525
2655
  domain: cfg.domain || 'feishu',
2526
2656
  });
2527
- logger.info('dsh-bridge: loaded saved feishu bot config, starting gateway');
2657
+ logger.info('dsh-bridge-gateway: loaded saved feishu bot config, starting gateway');
2528
2658
  await feishu.start().catch((err) => {
2529
- logger.error('dsh-bridge: feishu auto-start failed: %s', err?.message ?? err);
2659
+ logger.error('dsh-bridge-gateway: feishu auto-start failed: %s', err?.message ?? err);
2530
2660
  });
2531
2661
  }
2532
2662
  }
@@ -2546,7 +2676,7 @@ function apply(ctx, config = {}) {
2546
2676
  try {
2547
2677
  if (cfg.activeSessionId) {
2548
2678
  telegram.node.activeSessionId = cfg.activeSessionId;
2549
- logger.info('dsh-bridge: restored telegram active session: %s', cfg.activeSessionId);
2679
+ logger.info('dsh-bridge-gateway: restored telegram active session: %s', cfg.activeSessionId);
2550
2680
  } else {
2551
2681
  await telegram.node._pickDefaultSession().catch(() => {});
2552
2682
  }
@@ -2562,9 +2692,9 @@ function apply(ctx, config = {}) {
2562
2692
  botToken: cfg.botToken,
2563
2693
  proxy: cfg.proxy || '',
2564
2694
  });
2565
- logger.info('dsh-bridge: loaded saved telegram bot config, starting gateway');
2695
+ logger.info('dsh-bridge-gateway: loaded saved telegram bot config, starting gateway');
2566
2696
  await telegram.start().catch((err) => {
2567
- logger.error('dsh-bridge: telegram auto-start failed: %s', err?.message ?? err);
2697
+ logger.error('dsh-bridge-gateway: telegram auto-start failed: %s', err?.message ?? err);
2568
2698
  });
2569
2699
  }
2570
2700
  }
@@ -2670,11 +2800,11 @@ function apply(ctx, config = {}) {
2670
2800
  .catch(() => false)
2671
2801
  .then((lanAutoStart) => {
2672
2802
  if (!lanAutoStart) {
2673
- logger.info('dsh-bridge: LAN proxy auto-start disabled (default off; enable via lan.autoStart)');
2803
+ logger.info('dsh-bridge-gateway: LAN proxy auto-start disabled (default off; enable via lan.autoStart)');
2674
2804
  return;
2675
2805
  }
2676
2806
  void service.startProxy().catch((err) => {
2677
- logger.error('dsh-bridge: proxy start failed: %s', err?.message ?? err);
2807
+ logger.error('dsh-bridge-gateway: proxy start failed: %s', err?.message ?? err);
2678
2808
  });
2679
2809
  });
2680
2810
 
@@ -2687,7 +2817,7 @@ function apply(ctx, config = {}) {
2687
2817
  platformManager.dispose();
2688
2818
  authManager.dispose();
2689
2819
  await service.dispose();
2690
- }, 'dsh-bridge: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
2820
+ }, 'dsh-bridge-gateway: stop wechat, qq, feishu, telegram, proxy, auth and tunnels');
2691
2821
  }
2692
2822
 
2693
- export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4, listAllLanIPv4, LEGACY_BROWSER_POLYFILL };
2823
+ export { name, inject, apply, ProxyServer, BridgeService, selectLanIPv4, listAllLanIPv4, LEGACY_BROWSER_POLYFILL, resolveLoginPageIsLocal };