@wenbin_wb/dsh-bridge 2.8.5 → 2.8.7

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/client/index.js CHANGED
@@ -317,6 +317,78 @@ function QrBlock({ url, qr, onReset, auth, onNavigateSecurity }) {
317
317
  );
318
318
  }
319
319
 
320
+ const LanNetworkSelector = React.memo(function LanNetworkSelector({ lan, onSelectIp }) {
321
+ const interfaces = lan?.interfaces || [];
322
+ const selectedIp = lan?.selectedIp || '';
323
+ const currentIp = lan?.ip || '';
324
+ const [switching, setSwitching] = React.useState(false);
325
+
326
+ if (!interfaces || interfaces.length <= 1) return null;
327
+
328
+ const handleChange = async (e) => {
329
+ const val = e.target.value;
330
+ setSwitching(true);
331
+ try {
332
+ await onSelectIp(val || null);
333
+ } finally {
334
+ setSwitching(false);
335
+ }
336
+ };
337
+
338
+ return React.createElement('div', {
339
+ style: {
340
+ ...s.block,
341
+ background: 'var(--dsw-alias-bg-layer-2, rgba(243, 244, 246, 0.6))',
342
+ padding: '10px 12px',
343
+ borderRadius: 8,
344
+ border: '1px solid var(--dsw-alias-border-l2, #e5e7eb)',
345
+ marginTop: 8,
346
+ marginBottom: 6,
347
+ },
348
+ },
349
+ React.createElement('div', {
350
+ style: {
351
+ display: 'flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'space-between',
354
+ marginBottom: 6,
355
+ fontSize: 12,
356
+ fontWeight: 500,
357
+ color: 'var(--dsw-alias-label-primary, currentColor)',
358
+ },
359
+ },
360
+ React.createElement('span', { style: { display: 'inline-flex', alignItems: 'center', gap: 5 } },
361
+ '🛜 局域网网卡 / IP 选择'
362
+ ),
363
+ switching && React.createElement('span', {
364
+ style: { fontSize: 11, color: 'var(--dsw-alias-brand-primary, #4f6ef7)' },
365
+ }, '切换中…')
366
+ ),
367
+ React.createElement('div', { style: { ...s.muted, fontSize: 11, marginBottom: 6 } },
368
+ '检测到主机存在多张网卡(如物理 Wi-Fi、以太网、WSL 或虚拟机)。若默认 IP 无法被移动端访问,可手动切换:'
369
+ ),
370
+ React.createElement('select', {
371
+ style: {
372
+ ...s.input,
373
+ height: 32,
374
+ fontSize: 12,
375
+ padding: '0 8px',
376
+ background: 'var(--dsw-alias-bg-layer-1, #ffffff)',
377
+ cursor: 'pointer',
378
+ },
379
+ value: selectedIp,
380
+ onChange: handleChange,
381
+ disabled: switching,
382
+ },
383
+ React.createElement('option', { value: '' }, `⚡ 自动推荐 (${interfaces[0]?.address || currentIp} · ${interfaces[0]?.label || interfaces[0]?.name || ''})`),
384
+ interfaces.map((iface) => React.createElement('option', {
385
+ key: `${iface.name}-${iface.address}`,
386
+ value: iface.address,
387
+ }, `${iface.address} · ${iface.label || iface.name}${iface.isVirtual ? ' [虚拟/WSL]' : ''}`)),
388
+ ),
389
+ );
390
+ });
391
+
320
392
  const CustomTunnelGuide = React.memo(function CustomTunnelGuide() {
321
393
  return React.createElement('div', { style: s.block },
322
394
  React.createElement('a', {
@@ -2497,6 +2569,8 @@ function BridgePanel({ rpcCall }) {
2497
2569
  act(BRIDGE_ENDPOINTS.saveCloudflaredConfig, { token, hostname })
2498
2570
  , [act]);
2499
2571
 
2572
+ const onSelectLanIp = React.useCallback((ip) => act(BRIDGE_ENDPOINTS.setLanIp, { ip }), [act]);
2573
+
2500
2574
  const onStartCustom = React.useCallback(() => act(BRIDGE_ENDPOINTS.startCustomTunnel), [act]);
2501
2575
  const onStopCustom = React.useCallback(() => act(BRIDGE_ENDPOINTS.stopCustomTunnel), [act]);
2502
2576
  const onToggleCustomAutoStart = React.useCallback((autoStart) =>
@@ -2536,7 +2610,12 @@ function BridgePanel({ rpcCall }) {
2536
2610
  data: { running: status?.proxy?.running, url: status?.lan?.url, qr: status?.lan?.qr },
2537
2611
  auth: status?.auth,
2538
2612
  onNavigateSecurity: navSecurity,
2539
- });
2613
+ },
2614
+ React.createElement(LanNetworkSelector, {
2615
+ lan: status?.lan,
2616
+ onSelectIp: onSelectLanIp,
2617
+ })
2618
+ );
2540
2619
  } else if (activeTab === 'tunnel') {
2541
2620
  tabContent = React.createElement(React.Fragment, null,
2542
2621
  React.createElement(TunnelCard, {
@@ -3028,6 +3107,163 @@ function injectMobileStyles() {
3028
3107
  display: none !important;
3029
3108
  }
3030
3109
 
3110
+ /* 3.0 工作区 Workbench / 任务管理 / 多 Tab 栏移动端自适应适配 */
3111
+ body:not(.dsh-workbench-open) div[class*="nArs4W_panel"],
3112
+ body:not(.dsh-workbench-open) div[class*="workbench_panel"],
3113
+ body:not(.dsh-workbench-open) div[class*="workbenchPanel"],
3114
+ div[class*="nArs4W_panel"][class*="panelHidden"],
3115
+ div[class*="workbench_panel"][class*="panelHidden"],
3116
+ div[class*="workbenchPanel"][class*="panelHidden"],
3117
+ div[class*="panelHidden"] {
3118
+ display: none !important;
3119
+ visibility: hidden !important;
3120
+ pointer-events: none !important;
3121
+ width: 0 !important;
3122
+ height: 0 !important;
3123
+ max-height: 0 !important;
3124
+ z-index: -1 !important;
3125
+ opacity: 0 !important;
3126
+ transform: translateX(105%) !important;
3127
+ }
3128
+
3129
+ body.dsh-workbench-open div[class*="nArs4W_panel"]:not([class*="panelHidden"]),
3130
+ body.dsh-workbench-open div[class*="workbench_panel"]:not([class*="panelHidden"]),
3131
+ body.dsh-workbench-open div[class*="workbenchPanel"]:not([class*="panelHidden"]) {
3132
+ display: flex !important;
3133
+ visibility: visible !important;
3134
+ pointer-events: auto !important;
3135
+ top: var(--dsh-mobile-header-h, 52px) !important;
3136
+ height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
3137
+ max-height: calc(100dvh - var(--dsh-mobile-header-h, 52px)) !important;
3138
+ z-index: 50 !important;
3139
+ box-sizing: border-box !important;
3140
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3141
+ transform: none !important;
3142
+ opacity: 1 !important;
3143
+ }
3144
+
3145
+ /* Tab 栏:横向滑动手势 + 干净的底部边框,杜绝与顶部移动端 Header 重叠 */
3146
+ div[class*="nArs4W_tabBar"],
3147
+ div[class*="workbench_tabBar"],
3148
+ div[class*="tabBar"] {
3149
+ min-height: 40px !important;
3150
+ height: 40px !important;
3151
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3152
+ border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
3153
+ display: flex !important;
3154
+ align-items: center !important;
3155
+ justify-content: space-between !important;
3156
+ padding: 0 8px !important;
3157
+ gap: 6px !important;
3158
+ overflow: visible !important;
3159
+ box-sizing: border-box !important;
3160
+ }
3161
+
3162
+ div[class*="nArs4W_tabList"],
3163
+ div[class*="tabList"] {
3164
+ display: flex !important;
3165
+ align-items: center !important;
3166
+ gap: 6px !important;
3167
+ flex: 1 1 auto !important;
3168
+ min-width: 0 !important;
3169
+ overflow-x: auto !important;
3170
+ scrollbar-width: none !important;
3171
+ -webkit-overflow-scrolling: touch !important;
3172
+ }
3173
+ div[class*="nArs4W_tabList"]::-webkit-scrollbar,
3174
+ div[class*="tabList"]::-webkit-scrollbar {
3175
+ display: none !important;
3176
+ }
3177
+
3178
+ /* 单个 Tab 胶囊化,文字超长自动打点,防止 Tab 互相挤压 */
3179
+ div[class*="nArs4W_tab"],
3180
+ div[class*="workbench_tab"] {
3181
+ flex: 0 0 auto !important;
3182
+ max-width: 170px !important;
3183
+ min-width: 70px !important;
3184
+ height: 30px !important;
3185
+ padding: 0 8px 0 10px !important;
3186
+ border-radius: 6px !important;
3187
+ font-size: 12.5px !important;
3188
+ display: inline-flex !important;
3189
+ align-items: center !important;
3190
+ justify-content: space-between !important;
3191
+ gap: 6px !important;
3192
+ background: var(--dsw-alias-bg-layer-2, #f3f4f6) !important;
3193
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
3194
+ cursor: pointer !important;
3195
+ user-select: none !important;
3196
+ box-sizing: border-box !important;
3197
+ }
3198
+
3199
+ div[class*="nArs4W_tabActive"],
3200
+ div[class*="workbench_tabActive"] {
3201
+ background: var(--dsw-alias-bg-layer-1, #ffffff) !important;
3202
+ color: var(--dsw-alias-label-primary, #111827) !important;
3203
+ font-weight: 600 !important;
3204
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1) !important;
3205
+ }
3206
+
3207
+ span[class*="nArs4W_tabTitle"],
3208
+ span[class*="tabTitle"] {
3209
+ overflow: hidden !important;
3210
+ text-overflow: ellipsis !important;
3211
+ white-space: nowrap !important;
3212
+ flex: 1 1 auto !important;
3213
+ }
3214
+
3215
+ button[class*="nArs4W_tabClose"],
3216
+ button[class*="tabClose"] {
3217
+ width: 18px !important;
3218
+ height: 18px !important;
3219
+ border-radius: 50% !important;
3220
+ display: inline-flex !important;
3221
+ align-items: center !important;
3222
+ justify-content: center !important;
3223
+ flex-shrink: 0 !important;
3224
+ opacity: 0.6 !important;
3225
+ padding: 0 !important;
3226
+ }
3227
+
3228
+ button[class*="nArs4W_tabBarPlus"],
3229
+ button[class*="tabBarPlus"] {
3230
+ width: 28px !important;
3231
+ height: 28px !important;
3232
+ border-radius: 50% !important;
3233
+ display: inline-flex !important;
3234
+ align-items: center !important;
3235
+ justify-content: center !important;
3236
+ flex-shrink: 0 !important;
3237
+ }
3238
+
3239
+ /* 移动端面板右上角“返回对话 / ✕ 收起”按钮:常驻右侧,醒目且易触达 */
3240
+ .dsh-mobile-panel-close-btn {
3241
+ margin-left: 8px !important;
3242
+ flex: 0 0 auto !important;
3243
+ height: 28px !important;
3244
+ padding: 0 10px !important;
3245
+ border-radius: 14px !important;
3246
+ font-size: 12px !important;
3247
+ font-weight: 600 !important;
3248
+ background: #2563eb !important;
3249
+ color: #ffffff !important;
3250
+ border: none !important;
3251
+ display: inline-flex !important;
3252
+ align-items: center !important;
3253
+ justify-content: center !important;
3254
+ gap: 4px !important;
3255
+ cursor: pointer !important;
3256
+ user-select: none !important;
3257
+ box-shadow: 0 2px 6px rgba(37, 99, 235, 0.28) !important;
3258
+ white-space: nowrap !important;
3259
+ transition: transform 0.1s, opacity 0.15s !important;
3260
+ z-index: 10 !important;
3261
+ }
3262
+ .dsh-mobile-panel-close-btn:active {
3263
+ transform: scale(0.95) !important;
3264
+ opacity: 0.85 !important;
3265
+ }
3266
+
3031
3267
  /* 3.1 会话对话头部顶栏:移动端防挤压与空间释放优化(严格排除 .dsh-mobile-app-header) */
3032
3268
  div[class*="_centerCol"] header,
3033
3269
  header[class*="wSkVaW_header"] {
@@ -3222,19 +3458,53 @@ function injectMobileStyles() {
3222
3458
  transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
3223
3459
  overflow-y: auto !important;
3224
3460
  border-right: 1px solid rgba(0, 0, 0, 0.06) !important;
3461
+ pointer-events: auto !important;
3225
3462
  }
3226
3463
  body.dsh-drawer-open div[class*="_sidebarCol"] {
3227
3464
  transform: translateX(0) !important;
3228
3465
  box-shadow: 4px 0 28px rgba(0, 0, 0, 0.25) !important;
3466
+ pointer-events: auto !important;
3229
3467
  }
3230
3468
 
3231
- /* 抽屉内部:强制 100% 宽度,隐藏冗余折叠按钮 */
3469
+ /* 抽屉内部:强制 100% 宽度,无论内部状态如何均正常展开并展示 DSH 自带的顶部收起侧边栏图标 */
3232
3470
  body.dsh-drawer-open div[class*="hHd-Xa_root"] {
3233
3471
  width: 100% !important;
3234
3472
  max-width: 100% !important;
3473
+ min-width: 100% !important;
3474
+ display: flex !important;
3475
+ flex-direction: column !important;
3476
+ }
3477
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] div[class*="hHd-Xa_regionArea"],
3478
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] button[class*="hHd-Xa_newSession"],
3479
+ body.dsh-drawer-open div[class*="hHd-Xa_collapsed"] div[class*="qDHVXG_root"] {
3480
+ display: flex !important;
3481
+ visibility: visible !important;
3482
+ }
3483
+ div[class*="hHd-Xa_logoRow"] {
3484
+ display: flex !important;
3485
+ align-items: center !important;
3486
+ justify-content: space-between !important;
3487
+ width: 100% !important;
3488
+ padding: 10px 14px 6px 14px !important;
3489
+ box-sizing: border-box !important;
3235
3490
  }
3236
3491
  div[class*="hHd-Xa_logoRow"] button[class*="hHd-Xa_toggle"] {
3237
- display: none !important;
3492
+ display: inline-flex !important;
3493
+ align-items: center !important;
3494
+ justify-content: center !important;
3495
+ width: 32px !important;
3496
+ height: 32px !important;
3497
+ border-radius: 8px !important;
3498
+ color: var(--dsw-alias-label-secondary, #6b7280) !important;
3499
+ background: transparent !important;
3500
+ border: none !important;
3501
+ cursor: pointer !important;
3502
+ margin-left: auto !important;
3503
+ transition: background 0.15s, color 0.15s !important;
3504
+ }
3505
+ div[class*="hHd-Xa_logoRow"] button[class*="hHd-Xa_toggle"]:active {
3506
+ background: var(--dsw-alias-bg-layer-2, rgba(0, 0, 0, 0.06)) !important;
3507
+ color: var(--dsw-alias-label-primary, #111827) !important;
3238
3508
  }
3239
3509
 
3240
3510
  /* 设置弹窗打开时解除抽屉隐藏限制 */
@@ -3252,8 +3522,6 @@ function injectMobileStyles() {
3252
3522
  position: fixed;
3253
3523
  inset: 0;
3254
3524
  background: rgba(0, 0, 0, 0.4);
3255
- backdrop-filter: blur(4px);
3256
- -webkit-backdrop-filter: blur(4px);
3257
3525
  z-index: 9999;
3258
3526
  display: none !important;
3259
3527
  }
@@ -3505,7 +3773,8 @@ function injectMobileStyles() {
3505
3773
 
3506
3774
  @media (min-width: 769px) {
3507
3775
  .dsh-mobile-app-header,
3508
- .dsh-mobile-backdrop {
3776
+ .dsh-mobile-backdrop,
3777
+ .dsh-mobile-panel-close-btn {
3509
3778
  display: none !important;
3510
3779
  }
3511
3780
  }
@@ -3534,18 +3803,23 @@ function setupMobileExperience(rpcCall, ctx) {
3534
3803
  <line x1="3" y1="15" x2="14" y2="15"></line>
3535
3804
  </svg>
3536
3805
  `;
3537
- leftBtn.onclick = () => {
3806
+ leftBtn.onclick = (e) => {
3807
+ e.stopPropagation();
3538
3808
  const isOpen = document.body.classList.toggle('dsh-drawer-open');
3539
3809
  if (isOpen) {
3540
- const collapsedToggle = document.querySelector('div[class*="hHd-Xa_collapsed"] button[class*="hHd-Xa_toggle"]');
3541
- if (collapsedToggle) collapsedToggle.click();
3810
+ const expand = document.querySelector('button[aria-label*="打开侧边栏"], button[title*="打开侧边栏"]');
3811
+ if (expand) expand.click();
3542
3812
  }
3543
3813
  };
3544
3814
 
3545
- // 中间动态会话标题 (居中展示当前会话名称,避免顶部留白)
3815
+ // 中间动态会话标题 (居中展示当前会话名称,点击可快速切回对话流)
3546
3816
  titleEl = document.createElement('div');
3547
3817
  titleEl.className = 'dsh-mobile-header-title';
3548
3818
  titleEl.innerText = '新会话';
3819
+ titleEl.onclick = () => {
3820
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3821
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3822
+ };
3549
3823
 
3550
3824
  // 右侧 (+) 新建会话按钮 (DeepSeek App 圆形加号风格)
3551
3825
  const rightBtn = document.createElement('button');
@@ -3559,6 +3833,8 @@ function setupMobileExperience(rpcCall, ctx) {
3559
3833
  </svg>
3560
3834
  `;
3561
3835
  rightBtn.onclick = () => {
3836
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3837
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3562
3838
  const dshNewBtn = document.querySelector('button[aria-label="新建会话"]');
3563
3839
  if (dshNewBtn) dshNewBtn.click();
3564
3840
  };
@@ -3587,6 +3863,67 @@ function setupMobileExperience(rpcCall, ctx) {
3587
3863
  if (typeof ctx?.sessions?.list?.subscribe === 'function') {
3588
3864
  ctx.sessions.list.subscribe(syncMobileTitle);
3589
3865
  }
3866
+ if (typeof ctx?.sessions?.active?.subscribe === 'function') {
3867
+ ctx.sessions.active.subscribe(() => {
3868
+ if (document.body.classList.contains('dsh-drawer-open')) {
3869
+ document.body.classList.remove('dsh-drawer-open');
3870
+ }
3871
+ // 仅在移动端切换会话时自动收起右侧面板回到对话(PC端绝不干扰)
3872
+ if (typeof window !== 'undefined' && window.innerWidth <= 768) {
3873
+ document.body.classList.remove('dsh-workbench-open');
3874
+ const openPanels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3875
+ openPanels.forEach((p) => p.classList.add('nArs4W_panelHidden'));
3876
+ }
3877
+ });
3878
+ }
3879
+
3880
+ // 1.1 移动端右侧边栏 / Workbench 面板管理:仅在移动端(<= 768px)挂载“返回对话”收起按钮,PC端保持纯净
3881
+ const ensurePanelCloseButton = () => {
3882
+ if (typeof window === 'undefined') return;
3883
+ if (window.innerWidth > 768) {
3884
+ document.querySelectorAll('.dsh-mobile-panel-close-btn').forEach(btn => btn.remove());
3885
+ return;
3886
+ }
3887
+ const panels = document.querySelectorAll('div[class*="nArs4W_panel"]:not([class*="panelHidden"]), div[class*="workbench_panel"]:not([class*="panelHidden"])');
3888
+ panels.forEach((p) => {
3889
+ const bar = p.querySelector('div[class*="tabBar"], div[class*="nArs4W_tabBar"]');
3890
+ if (bar && !bar.querySelector('.dsh-mobile-panel-close-btn')) {
3891
+ const btn = document.createElement('button');
3892
+ btn.className = 'dsh-mobile-panel-close-btn';
3893
+ btn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg><span>返回对话</span>`;
3894
+ btn.onclick = (e) => {
3895
+ e.stopPropagation();
3896
+ document.body.classList.remove('dsh-workbench-open');
3897
+ p.classList.add('nArs4W_panelHidden');
3898
+ const collapseBtn = document.querySelector('button[class*="toggleButton"][aria-label*="收起"]');
3899
+ if (collapseBtn) collapseBtn.click();
3900
+ };
3901
+ bar.appendChild(btn);
3902
+ }
3903
+ });
3904
+ };
3905
+
3906
+ const panelObserver = new MutationObserver(ensurePanelCloseButton);
3907
+ panelObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
3908
+ window.addEventListener('resize', ensurePanelCloseButton);
3909
+
3910
+ // 移动端点击面板/工作区触发按钮时,自动激活 dsh-workbench-open
3911
+ document.addEventListener('click', (e) => {
3912
+ if (typeof window === 'undefined' || window.innerWidth > 768) return;
3913
+ const trigger = e.target.closest('button[aria-label*="面板"], button[aria-label*="工作区"], div[class*="toggleCluster"] button, button[class*="subagent"], div[class*="headerActions"] button, div[class*="titleRow"] button');
3914
+ if (trigger && !trigger.classList.contains('dsh-mobile-panel-close-btn') && !trigger.classList.contains('dsh-header-menu-btn') && !trigger.classList.contains('dsh-header-new-btn')) {
3915
+ document.body.classList.add('dsh-workbench-open');
3916
+ }
3917
+ }, true);
3918
+
3919
+ // 移动端点击 DSH 自带的收起侧边栏图标时,自动收起抽屉
3920
+ document.addEventListener('click', (e) => {
3921
+ if (typeof window === 'undefined' || window.innerWidth > 768) return;
3922
+ const toggle = e.target.closest('button[aria-label*="收起侧边栏"], button[title*="收起侧边栏"]');
3923
+ if (toggle) {
3924
+ document.body.classList.remove('dsh-drawer-open');
3925
+ }
3926
+ }, true);
3590
3927
 
3591
3928
  // 2. 创建背景遮罩(用于抽屉侧边栏)
3592
3929
  let backdrop = document.querySelector('.dsh-mobile-backdrop');
@@ -3599,7 +3936,7 @@ function setupMobileExperience(rpcCall, ctx) {
3599
3936
  document.body.appendChild(backdrop);
3600
3937
  }
3601
3938
 
3602
- // 3. 点击抽屉内的会话项自动收起抽屉(排除搜索、工作区、操作菜单、设置、输入等交互)
3939
+ // 3. 点击遮罩收起抽屉,或点击抽屉内的会话项自动收起抽屉
3603
3940
  let lastLongPressTime = 0;
3604
3941
 
3605
3942
  document.addEventListener('click', (e) => {
@@ -3647,11 +3984,17 @@ function setupMobileExperience(rpcCall, ctx) {
3647
3984
  }
3648
3985
  }
3649
3986
 
3650
- // 如果点击的是会话列表项本身(切换到该会话)
3651
- const sessionRow = e.target.closest('div[class*="sessionRow"], div[role="treeitem"]');
3987
+ // 点击会话项后平滑收起抽屉
3988
+ const sessionRow = e.target.closest('a, div[class*="sessionRow"], div[role="treeitem"]');
3652
3989
  if (sessionRow) {
3653
- document.body.classList.remove('dsh-drawer-open');
3990
+ setTimeout(() => {
3991
+ if (document.body.classList.contains('dsh-drawer-open')) {
3992
+ document.body.classList.remove('dsh-drawer-open');
3993
+ }
3994
+ }, 80);
3654
3995
  }
3996
+ } else if (e.target.classList?.contains('dsh-mobile-backdrop')) {
3997
+ document.body.classList.remove('dsh-drawer-open');
3655
3998
  }
3656
3999
  }, true);
3657
4000
 
@@ -4346,32 +4689,7 @@ function RemoteDirectoryFlow(props) {
4346
4689
  outcome.current = props;
4347
4690
  const armed = React.useRef(false);
4348
4691
 
4349
- React.useEffect(() => {
4350
- if (!open) {
4351
- armed.current = false;
4352
- return;
4353
- }
4354
- if (armed.current) return;
4355
- armed.current = true;
4356
-
4357
- // 1. 本机电脑访问(Localhost / 127.0.0.1 / Electron 客户端):直接唤起系统原生文件夹选择对话框!
4358
- if (isLocalEnvironment()) {
4359
- const pickFn = typeof pick === 'function' ? pick : (typeof window.__dshClientCtx?.workspaces?.pickDirectory === 'function' ? () => window.__dshClientCtx.workspaces.pickDirectory() : null);
4360
- if (pickFn) {
4361
- pickFn().then((chosenPath) => {
4362
- if (chosenPath === null) {
4363
- if (outcome.current?.onCancel) outcome.current.onCancel();
4364
- } else if (chosenPath) {
4365
- if (outcome.current?.onPicked) outcome.current.onPicked(chosenPath);
4366
- }
4367
- }, (err) => {
4368
- if (outcome.current?.onError) outcome.current.onError(err instanceof Error ? err.message : String(err));
4369
- });
4370
- return;
4371
- }
4372
- }
4373
-
4374
- // 2. 远程或移动端访问(手机或局域网跨设备/公网):呼出网页版远程目录树形选择器!
4692
+ const openRemoteModal = () => {
4375
4693
  if (typeof window.__dshOpenRemoteWorkspaceModal === 'function') {
4376
4694
  window.__dshOpenRemoteWorkspaceModal(
4377
4695
  (res) => {
@@ -4390,7 +4708,48 @@ function RemoteDirectoryFlow(props) {
4390
4708
  }
4391
4709
  }
4392
4710
  );
4711
+ } else if (outcome.current?.onCancel) {
4712
+ outcome.current.onCancel();
4713
+ }
4714
+ };
4715
+
4716
+ React.useEffect(() => {
4717
+ if (!open) {
4718
+ armed.current = false;
4719
+ return;
4393
4720
  }
4721
+ if (armed.current) return;
4722
+ armed.current = true;
4723
+
4724
+ // 1. 本机电脑或 Electron 环境:尝试唤起原生文件夹选择对话框
4725
+ const isNativeHost = typeof window !== 'undefined' && (window.electron || window.__DSH_NATIVE_HOST__);
4726
+ if (isNativeHost || isLocalEnvironment()) {
4727
+ const pickFn = typeof pick === 'function' ? pick : (typeof window.__dshClientCtx?.workspaces?.pickDirectory === 'function' ? () => window.__dshClientCtx.workspaces.pickDirectory() : null);
4728
+ if (pickFn) {
4729
+ try {
4730
+ const promise = pickFn();
4731
+ if (promise && typeof promise.then === 'function') {
4732
+ promise.then((chosenPath) => {
4733
+ if (chosenPath === null) {
4734
+ if (outcome.current?.onCancel) outcome.current.onCancel();
4735
+ } else if (chosenPath) {
4736
+ if (outcome.current?.onPicked) outcome.current.onPicked(chosenPath);
4737
+ }
4738
+ }).catch(() => {
4739
+ // 原生选择器在纯网页模式下报 needs the native capability,平滑降级至远程目录树选择器
4740
+ openRemoteModal();
4741
+ });
4742
+ return;
4743
+ }
4744
+ } catch {
4745
+ openRemoteModal();
4746
+ return;
4747
+ }
4748
+ }
4749
+ }
4750
+
4751
+ // 2. 远程或移动端访问(手机或局域网跨设备/公网):呼出网页版远程目录树形选择器!
4752
+ openRemoteModal();
4394
4753
  }, [open, pick]);
4395
4754
 
4396
4755
  return null;
@@ -0,0 +1,134 @@
1
+ # Fix: 自建隧道 SSE 504 超时 & 会话列表不显示
2
+
3
+ ## 问题现象
4
+
5
+ 通过自建 WebSocket 隧道(`wss://` 中转服务器)远程访问 DSH 时,出现两个问题:
6
+
7
+ 1. **`/plugins/events` SSE 端点返回 504 超时** — HMR 插件的 Server-Sent Events 连接永远不结束,隧道服务器超时后返回 504
8
+ 2. **Web 侧边栏会话列表不显示** — 页面其他功能正常,唯独会话列表空白
9
+
10
+ ## 根因分析
11
+
12
+ ### 问题 1:SSE 504 超时
13
+
14
+ 隧道协议将 HTTP 请求/响应封装为 WebSocket 消息:tunnel client 向本地 DSH 发起 HTTP 请求,**缓冲完整响应**后再通过 WebSocket 回传给 tunnel server,再由 tunnel server 转发给浏览器。
15
+
16
+ SSE(`text/event-stream`)响应是**永不结束**的流式连接 — 服务器持续推送事件,不调用 `res.end()`。原代码的 `res.on('end')` 回调永远不会触发,tunnel server 的超时(30s 非 API 路径 / 120s API 路径)到期后返回 504。
17
+
18
+ ### 问题 2:会话列表不显示
19
+
20
+ DSH 的 `session.list` RPC 返回所有会话的完整投影数据。在会话数量较多时(233 个会话),响应体高达 **63MB**,其中:
21
+
22
+ | 字段 | 大小 | 占比 |
23
+ |------|------|------|
24
+ | `contextHeaders` | 57.4 MB | 92% |
25
+ | `contextTimeline` | 5.0 MB | 8% |
26
+ | 其他投影字段 | ~0.2 MB | <1% |
27
+
28
+ `contextHeaders` 包含每个会话的完整对话上下文头(系统提示、消息头等),侧边栏列表**完全不需要**这些数据 — 打开会话时通过 WebSocket 实时获取即可。
29
+
30
+ 即使 gzip 压缩后仍有 13.5MB,通过 WebSocket 隧道传输需要 ~33s,超过 DSH 客户端的 `AbortSignal.timeout(30000)` 默认超时,导致请求被中止。
31
+
32
+ ## 修复方案
33
+
34
+ ### 修复 1:SSE 流式响应提前返回
35
+
36
+ ### 修复 1:SSE 流式响应提前返回
37
+
38
+ 在 `_handleHttpRequest()` 中检测 `text/event-stream` content-type,收集初始数据(2 个 chunk 或 500ms 超时)后立即返回响应,不等 `res.end()`:
39
+
40
+ ```javascript
41
+ if (isSSE) {
42
+ let sseSent = false;
43
+ const sseTimer = setTimeout(() => { /* 发送已收集数据 */ }, 500);
44
+ res.on('data', (c) => {
45
+ if (sseSent) return;
46
+ chunks.push(c);
47
+ if (chunks.length >= 2) {
48
+ clearTimeout(sseTimer);
49
+ sseSent = true;
50
+ // 立即发送已收集的初始数据
51
+ this._sendMessage({ type: 'response', ... });
52
+ res.destroy();
53
+ }
54
+ });
55
+ // ... error/end 处理
56
+ return;
57
+ }
58
+ ```
59
+
60
+ ### 修复 2:剥离 session.list 中的大字段
61
+
62
+ 对 `/api/session.list` 的 200 响应,解析 JSON 并删除每个会话投影中的 `contextHeaders` 和 `contextTimeline`:
63
+
64
+ ```javascript
65
+ if (path === '/api/session.list' && res.statusCode === 200) {
66
+ try {
67
+ const json = JSON.parse(bodyBuf.toString('utf8'));
68
+ if (json?.result?.ok && json.result.value?.items) {
69
+ for (const item of json.result.value.items) {
70
+ const proj = item?.projections?.values;
71
+ if (proj) {
72
+ delete proj.contextHeaders;
73
+ delete proj.contextTimeline;
74
+ }
75
+ }
76
+ bodyBuf = Buffer.from(JSON.stringify(json), 'utf8');
77
+ }
78
+ } catch {}
79
+ }
80
+ ```
81
+
82
+ ### 修复 3:大响应 gzip 压缩
83
+
84
+ 对超过 100KB 的可压缩响应(`text/*`、`application/json` 等)自动 gzip 压缩,设置 `content-encoding: gzip` 头:
85
+
86
+ ```javascript
87
+ if (bodyBuf.length > GZIP_THRESHOLD && compressible && !alreadyEncoded) {
88
+ bodyBuf = gzipSync(bodyBuf);
89
+ respHeaders['content-encoding'] = 'gzip';
90
+ respHeaders['content-length'] = String(bodyBuf.length);
91
+ }
92
+ ```
93
+
94
+ ### 修复 4:启用 WebSocket per-message 压缩
95
+
96
+ 将隧道 WebSocket 的 `perMessageDeflate` 从 `false` 改为启用,进一步减少隧道传输量:
97
+
98
+ ```javascript
99
+ this.ws = new WebSocket(url.toString(), {
100
+ handshakeTimeout: 10000,
101
+ perMessageDeflate: {
102
+ clientNoContextTakeover: true,
103
+ serverNoContextTakeover: true,
104
+ clientMaxWindowBits: 15,
105
+ serverMaxWindowBits: 15,
106
+ },
107
+ });
108
+ ```
109
+
110
+ ## 效果
111
+
112
+ | 指标 | 修复前 | 修复后 |
113
+ |------|--------|--------|
114
+ | `session.list` 响应大小 | 63 MB | ~290 KB (剥离后) / 31 KB (gzip 后) |
115
+ | `session.list` 隧道传输时间 | 33s (超时) | 0.74s |
116
+ | `/plugins/events` SSE | 504 超时 | 正常返回初始数据 |
117
+ | 侧边栏会话列表 | 不显示 | 正常显示 |
118
+
119
+ ## 影响范围
120
+
121
+ - **非隧道模式不受影响**:所有修改仅在 `_handleHttpRequest()` 中,只影响自建隧道的 HTTP 代理路径
122
+ - **WebSocket 透传不受影响**:`/api/events.mux` 和 `/api/events.host` 的 WebSocket 升级走独立的 `_handleWsOpen()` 路径,不受此修改影响
123
+ - **非 session.list 请求不受影响**:contextHeaders 剥离仅对 `/api/session.list` 路径生效
124
+ - **小响应不受影响**:gzip 压缩仅对超过 100KB 的可压缩响应生效
125
+ - **已有 content-encoding 的响应不受影响**:不重复压缩
126
+
127
+ ## 测试验证
128
+
129
+ - ✅ 本地 WebSocket 直连 DSH(`/api/events.mux`)正常接收 `session/subscribed` 事件
130
+ - ✅ 通过隧道的 WebSocket(`wss://ds.missus.top/api/events.mux`)正常接收 26+ 条消息
131
+ - ✅ 通过隧道的 `session.list` 请求:31KB / 0.74s(修复前 63MB / 33s 超时)
132
+ - ✅ 响应包含 234 个会话,`contextHeaders` 和 `contextTimeline` 已剥离,`title` 等字段完整
133
+ - ✅ TLS 证书有效,gzip content-encoding 头正确传递
134
+ >>>>>>> pr-21