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
package/client/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // dsh-bridge 客户端插件:设置页「远程访问」面板
1
+ // dsh-bridge-gateway 客户端插件:设置页「远程访问」面板
2
2
 
3
3
  // 兼容非 HTTPS 环境(如手机局域网 HTTP 访问):为非安全上下文补齐 crypto.randomUUID
4
4
  if (typeof window !== 'undefined') {
@@ -2295,7 +2295,7 @@ function BackupRestoreWidget({ rpcCall, onUpdate }) {
2295
2295
  const now = new Date();
2296
2296
  const dateStr = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}`;
2297
2297
  a.href = url;
2298
- a.download = `dsh-bridge-backup-${dateStr}.json`;
2298
+ a.download = `dsh-bridge-gateway-backup-${dateStr}.json`;
2299
2299
  document.body.appendChild(a);
2300
2300
  a.click();
2301
2301
  document.body.removeChild(a);
@@ -3496,12 +3496,216 @@ function BridgePanel({ rpcCall }) {
3496
3496
 
3497
3497
  // ---- 移动端自适应与触控交互增强 ----
3498
3498
 
3499
+ /**
3500
+ * 真机诊断叠加层(`?dsh-mobile=diag`)。
3501
+ *
3502
+ * 为什么需要它:用户真机上稳定复现「滚不到最新会话」,而我在仿真视口里用同样大的
3503
+ * 会话(28 轮 681 步)怎么都复现不出来。与其继续按猜测改代码、让用户一轮轮试,
3504
+ * 不如把关键指标直接画在屏幕上让用户截图 —— 拿到的就是那台设备的真实数字。
3505
+ *
3506
+ * 只读:不改任何布局,pointer-events:none 不挡操作。
3507
+ */
3508
+ function setupMobileDiag() {
3509
+ if (document.getElementById('dsh-mobile-diag')) return;
3510
+ const box = document.createElement('div');
3511
+ box.id = 'dsh-mobile-diag';
3512
+ box.style.cssText = [
3513
+ 'position:fixed', 'left:0', 'right:0', 'top:0', 'z-index:2147483647',
3514
+ 'background:rgba(0,0,0,.86)', 'color:#0f0', 'pointer-events:none',
3515
+ 'font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace',
3516
+ 'padding:5px 7px', 'white-space:pre-wrap', 'word-break:break-all',
3517
+ ].join(';');
3518
+ document.body.appendChild(box);
3519
+
3520
+ const tick = () => {
3521
+ const sb = document.querySelector('[class*="wSkVaW_scrollBody"]');
3522
+ const seat = document.querySelector('[class*="wSkVaW_composerSeat"]');
3523
+ const vv = window.visualViewport;
3524
+ const L = [];
3525
+ if (sb) {
3526
+ const r = sb.getBoundingClientRect();
3527
+ const max = sb.scrollHeight - sb.clientHeight;
3528
+ L.push('scrollBody top=' + Math.round(r.top) + ' h=' + Math.round(r.height)
3529
+ + ' pad=' + getComputedStyle(sb).paddingBottom);
3530
+ L.push(' scrollTop=' + Math.round(sb.scrollTop) + ' max=' + max
3531
+ + ' atBottom=' + (sb.scrollTop >= max - 2));
3532
+ L.push(' scrollH=' + sb.scrollHeight + ' clientH=' + sb.clientHeight
3533
+ + ' children=' + sb.children.length);
3534
+ // 虚拟列表渲染出的条目数
3535
+ const idx = sb.querySelectorAll('[data-index]');
3536
+ L.push(' virtItems=' + idx.length);
3537
+ } else {
3538
+ L.push('scrollBody: NOT FOUND');
3539
+ }
3540
+ if (seat) {
3541
+ const r = seat.getBoundingClientRect();
3542
+ L.push('composer pos=' + getComputedStyle(seat).position
3543
+ + ' top=' + Math.round(r.top) + ' h=' + Math.round(r.height));
3544
+ }
3545
+ L.push('inner=' + window.innerHeight
3546
+ + ' vv=' + (vv ? Math.round(vv.height) : '-')
3547
+ + ' vvTop=' + (vv ? Math.round(vv.offsetTop) : '-')
3548
+ + ' dpr=' + window.devicePixelRatio + ' w=' + window.innerWidth);
3549
+ L.push('body=' + Array.from(document.body.classList).join('.'));
3550
+ box.textContent = L.join('\n');
3551
+ };
3552
+ tick();
3553
+ window.setInterval(tick, 500);
3554
+ window.addEventListener('scroll', tick, true);
3555
+ window.addEventListener('resize', tick);
3556
+ }
3557
+
3558
+
3559
+ /**
3560
+ * 真机二分用的「可疑规则」分组。
3561
+ *
3562
+ * 背景:真机上移动层开着时 DSH 只渲染约 4 条消息(scrollH=795),关掉则渲染完整历史
3563
+ * (scrollH=15458)。`no-new`(关掉本轮新增规则)仍坏 → 元凶在主样式表里。
3564
+ * 主表中能影响会话内容区结构的只有下面三组,各自一个开关:
3565
+ * ?dsh-mobile=no-frame → 关掉主框架规则(height:100dvh / overflow:hidden 等)
3566
+ * ?dsh-mobile=no-centercol → 关掉中间列规则(display:flex / height:100%)
3567
+ * ?dsh-mobile=no-pad → 关掉滚动区的两条 padding-bottom
3568
+ * 多个开关可叠加:?dsh-mobile=no-frame,no-pad
3569
+ */
3570
+ const SUSPECT_CSS = {
3571
+ // _frame 规则 —— 真机二分结论(2026-10-17):
3572
+ // ?dsh-mobile=no-frameSize (去掉 width:100vw + height:100dvh)→ scrollH 恢复正常
3573
+ // ?dsh-mobile=no-frameOverflow(去掉 overflow:hidden) → scrollH 恢复正常
3574
+ // 两个属性各自都能触发,说明是「固定高度 + 裁剪」这个组合在作怪:
3575
+ // height:100dvh 把主框架钉成固定高、overflow:hidden 又让它成为裁剪容器,
3576
+ // DSH 的布局测量因此算错,导致只渲染约 4 条消息(scrollH≈795,正常应 10 万+)。
3577
+ // 故**这两个属性组已删除**,不再注入。只保留 padding-top(本规则的目的:
3578
+ // 为固定顶栏腾出空间)与 display/flex-direction/position(抽屉化的前提)。
3579
+ // 注意 grid-template-columns:1fr 也已去掉 —— 它是 grid 属性,在 flex 容器里无效。
3580
+ // padding-top / box-sizing 是这条规则的**目的**(为固定顶栏腾空间),任何分组都保留
3581
+ frameBase: `
3582
+ div[class*="_frame"] {
3583
+ padding-top: var(--dsh-mobile-header-h) !important;
3584
+ margin: 0 !important;
3585
+ box-sizing: border-box !important;
3586
+ }`,
3587
+ frameLayout: `
3588
+ div[class*="_frame"] {
3589
+ display: flex !important;
3590
+ flex-direction: column !important;
3591
+ position: relative !important;
3592
+ }`,
3593
+ centercol: ` div[class*="_centerCol"] {
3594
+ flex: 1 1 100% !important;
3595
+ width: 100% !important;
3596
+ min-width: 0 !important;
3597
+ max-width: 100% !important;
3598
+ display: flex !important;
3599
+ height: 100% !important;
3600
+ }`,
3601
+ pad: ` div[class*="wSkVaW_scrollBody"] {
3602
+ padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
3603
+ }
3604
+ div[class*="wSkVaW_scrollBody"] {
3605
+ padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)) !important;
3606
+ }`,
3607
+ };
3608
+
3609
+ /** 注入未被开关关掉的可疑规则组。 */
3610
+ function injectSuspectStyles(disabled) {
3611
+ const parts = Object.entries(SUSPECT_CSS)
3612
+ .filter(([k]) => !disabled.has(k))
3613
+ .map(([, v]) => v);
3614
+ if (parts.length === 0) return;
3615
+ if (document.getElementById('dsh-bridge-gateway-mobile-styles-suspect')) return;
3616
+ const style = document.createElement('style');
3617
+ style.id = 'dsh-bridge-gateway-mobile-styles-suspect';
3618
+ // **必须包回媒体查询**:这些规则原本都在 @media (max-width: 768px) 里,
3619
+ // 剪出来时若丢掉媒体查询,它们会在所有宽度生效 —— 实测会把桌面布局压塌
3620
+ // (整个会话内容被挤成底部一条)。这是拆表时踩的坑。
3621
+ style.textContent = '@media (max-width: 768px) {\n' + parts.join('\n') + '\n}';
3622
+ document.head.appendChild(style);
3623
+ }
3624
+
3625
+ /**
3626
+ * 本轮(2026-10-16)新增/改动的移动端规则,**独立成一个样式表**。
3627
+ *
3628
+ * 为什么要拆出来:真机上出现「滚不到最新会话」,`?dsh-mobile=off` 能消除,
3629
+ * 说明问题在本插件的移动层里;但 `no-js` 会让会话选择 UI 失效、没法二分。
3630
+ * 把本轮的规则单独成表后,可以用 `?dsh-mobile=no-new` 只关掉这一部分,
3631
+ * 其余移动层(抽屉化、宽度约束等承重布局)完整保留 —— 界面仍可用,才能测滚动。
3632
+ *
3633
+ * 另一个好处:将来再二分时,把可疑规则挪到这里即可,不必再动主表。
3634
+ */
3635
+ const NEW_MOBILE_CSS = `
3636
+ /* #2 面板打开时顶栏让位(body.dsh-panel-open 由 setupPanelAwareness 打上)。
3637
+ 实测:渠道中心面板标题在 y19,而顶栏占 y0-40 → 不改就是标题被盖住。
3638
+ 不用 :has() 是因为构建目标是 chrome100(:has() 要 105+)。 */
3639
+ body.dsh-panel-open .dsh-mobile-app-header {
3640
+ display: none !important;
3641
+ }
3642
+
3643
+ /* #6 双层头压缩:DSH 自带 min-height:76px,只改 padding 压不下去 —— 实测
3644
+ 76px 一直生效、titleRow 被拉伸填满(69px),两层头合计吃掉 128px(屏幕 15%)。
3645
+ 显式清零 + 把 padding 收到 2px,实测内容区 +51px。 */
3646
+ div[class*="_centerCol"] header,
3647
+ header[class*="wSkVaW_header"] {
3648
+ min-height: 0 !important;
3649
+ padding: 2px 16px !important;
3650
+ }
3651
+ div[class*="wSkVaW_titleRow"] {
3652
+ min-height: 0 !important;
3653
+ height: 32px !important;
3654
+ }
3655
+
3656
+ /* #4-C 表格横向滚动:table 元素的 overflow 在 CSS 里是**被忽略**的,必须配
3657
+ display:block 才会变成可滚块盒(插件原来只写了 overflow-x:auto,所以无效)。 */
3658
+ div[class*="wSkVaW_scrollBody"] table {
3659
+ display: block !important;
3660
+ max-width: 100% !important;
3661
+ width: 100% !important;
3662
+ overflow-x: auto !important;
3663
+ -webkit-overflow-scrolling: touch !important;
3664
+ }
3665
+
3666
+ /* #4-E 轨迹里的工具调用参数换行:该列内容宽 3049px 却只有 180px 可见(17×),
3667
+ 横滑要划 17 屏、实际读不了。overflow-wrap:anywhere 是必须的 —— bash 命令里
3668
+ 常有超长无空格 token。 */
3669
+ div[class*="wSkVaW_scrollBody"] [class*="resultRequest"] {
3670
+ white-space: normal !important;
3671
+ overflow-wrap: anywhere !important;
3672
+ word-break: break-word !important;
3673
+ text-overflow: clip !important;
3674
+ overflow: visible !important;
3675
+ }
3676
+
3677
+ /* #7 下拉改 body 级 portal(position:fixed)。为什么不能 absolute 挂在按钮里:
3678
+ 祖先链 titleRow 与 wSkVaW_root 都是 overflow:hidden(titleRow 那条还是插件
3679
+ 自己为了省略号设的)→ 下拉落在头部下方 y78 会被**整块裁掉**,实测中心点命中
3680
+ 的是消息文本。z-index 再高也没用,裁剪发生在层叠之前。
3681
+ 注意:**不能**写 top/left 的 !important —— 那会压过 positionTabDropdown()
3682
+ 写的内联坐标,把下拉钉在左上角。 */
3683
+ .dsh-mobile-tab-dropdown {
3684
+ position: fixed !important;
3685
+ }
3686
+ `;
3687
+
3688
+ /**
3689
+ * 注入本轮新增的样式表。**必须晚于** injectMobileStyles(),靠"同优先级下后者胜"
3690
+ * 覆盖主表里的原值。
3691
+ * @returns {void}
3692
+ */
3693
+ function injectNewMobileStyles() {
3694
+ if (document.getElementById('dsh-bridge-gateway-mobile-styles-new')) return;
3695
+ const style = document.createElement('style');
3696
+ style.id = 'dsh-bridge-gateway-mobile-styles-new';
3697
+ // 同上:本表规则原本都在 @media (max-width: 768px) 内,必须包回来,
3698
+ // 否则宽屏会被这些移动端覆盖规则污染(例如原生头被强制 padding:2px 16px)。
3699
+ style.textContent = '@media (max-width: 768px) {\n' + NEW_MOBILE_CSS + '\n}';
3700
+ document.head.appendChild(style);
3701
+ }
3702
+
3499
3703
  function injectMobileStyles() {
3500
3704
  if (typeof document === 'undefined') return;
3501
- if (document.getElementById('dsh-bridge-mobile-styles')) return;
3705
+ if (document.getElementById('dsh-bridge-gateway-mobile-styles')) return;
3502
3706
 
3503
3707
  const style = document.createElement('style');
3504
- style.id = 'dsh-bridge-mobile-styles';
3708
+ style.id = 'dsh-bridge-gateway-mobile-styles';
3505
3709
  style.textContent = `
