@linxin666/dsh-remote-web-ui 0.3.6 → 0.3.9

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.
Files changed (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17658
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -149
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -561
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -63
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -821
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -236
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -685
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -268
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1342
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. 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
- }
@@ -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
- })