dsh-pocket 2.10.0 → 2.10.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/client/client.js CHANGED
@@ -239,42 +239,104 @@ function MobileNavOverlay({ toggleSidebar, t }) {
239
239
  }, [mobile, open, toggleSidebar]);
240
240
  (0, import_react.useEffect)(() => {
241
241
  if (!mobile || !open) return;
242
+ let lastTouchNavAt = 0;
243
+ let lastTouchX = 0;
244
+ let lastTouchY = 0;
245
+ let suppressTouchClickUntil = 0;
246
+ let pendingTouchRow = null;
247
+ let selectedRowAtArm = null;
248
+ let navClickArrived = false;
249
+ let navObserver = null;
250
+ let navTimer = null;
251
+ const drawerRoot = () => document.querySelector(DRAWER_SELECTOR);
252
+ const disarmNav = () => {
253
+ navObserver?.disconnect();
254
+ navObserver = null;
255
+ if (navTimer !== null) window.clearTimeout(navTimer);
256
+ navTimer = null;
257
+ pendingTouchRow = null;
258
+ selectedRowAtArm = null;
259
+ navClickArrived = false;
260
+ };
261
+ const armNav = (row) => {
262
+ disarmNav();
263
+ pendingTouchRow = row;
264
+ const drawer = drawerRoot();
265
+ selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null;
266
+ if (drawer === null) return;
267
+ navObserver = new MutationObserver(() => {
268
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
269
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) {
270
+ disarmNav();
271
+ return;
272
+ }
273
+ const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null;
274
+ if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
275
+ disarmNav();
276
+ toggleSidebar();
277
+ }
278
+ });
279
+ navObserver.observe(drawer, {
280
+ childList: true,
281
+ subtree: true,
282
+ attributes: true,
283
+ attributeFilter: ["aria-selected"]
284
+ });
285
+ navTimer = window.setTimeout(disarmNav, 2e3);
286
+ };
287
+ const navigationTarget = (target) => {
288
+ if (document.querySelector('[aria-modal="true"]') !== null) return null;
289
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
290
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) return null;
291
+ if (!(target instanceof Element)) return null;
292
+ const drawer = drawerRoot();
293
+ if (drawer === null || !drawer.contains(target)) return null;
294
+ return navTargetFor(target);
295
+ };
296
+ const isPendingTouchClick = (event) => {
297
+ const capabilities = event.sourceCapabilities;
298
+ if (capabilities?.firesTouchEvents === true) return true;
299
+ return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24;
300
+ };
242
301
  const onDrawerClick = (event) => {
243
- if (document.querySelector('[aria-modal="true"]') !== null) return;
244
- const target = event.target;
245
- if (target === null) return;
246
- const drawer = document.querySelector(DRAWER_SELECTOR);
247
- if (drawer === null || !drawer.contains(target)) return;
248
- if (navTargetFor(target) !== null) toggleSidebar();
302
+ if (performance.now() < suppressTouchClickUntil) return;
303
+ if (pendingTouchRow !== null && performance.now() - lastTouchNavAt < 500 && isPendingTouchClick(event)) {
304
+ const target = navigationTarget(event.target);
305
+ const row = target?.closest('[role="treeitem"]');
306
+ if (row !== null && row !== void 0) {
307
+ pendingTouchRow = row;
308
+ navClickArrived = true;
309
+ return;
310
+ }
311
+ }
312
+ if (navigationTarget(event.target) !== null) toggleSidebar();
249
313
  };
