dsh-multi-chat 0.4.3 → 0.5.1
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/package.json
CHANGED
|
@@ -6,7 +6,7 @@ Multi-window wall plugin, browser half + node half: a grid of every running DSH
|
|
|
6
6
|
|
|
7
7
|
The wall's business state is a single store (`dsh.multi-wall`): the discovered port list and the grid column count, persisted across view switches and reloads. Discovery, liveness, create, and stop all flow through the node half's read-only JSON routes — `/multi/api/ports` (auto-discovery, excluding nothing so the serving instance is also watchable), `/multi/api/status` (liveness of a specific port list), `/multi/api/stop` (terminate a chosen instance), `/multi/api/create` (start a fresh instance, surfacing the child's stderr on failure), and `/multi/api/link` (phone access).
|
|
8
8
|
|
|
9
|
-
Phone/remote access: the official CLI forbids `--host 0.0.0.0` (it would expose remote code execution), so `/multi/api/link` lazily starts an **inline authenticated gateway** (raw `node:net` reverse proxy with an HMAC-signed session-cookie login, targets `127.0.0.1:<self-port>`, rewrites Host/Origin so the official `/api` browser-trust fence sees a local request, and passes WebSocket upgrades through). The route returns the LAN URLs plus the login token; the gateway falls back to an OS-assigned port when its intended port hits a Windows excluded range or is already bound.
|
|
9
|
+
Phone/remote access: the official CLI forbids `--host 0.0.0.0` (it would expose remote code execution), so `/multi/api/link` lazily starts an **inline authenticated gateway** (raw `node:net` reverse proxy with an HMAC-signed session-cookie login, targets `127.0.0.1:<self-port>`, rewrites Host/Origin so the official `/api` browser-trust fence sees a local request, and passes WebSocket upgrades through). The route returns the LAN URLs plus the login token. The returned addresses drop virtual NICs (VMware/VirtualBox/WSL/Docker/Hyper-V/VPN — unreachable from a phone) and put physical NICs (Wi-Fi/Ethernet) first; the gateway falls back to an OS-assigned port when its intended port hits a Windows excluded range or is already bound.
|
|
10
10
|
|
|
11
11
|
The `/client` exports the plugin body (`apply`/`inject`), the `WallView`/`WallToggle` components, the wall store factory, and the injected probe-face types.
|
|
12
12
|
|
|
@@ -14,8 +14,8 @@ window.__ModuleLoader__.load({
|
|
|
14
14
|
const zh = {
|
|
15
15
|
"toggle": "多窗口",
|
|
16
16
|
"toggle.aria": "打开或关闭多窗口",
|
|
17
|
-
"view.multiWall": "
|
|
18
|
-
"overlay.title": "
|
|
17
|
+
"view.multiWall": "多窗口",
|
|
18
|
+
"overlay.title": "多窗口",
|
|
19
19
|
"overlay.close": "关闭",
|
|
20
20
|
"scan": "发现实例",
|
|
21
21
|
"scan.from": "起始端口",
|
|
@@ -25,7 +25,7 @@ window.__ModuleLoader__.load({
|
|
|
25
25
|
"create.failed": "创建失败:{error}",
|
|
26
26
|
"create.unknown": "未知原因",
|
|
27
27
|
"exit": "退出",
|
|
28
|
-
"exit.aria": "
|
|
28
|
+
"exit.aria": "退出多窗口,返回对话",
|
|
29
29
|
"link": "手机访问",
|
|
30
30
|
"link.aria": "获取手机访问链接",
|
|
31
31
|
"link.fetching": "正在获取链接…",
|
|
@@ -60,8 +60,8 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
const en = {
|
|
61
61
|
"toggle": "Multi-Window",
|
|
62
62
|
"toggle.aria": "Toggle multi-window",
|
|
63
|
-
"view.multiWall": "Multi-Window
|
|
64
|
-
"overlay.title": "Multi-Window
|
|
63
|
+
"view.multiWall": "Multi-Window",
|
|
64
|
+
"overlay.title": "Multi-Window",
|
|
65
65
|
"overlay.close": "Close",
|
|
66
66
|
"scan": "Discover",
|
|
67
67
|
"scan.from": "Start port",
|
|
@@ -71,7 +71,7 @@ window.__ModuleLoader__.load({
|
|
|
71
71
|
"create.failed": "Create failed: {error}",
|
|
72
72
|
"create.unknown": "unknown reason",
|
|
73
73
|
"exit": "Exit",
|
|
74
|
-
"exit.aria": "Exit
|
|
74
|
+
"exit.aria": "Exit multi-window and return to chat",
|
|
75
75
|
"link": "Phone access",
|
|
76
76
|
"link.aria": "Get phone access link",
|
|
77
77
|
"link.fetching": "Fetching link…",
|
|
@@ -242,33 +242,33 @@ window.__ModuleLoader__.load({
|
|
|
242
242
|
document.head.appendChild(tag);
|
|
243
243
|
}
|
|
244
244
|
var WallView_module_css_default = {
|
|
245
|
-
"
|
|
246
|
-
"
|
|
247
|
-
"toolbar": "ouhzTG_toolbar",
|
|
245
|
+
"danger": "ouhzTG_danger",
|
|
246
|
+
"action": "ouhzTG_action",
|
|
248
247
|
"confirm": "ouhzTG_confirm",
|
|
249
|
-
"paneActions": "ouhzTG_paneActions",
|
|
250
|
-
"linkBar": "ouhzTG_linkBar",
|
|
251
|
-
"grid": "ouhzTG_grid",
|
|
252
|
-
"paneHead": "ouhzTG_paneHead",
|
|
253
|
-
"empty": "ouhzTG_empty",
|
|
254
|
-
"title": "ouhzTG_title",
|
|
255
248
|
"field": "ouhzTG_field",
|
|
256
|
-
"controls": "ouhzTG_controls",
|
|
257
249
|
"wall": "ouhzTG_wall",
|
|
258
|
-
"
|
|
250
|
+
"grid": "ouhzTG_grid",
|
|
251
|
+
"hint": "ouhzTG_hint",
|
|
259
252
|
"status": "ouhzTG_status",
|
|
260
|
-
"
|
|
261
|
-
"
|
|
262
|
-
"paneBody": "ouhzTG_paneBody",
|
|
253
|
+
"paneTitle": "ouhzTG_paneTitle",
|
|
254
|
+
"controls": "ouhzTG_controls",
|
|
263
255
|
"linkText": "ouhzTG_linkText",
|
|
256
|
+
"paneHead": "ouhzTG_paneHead",
|
|
257
|
+
"paneBody": "ouhzTG_paneBody",
|
|
258
|
+
"empty": "ouhzTG_empty",
|
|
264
259
|
"dot": "ouhzTG_dot",
|
|
265
|
-
"
|
|
260
|
+
"toolbar": "ouhzTG_toolbar",
|
|
261
|
+
"linkBar": "ouhzTG_linkBar",
|
|
262
|
+
"title": "ouhzTG_title",
|
|
263
|
+
"pane": "ouhzTG_pane",
|
|
264
|
+
"paneActions": "ouhzTG_paneActions",
|
|
265
|
+
"zoomed": "ouhzTG_zoomed"
|
|
266
266
|
};
|
|
267
267
|
//#endregion
|
|
268
268
|
//#region src/client/WallView.tsx
|
|
269
269
|
/**
|
|
270
270
|
* WallView: the multi-window wall as a `conversation.view` ring entry. When
|
|
271
|
-
* the header's "
|
|
271
|
+
* the header's "多窗口" tab is selected, the right panel swaps from the
|
|
272
272
|
* chat to this view — a toolbar plus a grid of iframes, one pane per running
|
|
273
273
|
* DSH instance (127.0.0.1:<port>). Pure additive UI; no existing slot is
|
|
274
274
|
* replaced.
|
|
@@ -469,7 +469,10 @@ window.__ModuleLoader__.load({
|
|
|
469
469
|
const copyFirstLink = async () => {
|
|
470
470
|
if (linkInfo === null || linkInfo.lan.length === 0) return;
|
|
471
471
|
try {
|
|
472
|
-
|
|
472
|
+
const base = linkInfo.lan[0] ?? "";
|
|
473
|
+
const token = linkInfo.token ?? "";
|
|
474
|
+
const url = token !== "" ? `${base}?token=${encodeURIComponent(token)}` : base;
|
|
475
|
+
await navigator.clipboard.writeText(url);
|
|
473
476
|
setLinkCopied(true);
|
|
474
477
|
} catch {
|
|
475
478
|
setLinkCopied(false);
|
|
@@ -657,15 +660,15 @@ window.__ModuleLoader__.load({
|
|
|
657
660
|
document.head.appendChild(tag);
|
|
658
661
|
}
|
|
659
662
|
var WallToggle_module_css_default = {
|
|
660
|
-
"
|
|
661
|
-
"
|
|
663
|
+
"label": "bmjS6q_label",
|
|
664
|
+
"row": "bmjS6q_row"
|
|
662
665
|
};
|
|
663
666
|
//#endregion
|
|
664
667
|
//#region src/client/WallToggle.tsx
|
|
665
668
|
/**
|
|
666
669
|
* WallToggle: the sidebar-foot shortcut. Clicking it opens the wall view —
|
|
667
670
|
* the `conversation.view` ring switches to the 'multi-wall' entry, which the
|
|
668
|
-
* header renders as the "
|
|
671
|
+
* header renders as the "多窗口" tab. The click is a plain user-equivalent
|
|
669
672
|
* activation: it finds the header's view-ring tab for this plugin's label and
|
|
670
673
|
* clicks it, so the official view-ring state machine (the chat store's active
|
|
671
674
|
* view field) performs the switch. No store is declared: the ring decides
|
|
@@ -119,6 +119,27 @@ cache-control: no-store\r
|
|
|
119
119
|
socket.end();
|
|
120
120
|
}
|
|
121
121
|
/**
|
|
122
|
+
* Send a 302 that mints a session cookie and drops the `token` query param.
|
|
123
|
+
* A `?token=` URL authenticates exactly one request, so on first arrival the
|
|
124
|
+
* gateway exchanges it for an HMAC-signed cookie and redirects to the same
|
|
125
|
+
* path without `token=` (keeps the secret out of the address bar / history).
|
|
126
|
+
*/
|
|
127
|
+
function respondSessionRedirect(socket, parsed, cookie) {
|
|
128
|
+
let location = parsed.target;
|
|
129
|
+
try {
|
|
130
|
+
const url = new URL(parsed.target, "http://gateway.local");
|
|
131
|
+
url.searchParams.delete("token");
|
|
132
|
+
location = url.pathname + url.search;
|
|
133
|
+
} catch {}
|
|
134
|
+
socket.write(`HTTP/1.1 302 Found\r
|
|
135
|
+
location: ${location}\r\nset-cookie: ${cookie}\r\ncontent-length: 0\r
|
|
136
|
+
connection: close\r
|
|
137
|
+
cache-control: no-store\r
|
|
138
|
+
\r
|
|
139
|
+
`);
|
|
140
|
+
socket.end();
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
122
143
|
* Rebuild the request head for the loopback target. Host and Origin point at
|
|
123
144
|
* `127.0.0.1:<target-port>` so the DSH `/api` browser-trust fence sees a local
|
|
124
145
|
* request. Non-upgrade requests get `connection: close` (fresh connection per
|
|
@@ -158,17 +179,16 @@ function startGateway(options) {
|
|
|
158
179
|
return false;
|
|
159
180
|
}
|
|
160
181
|
};
|
|
161
|
-
const
|
|
182
|
+
const authKind = (parsed) => {
|
|
162
183
|
const cookie = parsed.map.cookie ?? "";
|
|
163
184
|
const session = /(?:^|;\s*)dsh_gw_session=([^;]+)/.exec(cookie);
|
|
164
|
-
if (session !== null && verifySession(session[1] ?? "")) return
|
|
185
|
+
if (session !== null && verifySession(session[1] ?? "")) return "cookie";
|
|
165
186
|
const bearer = parsed.map.authorization ?? "";
|
|
166
|
-
if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return
|
|
187
|
+
if (bearer.startsWith("Bearer ") && bearer.slice(7) === token) return "bearer";
|
|
167
188
|
try {
|
|
168
|
-
|
|
169
|
-
} catch {
|
|
170
|
-
|
|
171
|
-
}
|
|
189
|
+
if (new URL(parsed.target, "http://gateway.local").searchParams.get("token") === token) return "query";
|
|
190
|
+
} catch {}
|
|
191
|
+
return "none";
|
|
172
192
|
};
|
|
173
193
|
const attempts = /* @__PURE__ */ new Map();
|
|
174
194
|
const isBlocked = (ip) => {
|
|
@@ -262,10 +282,15 @@ connection: close\r
|
|
|
262
282
|
handleLogin(socket, parsed, buffer.subarray(parsed.headBytes));
|
|
263
283
|
return;
|
|
264
284
|
}
|
|
265
|
-
|
|
285
|
+
const kind = authKind(parsed);
|
|
286
|
+
if (kind === "none") {
|
|
266
287
|
respondLogin(socket, name, path === "/" ? "/" : path);
|
|
267
288
|
return;
|
|
268
289
|
}
|
|
290
|
+
if (kind === "query") {
|
|
291
|
+
respondSessionRedirect(socket, parsed, `dsh_gw_session=${signSession(Date.now() + maxAgeHours * 36e5)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAgeHours * 3600}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
269
294
|
const target = connect(targetPort, "127.0.0.1");
|
|
270
295
|
const rest = buffer.subarray(parsed.headBytes);
|
|
271
296
|
let connected = false;
|
|
@@ -607,13 +632,67 @@ async function startInstance(launcher, port, timeoutMs = 2e4) {
|
|
|
607
632
|
}
|
|
608
633
|
}
|
|
609
634
|
/**
|
|
635
|
+
* Interface-name patterns that mark a *virtual* NIC (VM bridge, WSL,
|
|
636
|
+
* Docker, Hyper-V, VPN adapters, Loopback Pseudo-Instance, etc.). These
|
|
637
|
+
* interfaces are never reachable from a phone on the same LAN, so they are
|
|
638
|
+
* dropped from the link list entirely.
|
|
639
|
+
*/
|
|
640
|
+
const VIRTUAL_IFACE_PATTERNS = [
|
|
641
|
+
/vEthernet/i,
|
|
642
|
+
/vmware/i,
|
|
643
|
+
/virtualbox/i,
|
|
644
|
+
/^vbox/i,
|
|
645
|
+
/wsl/i,
|
|
646
|
+
/docker/i,
|
|
647
|
+
/hyper-v/i,
|
|
648
|
+
/tap-windows/i,
|
|
649
|
+
/^tap/i,
|
|
650
|
+
/^tun/i,
|
|
651
|
+
/ppp/i,
|
|
652
|
+
/loopback/i,
|
|
653
|
+
/apipa/i,
|
|
654
|
+
/^utun/i,
|
|
655
|
+
/^awdl/i,
|
|
656
|
+
/^llw/i,
|
|
657
|
+
/^bridge/i,
|
|
658
|
+
/bluetooth/i
|
|
659
|
+
];
|
|
660
|
+
/**
|
|
661
|
+
* Interface-name patterns that mark a *physical* NIC (Wi-Fi / Ethernet).
|
|
662
|
+
* Matches kept addresses are ordered before any unknown-but-surviving
|
|
663
|
+
* address so the phone-first address is the machine's real NIC.
|
|
664
|
+
*/
|
|
665
|
+
const PHYSICAL_IFACE_PATTERNS = [
|
|
666
|
+
/^(wi-?fi|wlan|wireless)/i,
|
|
667
|
+
/^(eth(ernet)?|以太网|以太)/i,
|
|
668
|
+
/^(en|wan)[0-9]/i,
|
|
669
|
+
/^本地连接/i,
|
|
670
|
+
/^e[0-9]+$/i,
|
|
671
|
+
/^w[0-9]+$/i
|
|
672
|
+
];
|
|
673
|
+
/**
|
|
610
674
|
* The non-loopback IPv4 addresses of this machine (the LAN reachable URLs).
|
|
675
|
+
* Virtual NICs (VM/WSL/Docker/VPN/loopback pseudo) are filtered out; the
|
|
676
|
+
* remaining addresses are ordered with physical NICs (Wi-Fi/Ethernet) first
|
|
677
|
+
* so the phone shows the actually-reachable LAN address at the top.
|
|
611
678
|
* @returns the address list (possibly empty).
|
|
612
679
|
*/
|
|
613
680
|
function lanAddresses() {
|
|
614
|
-
const
|
|
615
|
-
for (const ifaces of Object.
|
|
616
|
-
|
|
681
|
+
const candidates = [];
|
|
682
|
+
for (const [name, ifaces] of Object.entries(networkInterfaces())) {
|
|
683
|
+
const virtual = VIRTUAL_IFACE_PATTERNS.some((re) => re.test(name));
|
|
684
|
+
const physical = !virtual && PHYSICAL_IFACE_PATTERNS.some((re) => re.test(name));
|
|
685
|
+
for (const iface of ifaces ?? []) {
|
|
686
|
+
if (iface.family !== "IPv4" || iface.internal) continue;
|
|
687
|
+
if (virtual) continue;
|
|
688
|
+
candidates.push({
|
|
689
|
+
address: iface.address,
|
|
690
|
+
physical
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
candidates.sort((a, b) => Number(b.physical) - Number(a.physical));
|
|
695
|
+
return candidates.map((c) => c.address);
|
|
617
696
|
}
|
|
618
697
|
/**
|
|
619
698
|
* Register the probe routes. Everything lives under `/multi/api` so the
|