dsh-pocket 2.10.1 → 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 +182 -50
- package/client/index.jsx +12 -0
- package/client/mobile/MobileNavOverlay.tsx +117 -55
- package/client/mobile/mobile-apply.tsx +6 -10
- package/client/mobile/mobile.css.ts +79 -20
- package/lib/index.js +9 -2
- package/lib/proxy.mjs +235 -13
- package/package.json +1 -1
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 (
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 (
|
|
260
|
-
const
|
|
261
|
-
if (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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] [
|
|
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] [
|
|
842
|
-
flex: 0
|
|
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] [
|
|
845
|
-
flex: 1 1
|
|
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
|
|
972
|
+
padding: 8px 12px 0 !important;
|
|
858
973
|
}
|
|
859
|
-
/*
|
|
860
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1592
|
-
|
|
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.
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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 (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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] [
|
|
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] [
|
|
351
|
-
flex: 0
|
|
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] [
|
|
354
|
-
flex: 1 1
|
|
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
|
|
417
|
+
padding: 8px 12px 0 !important;
|
|
367
418
|
}
|
|
368
|
-
/*
|
|
369
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
/**
|
|
70
|
-
|
|
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)放行:
|
|
@@ -306,6 +419,34 @@ function isHtmlRequest(req) {
|
|
|
306
419
|
return pathname === '/' || /\.html?$/i.test(pathname);
|
|
307
420
|
}
|
|
308
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
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
309
450
|
/** 校验请求是否已认证。返回 { ok, rawQueryToken }:
|
|
310
451
|
* - ok=true:已认证;rawQueryToken 是 URL `?token=<PIN>` 命中的那条原始密码(用于种 cookie);
|
|
311
452
|
* - 已有 cookie 命中 → rawQueryToken=null(不要重复种)。 */
|
|
@@ -316,7 +457,7 @@ function authCheck(req, tokens, sessionKey) {
|
|
|
316
457
|
const cookieTok = cookies[TOKEN_COOKIE];
|
|
317
458
|
if (cookieTok) {
|
|
318
459
|
for (const token of list) {
|
|
319
|
-
if (cookieTok
|
|
460
|
+
if (safeEqual(cookieTok, cookieFor(token, sessionKey))) return { ok: true, rawQueryToken: null };
|
|
320
461
|
}
|
|
321
462
|
}
|
|
322
463
|
const qTok = new URL(req.url ?? '/', 'http://x').searchParams.get('token');
|
|
@@ -326,7 +467,7 @@ function authCheck(req, tokens, sessionKey) {
|
|
|
326
467
|
// 种 HttpOnly 哈希 cookie,让浏览器后续子资源(assets/*.js 等)也走 cookie
|
|
327
468
|
// 路径——避免「主页 200 但子资源 401」白屏(issue #35)。
|
|
328
469
|
for (const token of list) {
|
|
329
|
-
if (qTok
|
|
470
|
+
if (safeEqual(qTok, token)) return { ok: true, rawQueryToken: qTok };
|
|
330
471
|
}
|
|
331
472
|
}
|
|
332
473
|
return { ok: false, rawQueryToken: null };
|
|
@@ -353,13 +494,37 @@ function maybeSeedAuthCookie(req, res, rawToken, sessionKey) {
|
|
|
353
494
|
};
|
|
354
495
|
}
|
|
355
496
|
|
|
356
|
-
/**
|
|
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
|
+
*/
|
|
357
505
|
function loopbackAuthority(headers, upstream) {
|
|
358
506
|
const authority = `${upstream.host}:${upstream.port}`;
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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;
|
|
363
528
|
}
|
|
364
529
|
|
|
365
530
|
// ---------- dsh web 浏览器会话 token(issue #77) ----------
|
|
@@ -587,7 +752,9 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
587
752
|
typeof handshakeLimit === 'number' ? { max: handshakeLimit } : {},
|
|
588
753
|
);
|
|
589
754
|
const server = createServer((req, res) => {
|
|
590
|
-
|
|
755
|
+
// 策略判定一律用 policyHost(issue #90):Host 头可伪造,用不可伪造的 TCP 源地址
|
|
756
|
+
// 给它设下限。转发给上游的 Host 由 loopbackAuthority 单独改写,不受这里影响。
|
|
757
|
+
const host = policyHost(req, String(req.headers.host ?? ''));
|
|
591
758
|
const isPublic = classifyHost(host) === 'public';
|
|
592
759
|
// 局域网访问关闭(issue #54):拦截经局域网 IP/主机名访问的请求;
|
|
593
760
|
// 公网(含任意非内网 Host——issue #66 fail closed)与 loopback(本机/cloudflared 回连)放行,
|
|
@@ -647,8 +814,35 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
647
814
|
});
|
|
648
815
|
return;
|
|
649
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
|
+
}
|
|
650
840
|
const authResult = authCheck(req, acceptedTokens, sessionKey);
|
|
651
841
|
if (!authResult.ok) {
|
|
842
|
+
if (isGuess) {
|
|
843
|
+
limiter?.record(ip);
|
|
844
|
+
log?.(`dsh-pocket: bad ?token= from ${ip} | URL 密码错误 IP: ${ip}`);
|
|
845
|
+
}
|
|
652
846
|
if (isHtmlRequest(req)) {
|
|
653
847
|
// 锁定期间打开登录页也给提示(HTTP 200 + 锁定文案;429 语义留给 POST 拒绝)
|
|
654
848
|
const rl = limiter?.status(ip) ?? { locked: false, retryAfter: 0 };
|
|
@@ -661,7 +855,10 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
661
855
|
return;
|
|
662
856
|
}
|
|
663
857
|
// ?token=<原始 PIN> 直达时种 HttpOnly cookie,让浏览器后续子资源也走 cookie 路径(issue #35)
|
|
664
|
-
if (authResult.rawQueryToken)
|
|
858
|
+
if (authResult.rawQueryToken) {
|
|
859
|
+
limiter?.clear(ip); // 与 POST 登录成功一致:正确的分享链接不该逐步累积到锁定
|
|
860
|
+
maybeSeedAuthCookie(req, res, authResult.rawQueryToken, sessionKey);
|
|
861
|
+
}
|
|
665
862
|
}
|
|
666
863
|
}
|
|
667
864
|
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
@@ -778,6 +975,13 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
778
975
|
delete outHeaders['content-length'];
|
|
779
976
|
delete outHeaders['transfer-encoding'];
|
|
780
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'];
|
|
781
985
|
res.writeHead(proxyRes.statusCode ?? 200, outHeaders);
|
|
782
986
|
res.end(out);
|
|
783
987
|
});
|
|
@@ -837,7 +1041,8 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
837
1041
|
|
|
838
1042
|
// WebSocket upgrade(DSH 的 /api/events.mux + events.host 流式通道)原样透传
|
|
839
1043
|
server.on('upgrade', (req, socket, head) => {
|
|
840
|
-
|
|
1044
|
+
// 与 HTTP 侧同一套判定(issue #90):否则伪造 Host 的 WS 握手仍可绕过局域网开关
|
|
1045
|
+
const host = policyHost(req, String(req.headers.host ?? ''));
|
|
841
1046
|
const isPublic = classifyHost(host) === 'public';
|
|
842
1047
|
// 局域网访问关闭:拦截经局域网 Host 的 WS 握手(公网/loopback 放行——issue #66 fail closed)
|
|
843
1048
|
if (!isPublic && !isLoopbackHost(host) && !lanAccessEnabled()) {
|
|
@@ -850,12 +1055,29 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
850
1055
|
const token = isProtectedHost(host, auth.isProtected) ? (auth.getToken?.(host) ?? null) : null;
|
|
851
1056
|
const altTokens = token && typeof auth.getAltTokens === 'function' ? (auth.getAltTokens(host) ?? []) : [];
|
|
852
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
|
+
}
|
|
853
1070
|
const wsAuth = authCheck(req, acceptedTokens, auth.sessionKey ?? null);
|
|
854
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
|
+
}
|
|
855
1076
|
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
|
|
856
1077
|
socket.destroy();
|
|
857
1078
|
return;
|
|
858
1079
|
}
|
|
1080
|
+
if (token && wsAuth.ok && wsGuess) limiter?.clear(wsIp);
|
|
859
1081
|
}
|
|
860
1082
|
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
861
1083
|
const proxyReq = httpRequest({
|
package/package.json
CHANGED