dsh-remote-dsh 0.1.0

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/lib/client.js ADDED
@@ -0,0 +1,946 @@
1
+ /**
2
+ * dsh-remote-dsh — browser half.
3
+ *
4
+ * Hand-authored bundle in the client module system's wrapper format
5
+ * (`window.__ModuleLoader__.load({ id, factory })`); the loader materializes
6
+ * the factory and activates the returned exports as an ordinary Cordis plugin.
7
+ * The package deliberately has no build step, so this file is the source.
8
+ *
9
+ * Three official extension seats carry the feature — no DOM scraping:
10
+ *
11
+ * - `sidebar.panellist` (root list) contributes the rail row and its label.
12
+ * The shell owns the button, the tooltip, and the accessible name, and
13
+ * renders the row near the top of the column, between "New Session" and the
14
+ * session list.
15
+ * - `main` (root keyed) must exist under the SAME id or ui-layout refuses the
16
+ * selection ("selecting a missing main entry throws"), so this registration
17
+ * is what makes the rail row selectable. It renders nothing: the visible
18
+ * surface is the overlay below.
19
+ * - `shell.overlay` (root list) draws the takeover. Its layer is
20
+ * `position: absolute; inset: 0` inside the app frame, so one element there
21
+ * covers the entire page — sidebar included — which is the point: clicking
22
+ * the rail row makes the whole page the remote DSH, not a panel beside the
23
+ * conversation.
24
+ *
25
+ * The overlay stays MOUNTED while another panel is selected and is hidden with
26
+ * `display: none` instead of unmounting. Removing an iframe from the document
27
+ * destroys its browsing context and reloads the remote SPA on the way back;
28
+ * `display: none` keeps the document, its session, and its scroll position
29
+ * alive, so returning to the remote view is instant.
30
+ *
31
+ * The overlay embeds the remote DSH in an iframe. That only works when the
32
+ * remote is same-site with this page: its session cookie is `HttpOnly;
33
+ * SameSite=Strict`, so a remote reached at a public domain is cross-site, its
34
+ * cookie is withheld, and its own index.html answers 401. A loopback port
35
+ * forward (frpc stcp/xtcp visitor, `ssh -L`, …) satisfies this, because
36
+ * SameSite ignores the port.
37
+ */
38
+
39
+ window.__ModuleLoader__.load({ id: "dsh-remote-dsh", factory: (require) => {
40
+ var module = { exports: {} }; var exports = module.exports;
41
+ "use strict";
42
+
43
+ var React = require("react");
44
+ var h = React.createElement;
45
+
46
+ /** Same-origin route family owned by this plugin's Node half. */
47
+ var API = "/api/remote-dsh";
48
+
49
+ /** The list id, the `main` key, and the overlay row id all share this value. */
50
+ var PANEL_ID = "remote-dsh";
51
+
52
+ /** localStorage key remembering the last selected host. */
53
+ var ACTIVE_KEY = "dsh-remote-dsh:active";
54
+
55
+ /** localStorage key prefix marking a host whose token was already submitted. */
56
+ var PAIRED_KEY = "dsh-remote-dsh:paired:";
57
+
58
+ /** Probe cadence for the selected host, in milliseconds. */
59
+ var PROBE_INTERVAL_MS = 10000;
60
+
61
+ /** Palette entries resolved from the shipped theme tokens, each with a literal fallback. */
62
+ var COLOR = {
63
+ text: "var(--dsw-alias-label-primary, #e6e6e6)",
64
+ dim: "var(--dsw-alias-label-caption, rgba(150,150,160,.95))",
65
+ faint: "var(--dsw-alias-label-dimmed, rgba(140,140,150,.75))",
66
+ border: "var(--dsw-alias-border-l2, rgba(128,128,140,.22))",
67
+ panel: "var(--dsw-alias-bg-base, #101014)",
68
+ raised: "var(--dsw-alias-bg-layer-1, rgba(128,128,140,.08))",
69
+ chrome: "var(--dsw-alias-bg-layer-2, rgba(128,128,140,.12))",
70
+ accent: "var(--dsw-alias-button-primary-fill, #4d6bfe)",
71
+ ok: "#3ecf8e",
72
+ warn: "#e0a340",
73
+ bad: "#e07171",
74
+ // Session-state colors, read from the SAME tokens the sidebar's own StateDot
75
+ // uses, so the badge matches the remote's session list exactly.
76
+ running: "var(--dsw-static-deepseek-450, #4d6bfe)",
77
+ done: "var(--dsw-alias-state-success-primary, #3ecf8e)",
78
+ attention: "var(--dsw-alias-state-warn-primary, #e0a340)",
79
+ failure: "var(--dsw-alias-state-error-primary, #e07171)",
80
+ };
81
+
82
+ /** Cadence for reading the aggregate remote session state, in milliseconds. */
83
+ var STATUS_POLL_MS = 15000;
84
+
85
+ /** The empty snapshot; also the server snapshot, so SSR does not throw. */
86
+ var EMPTY_TOTALS = { running: 0, unread: 0, unreachable: 0 };
87
+
88
+ /** localStorage key prefix recording when a host's peer view was last open. */
89
+ var LAST_SEEN_KEY = "dsh-remote-dsh:last-seen:";
90
+
91
+ /**
92
+ * When this browser last had a host's remote view open.
93
+ *
94
+ * "Idle" is a permanent property, so a badge that counted idle sessions could
95
+ * never clear. Comparing each session's `updatedAt` against this timestamp is
96
+ * what makes the green dot mean "activity since you last looked" instead.
97
+ * @param hostId - registry row id.
98
+ * @returns epoch milliseconds, or 0 when never seen.
99
+ */
100
+ function readLastSeen(hostId) {
101
+ try { return Number(window.localStorage.getItem(LAST_SEEN_KEY + hostId)) || 0; } catch (error) { return 0; }
102
+ }
103
+
104
+ /** Record that a host's remote view is being looked at now. */
105
+ function writeLastSeen(hostId, at) {
106
+ try { window.localStorage.setItem(LAST_SEEN_KEY + hostId, String(at)); } catch (error) { /* private mode */ }
107
+ }
108
+
109
+ /** Host whose remote view is open right now, or null. Read by the poller. */
110
+ var viewingHostId = null;
111
+
112
+ /**
113
+ * Shared session-state store.
114
+ *
115
+ * The rail icon and the overlay are separate slot components, so the data lives
116
+ * in one module-level store that both read through `useSyncExternalStore`, while
117
+ * `apply` owns the polling lifetime.
118
+ * @returns a minimal external store.
119
+ */
120
+ function createStatusStore() {
121
+ var snapshot = { totals: EMPTY_TOTALS, hosts: [], failed: true };
122
+ var listeners = [];
123
+ var notify = function () { listeners.slice().forEach(function (listener) { listener() }) };
124
+ var totalsOf = function (hosts) {
125
+ var totals = { running: 0, unread: 0, unreachable: 0 };
126
+ hosts.forEach(function (row) {
127
+ if (row.reachable === true && row.peer === true) {
128
+ totals.running += row.running || 0;
129
+ totals.unread += row.unread || 0;
130
+ } else {
131
+ totals.unreachable += 1;
132
+ }
133
+ });
134
+ return totals;
135
+ };
136
+ return {
137
+ getSnapshot: function () { return snapshot },
138
+ subscribe: function (listener) {
139
+ listeners.push(listener);
140
+ return function () {
141
+ var at = listeners.indexOf(listener);
142
+ if (at !== -1) listeners.splice(at, 1);
143
+ };
144
+ },
145
+ set: function (next) {
146
+ snapshot = next;
147
+ notify();
148
+ },
149
+ totalsOf: totalsOf,
150
+ /**
151
+ * Declare which host's remote view is open. Opening one marks its current
152
+ * activity as seen immediately, so the dot clears on the spot instead of
153
+ * waiting for the next poll.
154
+ * @param hostId - the viewed host, or null when the view is closed.
155
+ */
156
+ setViewing: function (hostId) {
157
+ viewingHostId = hostId;
158
+ if (hostId === null || hostId === undefined) return;
159
+ writeLastSeen(hostId, Date.now());
160
+ var changed = false;
161
+ var hosts = snapshot.hosts.map(function (row) {
162
+ if (row.id !== hostId || !row.unread) return row;
163
+ changed = true;
164
+ return Object.assign({}, row, { unread: 0 });
165
+ });
166
+ if (!changed) return;
167
+ snapshot = { totals: totalsOf(hosts), hosts: hosts, failed: snapshot.failed };
168
+ notify();
169
+ },
170
+ };
171
+ }
172
+
173
+ /** One module-level store; `apply` starts and stops its polling. */
174
+ var statusStore = createStatusStore();
175
+
176
+ /** Path a peer serves its own session state on. */
177
+ var PEER_STATUS_PATH = "/api/remote-dsh/self-status";
178
+
179
+ /** State-payload contract version this reader understands. */
180
+ var PEER_STATUS_VERSION = 3;
181
+
182
+ /**
183
+ * Read one peer's self-status directly from the browser.
184
+ *
185
+ * Cross-origin on purpose: DSH sends no CORS headers for its own `/api`, so the
186
+ * only cross-origin read that can work is one this plugin's peer route opts
187
+ * into. The request stays a CORS-simple GET — no `content-type`, no credentials
188
+ * — so it needs no preflight, and the peer accepts it because the origin is
189
+ * loopback. Routing it through the local Host half instead would work too, but
190
+ * would tie the badge to a Host module reload, which needs a DSH restart.
191
+ * @param host - a registry row.
192
+ * @param elapsedMs - how long ago this browser last viewed the host.
193
+ * @returns a per-host aggregate row; never rejects.
194
+ */
195
+ function readPeer(host, elapsedMs) {
196
+ var base = { id: host.id, name: host.name, url: host.url };
197
+ return fetch(host.url + PEER_STATUS_PATH, {
198
+ method: "GET",
199
+ headers: { accept: "application/json" },
200
+ credentials: "omit",
201
+ cache: "no-store",
202
+ }).then(function (response) {
203
+ if (!response.ok) return Object.assign(base, { reachable: true, peer: false });
204
+ return response.json().then(function (body) {
205
+ if (body === null || typeof body !== "object" || body.version !== PEER_STATUS_VERSION) {
206
+ return Object.assign(base, { reachable: true, peer: false });
207
+ }
208
+ if (body.available !== true) {
209
+ return Object.assign(base, { reachable: true, peer: true, running: 0, unread: 0 });
210
+ }
211
+ const sessions = Array.isArray(body.sessions) ? body.sessions : [];
212
+ let running = 0;
213
+ let unread = 0;
214
+ sessions.forEach(function (session) {
215
+ if (session === null || typeof session !== "object") return;
216
+ if (session.running === true) { running += 1; return; }
217
+ if (isUnreadActivity(session, elapsedMs)) unread += 1;
218
+ });
219
+ return Object.assign(base, { reachable: true, peer: true, running: running, unread: unread });
220
+ });
221
+ }).catch(function () {
222
+ return Object.assign(base, { reachable: false, peer: false });
223
+ });
224
+ }
225
+
226
+ /**
227
+ * Whether one peer session counts as unread activity.
228
+ *
229
+ * `session.ageMs` is how long ago the peer last touched the session and
230
+ * `elapsedMs` is how long ago this browser last looked — both durations, so the
231
+ * verdict never depends on the two machines' clocks agreeing. A running session
232
+ * is never "unread": it is reported by the running count instead.
233
+ * @param session - one `{ running, ageMs }` row from a peer.
234
+ * @param elapsedMs - milliseconds since this browser last viewed the host.
235
+ * @returns true when the activity arrived after the last look.
236
+ */
237
+ function isUnreadActivity(session, elapsedMs) {
238
+ if (session === null || typeof session !== "object") return false;
239
+ if (session.running === true) return false;
240
+ var age = Number(session.ageMs);
241
+ // A row without a usable duration is not evidence of recent activity, so it
242
+ // counts as seen rather than as brand new.
243
+ if (!Number.isFinite(age)) return false;
244
+ return age < elapsedMs;
245
+ }
246
+
247
+ /**
248
+ * Poll every configured host and publish the aggregate to the store.
249
+ *
250
+ * A host whose remote view is currently open has its "last seen" stamp advanced
251
+ * on every poll, so anything that happens while the operator is watching counts
252
+ * as seen — the dot then only reappears for activity that arrives while they are
253
+ * looking somewhere else.
254
+ * @returns the disposer stopping the interval.
255
+ */
256
+ function startStatusPolling() {
257
+ var stopped = false;
258
+ var run = function () {
259
+ api("/hosts", { method: "GET" }).then(function (body) {
260
+ var hosts = Array.isArray(body.hosts) ? body.hosts : [];
261
+ var now = Date.now();
262
+ return Promise.all(hosts.map(function (host) {
263
+ var viewing = viewingHostId === host.id;
264
+ if (viewing) writeLastSeen(host.id, now);
265
+ // Viewing means "seen up to now", so nothing counts as unread; otherwise
266
+ // compare against how long ago this browser last looked.
267
+ return readPeer(host, viewing ? 0 : Math.max(0, now - readLastSeen(host.id)));
268
+ }));
269
+ }).then(function (rows) {
270
+ if (stopped) return;
271
+ statusStore.set({ totals: statusStore.totalsOf(rows), hosts: rows, failed: false });
272
+ }).catch(function () {
273
+ if (stopped) return;
274
+ statusStore.set({ totals: { running: 0, unread: 0, unreachable: -1 }, hosts: [], failed: true });
275
+ });
276
+ };
277
+ run();
278
+ var timer = window.setInterval(run, STATUS_POLL_MS);
279
+ return function () { stopped = true; window.clearInterval(timer) };
280
+ }
281
+
282
+ /** One colored dot followed by its count. */
283
+ function countBadge(color, count, label) {
284
+ return h("span", {
285
+ key: label,
286
+ title: label + " " + String(count),
287
+ style: { display: "inline-flex", alignItems: "center", gap: 3, fontSize: 11, lineHeight: "14px", color: COLOR.dim },
288
+ }, h(Dot, { color: color }), h("span", null, String(count)));
289
+ }
290
+
291
+ /**
292
+ * Render the session-state badge that rides the rail glyph.
293
+ *
294
+ * Wide rows show a dot and count per state; the collapsed rail cell is a fixed
295
+ * 36px box, so it carries a single dot in the highest-priority color and leaves
296
+ * the counts to the tooltip. Colors and the running-over-idle priority mirror
297
+ * the sidebar's own session dots.
298
+ * @param totals - aggregate counts.
299
+ * @param compact - true inside the 36px rail cell.
300
+ * @returns the badge element, or null when there is nothing to say.
301
+ */
302
+ function statusBadge(totals, compact) {
303
+ var running = totals.running > 0;
304
+ var unread = totals.unread > 0;
305
+ var unreachable = totals.unreachable > 0;
306
+ if (compact) {
307
+ var color = running ? COLOR.running : (unread ? COLOR.done : (unreachable ? COLOR.failure : null));
308
+ return color === null ? null : h(Dot, { color: color });
309
+ }
310
+ var parts = [];
311
+ if (running) parts.push(countBadge(COLOR.running, totals.running, "运行中"));
312
+ if (unread) parts.push(countBadge(COLOR.done, totals.unread, "上次查看后有活动"));
313
+ if (unreachable) parts.push(countBadge(COLOR.failure, totals.unreachable, "状态不可读"));
314
+ if (parts.length === 0) return null;
315
+ return h("span", { style: { display: "inline-flex", alignItems: "center", gap: 6 } }, parts);
316
+ }
317
+
318
+ /**
319
+ * Human-readable breakdown for the glyph tooltip.
320
+ * @param totals - aggregate counts.
321
+ * @param hosts - per-host rows.
322
+ * @returns the tooltip text.
323
+ */
324
+ function statusTooltip(totals, hosts) {
325
+ var lines = [];
326
+ if (totals.running > 0) lines.push("运行中 " + String(totals.running));
327
+ if (totals.unread > 0) lines.push("上次查看后有活动 " + String(totals.unread) + "(打开远程即清零)");
328
+ if (totals.unreachable > 0) lines.push("状态不可读 " + String(totals.unreachable));
329
+ if (lines.length === 0) return "远程 · 无新活动";
330
+ var perHost = (hosts || [])
331
+ .filter(function (host) { return host && host.reachable === true && host.peer === true })
332
+ .map(function (host) {
333
+ return host.name + ":" + String(host.running || 0) + " 运行 / " + String(host.unread || 0) + " 有活动";
334
+ });
335
+ return "远程 · " + lines.join(",") + (perHost.length === 0 ? "" : "\n" + perHost.join("\n"));
336
+ }
337
+
338
+ /**
339
+ * Call the plugin's own JSON route family.
340
+ * @param path - path after the API prefix.
341
+ * @param options - fetch options.
342
+ * @returns the parsed JSON body.
343
+ */
344
+ function api(path, options) {
345
+ var init = options || {};
346
+ init.headers = { "content-type": "application/json" };
347
+ return fetch(API + path, init).then(function (response) {
348
+ return response.json().catch(function () { return {}; }).then(function (body) {
349
+ if (!response.ok) throw new Error((body && body.error) || ("HTTP " + response.status));
350
+ return body;
351
+ });
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Whether an origin is loopback — the precondition for iframe embedding.
357
+ * @param value - origin string.
358
+ * @returns true when the hostname is loopback.
359
+ */
360
+ function isLoopbackUrl(value) {
361
+ try {
362
+ var name = new URL(value).hostname;
363
+ return name === "127.0.0.1" || name === "localhost" || name === "::1" || name === "[::1]";
364
+ } catch (error) {
365
+ return false;
366
+ }
367
+ }
368
+
369
+ /** Whether this browser already submitted a token for a host. */
370
+ function isPaired(hostId) {
371
+ if (!hostId) return false;
372
+ try { return window.localStorage.getItem(PAIRED_KEY + hostId) !== null; } catch (error) { return false; }
373
+ }
374
+
375
+ /** Remember that a host's token was submitted, so its settings stay collapsed. */
376
+ function markPaired(hostId) {
377
+ try { window.localStorage.setItem(PAIRED_KEY + hostId, String(Date.now())); } catch (error) { /* private mode */ }
378
+ }
379
+
380
+ /** One small colored status dot. */
381
+ function Dot(props) {
382
+ return h("span", {
383
+ style: {
384
+ width: 7, height: 7, borderRadius: "50%", flexShrink: 0,
385
+ background: props.color, display: "inline-block",
386
+ },
387
+ });
388
+ }
389
+
390
+ /** A flat button styled from theme tokens. */
391
+ function Button(props) {
392
+ var base = {
393
+ appearance: "none", border: "1px solid " + COLOR.border, borderRadius: 6,
394
+ background: props.primary ? COLOR.accent : "transparent",
395
+ color: props.primary ? "#fff" : COLOR.text,
396
+ font: "inherit", fontSize: 12, lineHeight: "16px", padding: "4px 10px",
397
+ cursor: props.disabled ? "default" : "pointer",
398
+ opacity: props.disabled ? 0.4 : 1, whiteSpace: "nowrap",
399
+ };
400
+ return h("button", {
401
+ type: "button",
402
+ style: Object.assign(base, props.style || {}),
403
+ disabled: props.disabled === true,
404
+ title: props.title,
405
+ onClick: props.disabled === true ? undefined : props.onClick,
406
+ }, props.label);
407
+ }
408
+
409
+ /** A muted separator between toolbar groups. */
410
+ function Divider() {
411
+ return h("span", { style: { width: 1, height: 18, background: COLOR.border } });
412
+ }
413
+
414
+ /**
415
+ * The rail icon. Icon only: the shell supplies the button, the tooltip, and the
416
+ * accessible name from the list registration's label, and wraps this in an
417
+ * `aria-hidden` glyph span.
418
+ */
419
+ function RemoteIcon(props) {
420
+ var size = props && typeof props.size === "number" ? props.size : 16;
421
+ // The shell renders the wide row at 16 and the collapsed rail at 18 (its own
422
+ // owner-prop contract), which is the only signal this seat gets about how
423
+ // much room the glyph has.
424
+ var compact = size > 16;
425
+ var snapshot = React.useSyncExternalStore(
426
+ statusStore.subscribe, statusStore.getSnapshot, statusStore.getSnapshot,
427
+ );
428
+ var glyph = h("svg", {
429
+ viewBox: "0 0 16 16", width: size, height: size, fill: "none",
430
+ stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round",
431
+ },
432
+ h("circle", { cx: 8, cy: 8, r: 6.1 }),
433
+ h("path", { d: "M1.9 8h12.2" }),
434
+ h("path", { d: "M8 1.9c1.75 1.9 1.75 10.3 0 12.2" }),
435
+ h("path", { d: "M8 1.9c-1.75 1.9-1.75 10.3 0 12.2" }),
436
+ );
437
+ var badge = statusBadge(snapshot.totals, compact);
438
+ if (badge === null) return glyph;
439
+ return h("span", {
440
+ "data-dsh-remote-badge": "",
441
+ title: statusTooltip(snapshot.totals, snapshot.hosts),
442
+ style: { display: "inline-flex", alignItems: "center", gap: compact ? 3 : 7 },
443
+ }, glyph, badge);
444
+ }
445
+
446
+ /**
447
+ * The full-page remote workspace.
448
+ *
449
+ * Rendered into `shell.overlay`, so its own box covers the app frame. It stays
450
+ * mounted for the plugin's whole life and is hidden — never unmounted — while
451
+ * the rail row is not selected, which is what keeps the embedded remote SPA
452
+ * from reloading on every visit. Every hook runs unconditionally; the effects
453
+ * return early while hidden so a background tab does no polling.
454
+ *
455
+ * `usePanelInfo` is a framework standard prop on every slot scope, which is
456
+ * how this knows whether the takeover is on.
457
+ *
458
+ * @param props - framework props plus `onExit`.
459
+ */
460
+ function RemoteWorkspace(props) {
461
+ var usePanelInfo = props.usePanelInfo;
462
+ var active = usePanelInfo(function (info) { return info.activePanelId === PANEL_ID; });
463
+
464
+ var hostsState = React.useState([]);
465
+ var hosts = hostsState[0];
466
+ var setHosts = hostsState[1];
467
+
468
+ var activeIdState = React.useState(function () {
469
+ try { return window.localStorage.getItem(ACTIVE_KEY); } catch (error) { return null; }
470
+ });
471
+ var activeId = activeIdState[0];
472
+ var setActiveId = activeIdState[1];
473
+
474
+ var probeState = React.useState(null);
475
+ var probe = probeState[0];
476
+ var setProbe = probeState[1];
477
+
478
+ var formState = React.useState(null);
479
+ var form = formState[0];
480
+ var setForm = formState[1];
481
+
482
+ var noticeState = React.useState(null);
483
+ var notice = noticeState[0];
484
+ var setNotice = noticeState[1];
485
+
486
+ var tokenState = React.useState("");
487
+ var token = tokenState[0];
488
+ var setToken = tokenState[1];
489
+
490
+ var pairState = React.useState(null);
491
+ var pairing = pairState[0];
492
+ var setPairing = pairState[1];
493
+
494
+ var nonceState = React.useState(0);
495
+ var nonce = nonceState[0];
496
+ var setNonce = nonceState[1];
497
+
498
+ var settingsState = React.useState(false);
499
+ var settingsOpen = settingsState[0];
500
+ var setSettingsOpen = settingsState[1];
501
+
502
+ var fromServer = React.useRef(false);
503
+
504
+ var load = React.useCallback(function () {
505
+ return api("/hosts", { method: "GET" }).then(function (body) {
506
+ var next = Array.isArray(body.hosts) ? body.hosts : [];
507
+ fromServer.current = true;
508
+ setHosts(next);
509
+ return next;
510
+ }).catch(function (error) {
511
+ setNotice("读取主机列表失败:" + String(error.message || error));
512
+ return [];
513
+ });
514
+ }, []);
515
+
516
+ React.useEffect(function () {
517
+ if (!active) return undefined;
518
+ void load();
519
+ return undefined;
520
+ }, [active, load]);
521
+
522
+ // Keep the selection pointing at a host that still exists.
523
+ React.useEffect(function () {
524
+ if (!active || !fromServer.current) return;
525
+ if (hosts.length === 0) {
526
+ if (activeId !== null) setActiveId(null);
527
+ return;
528
+ }
529
+ if (!hosts.some(function (host) { return host.id === activeId; })) {
530
+ setActiveId(hosts[0].id);
531
+ }
532
+ }, [active, hosts, activeId, setActiveId]);
533
+
534
+ React.useEffect(function () {
535
+ try {
536
+ if (activeId === null) window.localStorage.removeItem(ACTIVE_KEY);
537
+ else window.localStorage.setItem(ACTIVE_KEY, activeId);
538
+ } catch (error) { /* private mode: selection simply does not persist */ }
539
+ }, [activeId]);
540
+
541
+ var selected = null;
542
+ for (var index = 0; index < hosts.length; index += 1) {
543
+ if (hosts[index].id === activeId) { selected = hosts[index]; break; }
544
+ }
545
+ var selectedUrl = selected === null ? null : selected.url;
546
+
547
+ // Probe the selected host, then keep polling it while the takeover is on.
548
+ React.useEffect(function () {
549
+ if (!active || selectedUrl === null) { setProbe(null); return undefined; }
550
+ var cancelled = false;
551
+ var run = function () {
552
+ api("/probe", { method: "POST", body: JSON.stringify({ url: selectedUrl }) })
553
+ .then(function (result) { if (!cancelled) setProbe(result); })
554
+ .catch(function (error) {
555
+ if (!cancelled) setProbe({ reachable: false, error: String(error.message || error) });
556
+ });
557
+ };
558
+ run();
559
+ var timer = window.setInterval(run, PROBE_INTERVAL_MS);
560
+ return function () { cancelled = true; window.clearInterval(timer); };
561
+ }, [active, selectedUrl]);
562
+
563
+ // Tell the badge store which host's view is open: opening one clears that
564
+ // host's unread count on the spot, and anything arriving while it stays open
565
+ // counts as seen.
566
+ React.useEffect(function () {
567
+ statusStore.setViewing(active ? activeId : null);
568
+ return function () { statusStore.setViewing(null); };
569
+ }, [active, activeId]);
570
+
571
+ // Escape leaves, as long as focus has not been handed to the remote document
572
+ // (a focused iframe swallows the key, which is why the button is primary).
573
+ var onExit = props.onExit;
574
+ React.useEffect(function () {
575
+ if (!active) return undefined;
576
+ var onKey = function (event) { if (event.key === "Escape") onExit(); };
577
+ window.addEventListener("keydown", onKey);
578
+ return function () { window.removeEventListener("keydown", onKey); };
579
+ }, [active, onExit]);
580
+
581
+ var save = React.useCallback(function (input) {
582
+ var isNew = typeof input.id !== "string" || input.id === "";
583
+ return api("/hosts", { method: "POST", body: JSON.stringify(input) }).then(function (body) {
584
+ setHosts(Array.isArray(body.hosts) ? body.hosts : []);
585
+ setForm(null);
586
+ setNotice(null);
587
+ if (isNew) {
588
+ var created = (body.hosts || []).filter(function (host) { return host.url === input.url; }).pop();
589
+ if (created) setActiveId(created.id);
590
+ // A host that was just added has never been paired and pairing is the
591
+ // immediate next step, so this is the one automatic open — triggered by
592
+ // the user's own add, never by the default state.
593
+ setSettingsOpen(true);
594
+ }
595
+ }).catch(function (error) {
596
+ setNotice("保存失败:" + String(error.message || error));
597
+ });
598
+ }, []);
599
+
600
+ var remove = React.useCallback(function (id) {
601
+ return api("/hosts/" + encodeURIComponent(id), { method: "DELETE" }).then(function (body) {
602
+ setHosts(Array.isArray(body.hosts) ? body.hosts : []);
603
+ setPairing(null);
604
+ setSettingsOpen(false);
605
+ setNotice(null);
606
+ }).catch(function (error) {
607
+ setNotice("删除失败:" + String(error.message || error));
608
+ });
609
+ }, []);
610
+
611
+ var frameSrc = null;
612
+ if (selected !== null) {
613
+ frameSrc = pairing === selected.id && token !== ""
614
+ ? selected.url + "/?token=" + encodeURIComponent(token)
615
+ : selected.url + "/";
616
+ }
617
+
618
+ // Short label for the bar; the full sentence rides the tooltip. The probe
619
+ // only proves the port and the remote DSH are alive — whether THIS browser is
620
+ // paired is a cookie fact it cannot see, so the label never claims pairing.
621
+ var status = { color: COLOR.faint, text: "未探测", detail: "尚未探测该主机。" };
622
+ if (probe !== null) {
623
+ if (probe.reachable !== true) {
624
+ status = {
625
+ color: COLOR.bad, text: "端口不通",
626
+ detail: "端口不通" + (probe.error ? " · " + probe.error : ""),
627
+ };
628
+ } else if (probe.dshAuthRequired === true) {
629
+ status = {
630
+ color: COLOR.ok, text: "已连通",
631
+ detail: "已连通 · 远程 DSH 存活。若画面提示需要认证,粘贴 token 配对。",
632
+ };
633
+ } else {
634
+ status = { color: COLOR.ok, text: "已连通", detail: "已连通 · HTTP " + String(probe.status) };
635
+ }
636
+ }
637
+
638
+ var chips = hosts.map(function (host) {
639
+ var isSelected = host.id === activeId;
640
+ return h("button", {
641
+ key: host.id,
642
+ type: "button",
643
+ title: host.url,
644
+ onClick: function () { setActiveId(host.id); setNotice(null); },
645
+ style: {
646
+ appearance: "none", font: "inherit", fontSize: 12, cursor: "pointer",
647
+ display: "inline-flex", alignItems: "center", gap: 6,
648
+ padding: "4px 10px", borderRadius: 999,
649
+ border: "1px solid " + (isSelected ? COLOR.accent : COLOR.border),
650
+ background: isSelected ? COLOR.raised : "transparent",
651
+ color: COLOR.text,
652
+ },
653
+ },
654
+ h(Dot, { color: isSelected ? status.color : COLOR.faint }),
655
+ h("span", null, host.name),
656
+ );
657
+ });
658
+
659
+ // The settings row is COLLAPSED by default, and the ⚙ toggle is the only
660
+ // thing that opens or closes it. Nothing may OR itself into this predicate:
661
+ // a per-host condition that forced the row open is exactly what used to
662
+ // make ⚙ unable to collapse it. `unpaired` only informs the tooltip.
663
+ var unpaired = selected !== null && !isPaired(selected.id);
664
+ var showSettings = settingsOpen === true;
665
+
666
+ var bar = h("div", {
667
+ style: {
668
+ flexShrink: 0, background: COLOR.chrome, borderBottom: "1px solid " + COLOR.border,
669
+ },
670
+ },
671
+ h("div", {
672
+ style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", padding: "6px 12px" },
673
+ },
674
+ h(Button, {
675
+ label: "← 返回本地 DSH",
676
+ onClick: onExit,
677
+ title: "回到本地会话(Esc)",
678
+ style: { fontWeight: 600 },
679
+ }),
680
+ h(Divider),
681
+ chips,
682
+ h(Button, {
683
+ label: "+ 添加",
684
+ title: "添加一台远程 DSH",
685
+ onClick: function () { setForm({ id: "", name: "", url: "http://127.0.0.1:3081" }); setNotice(null); },
686
+ }),
687
+ h(Button, {
688
+ label: "⚙",
689
+ title: showSettings
690
+ ? "收起设置"
691
+ : (unpaired ? "展开设置(该主机尚未配对过,展开可粘贴 token)" : "展开设置"),
692
+ onClick: function () { setSettingsOpen(!settingsOpen); },
693
+ }),
694
+ h("span", { style: { flex: 1 } }),
695
+ h(Dot, { color: status.color }),
696
+ h("span", { title: status.detail, style: { fontSize: 12, color: COLOR.dim, cursor: "default" } }, status.text),
697
+ ),
698
+ showSettings && selected !== null && form === null
699
+ ? h("div", {
700
+ style: {
701
+ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap",
702
+ padding: "0 12px 8px", fontSize: 12, color: COLOR.dim,
703
+ },
704
+ },
705
+ h("code", { style: { color: COLOR.faint } }, selected.url),
706
+ h("span", { style: { flex: 1 } }),
707
+ h("input", {
708
+ value: token,
709
+ placeholder: "粘贴远程 dsh web 打印的 token",
710
+ onChange: function (event) { setToken(event.target.value); },
711
+ style: {
712
+ font: "inherit", fontSize: 12, width: 260, padding: "4px 8px", borderRadius: 6,
713
+ border: "1px solid " + COLOR.border, background: "transparent", color: COLOR.text,
714
+ },
715
+ }),
716
+ h(Button, {
717
+ label: "配对",
718
+ disabled: token.trim() === "",
719
+ title: "把 token 交给远程实例换取会话 cookie",
720
+ onClick: function () { setPairing(selected.id); setNotice(null); setNonce(nonce + 1); },
721
+ }),
722
+ h(Button, { label: "重载", title: "重新加载远程画面", onClick: function () { setPairing(null); setNonce(nonce + 1); } }),
723
+ h(Button, {
724
+ label: "新窗口",
725
+ title: "在浏览器新标签页打开",
726
+ onClick: function () { window.open(selected.url + "/", "_blank", "noopener,noreferrer"); },
727
+ }),
728
+ h(Button, {
729
+ label: "编辑",
730
+ onClick: function () { setForm({ id: selected.id, name: selected.name, url: selected.url }); },
731
+ }),
732
+ h(Button, {
733
+ label: "删除",
734
+ onClick: function () {
735
+ if (window.confirm("删除远程主机「" + selected.name + "」?")) void remove(selected.id);
736
+ },
737
+ }),
738
+ )
739
+ : null,
740
+ );
741
+
742
+ var body = null;
743
+ if (form !== null) {
744
+ body = h("form", {
745
+ style: { padding: 16, display: "flex", flexDirection: "column", gap: 10, maxWidth: 560 },
746
+ onSubmit: function (event) {
747
+ event.preventDefault();
748
+ void save({ id: form.id, name: form.name, url: form.url });
749
+ },
750
+ },
751
+ h("label", { style: { fontSize: 12, color: COLOR.dim } }, "名称"),
752
+ h("input", {
753
+ value: form.name, placeholder: "例如 服务器 A", autoFocus: true,
754
+ onChange: function (event) { setForm(Object.assign({}, form, { name: event.target.value })); },
755
+ style: {
756
+ font: "inherit", padding: "6px 10px", borderRadius: 6,
757
+ border: "1px solid " + COLOR.border, background: "transparent", color: COLOR.text,
758
+ },
759
+ }),
760
+ h("label", { style: { fontSize: 12, color: COLOR.dim } }, "地址(必须是本地转发出来的 127.0.0.1 端口)"),
761
+ h("input", {
762
+ value: form.url, placeholder: "http://127.0.0.1:3081",
763
+ onChange: function (event) { setForm(Object.assign({}, form, { url: event.target.value })); },
764
+ style: {
765
+ font: "inherit", padding: "6px 10px", borderRadius: 6,
766
+ border: "1px solid " + COLOR.border, background: "transparent", color: COLOR.text,
767
+ },
768
+ }),
769
+ !isLoopbackUrl(form.url) && form.url.trim() !== ""
770
+ ? h("div", { style: { fontSize: 12, color: COLOR.warn, lineHeight: 1.6 } },
771
+ "⚠ 该地址不是本地回环地址。远程 DSH 的会话 cookie 是 SameSite=Strict,"
772
+ + "跨站 iframe 不会带上它,嵌入会直接 401。请改用 frpc stcp/xtcp visitor "
773
+ + "或 ssh -L 把它映射成本地 127.0.0.1 端口。")
774
+ : null,
775
+ h("div", { style: { display: "flex", gap: 8, marginTop: 4 } },
776
+ h(Button, { label: "保存", primary: true, onClick: function () { void save({ id: form.id, name: form.name, url: form.url }); } }),
777
+ h(Button, { label: "取消", onClick: function () { setForm(null); } }),
778
+ ),
779
+ );
780
+ } else if (selected === null) {
781
+ body = h("div", {
782
+ style: { flex: 1, display: "flex", alignItems: "center", justifyContent: "center", padding: 24 },
783
+ }, h("div", { style: { fontSize: 13, color: COLOR.dim, textAlign: "center", lineHeight: 1.9 } },
784
+ h("div", null, "还没有配置远程 DSH。"),
785
+ h("div", { style: { color: COLOR.faint, fontSize: 12 } },
786
+ "先在另一台主机上跑 dsh web,再用 frpc stcp/xtcp visitor 或 ssh -L 把它映射到本机 127.0.0.1 端口。"),
787
+ ));
788
+ } else {
789
+ body = h("iframe", {
790
+ key: String(nonce) + ":" + (pairing === selected.id ? "pair" : "live") + ":" + selected.id,
791
+ src: frameSrc,
792
+ title: selected.name + " 远程 GUI",
793
+ referrerPolicy: "no-referrer",
794
+ allow: "clipboard-write; clipboard-read; fullscreen",
795
+ onLoad: function () {
796
+ if (pairing === selected.id) {
797
+ markPaired(selected.id);
798
+ setPairing(null);
799
+ setSettingsOpen(false);
800
+ setToken("");
801
+ setNotice("已提交 token。若画面仍提示需要认证,说明 token 已失效——到远程主机重启后的 dsh web 输出里取新的。");
802
+ }
803
+ },
804
+ style: { flex: 1, width: "100%", border: "none", background: COLOR.panel, minHeight: 0 },
805
+ });
806
+ }
807
+
808
+ return h("div", {
809
+ "data-dsh-remote-workspace": "",
810
+ "data-active": active ? "true" : undefined,
811
+ style: {
812
+ position: "absolute", inset: 0, zIndex: 1,
813
+ // Hidden, never unmounted: `display: none` keeps the iframe's browsing
814
+ // context, so the remote SPA does not reload when the rail row is
815
+ // reselected.
816
+ display: active ? "flex" : "none",
817
+ flexDirection: "column", minHeight: 0,
818
+ background: COLOR.panel, color: COLOR.text,
819
+ },
820
+ },
821
+ bar,
822
+ !isLoopbackUrl(selected === null ? "" : selected.url) && selected !== null
823
+ ? h("div", {
824
+ style: {
825
+ padding: "6px 12px", fontSize: 12, color: COLOR.warn,
826
+ borderBottom: "1px solid " + COLOR.border, flexShrink: 0,
827
+ },
828
+ }, "⚠ 非回环地址:SameSite=Strict cookie 不会随跨站 iframe 发送,嵌入会 401。")
829
+ : null,
830
+ notice !== null
831
+ ? h("div", {
832
+ style: {
833
+ padding: "6px 12px", fontSize: 12, color: COLOR.dim,
834
+ borderBottom: "1px solid " + COLOR.border, flexShrink: 0,
835
+ },
836
+ }, notice)
837
+ : null,
838
+ body,
839
+ );
840
+ }
841
+
842
+ /**
843
+ * Raise this plugin's rail row above the local "New Session" button.
844
+ *
845
+ * The sidebar shell draws its own blocks in a fixed order — brand row, New
846
+ * Session, the `sidebar.panellist` nav, the workspace browser, the foot — and
847
+ * the panellist seat sits BELOW New Session. There is no seat in the brand row
848
+ * itself: its two slots render inside an `aria-hidden` button that starts a
849
+ * session, so interactive content cannot go there. The row is therefore raised
850
+ * with flex `order` on the shell's column.
851
+ *
852
+ * `.logoRow` and `.panelList` both carry `margin-bottom: 8px` and New Session
853
+ * keeps its own, so the swap needs no spacing fix-up: the 8px rhythm is
854
+ * already the same on both sides.
855
+ *
856
+ * Addressing: `[data-slot="sidebar"]` is the documented anchor seam every slot
857
+ * render site exposes for styles, and the class substrings survive CSS-module
858
+ * hashing (`WNUpnq_panelList`). If the shell renames either class the rule
859
+ * simply stops matching and the row stays where it is — the failure mode is
860
+ * "no change", never breakage.
861
+ *
862
+ * The rule moves the whole panellist nav, not one row: every global panel
863
+ * shares that seat, so a second plugin's panel would move up with this one.
864
+ * That grouping (global panels above the local New Session) is the intent.
865
+ */
866
+ var SIDEBAR_ORDER_CSS = '[data-slot="sidebar"] [class*="logoRow"]{order:-2}'
867
+ + '[data-slot="sidebar"] [class*="panelList"]{order:-1}';
868
+
869
+ /**
870
+ * Install the sidebar order rule and return its disposer.
871
+ * @returns a function removing the injected stylesheet.
872
+ */
873
+ function installSidebarOrder() {
874
+ var style = document.createElement("style");
875
+ style.setAttribute("data-dsh-remote-dsh", "sidebar-order");
876
+ style.textContent = SIDEBAR_ORDER_CSS;
877
+ document.head.appendChild(style);
878
+ return function () { style.remove(); };
879
+ }
880
+
881
+ /** Required services. */
882
+ var inject = ["slots"];
883
+
884
+ /**
885
+ * Client plugin body: contribute the rail row, the selection key, and the
886
+ * full-page takeover.
887
+ * @param ctx - client root context.
888
+ */
889
+ function apply(ctx) {
890
+ // A host running the peer role publishes its state to somebody else; it must
891
+ // not grow a rail row of its own. The marker is injected into the index by
892
+ // this same package's Node half, so the decision is synchronous — an async
893
+ // role probe would flash the row before hiding it.
894
+ if (typeof window !== "undefined" && window.__DSH_REMOTE_DDH_ROLE__ === "peer") return;
895
+
896
+ // The rail badge's data. One interval for the whole page, owned by this fiber.
897
+ ctx.effect(function () { return startStatusPolling(); }, "dsh-remote-dsh: status polling");
898
+
899
+ // The rail row has to sit above New Session; the sidebar shell draws the
900
+ // panellist seat below it, so the column order is corrected with one scoped
901
+ // stylesheet owned by this fiber.
902
+ ctx.effect(function () { return installSidebarOrder(); }, "dsh-remote-dsh: sidebar order");
903
+
904
+ // Selection is layout state, so leaving is `selectPanel(null)`: the same
905
+ // call the shell's own rail rows make. Read optionally — a composition
906
+ // without ui-layout simply gets a no-op exit.
907
+ var exit = function () {
908
+ var layout = ctx.get("layout");
909
+ if (layout !== undefined) layout.selectPanel(null);
910
+ };
911
+
912
+ function Overlay(props) {
913
+ return h(RemoteWorkspace, Object.assign({}, props, { onExit: exit }));
914
+ }
915
+
916
+ ctx.slots.inject("sidebar.panellist", function () {
917
+ return ctx.slots.register({
918
+ name: "sidebar.panellist",
919
+ id: PANEL_ID,
920
+ order: 90,
921
+ label: "远程",
922
+ }, RemoteIcon);
923
+ });
924
+
925
+ // Exists only to satisfy ui-layout's pair check; renders nothing because the
926
+ // overlay covers the frame the moment this key becomes active.
927
+ ctx.slots.inject("main", function () {
928
+ return ctx.slots.register({ name: "main", key: PANEL_ID }, function () { return null; });
929
+ });
930
+
931
+ ctx.slots.inject("shell.overlay", function () {
932
+ return ctx.slots.register({ name: "shell.overlay", id: PANEL_ID, order: 10 }, Overlay);
933
+ });
934
+ }
935
+
936
+ module.exports.apply = apply;
937
+ module.exports.inject = inject;
938
+ module.exports.RemoteWorkspace = RemoteWorkspace;
939
+ module.exports.RemoteIcon = RemoteIcon;
940
+ module.exports.PANEL_ID = PANEL_ID;
941
+ module.exports.statusBadge = statusBadge;
942
+ module.exports.statusTooltip = statusTooltip;
943
+ module.exports.isUnreadActivity = isUnreadActivity;
944
+ module.exports.createStatusStore = createStatusStore;
945
+ module.exports.statusStore = statusStore;
946
+ return module.exports; } });