3506
3710
  /* DSH Bridge 隐藏 Tab 栏原生滚动条并保持平滑滑动 */
3507
3711
  .dsh-tabbar-container {
@@ -3516,25 +3720,16 @@ function injectMobileStyles() {
3516
3720
 
3517
3721
  /* DSH Bridge 移动端自适应与触控交互增强样式 */
3518
3722
  :root {
3519
- --dsh-mobile-header-h: 52px;
3723
+ /* 顶栏高度 52 -> 40(2026-10-16 真机反馈:"上面高度太高了,把对话框的空间压缩了")。
3724
+ 40px 仍能容纳 32px 的 ☰/⊕ 按钮与一行会话名。 */
3725
+ --dsh-mobile-header-h: 40px;
3520
3726
  --dsh-mobile-safe-top: env(safe-area-inset-top, 0px);
3521
3727
  --dsh-mobile-safe-bottom: env(safe-area-inset-bottom, 0px);
3522
3728
  }
3523
3729
 
3524
3730
  @media (max-width: 768px) {
3525
3731
  /* 1. 主框架为 Header 腾出顶部空间 */
3526
- div[class*="_frame"] {
3527
- display: flex !important;
3528
- flex-direction: column !important;
3529
- width: 100vw !important;
3530
- height: 100dvh !important;
3531
- margin: 0 !important;
3532
- padding-top: var(--dsh-mobile-header-h) !important;
3533
- position: relative !important;
3534
- grid-template-columns: 1fr !important;
3535
- overflow: hidden !important;
3536
- box-sizing: border-box !important;
3537
- }
3732
+
3538
3733
 
3539
3734
  /* 2. 顶部原生导航条:100% 还原 DeepSeek App (左侧双横线,右侧(+),中间留白,无多余设置按钮) */
3540
3735
  .dsh-mobile-app-header {
@@ -3614,14 +3809,7 @@ function injectMobileStyles() {
3614
3809
  }
3615
3810
 
3616
3811
  /* 3. 中间主内容区与输入框 */
3617
- div[class*="_centerCol"] {
3618
- flex: 1 1 100% !important;
3619
- width: 100% !important;
3620
- min-width: 0 !important;
3621
- max-width: 100% !important;
3622
- display: flex !important;
3623
- height: 100% !important;
3624
- }
3812
+
3625
3813
 
3626
3814
  div[class*="_detailsCol"],
3627
3815
  div[class*="toggleCluster"],
@@ -4012,9 +4200,7 @@ function injectMobileStyles() {
4012
4200
  }
4013
4201
 
4014
4202
  /* 输入框底座:DeepSeek App 居中及底部固定 */
4015
- div[class*="wSkVaW_scrollBody"] {
4016
- padding-bottom: max(16px, env(safe-area-inset-bottom)) !important;
4017
- }
4203
+
4018
4204
 
4019
4205
  /* 输入卡片:DeepSeek App 圆角大胶囊造型 */
4020
4206
  div[class*="uV2eYG_card"] {
@@ -4208,11 +4394,20 @@ function injectMobileStyles() {
4208
4394
  }
4209
4395
 
4210
4396
  /* 输入框贴底:scrollBody 的 16px 底部衬距是空隙来源,收掉只留安全区;
4211
- 再垫 6px 让统计药丸完整可见 */
4212
- div[class*="wSkVaW_scrollBody"] {
4213
- padding-bottom: calc(6px + env(safe-area-inset-bottom, 0px)) !important;
4214
- }
4215
- div[class*="wSkVaW_composerStack"] {
4397
+ 再垫 6px 让统计药丸完整可见。
4398
+
4399
+ ⚠️ **不要**再试图在这里加 --dsh-composer-height、也不要给 composerSeat 加
4400
+ position:fixed(2026-10-16 两次尝试,均已回退)。原因:DSH 的滚动锚定
4401
+ (dsh-client-ui-conversation)是**按 DOM 流内位置**遍历祖先算偏移来修正
4402
+ scrollTop 的;把 composer 摘出文档流、或给滚动内容加 padding,锚定就算错,
4403
+ 真机症状是「进去停在很久以前的某条,点『加载更早』后再也回不到最新」。
4404
+ 这个遮挡是 DSH 对话视图 sticky 吸底设计的固有代价;DSH 另有"覆盖层模式"
4405
+ (:has([data-conversation-composer-overlay]):composerSeat 转 absolute、
4406
+ viewArea 自己滚),但那是 **dsh-client-ui-trajectory(轨迹视图)** 在用的,
4407
+ 对话视图没有开。要根治得走 DSH 侧,不是插件 CSS 能补的。 */
4408
+
4409
+
4410
+ div[class*="wSkVaW_composerStack"] {
4216
4411
  padding-bottom: env(safe-area-inset-bottom, 0px) !important;
4217
4412
  }
4218
4413
 
@@ -4519,15 +4714,15 @@ function injectMobileStyles() {
4519
4714
  box-sizing: border-box !important;
4520
4715
  }
4521
4716
 
4522
- /* 7. 代码块、表格与徽标自适应 */
4523
- pre, code, pre > code, table {
4717
+ /* 7. 代码块与徽标自适应 */
4718
+ pre, code, pre > code {
4524
4719
  max-width: 100% !important;
4525
4720
  overflow-x: auto !important;
4526
4721
  -webkit-overflow-scrolling: touch !important;
4527
4722
  font-size: 12.5px !important;
4528
4723
  }
4529
4724
 
4530
- /* 状态徽标与药丸按钮永不折字 */
4725
+ /* 状态徽标与药丸按钮永不折字 */
4531
4726
  span[style*="border-radius: 999"],
4532
4727
  span[style*="border-radius:999"] {
4533
4728
  white-space: nowrap !important;
@@ -4776,10 +4971,125 @@ function createUpstreamBridge(ctx) {
4776
4971
  // ---- 移动端自适应与触控交互增强 ----
4777
4972
  // 上游 DOM 依赖统一经上方契约层(U.*)访问;见 UPSTREAM_ARIA / UPSTREAM_SEL。
4778
4973
 
4974
+ /**
4975
+ * 面板打开时让顶栏让位(#2,2026-10-16)。
4976
+ *
4977
+ * 症状:打开 dsh-chanhub 的「渠道中心」后,面板标题与插件顶栏的会话名叠在同一位置。
4978
+ * 实测:插件顶栏是 position:fixed + z-index:9998,而面板根 .dshc-center-backdrop
4979
+ * 是全屏 fixed、z-index:80;面板标题「渠道中心」落在 y19,正好在顶栏的 y0-40 之内
4980
+ * —— 顶栏把面板标题盖住了。
4981
+ *
4982
+ * 做法:面板可见时给 <body> 打 dsh-panel-open,CSS 据此隐藏顶栏。
4983
+ * **不用 CSS :has()**:本插件构建目标是 chrome100,:has() 需要 chrome105+
4984
+ * (文件里现存的一处 :has() 属于历史遗留,不该再扩大它的使用面)。
4985
+ *
4986
+ * @param {object} ctx cordis 上下文
4987
+ * @returns {() => void} 解绑函数
4988
+ */
4989
+ function setupPanelAwareness(ctx) {
4990
+ if (typeof document === 'undefined') return () => {};
4991
+ // 已知的面板根。加新面板时在这里补一行即可。
4992
+ const PANEL_SELECTORS = [
4993
+ '.dshc-center-backdrop', // dsh-chanhub 渠道中心
4994
+ '[data-dsh-panel-host]', // dsh-better-sidebar(注意:常驻 DOM,需判可见)
4995
+ '[data-dsh-better-sidebar]',
4996
+ ];
4997
+
4998
+ const isPanelOpen = () => PANEL_SELECTORS.some((sel) => {
4999
+ const el = document.querySelector(sel);
5000
+ if (!el) return false;
5001
+ const cs = getComputedStyle(el);
5002
+ if (cs.display === 'none' || cs.visibility === 'hidden') return false;
5003
+ const r = el.getBoundingClientRect();
5004
+ return r.width > 0 && r.height > 0;
5005
+ });
5006
+
5007
+ const sync = () => {
5008
+ const open = isPanelOpen();
5009
+ if (document.body.classList.contains('dsh-panel-open') !== open) {
5010
+ document.body.classList.toggle('dsh-panel-open', open);
5011
+ }
5012
+ };
5013
+
5014
+ sync();
5015
+ const observer = new MutationObserver(sync);
5016
+ // 只监听 body 的**直接子节点增删**:面板(.dshc-center-backdrop)就是 body 的直接
5017
+ // 子节点(实测 depth=1)。**不要**用 subtree/attributes —— 那是全文档级监听,会话
5018
+ // 流式输出时每秒上千次回调,每次还要 getComputedStyle + getBoundingClientRect,
5019
+ // 会把主线程拖垮(实测症状:滚动卡住、界面与后端不同步)。
5020
+ observer.observe(document.body, { childList: true });
5021
+ const timer = setInterval(sync, 1000);
5022
+
5023
+ const dispose = () => { observer.disconnect(); clearInterval(timer); };
5024
+ ctx?.effect?.(() => dispose);
5025
+ return dispose;
5026
+ }
5027
+
4779
5028
  function setupMobileExperience(rpcCall, ctx) {
4780
5029
  if (typeof document === 'undefined' || typeof window === 'undefined') return;
4781
- injectMobileStyles();
5030
+
5031
+ // ---- 逃生开关(2026-10-16 加)----
5032
+ // 真机上出现过「进去停在很久以前某条、滚不到最新」,而我在仿真视口里用同样大的
5033
+ // 会话也复现不出来。与其一轮轮改代码让你试,不如给一个能**一步二分**的开关:
5034
+ // ?dsh-mobile=off → 完全禁用(样式 + 全部 JS)
5035
+ // ?dsh-mobile=no-js → 只保留 CSS,跳过全部 JS(二分用)
5036
+ // ?dsh-mobile=no-css → 只跑 JS,不注入 CSS(二分用;界面会错乱)
5037
+ // ?dsh-mobile=no-new → 保留移动端布局,只关掉本轮新增/改动的规则(**界面仍可用**)
5038
+ // ?dsh-mobile=no-observer → 关掉全文档 MutationObserver(定位滚动卡死用)
5039
+ // ?dsh-diag=1(独立参数)→ 叠加只读诊断信息;可与 dsh-mobile 任意取值组合,
5040
+ // 用来对比「开/关移动层」两种状态的真实指标
5041
+ // ?dsh-mobile=on → 恢复
5042
+ // 用 localStorage 记住,这样在「添加到桌面」的 PWA(没有地址栏)里也能生效。
5043
+ // 带 off 还坏 → 问题在 DSH 或环境;不带才坏 → 问题在本层。
5044
+ const MOBILE_FLAG_KEY = 'dsh-bridge-gateway:mobile';
5045
+ const flag = new URLSearchParams(window.location.search).get('dsh-mobile');
5046
+ if (flag !== null) {
5047
+ try { window.localStorage.setItem(MOBILE_FLAG_KEY, flag); } catch { /* 隐私模式 */ }
5048
+ }
5049
+ let mobileFlag = 'on';
5050
+ try { mobileFlag = window.localStorage.getItem(MOBILE_FLAG_KEY) || 'on'; } catch { /* 读不到就当开启 */ }
5051
+ document.documentElement.dataset.dshMobile = mobileFlag;
5052
+
5053
+ // 细分开关(同一次会话里就能二分出是哪一半):
5054
+ // no-js → 只注入 CSS,跳过全部 JS
5055
+ // no-css → 只跑 JS,不注入 CSS
5056
+ // 诊断层是**独立参数**(dsh-diag=1),不受 dsh-mobile 取值影响 ——
5057
+ // 这样同一台设备、同一个会话可以分别测「移动层开」与「移动层关」两种状态的指标,
5058
+ // 拿到差值才能定位(我仿真复现不出,只能靠真机的对照数据)。
5059
+ const diagOn = new URLSearchParams(window.location.search).get('dsh-diag') === '1';
5060
+ if (diagOn) window.setTimeout(setupMobileDiag, 1500);
5061
+
5062
+ // 逗号分隔的多开关,例如 no-frame,no-pad
5063
+ const flagSet = new Set(String(mobileFlag).split(',').map((x) => x.trim()).filter(Boolean));
5064
+ const disabledSuspects = new Set(
5065
+ [...flagSet].filter((f) => f.startsWith('no-')).map((f) => f.slice(3)),
5066
+ );
5067
+ // 兼容:no-frame 等价于同时关掉 frameLayout / frameSize / frameOverflow
5068
+ if (disabledSuspects.has('frame')) {
5069
+ for (const k of ['frameLayout', 'frameSize', 'frameOverflow']) disabledSuspects.add(k);
5070
+ }
5071
+
5072
+ const withCss = !flagSet.has('off') && !flagSet.has('no-css');
5073
+ const withJs = !flagSet.has('off') && !flagSet.has('no-js');
5074
+ // no-new:保留承重的移动端布局(抽屉化等),只关掉本轮新增/改动的规则。
5075
+ // 这是唯一能"界面仍可用"地二分本轮改动的开关(no-js 会让会话选择 UI 失效)。
5076
+ const withNew = withCss && !flagSet.has('no-new');
5077
+ // no-observer:关掉那个全文档 MutationObserver(定位「滚动卡死」用)
5078
+ const MOBILE_NO_OBSERVER = flagSet.has('no-observer');
5079
+ if (!withCss && !withJs) {
5080
+ // 整层关掉,但诊断层仍要跑(对照用)
5081
+ if (diagOn) window.setTimeout(setupMobileDiag, 1500);
5082
+ return;
5083
+ }
5084
+
5085
+ if (withCss) {
5086
+ injectMobileStyles();
5087
+ injectSuspectStyles(disabledSuspects);
5088
+ }
5089
+ if (withNew) injectNewMobileStyles();
5090
+ if (!withJs) return;
4782
5091
  const U = createUpstreamBridge(ctx);
5092
+ setupPanelAwareness(ctx);
4783
5093
 
4784
5094
  // 1. 创建顶部 DeepSeek App 风格导航条 (Header: 左侧双横线,中间当前会话标题,右侧(+))
4785
5095
  let header = document.querySelector('.dsh-mobile-app-header');
@@ -4834,18 +5144,66 @@ function setupMobileExperience(rpcCall, ctx) {
4834
5144
  document.body.appendChild(header);
4835
5145
  }
4836
5146
 
5147
+ // ---------------------------------------------------------------------------
5148
+ // 顶栏可见性兜底(2026-10-16)
5149
+ // ---------------------------------------------------------------------------
5150
+ // 这个顶栏只在窄屏出现,而它的**全部样式**都写在 @media (max-width: 768px) 里,
5151
+ // 宽屏只靠末尾一条 `@media (min-width: 769px) { display: none !important }` 藏起来。
5152
+ // 于是存在一个脆弱窗口:样式表晚到、被卸载,或页面处于「重连/热更中途」的中间态时,
5153
+ // 这个无样式的 <header> 会以块级元素落进侧边栏栅格 —— 表现为左下角一堆被裁切的图标
5154
+ // (☰ / 新会话 / ⊕)。已实测复现。
5155
+ //
5156
+ // 这里用内联 display 做兜底,并且**不依赖"CSS 的 !important 压过内联"这种隐式契约**:
5157
+ // 窄屏由 matchMedia 主动把内联值让开,交还给 CSS 的 display:flex。
5158
+ if (header && !header.dataset.dshVisibilityBound) {
5159
+ header.dataset.dshVisibilityBound = '1';
5160
+ // 先无条件隐藏:即使下面 matchMedia 不可用,也绝不会露出无样式块
5161
+ header.style.display = 'none';
5162
+ try {
5163
+ const mq = window.matchMedia('(max-width: 768px)');
5164
+ const syncHeaderVisibility = () => {
5165
+ // 窄屏清空内联值 → 由 CSS 决定(flex);宽屏内联 none → 不依赖 CSS 也在
5166
+ header.style.display = mq.matches ? '' : 'none';
5167
+ };
5168
+ syncHeaderVisibility();
5169
+ if (typeof mq.addEventListener === 'function') mq.addEventListener('change', syncHeaderVisibility);
5170
+ else if (typeof mq.addListener === 'function') mq.addListener(syncHeaderVisibility);
5171
+ } catch { /* 不支持 matchMedia 的极老内核:保持内联隐藏,宁可不显示也不露无样式块 */ }
5172
+ }
5173
+
4837
5174
  // 绑定会话标题实时同步 (切换会话或收到首条回复自动更新)
5175
+ /**
5176
+ * 把当前会话名写进移动端顶栏。
5177
+ *
5178
+ * 历史坑(2026-10-16 修复):旧实现读 `snap.current`,而 **DSH 0.1.7 起
5179
+ * sessions list 快照已不再提供 current 字段** → 恒 undefined → 标题永远停在
5180
+ * "新会话",即使已经打开了一个会话(用户反馈:"会话界面最上面不应该显示新会话,
5181
+ * 应该显示会话名")。因为读不到就静默回落到默认文案,这个 bug 不会报错。
5182
+ *
5183
+ * 正确判据来自 DSH 自己的实现(dsh-client-ui-session 的 publishMain):
5184
+ * 「当前会话」= 快照里 retainedBy.mainView > 0 的那一个;取不到再退回第一个
5185
+ * 非空会话。不依赖 session 作用域插槽,因此不用改动顶栏结构。
5186
+ */
5187
+ const pickCurrentSession = (snap) => {
5188
+ const list = snap?.byId ? Object.values(snap.byId) : [];
5189
+ if (!list.length) return null;
5190
+ const retained = list.find((x) => (x?.retainedBy?.mainView ?? 0) > 0);
5191
+ if (retained) return retained;
5192
+ return list.find((x) => !x?.blank) || null;
5193
+ };
5194
+
4838
5195
  const syncMobileTitle = () => {
4839
5196
  if (!titleEl) titleEl = document.querySelector('.dsh-mobile-header-title');
4840
5197
  if (!titleEl) return;
4841
5198
  const snap = ctx?.sessions?.list?.getSnapshot?.();
4842
5199
  if (!snap) return;
4843
- const cur = snap.current ? snap.byId?.[snap.current] : null;
5200
+ const cur = pickCurrentSession(snap);
4844
5201
  if (!cur || cur.blank) {
4845
5202
  titleEl.innerText = '新会话';
4846
5203
  } else {
4847
5204
  titleEl.innerText = cur.displayTitle || cur.title || '会话';
4848
5205
  }
5206
+ titleEl.title = titleEl.innerText;
4849
5207
  };
4850
5208
 
4851
5209
  syncMobileTitle();
@@ -4890,9 +5248,29 @@ function setupMobileExperience(rpcCall, ctx) {
4890
5248
  });
4891
5249
  };
4892
5250
 
4893
- const panelObserver = new MutationObserver(ensurePanelCloseButton);
4894
- panelObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
4895
- window.addEventListener('resize', ensurePanelCloseButton);
5251
+ // ---- 观察器必须按帧合并(2026-10-17 修)----
5252
+ // 原实现把 ensurePanelCloseButton 直接当 MutationObserver 回调,而监听范围是
5253
+ // **全文档子树 + class 属性**:会话流式输出时 DOM 每秒变上千次,于是
5254
+ // ensurePanelCloseButton 也跑上千次,每次都要 openPanels() + querySelector
5255
+ // (而且它没有廉价早退)。主线程被占满的直接后果就是**滚动卡死、界面与后端不同步**
5256
+ // —— 用户真机症状「滚不到最新会话、加载更早后回不去」。
5257
+ // 按帧合并后最多每帧一次(≤60/s),同一帧内的多次变更只算一次。
5258
+ let panelCloseRaf = 0;
5259
+ const schedulePanelCloseButton = () => {
5260
+ if (panelCloseRaf) return;
5261
+ panelCloseRaf = window.requestAnimationFrame(() => {
5262
+ panelCloseRaf = 0;
5263
+ ensurePanelCloseButton();
5264
+ });
5265
+ };
5266
+ if (MOBILE_NO_OBSERVER) {
5267
+ // 二分开关:完全关掉这个观察器(面板关闭按钮会缺失,仅用于定位问题)
5268
+ document.documentElement.dataset.dshMobileObserver = 'off';
5269
+ } else {
5270
+ const panelObserver = new MutationObserver(schedulePanelCloseButton);
5271
+ panelObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
5272
+ }
5273
+ window.addEventListener('resize', schedulePanelCloseButton);
4896
5274
 
4897
5275
  // 1.5 移动端标题行「对话」下拉:隐藏原生 tablist,在标准模式右边注入一个
4898
5276
  // 原生样式的「对话 ▾」按钮,点开下拉选择 对话/轨迹/上下文 切换视图。
@@ -4908,6 +5286,20 @@ function setupMobileExperience(rpcCall, ctx) {
4908
5286
  }
4909
5287
  };
4910
5288
 
5289
+ /**
5290
+ * 把下拉定位到按钮正下方。
5291
+ * 下拉是 body 级 portal(position:fixed),坐标得自己算 —— 这是脱离祖先
5292
+ * overflow:hidden 裁剪的代价,也是原生 popover 同样的做法。
5293
+ */
5294
+ const positionTabDropdown = () => {
5295
+ const dd = mobileTabState.dropdown;
5296
+ const btn = mobileTabState.btn;
5297
+ if (!dd || !btn || !btn.isConnected) return;
5298
+ const r = btn.getBoundingClientRect();
5299
+ dd.style.top = Math.round(r.bottom + 6) + 'px';
5300
+ dd.style.left = Math.round(r.left) + 'px';
5301
+ };
5302
+
4911
5303
  const renderTabDropdown = () => {
4912
5304
  closeTabDropdown();
4913
5305
  if (!mobileTabState.btn) return;
@@ -4944,8 +5336,10 @@ function setupMobileExperience(rpcCall, ctx) {
4944
5336
  };
4945
5337
  dropdown.appendChild(option);
4946
5338
  });
4947
- mobileTabState.btn.appendChild(dropdown);
5339
+ // 挂到 <body>:绕开祖先的 overflow:hidden 裁剪(见 .dsh-mobile-tab-dropdown 注释)
5340
+ document.body.appendChild(dropdown);
4948
5341
  mobileTabState.dropdown = dropdown;
5342
+ positionTabDropdown();
4949
5343
  };
4950
5344
 
4951
5345
  const syncTabButtonLabel = () => {
@@ -5017,6 +5411,8 @@ function setupMobileExperience(rpcCall, ctx) {
5017
5411
  titleExtrasObserver.observe(document.body, { childList: true, subtree: true });
5018
5412
  window.addEventListener('resize', () => {
5019
5413
  if (window.innerWidth > 768) closeTabDropdown();
5414
+ // 横竖屏切换后按钮会移位,portal 化的下拉得跟着重算坐标
5415
+ else positionTabDropdown();
5020
5416
  });
5021
5417
  document.addEventListener(
5022
5418
  'click',
package/docs/banner.jpg CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  # 自建隧道服务器搭建教程
2
2
 
3
- dsh-bridge 的「自建隧道」功能需要一台有公网 IP 的服务器来中转流量。本教程提供一键部署脚本,执行完成后直接复制输出的配置信息填入 dsh-bridge 即可。
3
+ dsh-bridge-gateway 的「自建隧道」功能需要一台有公网 IP 的服务器来中转流量。本教程提供一键部署脚本,执行完成后直接复制输出的配置信息填入 dsh-bridge-gateway 即可。
4
4
 
5
5
  ## 前置条件
6
6
 
@@ -41,7 +41,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/lament-z/dsh-bridge-gateway/
41
41
  │ xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
42
42
  └──────────────────────────────────────────────────────────────┘
43
43
 
44
- 连接成功后,dsh-bridge 会显示以下公网访问地址:
44
+ 连接成功后,dsh-bridge-gateway 会显示以下公网访问地址:
45
45
  http://YOUR_IP:3000/a7f2k9m3x1b4c8d2
46
46
 
47
47
  安全说明:该地址含随机路径(/a7f2k9m3x1b4c8d2),不知道路径无法访问。
@@ -56,7 +56,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/lament-z/dsh-bridge-gateway/
56
56
 
57
57
  | 层 | 机制 | 说明 |
58
58
  |----|------|------|
59
- | WebSocket 连接层 | 连接令牌(TOKEN) | dsh-bridge 连接服务端时验证,防止他人建立隧道连接 |
59
+ | WebSocket 连接层 | 连接令牌(TOKEN) | dsh-bridge-gateway 连接服务端时验证,防止他人建立隧道连接 |
60
60
  | HTTP 访问层 | 随机路径前缀 | 所有不含该路径的请求返回 404,端口扫描看不出服务存在 |
61
61
 
62
62
  最终对外的公网地址形如 `http://IP:PORT/a7f2k9m3x1b4c8d2`,路径本身即是密钥——不知道完整地址就无法访问你的 DSH。
@@ -157,7 +157,7 @@ nginx -t && systemctl restart nginx
157
157
  PUBLIC_URL=https://tunnel.example.com
158
158
  ```
159
159
 
160
- 重启服务并更新 dsh-bridge 中的地址为 `wss://tunnel.example.com/connect`:
160
+ 重启服务并更新 dsh-bridge-gateway 中的地址为 `wss://tunnel.example.com/connect`:
161
161
 
162
162
  ```bash
163
163
  systemctl restart dsh-tunnel
@@ -174,7 +174,7 @@ systemctl restart dsh-tunnel
174
174
  隧道服务端(你的公网服务器)
175
175
  │ WebSocket 实时转发
176
176
  ▼
177
- dsh-bridge(你的本地电脑,主动发起连接)
177
+ dsh-bridge-gateway(你的本地电脑,主动发起连接)
178
178
  │ HTTP 本地转发
179
179
  ▼
180
180
  DSH(127.0.0.1:3080)
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## 目标
4
4
 
5
- 将 `dsh-bridge` 从单一微信集成重构为支持多 IM 平台的架构:
5
+ 将 `dsh-bridge-gateway` 从单一微信集成重构为支持多 IM 平台的架构:
6
6
  - 抽象出平台无关的核心逻辑(会话管理、消息路由、审批流程)
7
7
  - 定义统一的平台接口(Platform Interface)
8
8
  - 每个 IM 平台实现自己的适配器(WeChat / QQ / Feishu / Telegram)