@linxin666/dsh-remote-web-ui 0.1.6 → 0.1.7
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/README.md +14 -5
- package/lib/mobile.js +133 -9
- package/lib/mobile.js.map +1 -1
- package/lib/types/mobile/mux.d.ts +66 -2
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +155 -8
- package/lib/types/mobile/mux.test.d.ts +2 -0
- package/lib/types/mobile/mux.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.test.js +141 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +6 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +20 -6
- package/lib/types/mobile/views/ChatView.test.js +92 -2
- package/package.json +1 -1
- package/src/mobile/mux.test.ts +172 -0
- package/src/mobile/mux.ts +170 -8
- package/src/mobile/views/App.tsx +7 -0
- package/src/mobile/views/ChatView.test.tsx +98 -3
- package/src/mobile/views/ChatView.tsx +20 -6
package/README.md
CHANGED
|
@@ -230,10 +230,19 @@ Notes:
|
|
|
230
230
|
- Quick tunnels are free and need no login, but the hostname is random per
|
|
231
231
|
run: every `cloudflared` restart changes it, so update `--trusted-host`
|
|
232
232
|
and `publicBaseUrl` together. Cloudflare documents no uptime guarantees;
|
|
233
|
-
in-flight-request concurrency is capped (HTTP 429 past it), and Quick
|
|
234
|
-
Tunnels do not
|
|
235
|
-
|
|
236
|
-
|
|
233
|
+
in-flight-request concurrency is capped (HTTP 429 past it), and **Quick
|
|
234
|
+
Tunnels do not forward Server-Sent Events**. `Tailscale Serve` (and
|
|
235
|
+
`tailscale serve` on a single port) behaves the same way. SSE is how the
|
|
236
|
+
phone receives **live messages** in real time, so over a quick tunnel or
|
|
237
|
+
Tailscale Serve the mobile chat falls back to polling: the phone still
|
|
238
|
+
sends and receives messages (everything else rides plain HTTP, which does
|
|
239
|
+
forward), only a new message may arrive a few seconds late instead of
|
|
240
|
+
instantly. The plugin polls `session.history` on a short interval once the
|
|
241
|
+
SSE channel goes silent, and resumes streaming as soon as SSE works again.
|
|
242
|
+
For true real-time push, point the QR at a tunnel that forwards SSE — a
|
|
243
|
+
Cloudflare **named tunnel** (domain hosted on Cloudflare, see below), or a
|
|
244
|
+
plain TCP port forward (LAN address, the `tailscale up` virtual-interface
|
|
245
|
+
address, or a manual `ssh -L` / cloudflared TCP tunnel to the port).
|
|
237
246
|
- A quick tunnel is public: anyone with the URL can load the static page.
|
|
238
247
|
The pairing gate is the real fence — unpaired devices get 403 on every
|
|
239
248
|
`/api` call — so keep `requirePairingForLan` on.
|
|
@@ -346,4 +355,4 @@ QR as a dependency-free SVG component — no canvas, no server-side image
|
|
|
346
355
|
generation. It is inlined into the client bundle at build time (like the
|
|
347
356
|
official skin/turtle-ui plugins inline their non-shared deps), so profile
|
|
348
357
|
installations need no extra runtime dependency beyond the dsh peer closure.
|
|
349
|
-
`schemastery` is the DSH-standard config schema validator.
|
|
358
|
+
`schemastery` is the DSH-standard config schema validator.
|
package/lib/mobile.js
CHANGED
|
@@ -12764,17 +12764,41 @@ discriminatedUnion("type", [
|
|
|
12764
12764
|
function browserSource(url) {
|
|
12765
12765
|
return new EventSource(url);
|
|
12766
12766
|
}
|
|
12767
|
+
const DEFAULT_POLL_INTERVAL_MS = 3e3;
|
|
12768
|
+
const DEFAULT_STALL_THRESHOLD_MS = 12e3;
|
|
12769
|
+
/** Poll window: enough recent events to cover a few seconds of agent output. */
|
|
12770
|
+
const DEFAULT_POLL_PAGE_SIZE = 50;
|
|
12767
12771
|
/**
|
|
12768
12772
|
* Keep one SSE subscription open, fanning validated frames out to
|
|
12769
12773
|
* subscribers. EventSource owns reconnection (with its own backoff); this
|
|
12770
|
-
* class only manages the subscription lifecycle
|
|
12774
|
+
* class only manages the subscription lifecycle, plus a polling fallback
|
|
12775
|
+
* that keeps the open session live when the SSE channel cannot deliver.
|
|
12771
12776
|
*/
|
|
12772
12777
|
var MuxClient = class {
|
|
12773
12778
|
sourceFactory;
|
|
12779
|
+
pollLatest;
|
|
12780
|
+
pollIntervalMs;
|
|
12781
|
+
stallThresholdMs;
|
|
12782
|
+
now;
|
|
12774
12783
|
listeners = /* @__PURE__ */ new Set();
|
|
12775
12784
|
source;
|
|
12776
12785
|
stopped = false;
|
|
12777
12786
|
url;
|
|
12787
|
+
/** The session to keep live via fallback polling (undefined = none). */
|
|
12788
|
+
observeSessionId;
|
|
12789
|
+
/** Last epoch ms the SSE channel produced a frame (or the stream opened). */
|
|
12790
|
+
lastDataAt = 0;
|
|
12791
|
+
/**
|
|
12792
|
+
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
12793
|
+
* delivered frame proves the tunnel forwards SSE; silence alone then means
|
|
12794
|
+
* the agent idle, not a dead channel — only an onerror re-arms fallback).
|
|
12795
|
+
*/
|
|
12796
|
+
sseAlive = false;
|
|
12797
|
+
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
12798
|
+
pollWatermark = /* @__PURE__ */ new Map();
|
|
12799
|
+
stallTimer;
|
|
12800
|
+
pollTimer;
|
|
12801
|
+
polling = false;
|
|
12778
12802
|
/**
|
|
12779
12803
|
* @param url - the mobile events endpoint (browser-relative).
|
|
12780
12804
|
* @param options - seams.
|
|
@@ -12782,17 +12806,25 @@ var MuxClient = class {
|
|
|
12782
12806
|
constructor(url = "/m/api/events.mux", options = {}) {
|
|
12783
12807
|
this.url = url;
|
|
12784
12808
|
this.sourceFactory = options.sourceFactory ?? browserSource;
|
|
12809
|
+
this.pollLatest = options.pollLatest ?? ((sessionId) => history(sessionId, void 0, DEFAULT_POLL_PAGE_SIZE));
|
|
12810
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
12811
|
+
this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS;
|
|
12812
|
+
this.now = options.now ?? (() => Date.now());
|
|
12785
12813
|
}
|
|
12786
12814
|
/** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
|
|
12787
12815
|
start() {
|
|
12788
12816
|
this.stopped = false;
|
|
12789
|
-
|
|
12790
|
-
this.connect();
|
|
12817
|
+
this.lastDataAt = this.now();
|
|
12818
|
+
if (this.source === void 0) this.connect();
|
|
12819
|
+
this.startStallChecker();
|
|
12791
12820
|
}
|
|
12792
12821
|
/** Close for good. */
|
|
12793
12822
|
stop() {
|
|
12794
12823
|
this.stopped = true;
|
|
12824
|
+
this.stopStallChecker();
|
|
12825
|
+
this.stopPolling();
|
|
12795
12826
|
this.closeSource();
|
|
12827
|
+
this.observeSessionId = void 0;
|
|
12796
12828
|
}
|
|
12797
12829
|
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
12798
12830
|
onFrame(listener) {
|
|
@@ -12801,16 +12833,94 @@ var MuxClient = class {
|
|
|
12801
12833
|
this.listeners.delete(listener);
|
|
12802
12834
|
};
|
|
12803
12835
|
}
|
|
12836
|
+
/**
|
|
12837
|
+
* Point the fallback at one open session (or `undefined` to stop it).
|
|
12838
|
+
* While the SSE channel is stalled this client polls that session's
|
|
12839
|
+
* history and re-emits new events as `session/event` frames.
|
|
12840
|
+
*/
|
|
12841
|
+
observe(sessionId) {
|
|
12842
|
+
this.observeSessionId = sessionId;
|
|
12843
|
+
if (sessionId === void 0) {
|
|
12844
|
+
this.stopPolling();
|
|
12845
|
+
return;
|
|
12846
|
+
}
|
|
12847
|
+
if (!this.polling && !this.stopped && !this.sseAlive && this.now() - this.lastDataAt > this.stallThresholdMs) this.startPolling();
|
|
12848
|
+
}
|
|
12804
12849
|
connect() {
|
|
12850
|
+
this.sseAlive = false;
|
|
12805
12851
|
const source = this.sourceFactory(this.url);
|
|
12806
12852
|
this.source = source;
|
|
12807
12853
|
source.onmessage = (event) => {
|
|
12808
12854
|
this.handleMessage(event.data);
|
|
12809
12855
|
};
|
|
12810
12856
|
source.onerror = () => {
|
|
12811
|
-
if (this.stopped && this.source === source)
|
|
12857
|
+
if (this.stopped && this.source === source) {
|
|
12858
|
+
this.closeSource();
|
|
12859
|
+
return;
|
|
12860
|
+
}
|
|
12861
|
+
this.sseAlive = false;
|
|
12862
|
+
if (this.observeSessionId !== void 0) this.startPolling();
|
|
12812
12863
|
};
|
|
12813
12864
|
}
|
|
12865
|
+
startStallChecker() {
|
|
12866
|
+
this.stopStallChecker();
|
|
12867
|
+
this.stallTimer = setInterval(() => {
|
|
12868
|
+
if (this.stopped) return;
|
|
12869
|
+
if (this.observeSessionId === void 0) return;
|
|
12870
|
+
if (this.polling) return;
|
|
12871
|
+
if (this.sseAlive) return;
|
|
12872
|
+
if (this.now() - this.lastDataAt > this.stallThresholdMs) this.startPolling();
|
|
12873
|
+
}, 1e3);
|
|
12874
|
+
}
|
|
12875
|
+
stopStallChecker() {
|
|
12876
|
+
if (this.stallTimer !== void 0) {
|
|
12877
|
+
clearInterval(this.stallTimer);
|
|
12878
|
+
this.stallTimer = void 0;
|
|
12879
|
+
}
|
|
12880
|
+
}
|
|
12881
|
+
startPolling() {
|
|
12882
|
+
if (this.polling || this.stopped) return;
|
|
12883
|
+
this.polling = true;
|
|
12884
|
+
this.pollTick();
|
|
12885
|
+
this.pollTimer = setInterval(() => {
|
|
12886
|
+
this.pollTick();
|
|
12887
|
+
}, this.pollIntervalMs);
|
|
12888
|
+
}
|
|
12889
|
+
stopPolling() {
|
|
12890
|
+
this.polling = false;
|
|
12891
|
+
if (this.pollTimer !== void 0) {
|
|
12892
|
+
clearInterval(this.pollTimer);
|
|
12893
|
+
this.pollTimer = void 0;
|
|
12894
|
+
}
|
|
12895
|
+
}
|
|
12896
|
+
/**
|
|
12897
|
+
* Fetch the latest history page for the observed session and re-emit any
|
|
12898
|
+
* event above the per-session watermark as a `session/event` frame.
|
|
12899
|
+
* Idempotent by seq: listeners (and the fold) never see a duplicate.
|
|
12900
|
+
*/
|
|
12901
|
+
async pollTick() {
|
|
12902
|
+
const sessionId = this.observeSessionId;
|
|
12903
|
+
if (sessionId === void 0) {
|
|
12904
|
+
this.stopPolling();
|
|
12905
|
+
return;
|
|
12906
|
+
}
|
|
12907
|
+
try {
|
|
12908
|
+
const page = await this.pollLatest(sessionId);
|
|
12909
|
+
let maxSeq = this.pollWatermark.get(sessionId) ?? -1;
|
|
12910
|
+
for (const entry of page.events) {
|
|
12911
|
+
const event = entry.event;
|
|
12912
|
+
const seq = typeof event?.seq === "number" ? event.seq : -1;
|
|
12913
|
+
if (seq <= maxSeq) continue;
|
|
12914
|
+
maxSeq = seq;
|
|
12915
|
+
this.emit({
|
|
12916
|
+
type: "session/event",
|
|
12917
|
+
sessionId,
|
|
12918
|
+
event
|
|
12919
|
+
});
|
|
12920
|
+
}
|
|
12921
|
+
this.pollWatermark.set(sessionId, maxSeq);
|
|
12922
|
+
} catch {}
|
|
12923
|
+
}
|
|
12814
12924
|
handleMessage(data) {
|
|
12815
12925
|
if (typeof data !== "string" || data === "") return;
|
|
12816
12926
|
let parsed;
|
|
@@ -12823,8 +12933,14 @@ var MuxClient = class {
|
|
|
12823
12933
|
if (!envelope.success) return;
|
|
12824
12934
|
const frame = muxFrameSchema.safeParse(envelope.data.payload);
|
|
12825
12935
|
if (!frame.success) return;
|
|
12936
|
+
this.sseAlive = true;
|
|
12937
|
+
this.lastDataAt = this.now();
|
|
12938
|
+
if (this.polling) this.stopPolling();
|
|
12939
|
+
this.emit(frame.data);
|
|
12940
|
+
}
|
|
12941
|
+
emit(frame) {
|
|
12826
12942
|
for (const listener of this.listeners) try {
|
|
12827
|
-
listener(frame
|
|
12943
|
+
listener(frame);
|
|
12828
12944
|
} catch {}
|
|
12829
12945
|
}
|
|
12830
12946
|
closeSource() {
|
|
@@ -13499,7 +13615,6 @@ function ChatView({ session, mux, onBack }) {
|
|
|
13499
13615
|
setLoading(false);
|
|
13500
13616
|
const projections = page.projections?.values;
|
|
13501
13617
|
setPermissions(parsePermissionSelect(projections?.["permissions"]));
|
|
13502
|
-
scrollToBottom();
|
|
13503
13618
|
}, (reason) => {
|
|
13504
13619
|
if (cancelled) return;
|
|
13505
13620
|
setError(errorText(reason));
|
|
@@ -13523,14 +13638,20 @@ function ChatView({ session, mux, onBack }) {
|
|
|
13523
13638
|
if (frame.type === "session/projection" && frame.sessionId === session.sessionId && frame.key === "permissions") setPermissions(parsePermissionSelect(frame.value));
|
|
13524
13639
|
});
|
|
13525
13640
|
}, [mux, session.sessionId]);
|
|
13526
|
-
(0, import_react.useEffect)(() => {
|
|
13527
|
-
if (messages.some((message) => message.pending === true)) scrollToBottom();
|
|
13528
|
-
}, [messages]);
|
|
13529
13641
|
const scrollToBottom = (0, import_react.useCallback)(() => {
|
|
13530
13642
|
const el = scrollRef.current;
|
|
13531
13643
|
if (el === void 0) return;
|
|
13532
13644
|
el.scrollTop = el.scrollHeight;
|
|
13533
13645
|
}, []);
|
|
13646
|
+
const lastMessageKeyRef = (0, import_react.useRef)(void 0);
|
|
13647
|
+
(0, import_react.useEffect)(() => {
|
|
13648
|
+
const last = messages[messages.length - 1];
|
|
13649
|
+
if (last === void 0) return;
|
|
13650
|
+
const key = last.seq + ":" + (last.pending === true ? "p" : "f");
|
|
13651
|
+
if (key === lastMessageKeyRef.current) return;
|
|
13652
|
+
lastMessageKeyRef.current = key;
|
|
13653
|
+
scrollToBottom();
|
|
13654
|
+
}, [messages, scrollToBottom]);
|
|
13534
13655
|
/** Load one older page and prepend it. The fold is directional (incremental
|
|
13535
13656
|
* tails only), so the older page folds standalone and concatenates ahead —
|
|
13536
13657
|
* host page boundaries never cut a message, so the seam is exact. */
|
|
@@ -14535,6 +14656,9 @@ function App() {
|
|
|
14535
14656
|
mux.stop();
|
|
14536
14657
|
};
|
|
14537
14658
|
}, []);
|
|
14659
|
+
(0, import_react.useEffect)(() => {
|
|
14660
|
+
muxRef.current?.observe(route.kind === "chat" ? route.session.sessionId : void 0);
|
|
14661
|
+
}, [route]);
|
|
14538
14662
|
const back = (0, import_react.useCallback)(() => {
|
|
14539
14663
|
setRoute((previous) => {
|
|
14540
14664
|
if (previous.kind === "chat") return {
|