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 +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 +396 -21
- 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)放行:
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
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