250
- document.addEventListener("click", onDrawerClick, true);
251
- return () => document.removeEventListener("click", onDrawerClick, true);
252
- }, [mobile, open, toggleSidebar]);
253
- (0, import_react.useEffect)(() => {
254
- if (!mobile || !open) return;
255
- let timer = null;
256
314
  const onDrawerPointerUp = (event) => {
257
315
  if (event.pointerType !== "touch" && event.pointerType !== "pen") return;
258
- const target = event.target;
259
- if (!(target instanceof Element)) return;
260
- const drawer = document.querySelector(DRAWER_SELECTOR);
261
- if (drawer === null || !drawer.contains(target)) return;
262
- if (navTargetFor(target) === null) return;
263
- if (timer !== null) return;
264
- timer = window.setTimeout(() => {
265
- timer = null;
266
- const frame = document.querySelector('[data-mobile-nav="frame"]');
267
- if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) return;
268
- const row = navTargetFor(target);
269
- row?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
270
- }, 0);
316
+ const target = navigationTarget(event.target);
317
+ if (target === null) return;
318
+ const row = target.closest('[role="treeitem"]');
319
+ if (row !== null) {
320
+ if (row.getAttribute("aria-selected") === "true") {
321
+ suppressTouchClickUntil = performance.now() + 500;
322
+ toggleSidebar();
323
+ } else {
324
+ lastTouchNavAt = performance.now();
325
+ lastTouchX = event.clientX;
326
+ lastTouchY = event.clientY;
327
+ armNav(row);
328
+ }
329
+ return;
330
+ }
271
331
  };
332
+ document.addEventListener("click", onDrawerClick, true);
272
333
  document.addEventListener("pointerup", onDrawerPointerUp, true);
273
334
  return () => {
274
- if (timer !== null) window.clearTimeout(timer);
335
+ disarmNav();
336
+ document.removeEventListener("click", onDrawerClick, true);
275
337
  document.removeEventListener("pointerup", onDrawerPointerUp, true);
276
338
  };
277
- }, [mobile, open]);
339
+ }, [mobile, open, toggleSidebar]);
278
340
  (0, import_react.useEffect)(() => {
279
341
  if (!mobile || !open) return;
280
342
  const onOutsideClick = (event) => {
@@ -283,6 +345,8 @@ function MobileNavOverlay({ toggleSidebar, t }) {
283
345
  if (target === null) return;
284
346
  if (target.closest(TOGGLE_SELECTOR) !== null) return;
285
347
  if (isOverlayTap(target)) return;
348
+ const frame = document.querySelector('[data-mobile-nav="frame"]');
349
+ if (frame === null || frame.hasAttribute("data-sidebar-collapsed")) return;
286
350
  const drawer = document.querySelector(DRAWER_SELECTOR);
287
351
  if (drawer !== null && drawer.contains(target)) return;
288
352
  toggleSidebar();
@@ -824,26 +888,77 @@ var MOBILE_CSS = `
824
888
  font-size: 15px !important;
825
889
  }
826
890
 
891
+ /* Keep DSH's own process disclosures and their expand/collapse behaviour,
892
+ but remove desktop-sized vertical breathing room between consecutive
893
+ context, Skill, and system-prompt entries. */
894
+ [data-phase] [data-turn-process] {
895
+ height: 28px !important;
896
+ padding-bottom: 4px !important;
897
+ margin-bottom: 4px !important;
898
+ }
899
+ [data-phase] [data-turn-process][data-open] {
900
+ margin-bottom: 4px !important;
901
+ }
902
+ [data-phase] [data-disclosure-row] {
903
+ min-height: 24px !important;
904
+ }
905
+ [data-phase] :is([data-context-injection-body], [data-system-prompt-body]) {
906
+ margin-top: 2px !important;
907
+ }
908
+
827
909
  /* --- Composer bottom row on mobile ---
828
- The official row gives the model pill (trailing) flex:0 0 auto, which
829
- squeezes the agent-permission pill (modes) down to 15px: the pill's
830
- chevron then overflows on top of the model name. Let the permission
831
- pill keep its natural width and let the model pill shrink instead.
832
- Anchored by the composer card (:has(textarea)): row = last child,
833
- tools = first child, permission pill = its 2nd child, model pill =
834
- row's last child. */
835
- [data-phase] [class*="_card"]:has(textarea) > :last-child {
836
- gap: 8px !important;
910
+ Keep add, permission, model, reasoning and send controls on one line at
911
+ the Honor 50's 360px CSS viewport. DSH's stable data-composer-card hook
912
+ survives the editor's textarea -> contenteditable migration. */
913
+ [data-phase] [data-composer-card="true"] > [class$="_row"] {
914
+ flex-wrap: nowrap !important;
915
+ gap: 6px !important;
837
916
  }
838
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :first-child {
917
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child {
839
918
  gap: 8px !important;
919
+ min-width: 0 !important;
840
920
  }
841
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :first-child > :nth-child(2) {
842
- flex: 0 0 auto !important;
921
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child > :nth-child(2) {
922
+ flex: 0 1 auto !important;
923
+ min-width: 0 !important;
843
924
  }
844
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :last-child {
845
- flex: 1 1 auto !important;
925
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :last-child {
926
+ flex: 1 1 0 !important;
846
927
  min-width: 0 !important;
928
+ gap: 6px !important;
929
+ }
930
+
931
+ /* --- Composer popups as bottom sheets on mobile ---
932
+ Two composer-anchored popups break on phones (field: bottom + side
933
+ cut, only part of the popup visible):
934
+ 1. the model pill menu ([role=menu], max 360px, opens upward from the
935
+ pill inside the composer card);
936
+ 2. the "/" command palette (max 320px card with the search box \u2014 it
937
+ hosts the /model popupSelect list: search + provider-grouped rows).
938
+ Both are position:absolute INSIDE the conversation scrollBody
939
+ (overflow:hidden) and the shell center column (overflow:hidden), so
940
+ the scroll containers clip them mid-list. Forensics showed neither
941
+ layer creates a containing block (no transform/contain/will-change),
942
+ so on mobile we snap whichever popup is open to a viewport-anchored
943
+ sheet: fixed positioning escapes the scroll clip entirely, width is
944
+ deterministic, safe-area keeps it off the gesture bar. A transient
945
+ picker covering the composer is standard mobile UX; selection or an
946
+ outside tap still dismisses it. */
947
+ [data-phase] [class$="_root"]:has(> [aria-haspopup="menu"]) > [role="menu"],
948
+ [data-phase] [class$="_card"]:has(> [class$="_search"]) {
949
+ position: fixed !important;
950
+ left: 12px !important;
951
+ right: 12px !important;
952
+ top: auto !important;
953
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 12px) !important;
954
+ width: auto !important;
955
+ min-width: 0 !important;
956
+ max-width: none !important;
957
+ max-height: min(65vh, 480px) !important;
958
+ max-height: min(65dvh, 480px) !important;
959
+ z-index: 130 !important;
960
+ border-radius: 14px !important;
961
+ box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
847
962
  }
848
963
 
849
964
  /* --- Session header on mobile ---
@@ -854,14 +969,22 @@ var MOBILE_CSS = `
854
969
  header > :first-child titleRow (titleCluster + utilities)
855
970
  header > :first-child > :last-child headerUtilities (Session log seat) */
856
971
  [data-phase] header {
857
- padding-right: 12px !important;
972
+ padding: 8px 12px 0 !important;
858
973
  }
859
- /* Give the title row a lane clear of the absolutely-placed toggle, then
860
- balance the header: with header padding-right 12px, a 20px left
861
- padding puts the title's geometric center exactly on the viewport
862
- center (measured 195/195 at 390px). */
974
+ /* The directory and Files controls are absolutely positioned, so reserve
975
+ their lanes and let the title use the remaining width without squeezing. */
863
976
  [data-phase] header > :first-child {
864
- padding-left: 20px !important;
977
+ min-height: 36px !important;
978
+ padding: 0 32px !important;
979
+ }
980
+ [data-phase] header [class$="_titleCluster"],
981
+ [data-phase] header [class$="_crumbs"] {
982
+ min-width: 0 !important;
983
+ }
984
+ [data-phase] header button[class*="_crumb"] {
985
+ max-width: calc(100vw - 104px) !important;
986
+ padding-left: 0 !important;
987
+ padding-right: 0 !important;
865
988
  }
866
989
  /* The directory toggle sits at the far left of the header (the header
867
990
  is position:relative; the data-slot wrappers are display:contents). */
@@ -1588,11 +1711,10 @@ function mobileApply(ctx) {
1588
1711
  }
1589
1712
  };
1590
1713
  const mark = () => {
1591
- for (const root of document.querySelectorAll('[data-phase] [class$="_root"]')) {
1592
- if (root.closest('[class$="_composerStack"]') === null) continue;
1714
+ const selector = '[data-phase] [data-slot="conversation.composer.dock"] [class$="_root"]';
1715
+ for (const root of document.querySelectorAll(selector)) {
1593
1716
  const text = root.textContent ?? "";
1594
1717
  if (!/(turns|steps|\bLLM\b|轮|步)/.test(text)) continue;
1595
- if (root.querySelector("textarea") !== null) continue;
1596
1718
  root.setAttribute("data-mobile-nav", "stats");
1597
1719
  moveTps(root);
1598
1720
  return;
@@ -2540,6 +2662,16 @@ function PocketSettingsTab({ rpcCall, t }) {
2540
2662
  );
2541
2663
  }
2542
2664
  function apply(ctx) {
2665
+ if (ctx?.connection) {
2666
+ try {
2667
+ Object.defineProperty(ctx.connection, "isLoopback", { value: true, writable: true, configurable: true });
2668
+ } catch {
2669
+ try {
2670
+ ctx.connection.isLoopback = true;
2671
+ } catch {
2672
+ }
2673
+ }
2674
+ }
2543
2675
  mobileApply(ctx);
2544
2676
  const rpcCall = (endpoint, payload, signal) => ctx.connection.rpc.call(POCKET_RPC_CHANNEL, endpoint, payload, signal);
2545
2677
  const translate = ctx.locale.bind(NS2);
package/client/index.jsx CHANGED
@@ -625,6 +625,18 @@ function PocketSettingsTab({ rpcCall, t }) {
625
625
  }
626
626
 
627
627
  export function apply(ctx) {
628
+ // 双保险:确保 connection.isLoopback 为 true(issue #58)。
629
+ // 主修复在代理注入的 loopback 补丁(proxy.mjs LOOPBACK_ENV_PATCH)——它在
630
+ // connection 模块 provide 时就改写句柄,早于 ui-settings 选择镜像模式;
631
+ // 这里兜底覆盖时序差异(若本插件 apply 晚于 ui-settings,则只能影响后续读者)。
632
+ if (ctx?.connection) {
633
+ try {
634
+ Object.defineProperty(ctx.connection, 'isLoopback', { value: true, writable: true, configurable: true });
635
+ } catch {
636
+ try { ctx.connection.isLoopback = true; } catch { /* 忽略 */ }
637
+ }
638
+ }
639
+
628
640
  // 移动端适配(dsh-web-mobile 移植):抽屉布局/触控/安全区,仅窄屏生效
629
641
  mobileApply(ctx);
630
642
 
@@ -102,9 +102,9 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
102
102
 
103
103
  // Navigation inside the drawer closes it: tapping a session row or a
104
104
  // plugin takeover entry (task board / ssh) must hand the screen to the
105
- // content it just opened. Capture phase the drawer closes before the
106
- // shell or a plugin processes the click, so takeover panels never render
107
- // under the open drawer.
105
+ // content it just opened. Mouse clicks keep the direct close path. Touch
106
+ // session rows close only after aria-selected changes, so live session
107
+ // updates cannot strand a delayed click on a detached DOM target.
108
108
  //
109
109
  // Deliberately NOT closed by this rule:
110
110
  // - Settings / Session log: their dialogs render INSIDE the drawer DOM
@@ -115,67 +115,127 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
115
115
  // - Anything while a modal dialog is open: the dialog owns the screen.
116
116
  useEffect(() => {
117
117
  if (!mobile || !open) return
118
- const onDrawerClick = (event: MouseEvent) => {
119
- if (document.querySelector('[aria-modal="true"]') !== null) return
120
- const target = event.target as HTMLElement | null
121
- if (target === null) return
122
- const drawer = document.querySelector<HTMLElement>(DRAWER_SELECTOR)
123
- if (drawer === null || !drawer.contains(target)) return
124
- // A session row's own action buttons — the "Session actions" kebab
125
- // (delete / rename), revealed on hover / long-press — open an edit
126
- // menu. Tapping one must NOT count as tapping the row, or the drawer
127
- // would close and take the just-opened menu with it.
128
- if (navTargetFor(target) !== null) toggleSidebar()
118
+ let lastTouchNavAt = 0
119
+ let lastTouchX = 0
120
+ let lastTouchY = 0
121
+ let suppressTouchClickUntil = 0
122
+ let pendingTouchRow: Element | null = null
123
+ let selectedRowAtArm: Element | null = null
124
+ let navClickArrived = false
125
+ let navObserver: MutationObserver | null = null
126
+ let navTimer: number | null = null
127
+
128
+ const drawerRoot = (): HTMLElement | null =>
129
+ document.querySelector<HTMLElement>(DRAWER_SELECTOR)
130
+
131
+ const disarmNav = (): void => {
132
+ navObserver?.disconnect()
133
+ navObserver = null
134
+ if (navTimer !== null) window.clearTimeout(navTimer)
135
+ navTimer = null
136
+ pendingTouchRow = null
137
+ selectedRowAtArm = null
138
+ navClickArrived = false
139
+ }
140
+
141
+ const armNav = (row: Element): void => {
142
+ disarmNav()
143
+ pendingTouchRow = row
144
+ const drawer = drawerRoot()
145
+ selectedRowAtArm = drawer?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
146
+ if (drawer === null) return
147
+ navObserver = new MutationObserver(() => {
148
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
149
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) {
150
+ disarmNav()
151
+ return
152
+ }
153
+ const selectedRow = drawerRoot()?.querySelector('[role="treeitem"][aria-selected="true"]') ?? null
154
+ if (navClickArrived && selectedRow !== null && selectedRow !== selectedRowAtArm) {
155
+ disarmNav()
156
+ toggleSidebar()
157
+ }
158
+ })
159
+ navObserver.observe(drawer, {
160
+ childList: true,
161
+ subtree: true,
162
+ attributes: true,
163
+ attributeFilter: ['aria-selected'],
164
+ })
165
+ navTimer = window.setTimeout(disarmNav, 2000)
166
+ }
167
+
168
+ const navigationTarget = (target: EventTarget | null): Element | null => {
169
+ if (document.querySelector('[aria-modal="true"]') !== null) return null
170
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
171
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return null
172
+ if (!(target instanceof Element)) return null
173
+ const drawer = drawerRoot()
174
+ if (drawer === null || !drawer.contains(target)) return null
175
+ return navTargetFor(target)
176
+ }
177
+
178
+ const isPendingTouchClick = (event: MouseEvent): boolean => {
179
+ const capabilities = (event as MouseEvent & {
180
+ sourceCapabilities?: { firesTouchEvents?: boolean }
181
+ }).sourceCapabilities
182
+ if (capabilities?.firesTouchEvents === true) return true
183
+ return Math.hypot(event.clientX - lastTouchX, event.clientY - lastTouchY) <= 24
184
+ }
185
+
186
+ const onDrawerClick = (event: MouseEvent): void => {
187
+ // A touch row tap owns the close through the selection observer. Let the
188
+ // browser click reach React without a capture-phase close racing it.
189
+ if (performance.now() < suppressTouchClickUntil) return
190
+ if (
191
+ pendingTouchRow !== null
192
+ && performance.now() - lastTouchNavAt < 500
193
+ && isPendingTouchClick(event)
194
+ ) {
195
+ const target = navigationTarget(event.target)
196
+ const row = target?.closest('[role="treeitem"]')
197
+ if (row !== null && row !== undefined) {
198
+ pendingTouchRow = row
199
+ navClickArrived = true
200
+ return
201
+ }
202
+ }
203
+ if (navigationTarget(event.target) !== null) toggleSidebar()
129
204
  }
130
- document.addEventListener('click', onDrawerClick, true)
131
- return () => document.removeEventListener('click', onDrawerClick, true)
132
- }, [mobile, open, toggleSidebar])
133
205
 
134
- // iOS Safari touch self-heal (issue #72).
135
- //
136
- // A tap on a drawer row is delivered to the page as touchstart/touchend
137
- // plus a browser-synthesized click. On iOS that click is routinely
138
- // suppressed: a few px of finger drift is classified as a pan, and any DOM
139
- // shift under the finger before dispatch cancels it outright. When it does
140
- // not arrive, neither the row's own onClick nor the capture handler above
141
- // runs — the row looks completely dead ("抽屉点了没反应").
142
- //
143
- // So: on touch/pen pointerup inside the drawer that hits a navigation row,
144
- // arm a one-macrotask timer. When it fires:
145
- // - the drawer is already closed → the real click did arrive and handled
146
- // everything → do nothing (zero interference with the normal path);
147
- // - otherwise the click never came → re-dispatch a bubbling click on the
148
- // row ourselves. React's delegated listener runs the row's onClick (the
149
- // session opens / the workspace switches) and the same click bubbles
150
- // through the capture handler above, which closes the drawer.
151
- //
152
- // Mouse/pen-on-desktop keeps the plain click path: pointerType is 'mouse'.
153
- useEffect(() => {
154
- if (!mobile || !open) return
155
- let timer: number | null = null
156
206
  const onDrawerPointerUp = (event: PointerEvent): void => {
157
207
  if (event.pointerType !== 'touch' && event.pointerType !== 'pen') return
158
- const target = event.target
159
- if (!(target instanceof Element)) return
160
- const drawer = document.querySelector<HTMLElement>(DRAWER_SELECTOR)
161
- if (drawer === null || !drawer.contains(target)) return
162
- if (navTargetFor(target) === null) return
163
- if (timer !== null) return
164
- timer = window.setTimeout(() => {
165
- timer = null
166
- const frame = document.querySelector('[data-mobile-nav="frame"]')
167
- // Real click already closed it — nothing to heal.
168
- if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
169
- const row = navTargetFor(target)
170
- row?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
171
- }, 0)
208
+ const target = navigationTarget(event.target)
209
+ if (target === null) return
210
+
211
+ const row = target.closest('[role="treeitem"]')
212
+ if (row !== null) {
213
+ if (row.getAttribute('aria-selected') === 'true') {
214
+ // Already-selected rows will not navigate; closing now is safe.
215
+ suppressTouchClickUntil = performance.now() + 500
216
+ toggleSidebar()
217
+ } else {
218
+ // Let React navigate first, then close on the selection mutation.
219
+ lastTouchNavAt = performance.now()
220
+ lastTouchX = event.clientX
221
+ lastTouchY = event.clientY
222
+ armNav(row)
223
+ }
224
+ return
225
+ }
226
+
227
+ // Non-row targets keep their existing click path; only host
228
+ // session/search rows participate in the selected-state hand-off.
172
229
  }
230
+
231
+ document.addEventListener('click', onDrawerClick, true)
173
232
  document.addEventListener('pointerup', onDrawerPointerUp, true)
174
233
  return () => {
175
- if (timer !== null) window.clearTimeout(timer)
234
+ disarmNav()
235
+ document.removeEventListener('click', onDrawerClick, true)
176
236
  document.removeEventListener('pointerup', onDrawerPointerUp, true)
177
237
  }
178
- }, [mobile, open])
238
+ }, [mobile, open, toggleSidebar])
179
239
 
180
240
  // Tap-outside closes the drawer (issue #38). The backdrop is now
181
241
  // pointer-events: none (pure dimming layer that never steals clicks), so
@@ -198,6 +258,8 @@ export function MobileNavOverlay({ toggleSidebar, t }: MobileNavOverlayProps) {
198
258
  // unmounts with the sidebar, and the item's onClick never runs, so
199
259
  // every workspace control reads as "点了没反应" on a phone.
200
260
  if (isOverlayTap(target)) return
261
+ const frame = document.querySelector('[data-mobile-nav="frame"]')
262
+ if (frame === null || frame.hasAttribute('data-sidebar-collapsed')) return
201
263
  const drawer = document.querySelector<HTMLElement>(DRAWER_SELECTOR)
202
264
  if (drawer !== null && drawer.contains(target)) return
203
265
  toggleSidebar()
@@ -182,11 +182,10 @@ export function mobileApply(ctx): void {
182
182
 
183
183
  // The official conversation status row (turns / steps / LLM time / TTFT /
184
184
  // cache) has a hashed class, so the stylesheet cannot target it directly.
185
- // Mark the exact row on narrow screens by text: a [class$=_root] that
186
- // carries the metrics text and no textarea (the composer card also ends in
187
- // _root and can mention turns in its model line). The CSS then lays the
188
- // marked row out as ONE horizontally scrolling line with every metric
189
- // reachable.
185
+ // Its stable boundary is the official conversation.composer.dock slot. Mark
186
+ // only the metrics root inside that slot; never scan every *_root under the
187
+ // composer because the editor itself is now contenteditable (not textarea)
188
+ // and its root also contains the dock text.
190
189
  ctx.effect(() => {
191
190
  if (!narrow.matches) return () => {}
192
191
  // The composer root renders the TPS readout ("TPS 89.4 tok/s") as its
@@ -206,13 +205,10 @@ export function mobileApply(ctx): void {
206
205
  }
207
206
  }
208
207
  const mark = (): void => {
209
- for (const root of document.querySelectorAll('[data-phase] [class$="_root"]')) {
210
- // The status row lives inside the composer stack; message-area
211
- // blocks can also mention turns/steps and must be skipped.
212
- if (root.closest('[class$="_composerStack"]') === null) continue
208
+ const selector = '[data-phase] [data-slot="conversation.composer.dock"] [class$="_root"]'
209
+ for (const root of document.querySelectorAll(selector)) {
213
210
  const text = root.textContent ?? ''
214
211
  if (!/(turns|steps|\bLLM\b|轮|步)/.test(text)) continue
215
- if (root.querySelector('textarea') !== null) continue
216
212
  root.setAttribute('data-mobile-nav', 'stats')
217
213
  moveTps(root)
218
214
  return
@@ -333,26 +333,77 @@ export const MOBILE_CSS = `
333
333
  font-size: 15px !important;
334
334
  }
335
335
 
336
+ /* Keep DSH's own process disclosures and their expand/collapse behaviour,
337
+ but remove desktop-sized vertical breathing room between consecutive
338
+ context, Skill, and system-prompt entries. */
339
+ [data-phase] [data-turn-process] {
340
+ height: 28px !important;
341
+ padding-bottom: 4px !important;
342
+ margin-bottom: 4px !important;
343
+ }
344
+ [data-phase] [data-turn-process][data-open] {
345
+ margin-bottom: 4px !important;
346
+ }
347
+ [data-phase] [data-disclosure-row] {
348
+ min-height: 24px !important;
349
+ }
350
+ [data-phase] :is([data-context-injection-body], [data-system-prompt-body]) {
351
+ margin-top: 2px !important;
352
+ }
353
+
336
354
  /* --- Composer bottom row on mobile ---
337
- The official row gives the model pill (trailing) flex:0 0 auto, which
338
- squeezes the agent-permission pill (modes) down to 15px: the pill's
339
- chevron then overflows on top of the model name. Let the permission
340
- pill keep its natural width and let the model pill shrink instead.
341
- Anchored by the composer card (:has(textarea)): row = last child,
342
- tools = first child, permission pill = its 2nd child, model pill =
343
- row's last child. */
344
- [data-phase] [class*="_card"]:has(textarea) > :last-child {
345
- gap: 8px !important;
355
+ Keep add, permission, model, reasoning and send controls on one line at
356
+ the Honor 50's 360px CSS viewport. DSH's stable data-composer-card hook
357
+ survives the editor's textarea -> contenteditable migration. */
358
+ [data-phase] [data-composer-card="true"] > [class$="_row"] {
359
+ flex-wrap: nowrap !important;
360
+ gap: 6px !important;
346
361
  }
347
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :first-child {
362
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child {
348
363
  gap: 8px !important;
364
+ min-width: 0 !important;
349
365
  }
350
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :first-child > :nth-child(2) {
351
- flex: 0 0 auto !important;
366
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :first-child > :nth-child(2) {
367
+ flex: 0 1 auto !important;
368
+ min-width: 0 !important;
352
369
  }
353
- [data-phase] [class*="_card"]:has(textarea) > :last-child > :last-child {
354
- flex: 1 1 auto !important;
370
+ [data-phase] [data-composer-card="true"] > [class$="_row"] > :last-child {
371
+ flex: 1 1 0 !important;
355
372
  min-width: 0 !important;
373
+ gap: 6px !important;
374
+ }
375
+
376
+ /* --- Composer popups as bottom sheets on mobile ---
377
+ Two composer-anchored popups break on phones (field: bottom + side
378
+ cut, only part of the popup visible):
379
+ 1. the model pill menu ([role=menu], max 360px, opens upward from the
380
+ pill inside the composer card);
381
+ 2. the "/" command palette (max 320px card with the search box — it
382
+ hosts the /model popupSelect list: search + provider-grouped rows).
383
+ Both are position:absolute INSIDE the conversation scrollBody
384
+ (overflow:hidden) and the shell center column (overflow:hidden), so
385
+ the scroll containers clip them mid-list. Forensics showed neither
386
+ layer creates a containing block (no transform/contain/will-change),
387
+ so on mobile we snap whichever popup is open to a viewport-anchored
388
+ sheet: fixed positioning escapes the scroll clip entirely, width is
389
+ deterministic, safe-area keeps it off the gesture bar. A transient
390
+ picker covering the composer is standard mobile UX; selection or an
391
+ outside tap still dismisses it. */
392
+ [data-phase] [class$="_root"]:has(> [aria-haspopup="menu"]) > [role="menu"],
393
+ [data-phase] [class$="_card"]:has(> [class$="_search"]) {
394
+ position: fixed !important;
395
+ left: 12px !important;
396
+ right: 12px !important;
397
+ top: auto !important;
398
+ bottom: calc(env(safe-area-inset-bottom, 0px) + 12px) !important;
399
+ width: auto !important;
400
+ min-width: 0 !important;
401
+ max-width: none !important;
402
+ max-height: min(65vh, 480px) !important;
403
+ max-height: min(65dvh, 480px) !important;
404
+ z-index: 130 !important;
405
+ border-radius: 14px !important;
406
+ box-shadow: 0 -4px 28px rgba(0, 0, 0, .18) !important;
356
407
  }
357
408
 
358
409
  /* --- Session header on mobile ---
@@ -363,14 +414,22 @@ export const MOBILE_CSS = `
363
414
  header > :first-child titleRow (titleCluster + utilities)
364
415
  header > :first-child > :last-child headerUtilities (Session log seat) */
365
416
  [data-phase] header {
366
- padding-right: 12px !important;
417
+ padding: 8px 12px 0 !important;
367
418
  }
368
- /* Give the title row a lane clear of the absolutely-placed toggle, then
369
- balance the header: with header padding-right 12px, a 20px left
370
- padding puts the title's geometric center exactly on the viewport
371
- center (measured 195/195 at 390px). */
419
+ /* The directory and Files controls are absolutely positioned, so reserve
420
+ their lanes and let the title use the remaining width without squeezing. */
372
421
  [data-phase] header > :first-child {
373
- padding-left: 20px !important;
422
+ min-height: 36px !important;
423
+ padding: 0 32px !important;
424
+ }
425
+ [data-phase] header [class$="_titleCluster"],
426
+ [data-phase] header [class$="_crumbs"] {
427
+ min-width: 0 !important;
428
+ }
429
+ [data-phase] header button[class*="_crumb"] {
430
+ max-width: calc(100vw - 104px) !important;
431
+ padding-left: 0 !important;
432
+ padding-right: 0 !important;
374
433
  }
375
434
  /* The directory toggle sits at the far left of the header (the header
376
435
  is position:relative; the data-slot wrappers are display:contents). */
package/lib/index.js CHANGED
@@ -15,7 +15,7 @@ import { readFileSync, mkdirSync, writeFileSync } from 'node:fs';
15
15
  import { writeFile, readFile, mkdir, rm } from 'node:fs/promises';
16
16
  import { join, dirname } from 'node:path';
17
17
  import { homedir } from 'node:os';
18
- import { randomBytes } from 'node:crypto';
18
+ import { randomBytes, randomInt } from 'node:crypto';
19
19
 
20
20
  import { createPocketService } from './service.mjs';
21
21
  import { installPocketRpc } from './web-rpc.js';
@@ -65,8 +65,15 @@ function readPin(p) {
65
65
  } catch { /* 无文件 */ }
66
66
  return null;
67
67
  }
68
+ /**
69
+ * 生成 8 位访问 PIN(issue #90)。
70
+ * 必须用 CSPRNG。语言内置的非加密随机数(V8 的 xorshift128+)是可预测的:拿到少量
71
+ * 输出即可还原内部状态并推算后续值——用它生成访问密码,等于把 9×10⁷ 的搜索空间
72
+ * 进一步压缩。`randomInt(min, max)` 上界开区间,取值 10000000..99999999。
73
+ * test/auth-routing.test.js 有源码守卫,禁止这里再出现非加密随机数调用。
74
+ */
68
75
  function newPin() {
69
- return String(Math.floor(10000000 + Math.random() * 90000000));
76
+ return String(randomInt(10_000_000, 100_000_000));
70
77
  }
71
78
 
72
79
  // --- 公网密码 ---
package/lib/proxy.mjs CHANGED
@@ -14,7 +14,7 @@
14
14
  import { createServer } from 'node:http';
15
15
  import { request as httpRequest } from 'node:http';
16
16
  import { createGzip, createBrotliCompress, constants as zlibConstants } from 'node:zlib';
17
- import { createHash } from 'node:crypto';
17
+ import { createHash, timingSafeEqual } from 'node:crypto';
18
18
 
19
19
  const DEFAULT_UPSTREAM = { host: '127.0.0.1', port: 3080 };
20
20
 
@@ -66,8 +66,75 @@ function isCompressed(headers) {
66
66
  return /(^|,\s*)(gzip|br|deflate)(\s*,|$)/i.test(String(headers['content-encoding'] ?? ''));
67
67
  }
68
68
 
69
- /** 默认注入到经代理的 HTML 文档里:crypto.randomUUID polyfill(非安全上下文必需)。 */
70
- export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL;
69
+ /**
70
+ * DSH 客户端信任环境补丁(issue #58 的可行修法):
71
+ * 手机/局域网浏览器访问时 location.hostname 不是回环地址,dsh-client-connection 据此
72
+ * 把 ctx.connection.isLoopback 判定为 false,dsh-client-ui-settings 便把 settings 镜像
73
+ * 置为 memory 模式(不拉取 settings.describe),模型/通用设置页于是报
74
+ * "settings are unavailable in this browser"(issue #58)。
75
+ *
76
+ * 与已回退的「全局 let location + Proxy」伪装方案不同,本补丁**不碰 location**——
77
+ * 它包装 window.__ModuleLoader__,在 dsh-client-connection 模块向 Cordis 容器
78
+ * provide('connection') 时把服务句柄的 isLoopback 强制为 true。经本代理(带 PIN 鉴权、
79
+ * 请求头已回环化)的远程访问由此获得与本机一致的完整设置能力,会话列表回归不存在。
80
+ *
81
+ * 关键细节:HTML 只预加载 client-modules/client-runtime 两个 bundle(队列模式注册);
82
+ * connection 等插件 bundle 是 create() 启动后由加载器**动态**加载的,而 create() 会把
83
+ * facade.load 整体替换为 live 注册函数。因此不能只包一次 load 函数——必须在 facade
84
+ * 对象上把 load 装成访问器(getter/setter),每次赋值(队列→live 切换)都重新包装。
85
+ */
86
+ const LOOPBACK_ENV_PATCH = `<script data-dsh-pocket-loopback-patch="1">!function(){try{
87
+ var ml=window.__ModuleLoader__;
88
+ function wrapFactory(h){
89
+ var of=h.factory;
90
+ h.factory=function(r){
91
+ var exp=of.apply(this,arguments);
92
+ if(exp&&typeof exp.apply==='function'){
93
+ var oa=exp.apply;
94
+ exp.apply=function(ctx){
95
+ var op=ctx&&ctx.provide;
96
+ if(typeof op==='function'){
97
+ ctx.provide=function(n,v){
98
+ if(n==='connection'&&v&&typeof v==='object'){
99
+ try{Object.defineProperty(v,'isLoopback',{value:true,writable:true,configurable:true});}catch(e){v.isLoopback=true;}
100
+ }
101
+ return op.apply(this,arguments);
102
+ };
103
+ }
104
+ return oa.apply(this,arguments);
105
+ };
106
+ }
107
+ return exp;
108
+ };
109
+ }
110
+ function wrapLoad(fn){
111
+ return function(h){
112
+ if(h&&h.id&&String(h.id).indexOf('connection')!==-1&&typeof h.factory==='function'){wrapFactory(h);}
113
+ return fn.call(this,h);
114
+ };
115
+ }
116
+ function wrapFacade(facade){
117
+ if(!facade||facade._dsh_p_w)return facade;
118
+ var current=wrapLoad(facade.load);
119
+ try{
120
+ Object.defineProperty(facade,'load',{
121
+ configurable:true,
122
+ get:function(){return current;},
123
+ set:function(fn){current=wrapLoad(fn);}
124
+ });
125
+ }catch(e){facade.load=current;}
126
+ facade._dsh_p_w=true;
127
+ return facade;
128
+ }
129
+ if(ml){wrapFacade(ml);}
130
+ else{
131
+ var cur=undefined;
132
+ Object.defineProperty(window,'__ModuleLoader__',{configurable:true,enumerable:true,get:function(){return cur;},set:function(v){cur=wrapFacade(v);}});
133
+ }
134
+ }catch(e){}}();</script>`;
135
+
136
+ /** 默认注入到经代理的 HTML 文档里:polyfill + loopback 信任环境补丁(issue #58)。 */
137
+ export const DEFAULT_INJECT = RANDOM_UUID_POLYFILL + LOOPBACK_ENV_PATCH;
71
138
 
72
139
  /**
73
140
  * DSH Desktop advanced 模式不支持的提示覆盖层(issue #19)。
@@ -230,6 +297,52 @@ function isProtectedHost(host, isProtected) {
230
297
  return isProtected ? isProtected(host) : classifyHost(host) === 'public';
231
298
  }
232
299
 
300
+ /** 保护强度序:本机最弱(可免密)→ 局域网(按开关)→ 公网(永远要密码)。 */
301
+ const HOST_CLASS_RANK = { loopback: 0, lan: 1, public: 2 };
302
+
303
+ /**
304
+ * 按 TCP 源地址给出来源类别(issue #90)。
305
+ * 与 classifyHost 的区别在**兜底方向**:Host 头里认不出的形态按 loopback 处理
306
+ * (历史行为,避免裸 IPv6 之类把本机访问判成公网);而源地址认不出时必须按
307
+ * public 处理——源地址是我们唯一不可伪造的信息,兜底方向错了整条防线就白搭。
308
+ * @returns {'loopback'|'lan'|'public'|null} null 表示拿不到源地址(不做任何收紧)
309
+ */
310
+ export function classifySource(addr) {
311
+ let a = String(addr ?? '').trim().toLowerCase();
312
+ if (!a) return null;
313
+ if (a.startsWith('::ffff:')) a = a.slice(7); // IPv4-mapped IPv6(Node 双栈监听时常见)
314
+ if (a === '::1' || /^127\./.test(a)) return 'loopback';
315
+ // RFC1918 私网 + CGNAT 100.64/10(与 classifyHost 保持同一套网段判定)
316
+ if (/^(?:10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|100\.(?:6[4-9]|[7-9]\d|1(?:0\d|1\d|2[0-7]))\.)/.test(a)) return 'lan';
317
+ if (/^169\.254\./.test(a)) return 'lan'; // IPv4 link-local
318
+ if (/^(?:fe80:|f[cd][0-9a-f]{2}:)/.test(a)) return 'lan'; // IPv6 link-local / ULA
319
+ return 'public';
320
+ }
321
+
322
+ /**
323
+ * 用于策略判定的 Host(issue #90 第 7 条)。
324
+ *
325
+ * Host 头完全由客户端控制:能直连代理端口的人只要写 `Host: 127.0.0.1:3081`
326
+ * 就会被 classifyHost 判成本机,从而绕过局域网开关和局域网密码——用户若按
327
+ * README 关掉了局域网密码,这就是一条零认证通道。TCP 源地址无法伪造,用它给
328
+ * Host 声明设一个下限。
329
+ *
330
+ * **只收紧、绝不放松**:经 cloudflared 隧道进来的公网请求源地址正是 127.0.0.1,
331
+ * 若按源地址覆盖就会把公网访问降级成本机免密,比原来的问题更严重。所以仅当
332
+ * 声明的保护级别**低于**来源真实级别时,才改用源地址参与判定。
333
+ */
334
+ export function policyHost(req, host) {
335
+ const actual = classifySource(req?.socket?.remoteAddress);
336
+ if (!actual) return host;
337
+ const claimed = classifyHost(host);
338
+ if (HOST_CLASS_RANK[actual] <= HOST_CLASS_RANK[claimed]) return host;
339
+ // 用真实源地址替代被伪造的 Host 参与后续全部策略判定(密码归属、局域网开关、
340
+ // 局域网地址覆盖),保证各处判定看到的是同一个来源。
341
+ let addr = String(req.socket.remoteAddress);
342
+ if (addr.toLowerCase().startsWith('::ffff:')) addr = addr.slice(7);
343
+ return addr;
344
+ }
345
+
233
346
  /**
234
347
  * 该 Host 是否 loopback(本机 / cloudflared 回环)。
235
348
  * 「关闭局域网」只拦截经局域网 IP/主机名访问的请求,loopback 与公网(trycloudflare)放行:
@@ -296,7 +409,42 @@ code{background:#f3f4f6;padding:2px 6px;border-radius:6px;font-size:12px;color:#
296
409
  /** 请求是否期望 HTML(浏览器导航 → 返回登录页;API/WS → 401)。 */
297
410
  function isHtmlRequest(req) {
298
411
  const accept = String(req.headers.accept ?? '');
299
- return accept.includes('text/html') || req.url === '/' || /\.html?$/i.test(String(req.url));
412
+ if (accept.includes('text/html')) return true;
413
+ const url = String(req.url ?? '');
414
+ // 按 pathname 判断,别用 `url === '/'` 严格比 —— 根路径常带 query
415
+ // (`/?dsh-pocket-auth=1`、`/?dsh-pocket-retry=1`、`/?token=…`),
416
+ // 那些同样是浏览器导航,漏判会让它们拿到 401/303 而不是该给的页面。
417
+ let pathname = url;
418
+ try { pathname = new URL(url || '/', 'http://dsh.invalid').pathname; } catch { /* 用原值兜底 */ }
419
+ return pathname === '/' || /\.html?$/i.test(pathname);
420
+ }
421
+
422
+ /**
423
+ * 常量时间的密码比较(issue #90):普通 `===` 会在首个不同字节处提前返回,
424
+ * 理论上可被计时侧信道逐字节还原 PIN。长度不同直接判否(PIN 长度固定,
425
+ * 长度本身不是秘密),等长则走 timingSafeEqual。
426
+ */
427
+ function safeEqual(a, b) {
428
+ const ba = Buffer.from(String(a ?? ''), 'utf8');
429
+ const bb = Buffer.from(String(b ?? ''), 'utf8');
430
+ if (ba.length !== bb.length) return false;
431
+ return timingSafeEqual(ba, bb);
432
+ }
433
+
434
+ /**
435
+ * 请求是否携带 `?token=` —— 用于区分「一次密码尝试」与「普通未认证访问」(issue #90)。
436
+ * 只有前者计入限速失败:普通未认证访问(无 cookie 无 token)本来就该看到登录页,
437
+ * 若也计数,正常用户第一次打开页面就会把自己锁死(子资源还会放大)。
438
+ * cookie 不匹配同样不计数——cookie 值是 sha256(PIN:sessionKey),攻击者不知道
439
+ * 进程级 sessionKey,这条通道本身不可穷举;而 dsh web 重启后旧 cookie 必然失配,
440
+ * 计数只会误锁老用户。
441
+ */
442
+ function hasQueryToken(req) {
443
+ try {
444
+ return new URL(req.url ?? '/', 'http://x').searchParams.get('token') != null;
445
+ } catch {
446
+ return false;
447
+ }
300
448
  }
301
449
 
302
450
  /** 校验请求是否已认证。返回 { ok, rawQueryToken }:
@@ -309,7 +457,7 @@ function authCheck(req, tokens, sessionKey) {
309
457
  const cookieTok = cookies[TOKEN_COOKIE];
310
458
  if (cookieTok) {
311
459
  for (const token of list) {
312
- if (cookieTok === cookieFor(token, sessionKey)) return { ok: true, rawQueryToken: null };
460
+ if (safeEqual(cookieTok, cookieFor(token, sessionKey))) return { ok: true, rawQueryToken: null };
313
461
  }
314
462
  }
315
463
  const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
@@ -319,7 +467,7 @@ function authCheck(req, tokens, sessionKey) {
319
467
  // 种 HttpOnly 哈希 cookie,让浏览器后续子资源(assets/*.js 等)也走 cookie
320
468
  // 路径——避免「主页 200 但子资源 401」白屏(issue #35)。
321
469
  for (const token of list) {
322
- if (qTok === token) return { ok: true, rawQueryToken: qTok };
470
+ if (safeEqual(qTok, token)) return { ok: true, rawQueryToken: qTok };
323
471
  }
324
472
  }
325
473
  return { ok: false, rawQueryToken: null };
@@ -346,13 +494,37 @@ function maybeSeedAuthCookie(req, res, rawToken, sessionKey) {
346
494
  };
347
495
  }
348
496
 
349
- /** 把浏览器可见的权威改写成 loopback 权威(Host 和 Origin 都改)。 */
497
+ /**
498
+ * 把浏览器可见的权威改写成 loopback 权威。
499
+ * 除 Host/Origin 外,还必须规范化 Referer 与 Sec-Fetch-Site:DSH 宿主的特权方法
500
+ * 栅栏(settings.describe/credentials.* 等 PRIVILEGED_METHODS)会拒绝
501
+ * sec-fetch-site === 'cross-site',并校验 Origin 与 Host 匹配;远程访问的这两个头
502
+ * 若不改写,设置/凭据平面会在宿主侧被 403(issue #58 的另一半)。
503
+ * 统一小写化键名,避免 Node 原样转发时大小写键并存导致重复头。
504
+ */
350
505
  function loopbackAuthority(headers, upstream) {
351
506
  const authority = `${upstream.host}:${upstream.port}`;
352
- headers.Host = authority;
353
- if (headers.origin) headers.origin = `http://${authority}`;
354
- if (headers.Origin) headers.Origin = `http://${authority}`;
355
- return headers;
507
+ const out = {};
508
+ for (const [k, v] of Object.entries(headers)) {
509
+ const lk = k.toLowerCase();
510
+ if (lk === 'host' || lk === 'origin' || lk === 'referer' || lk === 'sec-fetch-site') continue;
511
+ out[lk] = v;
512
+ }
513
+ out.host = authority;
514
+ out.origin = `http://${authority}`;
515
+ const referer = headers.referer ?? headers.Referer;
516
+ if (referer) {
517
+ try {
518
+ const ref = new URL(referer);
519
+ ref.protocol = 'http:';
520
+ ref.host = authority;
521
+ out.referer = ref.toString();
522
+ } catch {
523
+ out.referer = `http://${authority}/`;
524
+ }
525
+ }
526
+ out['sec-fetch-site'] = 'same-origin';
527
+ return out;
356
528
  }
357
529
 
358
530
  // ---------- dsh web 浏览器会话 token(issue #77) ----------
@@ -407,6 +579,101 @@ export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launch
407
579
  return `${u.pathname}${u.search}`;
408
580
  }
409
581
 
582
+ // ---------- 会话握手重试计数(issue #91) ----------
583
+ // Safari(iOS/macOS)不持久化「http:// + 纯 IP 源」上由 3xx 响应下发的 cookie,
584
+ // 于是 dsh web 的 launch-token→cookie 握手永远收敛不了:代理每次 `GET /`
585
+ // 都补 `?token=`,上游每次 303 回 `/`,浏览器每次都不带 cookie → 无限重定向
586
+ // (Safari 报「发生了太多重定位」)。
587
+ //
588
+ // 两道防线:
589
+ // 1) 代理把这次 303 改写成 200 过渡页(Set-Cookie 照发 + meta refresh 跳回 `/`),
590
+ // 200 响应上的 cookie 不会被 Safari 的重定向 cookie 策略丢掉;
591
+ // 2) 万一 1) 也不管用,用下面的计数器在若干次尝试后停止注入 token 并给出
592
+ // 可操作提示页——宁可给用户一句人话,也不要无限转圈。
593
+ //
594
+ // 只按客户端 IP 计数(无需 cookie 支持,正适合「cookie 用不了」的这个场景)。
595
+ export const DEFAULT_HANDSHAKE_LIMIT = 3;
596
+ export const HANDSHAKE_WINDOW_MS = 60_000;
597
+ /** 提示页「重试」按钮用的查询参数:命中即清空该 IP 的失败计数,且不往上游透传。 */
598
+ export const HANDSHAKE_RETRY_PARAM = 'dsh-pocket-retry';
599
+
600
+ /** 摘掉某个查询参数后重新拼路径;解析失败或本来就没有则原样返回。 */
601
+ export function stripQueryParam(reqUrl, name) {
602
+ let u;
603
+ try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
604
+ if (!u.searchParams.has(name)) return reqUrl;
605
+ u.searchParams.delete(name);
606
+ return `${u.pathname}${u.search}`;
607
+ }
608
+
609
+ export function createHandshakeTracker({ max = DEFAULT_HANDSHAKE_LIMIT, windowMs = HANDSHAKE_WINDOW_MS } = {}) {
610
+ /** ip -> { count, start } */
611
+ const hits = new Map();
612
+ return {
613
+ /** 记一次握手注入,返回窗口内的累计次数。 */
614
+ record(ip, now = Date.now()) {
615
+ const rec = hits.get(ip);
616
+ if (!rec || now - rec.start > windowMs) {
617
+ hits.set(ip, { count: 1, start: now });
618
+ return 1;
619
+ }
620
+ rec.count += 1;
621
+ return rec.count;
622
+ },
623
+ /** 握手成功(拿到会话 cookie 的请求)→ 清零。 */
624
+ clear(ip) {
625
+ hits.delete(ip);
626
+ },
627
+ /** 该 IP 是否已达重试上限。 */
628
+ exhausted(ip) {
629
+ const rec = hits.get(ip);
630
+ return !!rec && rec.count >= max;
631
+ },
632
+ /** 清理过期条目,防长期运行内存膨胀。 */
633
+ prune(now = Date.now()) {
634
+ for (const [ip, rec] of hits) {
635
+ if (now - rec.start > windowMs) hits.delete(ip);
636
+ }
637
+ },
638
+ };
639
+ }
640
+
641
+ /** 握手过渡页:200 + Set-Cookie(由调用方带上)+ meta refresh 跳回干净根路径。 */
642
+ export function handshakePageHtml() {
643
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
644
+ <meta name="viewport" content="width=device-width,initial-scale=1">
645
+ <meta http-equiv="refresh" content="0; url=/">
646
+ <title>DSH Pocket · 正在进入 | opening…</title>
647
+ <style>
648
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
649
+ p{font-size:13px;color:#6b7280;margin:0}
650
+ </style></head><body><p>正在进入… | opening…</p></body></html>`;
651
+ }
652
+
653
+ /** 握手反复失败时的提示页(issue #91):说清原因并给出可操作的规避办法。 */
654
+ export function handshakeBlockedPageHtml() {
655
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
656
+ <meta name="viewport" content="width=device-width,initial-scale=1">
657
+ <title>DSH Pocket · 无法完成登录握手</title>
658
+ <style>
659
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}
660
+ .card{background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:24px 22px;max-width:380px;width:calc(100% - 40px)}
661
+ h1{font-size:15px;margin:0 0 10px;color:#111827}
662
+ p{font-size:13px;color:#6b7280;margin:0 0 10px;line-height:1.7}
663
+ code{background:#f3f4f6;padding:1px 5px;border-radius:4px;font-size:12px}
664
+ a{color:#4f6ef7}
665
+ </style></head><body><div class="card">
666
+ <h1>🔁 无法完成登录握手</h1>
667
+ <p>浏览器没有保存 DSH 下发的会话 cookie,代理反复重试后仍未成功,因此停在这里而不是无限跳转。</p>
668
+ <p><strong>Safari(iOS/macOS)</strong> 在 <code>http://</code> 纯 IP 地址上不会保存这类 cookie,局域网入口因此进不去。</p>
669
+ <p>可以试试:<br>
670
+ ① 换 Chromium 系浏览器(Chrome / Edge)打开局域网地址;<br>
671
+ ② 改用<strong>公网入口</strong>(设置页开启公网访问,拿到 <code>https://…trycloudflare.com</code> 地址)——HTTPS 域名上 Safari 正常。</p>
672
+ <p style="margin-top:14px"><a href="/?${HANDSHAKE_RETRY_PARAM}=1" style="display:inline-block;padding:8px 14px;background:#4f6ef7;color:#fff;border-radius:8px;text-decoration:none;font-size:13px">重试一次 | Retry</a></p>
673
+ <p style="color:#9ca3af;font-size:12px">Browser did not keep the session cookie, so the login handshake could not complete (issue #91). Safari over plain <code>http://</code> + IP is the known case — try Chrome, or use the public HTTPS entry.</p>
674
+ </div></body></html>`;
675
+ }
676
+
410
677
  // ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
411
678
  // DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
412
679
  // 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
@@ -478,10 +745,16 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
478
745
  * @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
479
746
  * @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
480
747
  */
481
- 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 = () => '' } = {}) {
748
+ 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 = () => '', handshakeLimit } = {}) {
482
749
  const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
750
+ // 会话握手重试计数(issue #91):Safari 在 http://IP 源上丢 3xx 的 cookie → 死循环
751
+ const handshake = createHandshakeTracker(
752
+ typeof handshakeLimit === 'number' ? { max: handshakeLimit } : {},
753
+ );
483
754
  const server = createServer((req, res) => {
484
- const host = String(req.headers.host ?? '');
755
+ // 策略判定一律用 policyHost(issue #90):Host 头可伪造,用不可伪造的 TCP 源地址
756
+ // 给它设下限。转发给上游的 Host 由 loopbackAuthority 单独改写,不受这里影响。
757
+ const host = policyHost(req, String(req.headers.host ?? ''));
485
758
  const isPublic = classifyHost(host) === 'public';
486
759
  // 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
487
760
  // 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
@@ -541,8 +814,35 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
541
814
  });
542
815
  return;
543
816
  }
817
+ // `?token=<PIN>` 是与 POST 登录等价的一次密码尝试(issue #90):此前只有 POST
818
+ // 分支调用 limiter.record(),这条通道既不计数也不受锁定约束,等于给攻击者留了
819
+ // 一个可全速穷举 8 位 PIN 的旁路。下面把它并入同一套限速。
820
+ const isGuess = hasQueryToken(req);
821
+ if (isGuess) {
822
+ const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
823
+ // 锁定期内直接拒绝、不做比对——否则锁定窗口本身就是免费的穷举窗口。
824
+ // 正确密码也一并拒绝,与 POST 登录的锁定语义保持一致。
825
+ if (rl.locked) {
826
+ if (isHtmlRequest(req)) {
827
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
828
+ res.end(loginPageHtml('locked', isPublic, rl.retryAfter));
829
+ } else {
830
+ res.writeHead(429, {
831
+ 'content-type': 'application/json',
832
+ 'cache-control': 'no-store',
833
+ 'retry-after': String(rl.retryAfter),
834
+ });
835
+ res.end('{"error":"too-many-attempts"}');
836
+ }
837
+ return;
838
+ }
839
+ }
544
840
  const authResult = authCheck(req, acceptedTokens, sessionKey);
545
841
  if (!authResult.ok) {
842
+ if (isGuess) {
843
+ limiter?.record(ip);
844
+ log?.(`dsh-pocket: bad ?token= from ${ip} | URL 密码错误 IP: ${ip}`);
845
+ }
546
846
  if (isHtmlRequest(req)) {
547
847
  // 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
548
848
  const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
@@ -555,24 +855,74 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
555
855
  return;
556
856
  }
557
857
  // ?token=<原始 PIN> 直达时种 HttpOnly cookie,让浏览器后续子资源也走 cookie 路径(issue #35)
558
- if (authResult.rawQueryToken) maybeSeedAuthCookie(req, res, authResult.rawQueryToken, sessionKey);
858
+ if (authResult.rawQueryToken) {
859
+ limiter?.clear(ip); // 与 POST 登录成功一致:正确的分享链接不该逐步累积到锁定
860
+ maybeSeedAuthCookie(req, res, authResult.rawQueryToken, sessionKey);
861
+ }
559
862
  }
560
863
  }
561
864
  const headers = loopbackAuthority({ ...req.headers }, upstream);
562
865
  // dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
563
866
  const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
564
867
  // 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
565
- const upstreamPath = upstreamPathWithLaunchToken(
566
- stripDesktopMarkers(req.url),
567
- req.method,
568
- req.headers.cookie,
569
- launchTok,
570
- );
868
+ // 握手重试上限(issue #91):Safari 不保存 http://IP 源上 3xx 下发的 cookie →
869
+ // 补 token → 上游 303 → 浏览器仍无 cookie → 无限循环。达到上限就别再补了,
870
+ // 让请求落到提示页,而不是继续转圈。
871
+ const handshakeIp = clientIp(req);
872
+ // `/?dsh-pocket-retry=1`:提示页上的「重试」入口——清掉这一轮的失败计数,让握手
873
+ // 重新走一遍(否则用户得干等窗口过期)。这参数是我们自己加的,不往上游透传。
874
+ let cleanPath = stripDesktopMarkers(req.url);
875
+ if (cleanPath.includes(HANDSHAKE_RETRY_PARAM)) {
876
+ handshake.clear(handshakeIp);
877
+ cleanPath = stripQueryParam(cleanPath, HANDSHAKE_RETRY_PARAM);
878
+ }
879
+ const handshakeOver = launchTok !== '' && handshake.exhausted(handshakeIp);
880
+ const upstreamPath = handshakeOver
881
+ ? cleanPath
882
+ : upstreamPathWithLaunchToken(cleanPath, req.method, req.headers.cookie, launchTok);
883
+ const didInjectToken = upstreamPath !== cleanPath;
884
+ if (didInjectToken) {
885
+ handshake.record(handshakeIp);
886
+ handshake.prune();
887
+ }
888
+ // 请求带上了会话 cookie → 这一轮的握手计数可以清掉了(说明 cookie 通路是好的)
889
+ if (!didInjectToken && String(req.headers.cookie ?? '').includes(DSH_AUTH_COOKIE)) {
890
+ handshake.clear(handshakeIp);
891
+ }
892
+ if (handshakeOver && isHtmlRequest(req)) {
893
+ // 已判定握不上手 → 停在这里给人话,别再转圈。API/WS 不走这里(上游会 401)。
894
+ res.writeHead(503, {
895
+ 'content-type': 'text/html; charset=utf-8',
896
+ 'cache-control': 'no-store',
897
+ 'x-dsh-pocket-handshake': 'blocked',
898
+ });
899
+ res.end(handshakeBlockedPageHtml());
900
+ return;
901
+ }
571
902
  const proxyReq = httpRequest(
572
903
  { host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
573
904
  (proxyRes) => {
574
905
  log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
575
906
  const contentType = String(proxyRes.headers['content-type'] ?? '');
907
+ // issue #91:我们刚注入了 launch token,上游回 303(换 cookie 后回干净根路径)。
908
+ // Safari 不保存 http://IP 源上 3xx 响应下发的 cookie,于是浏览器下次仍无 cookie
909
+ // → 代理再注入 → 再 303 → 死循环。这里把这次 303 改成 200 过渡页:Set-Cookie
910
+ // 照发(200 上的 cookie 不会被那条重定向策略丢掉),页面用 meta refresh 跳回 `/`。
911
+ if (didInjectToken && proxyRes.statusCode === 303 && isHtmlRequest(req)) {
912
+ const out = { ...proxyRes.headers };
913
+ delete out['content-length'];
914
+ delete out['transfer-encoding'];
915
+ delete out.location; // 自己跳,不留给浏览器去重做一次 303
916
+ const page = Buffer.from(handshakePageHtml(), 'utf8');
917
+ out['content-type'] = 'text/html; charset=utf-8';
918
+ out['content-length'] = String(page.length);
919
+ out['cache-control'] = 'no-store';
920
+ out['x-dsh-pocket-handshake'] = 'transition';
921
+ proxyRes.resume(); // 消费掉上游响应体,释放连接
922
+ res.writeHead(200, out);
923
+ res.end(page);
924
+ return;
925
+ }
576
926
  // issue #81:上游 desktop-browser-access 门禁(DSH Desktop 未开启「浏览器访问」时)
577
927
  // 对普通浏览器(含经本代理转发的手机)返回 403 text/plain "forbidden",且本代理无法
578
928
  // 携带 Electron renderer secret 绕过。对符合该特征的**浏览器导航**请求改写为可操作
@@ -625,6 +975,13 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
625
975
  delete outHeaders['content-length'];
626
976
  delete outHeaders['transfer-encoding'];
627
977
  outHeaders['content-length'] = String(out.length);
978
+ // 注入后的 HTML 携带本代理的动态补丁(含注入标记判重),
979
+ // 必须禁用缓存——否则手机/中间层(nginx 等)拿到没有补丁的旧
980
+ // 文档后,isLoopback 修复不生效且难以排查(表现为"改了没效果")。
981
+ outHeaders['cache-control'] = 'no-store';
982
+ delete outHeaders['etag'];
983
+ delete outHeaders['last-modified'];
984
+ delete outHeaders['expires'];
628
985
  res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
629
986
  res.end(out);
630
987
  });
@@ -684,7 +1041,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
684
1041
 
685
1042
  // WebSocket upgrade(DSH 的 /api/events.mux + events.host 流式通道)原样透传
686
1043
  server.on('upgrade', (req, socket, head) => {
687
- const host = String(req.headers.host ?? '');
1044
+ // HTTP 侧同一套判定(issue #90):否则伪造 Host 的 WS 握手仍可绕过局域网开关
1045
+ const host = policyHost(req, String(req.headers.host ?? ''));
688
1046
  const isPublic = classifyHost(host) === 'public';
689
1047
  // 局域网访问关闭:拦截经局域网 Host 的 WS 握手(公网/loopback 放行——issue #66 fail closed)
690
1048
  if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
@@ -697,12 +1055,29 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
697
1055
  const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
698
1056
  const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
699
1057
  const acceptedTokens = token ? [token, ...altTokens] : [];
1058
+ // WS 握手上的 ?token= 与 HTTP 侧同权(issue #90):不并入限速的话,攻击者
1059
+ // 只要把穷举换到 upgrade 请求上就照样不受限。
1060
+ const wsIp = clientIp(req);
1061
+ const wsGuess = hasQueryToken(req);
1062
+ if (token && wsGuess) {
1063
+ const rl = limiter?.status(wsIp) ?? { locked: false, retryAfter: 0 };
1064
+ if (rl.locked) {
1065
+ socket.write(`HTTP/1.1 429 Too Many Requests\r\nRetry-After: ${rl.retryAfter}\r\nConnection: close\r\n\r\n`);
1066
+ socket.destroy();
1067
+ return;
1068
+ }
1069
+ }
700
1070
  const wsAuth = authCheck(req, acceptedTokens, auth.sessionKey ?? null);
701
1071
  if (token && !wsAuth.ok) {
1072
+ if (wsGuess) {
1073
+ limiter?.record(wsIp);
1074
+ log?.(`dsh-pocket: bad ws ?token= from ${wsIp} | WS 密码错误 IP: ${wsIp}`);
1075
+ }
702
1076
  socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
703
1077
  socket.destroy();
704
1078
  return;
705
1079
  }
1080
+ if (token && wsAuth.ok && wsGuess) limiter?.clear(wsIp);
706
1081
  }
707
1082
  const headers = loopbackAuthority({ ...req.headers }, upstream);
708
1083
  const proxyReq = httpRequest({
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  "access": "public",
81
81
  "registry": "https://registry.npmjs.org/"
82
82
  },
83
- "version": "2.10.0"
83
+ "version": "2.10.2"
84
84
  }