@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.10
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.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +993 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +79 -39
- package/lib/types/client/locales.d.ts +32 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +42 -22
- package/lib/types/client/mobile-adapt.d.ts +49 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +1002 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +82 -39
- package/src/client/locales.ts +42 -22
- package/src/client/mobile-adapt.ts +954 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17658
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -149
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -561
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -63
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -821
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -685
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -268
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1342
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/mobile/mux.ts
DELETED
|
@@ -1,336 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mobile-surface live-event client: the plugin's `/m/api/events.mux` SSE
|
|
3
|
-
* channel (Server-Sent Events — the host bridges the mux stream onto it, so
|
|
4
|
-
* no WebSocket handshake or framing is needed on this side). The host
|
|
5
|
-
* pushes mux frames (subscribed baselines, session events, approvals,
|
|
6
|
-
* questions, queue snapshots, tasks, projections) as soon as the stream
|
|
7
|
-
* opens — no subscription handshake is needed. Frames arrive as
|
|
8
|
-
* server-request envelopes whose payload is the mux frame; unknown frame
|
|
9
|
-
* types are dropped so a newer host never breaks this client.
|
|
10
|
-
*
|
|
11
|
-
* EventSource reconnects automatically — but only over a tunnel that
|
|
12
|
-
* actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
|
|
13
|
-
* Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
|
|
14
|
-
* HTTP works, yet the SSE connection stays open or reconnects with zero
|
|
15
|
-
* bytes, so no live frame ever arrives. That is a transport-layer limit of
|
|
16
|
-
* the tunnel, not something the host can fix. This client therefore
|
|
17
|
-
* degrades gracefully: once the SSE channel has silently stalled (no frame
|
|
18
|
-
* for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
|
|
19
|
-
* an error), it starts polling the open session's history over plain HTTP
|
|
20
|
-
* (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
|
|
21
|
-
* and re-emits freshly appended events as `session/event` frames through
|
|
22
|
-
* the same subscriber contract, so listeners (and the message fold) behave
|
|
23
|
-
* exactly as if the frames had arrived over SSE. When the SSE channel
|
|
24
|
-
* delivers again, fallback polling stops and the live stream takes over.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
28
|
-
import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
|
|
29
|
-
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
|
|
30
|
-
import { history as fetchHistory, type HistoryPage } from './api.ts'
|
|
31
|
-
|
|
32
|
-
/** Injectable seams for tests. */
|
|
33
|
-
export interface MuxClientOptions {
|
|
34
|
-
/** EventSource factory (defaults to the browser EventSource). */
|
|
35
|
-
sourceFactory?: (url: string) => EventSourceLike
|
|
36
|
-
/**
|
|
37
|
-
* Fetch one history page (tail) for a session — the polling fallback's
|
|
38
|
-
* data source. Defaults to the mobile `session.history` RPC, which rides
|
|
39
|
-
* the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
|
|
40
|
-
*/
|
|
41
|
-
pollLatest?: (sessionId: string) => Promise<HistoryPage>
|
|
42
|
-
/** Initial poll cadence while SSE is stalled (default 3000 ms). Empty polls back off to 60000 ms. */
|
|
43
|
-
pollIntervalMs?: number
|
|
44
|
-
/** Initial SSE stall window (default 12000 ms); a previously-live stream gets three windows before fallback. */
|
|
45
|
-
stallThresholdMs?: number
|
|
46
|
-
/** Clock seam for tests (defaults to Date.now). */
|
|
47
|
-
now?: () => number
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** The EventSource subset this client uses (browser EventSource fits). */
|
|
51
|
-
export interface EventSourceLike {
|
|
52
|
-
onmessage: ((event: { data: string }) => void) | null
|
|
53
|
-
onerror: ((event: unknown) => void) | null
|
|
54
|
-
close(): void
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Browser default source factory. */
|
|
58
|
-
function browserSource(url: string): EventSourceLike {
|
|
59
|
-
// The DOM EventSource is structurally compatible; the `this`-typed handler
|
|
60
|
-
// signatures differ, so the narrow face takes it through an adapter cast.
|
|
61
|
-
return new EventSource(url) as unknown as EventSourceLike
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/** The `session/event` arm of the mux frame union. */
|
|
65
|
-
type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
|
|
66
|
-
|
|
67
|
-
const DEFAULT_POLL_INTERVAL_MS = 3000
|
|
68
|
-
const DEFAULT_STALL_THRESHOLD_MS = 12000
|
|
69
|
-
const LIVE_SSE_STALL_MULTIPLIER = 3
|
|
70
|
-
const MAX_POLL_BACKOFF_MS = 60000
|
|
71
|
-
/**
|
|
72
|
-
* Stall-check granularity: the single scheduler tick runs at least this
|
|
73
|
-
* often so fallback arms within a second of the stall threshold passing,
|
|
74
|
-
* while the poll cadence itself stays {@link MuxClientOptions.pollIntervalMs}.
|
|
75
|
-
*/
|
|
76
|
-
const STALL_CHECK_MS = 1000
|
|
77
|
-
/** Poll window: enough recent events to cover a few seconds of agent output. */
|
|
78
|
-
const DEFAULT_POLL_PAGE_SIZE = 50
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Keep one SSE subscription open, fanning validated frames out to
|
|
82
|
-
* subscribers. EventSource owns reconnection (with its own backoff); this
|
|
83
|
-
* class only manages the subscription lifecycle, plus a polling fallback
|
|
84
|
-
* that keeps the open session live when the SSE channel cannot deliver.
|
|
85
|
-
*/
|
|
86
|
-
export class MuxClient {
|
|
87
|
-
private readonly sourceFactory: (url: string) => EventSourceLike
|
|
88
|
-
private readonly pollLatest: (sessionId: string) => Promise<HistoryPage>
|
|
89
|
-
private readonly pollIntervalMs: number
|
|
90
|
-
private readonly stallThresholdMs: number
|
|
91
|
-
private readonly now: () => number
|
|
92
|
-
private readonly listeners = new Set<(frame: MuxFrame) => void>()
|
|
93
|
-
private source: EventSourceLike | undefined
|
|
94
|
-
private stopped = false
|
|
95
|
-
private readonly url: string
|
|
96
|
-
|
|
97
|
-
/** The session to keep live via fallback polling (undefined = none). */
|
|
98
|
-
private observeSessionId: string | undefined
|
|
99
|
-
/** Last epoch ms the SSE channel produced a frame (or the stream opened). */
|
|
100
|
-
private lastDataAt = 0
|
|
101
|
-
/**
|
|
102
|
-
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
103
|
-
* delivered frame proves the tunnel can forward SSE; sustained silence may
|
|
104
|
-
* still mean a suspended mobile tunnel, so polling re-arms after 3 windows).
|
|
105
|
-
*/
|
|
106
|
-
private sseAlive = false
|
|
107
|
-
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
108
|
-
private readonly pollWatermark = new Map<string, number>()
|
|
109
|
-
/** Single scheduler tick: both the stall check and the poll cadence ride this one interval. */
|
|
110
|
-
private tickTimer: ReturnType<typeof setInterval> | undefined
|
|
111
|
-
private polling = false
|
|
112
|
-
/** Epoch ms of the next due poll while polling (kept on the same tick timer). */
|
|
113
|
-
private nextPollAt = 0
|
|
114
|
-
/** Adaptive delay: productive polls reset it; empty/error polls add one base interval up to one minute. */
|
|
115
|
-
private pollDelayMs: number
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* @param url - the mobile events endpoint (browser-relative).
|
|
119
|
-
* @param options - seams.
|
|
120
|
-
*/
|
|
121
|
-
constructor(url = '/m/api/events.mux', options: MuxClientOptions = {}) {
|
|
122
|
-
this.url = url
|
|
123
|
-
this.sourceFactory = options.sourceFactory ?? browserSource
|
|
124
|
-
this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE))
|
|
125
|
-
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
126
|
-
this.pollDelayMs = this.pollIntervalMs
|
|
127
|
-
this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS
|
|
128
|
-
this.now = options.now ?? (() => Date.now())
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
|
|
132
|
-
start(): void {
|
|
133
|
-
this.stopped = false
|
|
134
|
-
this.lastDataAt = this.now()
|
|
135
|
-
if (this.source === undefined) this.connect()
|
|
136
|
-
this.startTick()
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** Close for good. */
|
|
140
|
-
stop(): void {
|
|
141
|
-
this.stopped = true
|
|
142
|
-
this.stopTick()
|
|
143
|
-
this.stopPolling()
|
|
144
|
-
this.closeSource()
|
|
145
|
-
this.observeSessionId = undefined
|
|
146
|
-
this.nextPollAt = 0
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
150
|
-
onFrame(listener: (frame: MuxFrame) => void): () => void {
|
|
151
|
-
this.listeners.add(listener)
|
|
152
|
-
return () => { this.listeners.delete(listener) }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Point the fallback at one open session (or `undefined` to stop it).
|
|
157
|
-
* While the SSE channel is stalled this client polls that session's
|
|
158
|
-
* history and re-emits new events as `session/event` frames.
|
|
159
|
-
*/
|
|
160
|
-
observe(sessionId: string | undefined): void {
|
|
161
|
-
this.observeSessionId = sessionId
|
|
162
|
-
if (sessionId === undefined) {
|
|
163
|
-
this.stopPolling()
|
|
164
|
-
return
|
|
165
|
-
}
|
|
166
|
-
// If SSE is already stalled for this session, start patching right away.
|
|
167
|
-
if (!this.polling && !this.stopped && this.isSseStalled()) this.startPolling()
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
private connect(): void {
|
|
171
|
-
// A fresh stream starts unknown; only a delivered frame proves it works.
|
|
172
|
-
this.sseAlive = false
|
|
173
|
-
const source = this.sourceFactory(this.url)
|
|
174
|
-
this.source = source
|
|
175
|
-
source.onmessage = (event) => {
|
|
176
|
-
this.handleMessage(event.data)
|
|
177
|
-
}
|
|
178
|
-
source.onerror = () => {
|
|
179
|
-
// EventSource reconnects by itself; when we are closing, detach first
|
|
180
|
-
// so the native reconnect cannot outlive stop(). Otherwise an error is
|
|
181
|
-
// a strong signal the transport is not delivering — degrade to polling.
|
|
182
|
-
if (this.stopped && this.source === source) {
|
|
183
|
-
this.closeSource()
|
|
184
|
-
return
|
|
185
|
-
}
|
|
186
|
-
this.sseAlive = false
|
|
187
|
-
if (this.observeSessionId !== undefined) this.startPolling()
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* The single scheduler tick is the only interval this client owns. One
|
|
193
|
-
* timer (at the finer of the stall-check and poll cadences) both arms the
|
|
194
|
-
* polling fallback once the stall threshold passes and drives each poll at
|
|
195
|
-
* {@link MuxClientOptions.pollIntervalMs} — one timer instead of two.
|
|
196
|
-
*/
|
|
197
|
-
private startTick(): void {
|
|
198
|
-
if (this.tickTimer !== undefined) return
|
|
199
|
-
const cadence = Math.min(this.pollIntervalMs, STALL_CHECK_MS)
|
|
200
|
-
this.tickTimer = setInterval(() => { this.tick() }, cadence)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
private stopTick(): void {
|
|
204
|
-
if (this.tickTimer !== undefined) {
|
|
205
|
-
clearInterval(this.tickTimer)
|
|
206
|
-
this.tickTimer = undefined
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private tick(): void {
|
|
211
|
-
if (this.stopped) return
|
|
212
|
-
if (this.observeSessionId === undefined) return
|
|
213
|
-
if (this.polling) {
|
|
214
|
-
// The stall phase has ended; the same tick now paces the adaptive polls.
|
|
215
|
-
if (this.now() >= this.nextPollAt) {
|
|
216
|
-
// pollTick schedules the next run after it settles, so slow requests
|
|
217
|
-
// cannot overlap with another scheduler tick.
|
|
218
|
-
this.nextPollAt = Number.POSITIVE_INFINITY
|
|
219
|
-
void this.pollTick()
|
|
220
|
-
}
|
|
221
|
-
return
|
|
222
|
-
}
|
|
223
|
-
if (this.isSseStalled()) this.startPolling()
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
private isSseStalled(): boolean {
|
|
227
|
-
const windowMs = this.sseAlive
|
|
228
|
-
? this.stallThresholdMs * LIVE_SSE_STALL_MULTIPLIER
|
|
229
|
-
: this.stallThresholdMs
|
|
230
|
-
return (this.now() - this.lastDataAt) > windowMs
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
private startPolling(): void {
|
|
234
|
-
if (this.polling || this.stopped) return
|
|
235
|
-
this.polling = true
|
|
236
|
-
this.pollDelayMs = this.pollIntervalMs
|
|
237
|
-
this.nextPollAt = Number.POSITIVE_INFINITY
|
|
238
|
-
void this.pollTick()
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
private stopPolling(): void {
|
|
242
|
-
this.polling = false
|
|
243
|
-
this.pollDelayMs = this.pollIntervalMs
|
|
244
|
-
this.nextPollAt = 0
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Fetch the latest history page for the observed session and re-emit any
|
|
249
|
-
* event above the per-session watermark as a `session/event` frame.
|
|
250
|
-
* Idempotent by seq: listeners (and the fold) never see a duplicate.
|
|
251
|
-
*/
|
|
252
|
-
private async pollTick(): Promise<void> {
|
|
253
|
-
const sessionId = this.observeSessionId
|
|
254
|
-
if (sessionId === undefined) {
|
|
255
|
-
this.stopPolling()
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
let emitted = 0
|
|
259
|
-
try {
|
|
260
|
-
const page = await this.pollLatest(sessionId)
|
|
261
|
-
let maxSeq = this.pollWatermark.get(sessionId) ?? -1
|
|
262
|
-
const ordered = [...page.events].sort((left, right) => {
|
|
263
|
-
const leftSeq = typeof left.event?.seq === 'number' ? left.event.seq : -1
|
|
264
|
-
const rightSeq = typeof right.event?.seq === 'number' ? right.event.seq : -1
|
|
265
|
-
return leftSeq - rightSeq
|
|
266
|
-
})
|
|
267
|
-
for (const entry of ordered) {
|
|
268
|
-
const event = entry.event
|
|
269
|
-
const seq = typeof event?.seq === 'number' ? event.seq : -1
|
|
270
|
-
if (seq <= maxSeq) continue
|
|
271
|
-
maxSeq = seq
|
|
272
|
-
emitted += 1
|
|
273
|
-
this.emit({ type: 'session/event', sessionId: sessionId as SessionEventFrame['sessionId'], event } as SessionEventFrame)
|
|
274
|
-
}
|
|
275
|
-
this.pollWatermark.set(sessionId, maxSeq)
|
|
276
|
-
} catch {
|
|
277
|
-
// Transient (network, pairing, history paging); retry with backoff.
|
|
278
|
-
} finally {
|
|
279
|
-
if (emitted > 0) {
|
|
280
|
-
this.pollDelayMs = this.pollIntervalMs
|
|
281
|
-
} else {
|
|
282
|
-
this.pollDelayMs = Math.min(MAX_POLL_BACKOFF_MS, this.pollDelayMs + this.pollIntervalMs)
|
|
283
|
-
}
|
|
284
|
-
if (this.polling && this.observeSessionId === sessionId) {
|
|
285
|
-
this.nextPollAt = this.now() + this.pollDelayMs
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
private handleMessage(data: string): void {
|
|
291
|
-
if (typeof data !== 'string' || data === '') return
|
|
292
|
-
let parsed: unknown
|
|
293
|
-
try {
|
|
294
|
-
parsed = JSON.parse(data)
|
|
295
|
-
} catch {
|
|
296
|
-
return
|
|
297
|
-
}
|
|
298
|
-
// The SSE channel carries server-request envelopes whose payload is the
|
|
299
|
-
// mux frame (same wire shape as the desktop mux channel).
|
|
300
|
-
const envelope = serverRequestSchema.safeParse(parsed)
|
|
301
|
-
if (!envelope.success) return
|
|
302
|
-
const frame = muxFrameSchema.safeParse(envelope.data.payload)
|
|
303
|
-
if (!frame.success) return
|
|
304
|
-
// A delivered frame proves the SSE channel is live (the tunnel forwards
|
|
305
|
-
// it) and delivers again — drop any fallback polling so the live stream
|
|
306
|
-
// takes over without double delivery.
|
|
307
|
-
this.sseAlive = true
|
|
308
|
-
this.lastDataAt = this.now()
|
|
309
|
-
if (this.polling) this.stopPolling()
|
|
310
|
-
this.emit(frame.data)
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
private emit(frame: MuxFrame): void {
|
|
314
|
-
for (const listener of this.listeners) {
|
|
315
|
-
try {
|
|
316
|
-
listener(frame)
|
|
317
|
-
} catch {
|
|
318
|
-
// A throwing subscriber must not break the emit loop.
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private closeSource(): void {
|
|
324
|
-
const source = this.source
|
|
325
|
-
this.source = undefined
|
|
326
|
-
if (source !== undefined) {
|
|
327
|
-
source.onmessage = null
|
|
328
|
-
source.onerror = null
|
|
329
|
-
try {
|
|
330
|
-
source.close()
|
|
331
|
-
} catch {
|
|
332
|
-
// Already closed.
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
package/src/mobile/pairing.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/** Pairing helpers owned by the standalone /m mobile surface. */
|
|
2
|
-
export type PairFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
3
|
-
|
|
4
|
-
export interface MobilePairInput {
|
|
5
|
-
token: string
|
|
6
|
-
workspaceId?: string
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export type MobilePairAccept =
|
|
10
|
-
| { ok: true }
|
|
11
|
-
| { ok: false; message: string }
|
|
12
|
-
|
|
13
|
-
export type MobilePairBootstrap =
|
|
14
|
-
| { kind: 'none' }
|
|
15
|
-
| { kind: 'accepted'; path: string }
|
|
16
|
-
| { kind: 'failed'; path: string; message: string }
|
|
17
|
-
|
|
18
|
-
const MOBILE_ROOT = '/m/'
|
|
19
|
-
|
|
20
|
-
/** Parse a pairing token or a copied pairing link without following its origin. */
|
|
21
|
-
export function parseMobilePairInput(value: string): MobilePairInput | undefined {
|
|
22
|
-
const trimmed = value.trim()
|
|
23
|
-
if (trimmed === '') return undefined
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const url = new URL(trimmed)
|
|
27
|
-
const token = url.searchParams.get('pair')
|
|
28
|
-
if (token === null || token === '') return undefined
|
|
29
|
-
const workspaceId = url.searchParams.get('workspace')
|
|
30
|
-
return {
|
|
31
|
-
token,
|
|
32
|
-
...(workspaceId !== null && workspaceId !== '' ? { workspaceId } : {}),
|
|
33
|
-
}
|
|
34
|
-
} catch {
|
|
35
|
-
return { token: trimmed }
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Build the safe, token-free mobile destination after successful pairing. */
|
|
40
|
-
export function mobilePairPath(workspaceId?: string): string {
|
|
41
|
-
return workspaceId === undefined ? MOBILE_ROOT : MOBILE_ROOT + '?workspace=' + encodeURIComponent(workspaceId)
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Accept one pairing token on this exact browser or installed-web-app context. */
|
|
45
|
-
export async function acceptMobilePair(token: string, fetcher: PairFetch = fetch): Promise<MobilePairAccept> {
|
|
46
|
-
try {
|
|
47
|
-
const response = await fetcher('/api/pair/accept', {
|
|
48
|
-
method: 'POST',
|
|
49
|
-
headers: { 'content-type': 'application/json' },
|
|
50
|
-
body: JSON.stringify({ token }),
|
|
51
|
-
})
|
|
52
|
-
if (response.ok) return { ok: true }
|
|
53
|
-
if (response.status === 404) return { ok: false, message: '配对链接无效或已过期。' }
|
|
54
|
-
if (response.status === 409) return { ok: false, message: '配对链接已被使用。' }
|
|
55
|
-
return { ok: false, message: '此设备无法使用该配对链接。' }
|
|
56
|
-
} catch {
|
|
57
|
-
return { ok: false, message: '无法连接到配对服务。' }
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Consume a QR pairing token before the mobile application starts making RPC calls. */
|
|
62
|
-
export async function consumeMobilePairUrl(href: string, fetcher: PairFetch = fetch): Promise<MobilePairBootstrap> {
|
|
63
|
-
let url: URL
|
|
64
|
-
try {
|
|
65
|
-
url = new URL(href)
|
|
66
|
-
} catch {
|
|
67
|
-
return { kind: 'none' }
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const token = url.searchParams.get('pair')
|
|
71
|
-
if (token === null || token === '') return { kind: 'none' }
|
|
72
|
-
|
|
73
|
-
const workspaceId = url.searchParams.get('workspace')
|
|
74
|
-
const path = mobilePairPath(workspaceId === null || workspaceId === '' ? undefined : workspaceId)
|
|
75
|
-
const result = await acceptMobilePair(token, fetcher)
|
|
76
|
-
return result.ok ? { kind: 'accepted', path } : { kind: 'failed', path, message: result.message }
|
|
77
|
-
}
|
package/src/mobile/rpc.ts
DELETED
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mobile-surface unary RPC over the shared /api transport: the four-quadrant
|
|
3
|
-
* envelope (client-request → server-response), minted rpcIds, and typed
|
|
4
|
-
* error mapping. This is a thin, self-contained slice of the harness
|
|
5
|
-
* apiproxy fetch carrier — the mobile page is an independent bundle and must
|
|
6
|
-
* not depend on the main UI's module loader, so the wire contract is
|
|
7
|
-
* reimplemented here over plain fetch.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** Transport-level failure (network, HTTP status, malformed envelope). */
|
|
11
|
-
export class RpcTransportError extends Error {
|
|
12
|
-
constructor(message: string) {
|
|
13
|
-
super(message)
|
|
14
|
-
this.name = 'RpcTransportError'
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** A business error the host answered with (200 + err result). */
|
|
19
|
-
export class RpcCallError extends Error {
|
|
20
|
-
/** The wire error (code + message + details). */
|
|
21
|
-
readonly error: { code: string; message: string }
|
|
22
|
-
|
|
23
|
-
constructor(error: { code: string; message: string }) {
|
|
24
|
-
super(error.message)
|
|
25
|
-
this.name = 'RpcCallError'
|
|
26
|
-
this.error = error
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
let rpcCounter = 0
|
|
31
|
-
|
|
32
|
-
/** Mint one process-unique rpcId (stable under crypto.randomUUID absence). */
|
|
33
|
-
export function mintRpcId(): string {
|
|
34
|
-
const random = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
35
|
-
? crypto.randomUUID()
|
|
36
|
-
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
37
|
-
rpcCounter += 1
|
|
38
|
-
return `${random}-${rpcCounter.toString(36)}`
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* One unary call: POST /m/api/<method> (the plugin's own mobile channel —
|
|
43
|
-
* NOT the connection plugin's /api prefix, so the tunneled Host never needs
|
|
44
|
-
* to enter the transport trust fence) with the client-request envelope,
|
|
45
|
-
* resolve the server-response value, reject with the mapped error classes.
|
|
46
|
-
* @param method - the dotted RPC method, e.g. `session.list`.
|
|
47
|
-
* @param payload - the business payload.
|
|
48
|
-
* @param signal - optional abort.
|
|
49
|
-
* @returns the response value.
|
|
50
|
-
*/
|
|
51
|
-
export async function callUnary<T>(
|
|
52
|
-
method: string,
|
|
53
|
-
payload: unknown,
|
|
54
|
-
signal?: AbortSignal,
|
|
55
|
-
): Promise<T> {
|
|
56
|
-
const rpcId = mintRpcId()
|
|
57
|
-
let response: Response
|
|
58
|
-
try {
|
|
59
|
-
response = await fetch(`/m/api/${method}`, {
|
|
60
|
-
method: 'POST',
|
|
61
|
-
headers: { 'content-type': 'application/json' },
|
|
62
|
-
body: JSON.stringify({ type: 'client-request', rpcId, method, payload }),
|
|
63
|
-
...(signal !== undefined ? { signal } : {}),
|
|
64
|
-
})
|
|
65
|
-
} catch (error) {
|
|
66
|
-
const reason = error instanceof DOMException && error.name === 'AbortError'
|
|
67
|
-
? 'aborted'
|
|
68
|
-
: error instanceof Error ? error.message : String(error)
|
|
69
|
-
throw new RpcTransportError(`transport failed: ${reason}`)
|
|
70
|
-
}
|
|
71
|
-
if (!response.ok) {
|
|
72
|
-
throw new RpcTransportError(`HTTP ${String(response.status)}`)
|
|
73
|
-
}
|
|
74
|
-
let envelope: unknown
|
|
75
|
-
try {
|
|
76
|
-
envelope = await response.json()
|
|
77
|
-
} catch {
|
|
78
|
-
throw new RpcTransportError('malformed response body')
|
|
79
|
-
}
|
|
80
|
-
const parsed = envelope as { type?: unknown; rpcId?: unknown; result?: unknown }
|
|
81
|
-
if (parsed?.type !== 'server-response' || parsed.rpcId !== rpcId) {
|
|
82
|
-
throw new RpcTransportError('response envelope mismatch')
|
|
83
|
-
}
|
|
84
|
-
const result = parsed.result as { ok?: boolean; value?: unknown; error?: { code: string; message: string } }
|
|
85
|
-
if (result?.ok === true) return result.value as T
|
|
86
|
-
if (result?.ok === false && result.error !== undefined) {
|
|
87
|
-
throw new RpcCallError(result.error)
|
|
88
|
-
}
|
|
89
|
-
throw new RpcTransportError('malformed result envelope')
|
|
90
|
-
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Theme toggle button: sits at the right end of every view header and flips
|
|
3
|
-
* the mobile surface between the light (default) and dark palettes. The
|
|
4
|
-
* glyph shows the theme you will switch TO (moon in light mode, sun in dark
|
|
5
|
-
* mode); the choice persists across visits.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { useSyncExternalStore } from 'react'
|
|
9
|
-
import { getMobileTheme, subscribeMobileTheme, toggleMobileTheme } from './mobile-theme.ts'
|
|
10
|
-
|
|
11
|
-
/** Sun glyph: circle + eight rays (inline SVG, no emoji). */
|
|
12
|
-
function SunIcon() {
|
|
13
|
-
return (
|
|
14
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" aria-hidden>
|
|
15
|
-
<circle cx="12" cy="12" r="4.2" />
|
|
16
|
-
<path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5 5l1.7 1.7M17.3 17.3 19 19M19 5l-1.7 1.7M6.7 17.3 5 19" />
|
|
17
|
-
</svg>
|
|
18
|
-
)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Moon glyph: crescent path (inline SVG, no emoji). */
|
|
22
|
-
function MoonIcon() {
|
|
23
|
-
return (
|
|
24
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
|
25
|
-
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z" />
|
|
26
|
-
</svg>
|
|
27
|
-
)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Render the header theme toggle.
|
|
32
|
-
* @returns the toggle button.
|
|
33
|
-
*/
|
|
34
|
-
export function ThemeToggle() {
|
|
35
|
-
const theme = useSyncExternalStore(subscribeMobileTheme, getMobileTheme)
|
|
36
|
-
const dark = theme === 'dark'
|
|
37
|
-
return (
|
|
38
|
-
<button
|
|
39
|
-
type="button"
|
|
40
|
-
className="mobile-theme-toggle"
|
|
41
|
-
aria-label={dark ? '切换到浅色' : '切换到深色'}
|
|
42
|
-
onClick={() => { toggleMobileTheme() }}
|
|
43
|
-
>
|
|
44
|
-
{dark ? <SunIcon /> : <MoonIcon />}
|
|
45
|
-
</button>
|
|
46
|
-
)
|
|
47
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
// @vitest-environment jsdom
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
-
import { cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
-
import { RpcTransportError } from '../rpc.ts'
|
|
5
|
-
|
|
6
|
-
const api = vi.hoisted(() => ({
|
|
7
|
-
fetchMobilePreferences: vi.fn(),
|
|
8
|
-
history: vi.fn(),
|
|
9
|
-
listSessions: vi.fn(),
|
|
10
|
-
listWorkspaces: vi.fn(),
|
|
11
|
-
prompt: vi.fn(),
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
vi.mock('../api.ts', () => api)
|
|
15
|
-
vi.mock('../mux.ts', () => ({
|
|
16
|
-
MuxClient: class {
|
|
17
|
-
start(): void {}
|
|
18
|
-
stop(): void {}
|
|
19
|
-
observe(): void {}
|
|
20
|
-
},
|
|
21
|
-
}))
|
|
22
|
-
vi.mock('./WorkspaceView.tsx', () => ({ WorkspaceView: () => <div>workspace-ready</div> }))
|
|
23
|
-
vi.mock('./SessionListView.tsx', () => ({ SessionListView: () => <div>sessions-ready</div> }))
|
|
24
|
-
vi.mock('./ChatView.tsx', () => ({ ChatView: () => <div>chat-ready</div> }))
|
|
25
|
-
|
|
26
|
-
import { App, mobilePairStateForError } from './App.tsx'
|
|
27
|
-
|
|
28
|
-
afterEach(() => {
|
|
29
|
-
cleanup()
|
|
30
|
-
vi.clearAllMocks()
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
describe('mobile paired-device gate', () => {
|
|
34
|
-
it('distinguishes a missing paired cookie from a transport outage', () => {
|
|
35
|
-
expect(mobilePairStateForError(new RpcTransportError('HTTP 403'))).toBe('unpaired')
|
|
36
|
-
expect(mobilePairStateForError(new RpcTransportError('HTTP 503'))).toBe('unavailable')
|
|
37
|
-
expect(mobilePairStateForError(new Error('offline'))).toBe('unavailable')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('keeps a valid paired context usable after a failed QR token', async () => {
|
|
41
|
-
api.fetchMobilePreferences.mockResolvedValue({ mobileEnterToSend: true })
|
|
42
|
-
render(<App initialPairError="配对链接已被使用。" />)
|
|
43
|
-
|
|
44
|
-
await waitFor(() => expect(screen.getByText('workspace-ready')).toBeDefined())
|
|
45
|
-
expect(screen.queryByRole('alert')).toBeNull()
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('shows the pairing screen only when the mobile gateway rejects the device', async () => {
|
|
49
|
-
api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 403'))
|
|
50
|
-
render(<App initialPairError="配对链接已被使用。" />)
|
|
51
|
-
|
|
52
|
-
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('配对链接已被使用。'))
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('shows a retryable connection failure rather than mounting remote views on outage', async () => {
|
|
56
|
-
api.fetchMobilePreferences.mockRejectedValue(new RpcTransportError('HTTP 503'))
|
|
57
|
-
render(<App />)
|
|
58
|
-
|
|
59
|
-
await waitFor(() => expect(screen.getByRole('alert').textContent).toContain('无法连接到运行中的 DSH host。'))
|
|
60
|
-
expect(screen.queryByText('workspace-ready')).toBeNull()
|
|
61
|
-
})
|
|
62
|
-
})
|