@linxin666/dsh-remote-web-ui 0.3.5 → 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 -17621
  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 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  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 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  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 -231
  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 -677
  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 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  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
@@ -1,245 +0,0 @@
1
- /**
2
- * Landing level: the workspace roster. The mobile surface opens straight
3
- * here — no new-session homepage — and every workspace row is a thin
4
- * fetch from workspace.list (the roster is small; sessions are not loaded
5
- * until a workspace is opened).
6
- */
7
-
8
- import { useEffect, useState } from 'react'
9
- import type { WorkspaceView as WorkspaceRow } from '@deepseek-ai/dsh-host-apiproxy/api/workspace'
10
- import { listWorkspaces, listDirectory, createWorkspace, type DirectoryListing, type DirectoryEntry } from '../api.ts'
11
- import { errorText } from './App.tsx'
12
- import { ThemeToggle } from '../theme-toggle.tsx'
13
-
14
- /** Props for the directory browser. */
15
- interface DirectoryBrowserProps {
16
- onCancel(): void
17
- onPick(workspace: WorkspaceRow): void
18
- }
19
-
20
- /**
21
- * Render the directory browser for creating a new workspace.
22
- */
23
- function DirectoryBrowser({ onCancel, onPick }: DirectoryBrowserProps) {
24
- const [listing, setListing] = useState<DirectoryListing | undefined>(undefined)
25
- const [error, setError] = useState<string | undefined>(undefined)
26
- const [creating, setCreating] = useState(false)
27
- const [path, setPath] = useState<string | undefined>(undefined)
28
- const [reload, setReload] = useState(0)
29
-
30
- useEffect(() => {
31
- let cancelled = false
32
- setError(undefined)
33
- setListing(undefined)
34
- void listDirectory(path).then(
35
- (res) => {
36
- if (cancelled) return
37
- setListing(res)
38
- },
39
- (reason: unknown) => {
40
- if (cancelled) return
41
- setError(errorText(reason))
42
- },
43
- )
44
- return () => { cancelled = true }
45
- }, [path, reload])
46
-
47
- const handleCreate = async () => {
48
- if (listing === undefined || creating) return
49
- setCreating(true)
50
- setError(undefined)
51
- try {
52
- const result = await createWorkspace(listing.path)
53
- onPick(result.workspace)
54
- } catch (reason: unknown) {
55
- setCreating(false)
56
- setError(errorText(reason))
57
- }
58
- }
59
-
60
- return (
61
- <div className="mobile dir-browser">
62
- <header className="mobile-header">
63
- <button type="button" className="mobile-headerAction" onClick={onCancel}>返回</button>
64
- <h1 className="mobile-title">选择目录</h1>
65
- </header>
66
-
67
- {listing !== undefined && (
68
- <div className="dir-crumbs">
69
- {listing.crumbs.map((crumb, idx) => (
70
- <span key={crumb.path}>
71
- <button
72
- type="button"
73
- className="dir-crumb"
74
- onClick={() => setPath(crumb.path)}
75
- >
76
- {crumb.name || '/'}
77
- </button>
78
- {idx < listing.crumbs.length - 1 && <span className="dir-crumb-separator">/</span>}
79
- </span>
80
- ))}
81
- </div>
82
- )}
83
-
84
- {error !== undefined ? (
85
- <div className="mobile-empty">
86
- <p className="mobile-error">{error}</p>
87
- <button type="button" className="mobile-button" onClick={() => setReload(n => n + 1)}>重试</button>
88
- </div>
89
- ) : listing === undefined ? (
90
- <div className="mobile-empty">
91
- <p className="mobile-muted">加载中…</p>
92
- </div>
93
- ) : (
94
- <ul className="mobile-list">
95
- {listing.entries.length === 0 ? (
96
- <div className="mobile-empty dir-empty">
97
- <p className="mobile-muted">空目录</p>
98
- </div>
99
- ) : (
100
- listing.entries.map((entry: DirectoryEntry) => (
101
- <li key={entry.path}>
102
- <button
103
- type="button"
104
- className={`mobile-row dir-entry ${entry.hidden ? 'dir-entry-hidden' : ''}`}
105
- onClick={() => setPath(entry.path)}
106
- >
107
- <span className="mobile-rowTitle">{entry.name}</span>
108
- </button>
109
- </li>
110
- ))
111
- )}
112
- </ul>
113
- )}
114
-
115
- <div className="dir-select">
116
- <button
117
- type="button"
118
- className="mobile-button"
119
- disabled={listing === undefined || creating}
120
- onClick={() => void handleCreate()}
121
- >
122
- {creating ? '创建中…' : '选择此目录'}
123
- </button>
124
- </div>
125
- </div>
126
- )
127
- }
128
-
129
- /** Props for the workspace roster. */
130
- export interface WorkspaceViewProps {
131
- /** Workspace carried by the pairing link; opened after the roster loads. */
132
- initialWorkspaceId?: string
133
- /** Open one workspace's session list. */
134
- onPick(workspace: WorkspaceRow): void
135
- }
136
-
137
- /**
138
- * Render the workspace roster.
139
- * @param props - the pick action.
140
- * @returns the roster.
141
- */
142
- export function WorkspaceView({ initialWorkspaceId, onPick }: WorkspaceViewProps) {
143
- const [items, setItems] = useState<WorkspaceRow[] | undefined>(undefined)
144
- const [error, setError] = useState<string | undefined>(undefined)
145
- // Bumped by the retry button to re-run the roster fetch effect.
146
- const [reload, setReload] = useState(0)
147
- const [isCreating, setIsCreating] = useState(false)
148
-
149
- useEffect(() => {
150
- let cancelled = false
151
- void listWorkspaces().then(
152
- (rows) => {
153
- if (cancelled) return
154
- const target = initialWorkspaceId === undefined
155
- ? undefined
156
- : rows.find(workspace => workspace.workspaceId === initialWorkspaceId)
157
- if (target !== undefined) {
158
- onPick(target)
159
- return
160
- }
161
- setItems(rows)
162
- },
163
- (reason: unknown) => {
164
- if (cancelled) return
165
- setError(errorText(reason))
166
- },
167
- )
168
- return () => { cancelled = true }
169
- }, [initialWorkspaceId, onPick, reload])
170
-
171
- if (isCreating) {
172
- return (
173
- <DirectoryBrowser
174
- onCancel={() => setIsCreating(false)}
175
- onPick={onPick}
176
- />
177
- )
178
- }
179
-
180
- if (error !== undefined) {
181
- return (
182
- <div className="mobile">
183
- <header className="mobile-header">
184
- <h1 className="mobile-title">工作区</h1>
185
- <ThemeToggle />
186
- </header>
187
- <div className="mobile-empty">
188
- <p className="mobile-error">加载失败:{error}</p>
189
- <button type="button" className="mobile-button" onClick={() => { setError(undefined); setItems(undefined); setReload(n => n + 1) }}>
190
- 重试
191
- </button>
192
- </div>
193
- </div>
194
- )
195
- }
196
-
197
- if (items === undefined) {
198
- return (
199
- <div className="mobile">
200
- <header className="mobile-header">
201
- <h1 className="mobile-title">工作区</h1>
202
- <ThemeToggle />
203
- </header>
204
- <div className="mobile-empty">
205
- <p className="mobile-muted">加载中…</p>
206
- </div>
207
- </div>
208
- )
209
- }
210
-
211
- return (
212
- <div className="mobile">
213
- <header className="mobile-header">
214
- <h1 className="mobile-title">工作区</h1>
215
- <ThemeToggle />
216
- </header>
217
- {items.length === 0 ? (
218
- <div className="mobile-empty">
219
- <p className="mobile-muted">暂无工作区</p>
220
- </div>
221
- ) : (
222
- <ul className="mobile-list">
223
- {items.map(workspace => (
224
- <li key={workspace.workspaceId}>
225
- <button type="button" className="mobile-row" onClick={() => { onPick(workspace) }}>
226
- <span className="mobile-rowTitle">{workspace.title}</span>
227
- <span className="mobile-rowMeta">{workspace.path}</span>
228
- <span className="mobile-chevron">›</span>
229
- </button>
230
- </li>
231
- ))}
232
- </ul>
233
- )}
234
- <div style={{ padding: '16px' }}>
235
- <button
236
- type="button"
237
- className="mobile-button"
238
- onClick={() => setIsCreating(true)}
239
- >
240
- + 新建工作区
241
- </button>
242
- </div>
243
- </div>
244
- )
245
- }
package/src/mobile-api.ts DELETED
@@ -1,418 +0,0 @@
1
- /**
2
- * The mobile surface's data channel: `/m/api` proxies the host ApiProxy
3
- * service for the standalone phone page. The phone's RPC calls ride THIS
4
- * prefix instead of the connection plugin's `/api` — so the tunneled Host
5
- * never needs to enter the connection trust fence (a distributable plugin
6
- * cannot change that fence), and this plugin's own pairing gate is the
7
- * access control instead.
8
- *
9
- * Security model:
10
- * - Every request must carry a live paired-device cookie (the same gate
11
- * semantic as the LAN fence), enforced before any host call.
12
- * - Only an explicit allowlist of methods is proxied ON THIS PREFIX. The
13
- * allowlist constrains the /m/api proxy alone: the same paired-device
14
- * cookie also passes the global api/gate, so a paired device is a
15
- * full-control credential for the host /api surface outside the SDK's
16
- * loopback-pinned privileged set (settings/credentials/agentPreset/host
17
- * actions/llm.discoverModels). Pairing is full device trust.
18
- * - `session.list` is paged here (the host API returns everything; this
19
- * layer slices stable pages) so the phone never transfers the whole list.
20
- * - The live mux stream is bridged over Server-Sent Events on the same
21
- * prefix (one-directional push; answers to questions/approvals ride the
22
- * unary channel), gated identically.
23
- */
24
-
25
- import type { IncomingMessage, ServerResponse } from 'node:http'
26
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
27
- import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy'
28
- import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
29
- import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
30
- import type { PendingTracker } from './mobile-pending.ts'
31
- import type { PairingService } from './pairing.ts'
32
- import { readBoundedJson, writeJson } from './http.ts'
33
- import { readCookie } from './gate.ts'
34
-
35
- /**
36
- * Methods the phone surface may call. Everything else is refused HERE — but
37
- * note the paired-device cookie also passes the global api/gate for the full
38
- * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
39
- * the allowlist only constrains this /m/api proxy, not the cookie's reach.
40
- * stop() revokes every device; the loopback panel can also revoke one
41
- * device at a time.
42
- */
43
- const MOBILE_ALLOWLIST = new Set([
44
- 'host.listDirectory',
45
- 'workspace.create',
46
- 'workspace.list',
47
- 'agentPreset.list',
48
- 'session.create',
49
- 'session.list',
50
- 'session.history',
51
- 'session.search',
52
- 'session.prompt',
53
- 'session.models',
54
- 'session.selectModel',
55
- 'session.rename',
56
- 'session.cancel',
57
- ])
58
-
59
- /**
60
- * Locally answered display-preference method (the phone's read-only
61
- * surface preferences; never proxied to the host ApiProxy and never a
62
- * settings-domain write).
63
- */
64
- const MOBILE_PREFERENCES_METHOD = 'mobile.preferences'
65
- const MOBILE_PENDING_METHOD = 'mobile.pending'
66
- const MOBILE_RESPOND_METHOD = 'mobile.respond'
67
- /** Execute a slash command through the host command registry (#1125). */
68
- const MOBILE_COMMAND_METHOD = 'mobile.command'
69
-
70
- /** One session.list page (thin phones load incrementally). */
71
- const SESSION_PAGE_SIZE = 20
72
- /** SSE keep-alive ping cadence for the live mux stream (single connection). */
73
- const DEFAULT_EVENTS_HEARTBEAT_MS = 15_000
74
-
75
- /** Encode one list position as an opaque continuation cursor. */
76
- function sessionListCursor(updatedAt: number, sessionId: string): string {
77
- return `${updatedAt}:${sessionId}`
78
- }
79
-
80
- /** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
81
- function parseSessionListCursor(cursor: string): { updatedAt: number; sessionId: string } | undefined {
82
- const separator = cursor.indexOf(':')
83
- if (separator < 0) return undefined
84
- const updatedAt = Number(cursor.slice(0, separator))
85
- if (!Number.isFinite(updatedAt)) return undefined
86
- return { updatedAt, sessionId: cursor.slice(separator + 1) }
87
- }
88
-
89
- /** Whether a row comes strictly after the cursor position. */
90
- function afterCursor(row: { updatedAt: number; sessionId: string }, position: { updatedAt: number; sessionId: string }): boolean {
91
- return row.updatedAt < position.updatedAt
92
- || (row.updatedAt === position.updatedAt && row.sessionId > position.sessionId)
93
- }
94
-
95
- /** Route-family dependencies. */
96
- export interface MobileApiDeps {
97
- /** The pairing service (device gate + cookie name). */
98
- service: PairingService
99
- /** The host ApiProxy service (injected by the plugin). */
100
- apiProxy: ApiProxy
101
- /** The pending tracker. */
102
- pendingTracker: PendingTracker
103
- /** The resolved mobile composer preference (live per request). */
104
- mobileEnterToSend: () => boolean
105
- /** SSE keep-alive ping cadence for the mux stream (default 15000 ms; test seam). */
106
- eventsHeartbeatMs?: number
107
- /**
108
- * Optional command dispatcher for executing slash commands on the mobile
109
- * surface. When absent, `mobile.command` returns an error; the mobile UI
110
- * can still fall back to `session.prompt` (which sends the line to the
111
- * model, not the command registry).
112
- */
113
- commandDispatcher?: {
114
- execute(
115
- sessionId: string,
116
- line: string,
117
- signal: AbortSignal,
118
- ): Promise<{ ok: true; result: unknown } | { error: string }>
119
- }
120
- }
121
-
122
- /** Mobile API route paths. */
123
- export const MOBILE_API_PATHS = {
124
- events: '/m/api/events.mux',
125
- } as const
126
-
127
- /** The mobile-api prefix (every other path under it is a method name). */
128
- const MOBILE_API_PREFIX = '/m/api'
129
- /** Method extraction: the prefix plus one slash. */
130
- const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`
131
-
132
- /**
133
- * Build the mobile data-channel routes.
134
- * @param deps - pairing service + apiProxy.
135
- * @returns the routes to register on webServer.
136
- */
137
- export function makeMobileApiRoutes(deps: MobileApiDeps): WebRoute[] {
138
- const { service, apiProxy, mobileEnterToSend } = deps
139
- const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS
140
-
141
- /**
142
- * Refresh the paired device's presence and report whether it is live.
143
- * The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
144
- * sender, so any activity on the mobile channel — a gated RPC, or the live
145
- * SSE stream staying open — must count as presence. Without this, an
146
- * idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
147
- * wrongly reports it as disconnected.
148
- */
149
- const touchDeviceFor = (req: IncomingMessage): boolean => {
150
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
151
- if (deviceId === undefined) return false
152
- return service.touchDevice(deviceId)
153
- }
154
-
155
- /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
156
- const gateOk = (req: IncomingMessage): boolean => {
157
- return touchDeviceFor(req)
158
- }
159
-
160
- const handleMethod = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
161
- if (req.method !== 'POST') {
162
- res.writeHead(405)
163
- res.end()
164
- return
165
- }
166
- if (!gateOk(req)) {
167
- writeJson(res, 403, { ok: false, error: { code: 'unpaired', message: 'mobile session is not paired' } })
168
- return
169
- }
170
- const pathname = new URL(req.url ?? '/', 'http://x').pathname
171
- if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
172
- writeJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown mobile api path' } })
173
- return
174
- }
175
- const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length)
176
- const local = method === MOBILE_PREFERENCES_METHOD
177
- || method === MOBILE_PENDING_METHOD
178
- || method === MOBILE_RESPOND_METHOD
179
- || method === MOBILE_COMMAND_METHOD
180
- if (!MOBILE_ALLOWLIST.has(method) && !local) {
181
- writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: `method ${method} is not exposed to the mobile surface` } })
182
- return
183
- }
184
- let envelope: unknown
185
- try {
186
- envelope = await readBoundedJson(req, 64 * 1024)
187
- } catch {
188
- writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'invalid json body' } })
189
- return
190
- }
191
- const parsed = envelope as { rpcId?: unknown; payload?: unknown }
192
- const rpcId = typeof parsed?.rpcId === 'string' ? parsed.rpcId : ''
193
- if (rpcId === '') {
194
- writeJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'missing rpcId' } })
195
- return
196
- }
197
- if (local) {
198
- if (method === MOBILE_PREFERENCES_METHOD) {
199
- writeJson(res, 200, {
200
- type: 'server-response',
201
- rpcId,
202
- result: { ok: true, value: { mobileEnterToSend: mobileEnterToSend() } },
203
- })
204
- } else if (method === MOBILE_PENDING_METHOD) {
205
- const payload = parsed.payload as any
206
- writeJson(res, 200, {
207
- type: 'server-response',
208
- rpcId,
209
- result: { ok: true, value: deps.pendingTracker.pending(payload?.sessionId) },
210
- })
211
- } else if (method === MOBILE_RESPOND_METHOD) {
212
- const payload = parsed.payload as any
213
- try {
214
- const receipt = await apiProxy.respond({
215
- type: 'client-response',
216
- rpcId: RpcId(payload.rpcId),
217
- result: { ok: true, value: payload.response },
218
- })
219
- writeJson(res, 200, {
220
- type: 'server-response',
221
- rpcId,
222
- result: { ok: true, value: receipt },
223
- })
224
- } catch (error) {
225
- const message = error instanceof Error ? error.message : String(error)
226
- writeJson(res, 200, {
227
- type: 'server-response',
228
- rpcId,
229
- result: { ok: false, error: { code: 'internal', message } },
230
- })
231
- }
232
- } else if (method === MOBILE_COMMAND_METHOD) {
233
- const payload = parsed.payload as { sessionId?: string; line?: string } | undefined
234
- const sessionId = payload?.sessionId
235
- const line = payload?.line
236
- if (typeof sessionId !== 'string' || typeof line !== 'string') {
237
- writeJson(res, 200, {
238
- type: 'server-response',
239
- rpcId,
240
- result: { ok: false, error: { code: 'bad-request', message: 'missing sessionId or line' } },
241
- })
242
- } else if (deps.commandDispatcher === undefined) {
243
- writeJson(res, 200, {
244
- type: 'server-response',
245
- rpcId,
246
- result: { ok: false, error: { code: 'unavailable', message: 'command dispatcher is not available' } },
247
- })
248
- } else {
249
- try {
250
- const abort = new AbortController()
251
- res.on('close', () => { if (!res.writableEnded) abort.abort() })
252
- const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal)
253
- if ('error' in outcome) {
254
- writeJson(res, 200, {
255
- type: 'server-response',
256
- rpcId,
257
- result: { ok: false, error: { code: outcome.error, message: outcome.error } },
258
- })
259
- } else {
260
- writeJson(res, 200, {
261
- type: 'server-response',
262
- rpcId,
263
- result: { ok: true, value: { matched: true, result: outcome.result } },
264
- })
265
- }
266
- } catch (error) {
267
- const message = error instanceof Error ? error.message : String(error)
268
- writeJson(res, 200, {
269
- type: 'server-response',
270
- rpcId,
271
- result: { ok: false, error: { code: 'internal', message } },
272
- })
273
- }
274
- }
275
- }
276
- return
277
- }
278
- try {
279
- // Cancel the host-side work when the phone goes away mid-call (the
280
- // response stream closing before we answer means nobody is listening).
281
- const abort = new AbortController()
282
- res.on('close', () => { if (!res.writableEnded) abort.abort() })
283
- const response = await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal)
284
- writeJson(res, 200, response)
285
- } catch (error) {
286
- const message = error instanceof Error ? error.message : String(error)
287
- writeJson(res, 200, {
288
- type: 'server-response',
289
- rpcId,
290
- result: { ok: false, error: { code: 'internal', message } },
291
- })
292
- }
293
- }
294
-
295
- /** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
296
- const handleEvents = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
297
- if (req.method !== 'GET') {
298
- res.writeHead(405)
299
- res.end()
300
- return
301
- }
302
- if (!gateOk(req)) {
303
- writeJson(res, 403, {
304
- ok: false,
305
- error: { code: 'unpaired', message: 'mobile session is not paired' },
306
- })
307
- return
308
- }
309
- res.writeHead(200, {
310
- 'content-type': 'text/event-stream; charset=utf-8',
311
- 'cache-control': 'no-cache',
312
- connection: 'keep-alive',
313
- })
314
- const controller = new AbortController()
315
- let closed = false
316
- const heartbeat = setInterval(() => {
317
- if (closed) return
318
- // An open SSE stream proves the phone is still live even while the agent
319
- // idles (no RPC traffic), so refresh presence alongside the transport
320
- // keepalive — otherwise an idle phone drifts to "disconnected".
321
- touchDeviceFor(req)
322
- try {
323
- res.write(': ping\n\n')
324
- } catch {
325
- // The write failed; the close handler tears the subscription down.
326
- }
327
- }, eventsHeartbeatMs)
328
- const onClose = (): void => {
329
- if (closed) return
330
- closed = true
331
- controller.abort()
332
- clearInterval(heartbeat)
333
- }
334
- res.on('close', onClose)
335
- req.on('close', onClose)
336
- try {
337
- const frames = apiProxy.events.mux({ rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`), payload: {} }, controller.signal)
338
- for await (const frame of frames) {
339
- if (closed) break
340
- deps.pendingTracker.onFrame(frame as any)
341
- res.write(`data: ${JSON.stringify(frame)}\n\n`)
342
- }
343
- } catch {
344
- // The stream ended or errored; the EventSource reconnects.
345
- } finally {
346
- controller.abort()
347
- clearInterval(heartbeat)
348
- }
349
- if (!closed) res.end()
350
- }
351
-
352
- return [
353
- { kind: 'prefix', path: MOBILE_API_PREFIX, handler: handleMethod },
354
- { kind: 'exact', path: MOBILE_API_PATHS.events, handler: handleEvents },
355
- ]
356
- }
357
-
358
- /** Dispatch one allowlisted method through the host ApiProxy. */
359
- async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rpcId: string, signal?: AbortSignal): Promise<unknown> {
360
- const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
361
- if (method === 'session.list') {
362
- const full = await apiProxy.sessions.list(request as never)
363
- // The error path must carry the same 'server-response' envelope the
364
- // success path builds, or the phone's callUnary throws a transport error
365
- // and masks the real business error.
366
- if (!full.result.ok) return { type: 'server-response' as const, rpcId, result: full.result }
367
- const items = full.result.value.items as Array<{ updatedAt: number; sessionId: string }>
368
- const cursor = (payload as { cursor?: string } | undefined)?.cursor
369
- // Every call pages (the first call with no cursor IS the first page):
370
- // the phone must never transfer the whole session list at once.
371
- // One stable page over (updatedAt desc, sessionId asc); pages never skip
372
- // or repeat a row while the list changes between calls.
373
- items.sort((a, b) => b.updatedAt - a.updatedAt
374
- || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0))
375
- const position = cursor === undefined ? undefined : parseSessionListCursor(cursor)
376
- const from = position === undefined ? 0 : items.findIndex(row => afterCursor(row, position))
377
- const start = from < 0 ? items.length : from
378
- const page = items.slice(start, start + SESSION_PAGE_SIZE)
379
- const last = page[page.length - 1]
380
- const nextCursor = last !== undefined && start + page.length < items.length
381
- ? sessionListCursor(last.updatedAt, last.sessionId)
382
- : undefined
383
- return {
384
- type: 'server-response',
385
- rpcId,
386
- result: {
387
- ok: true,
388
- value: {
389
- items: page,
390
- hasMore: nextCursor !== undefined,
391
- ...(nextCursor !== undefined ? { nextCursor } : {}),
392
- },
393
- },
394
- }
395
- }
396
- // The ApiProxy unary methods resolve to the internal response shape
397
- // ({ rpcId, result }) without the transport envelope the phone's callUnary
398
- // requires — wrap every pass-through in the same 'server-response'
399
- // envelope session.list builds above.
400
- const wrap = (response: { rpcId: string; result: unknown }): unknown => ({
401
- type: 'server-response' as const,
402
- rpcId,
403
- result: response.result,
404
- })
405
- if (method === 'workspace.list') return wrap(await apiProxy.workspace.list(request as never))
406
- if (method === 'workspace.create') return wrap(await apiProxy.workspace.create(request as never))
407
- if (method === 'host.listDirectory') return wrap(await apiProxy.host.listDirectory(request as never, signal ?? new AbortController().signal))
408
- if (method === 'agentPreset.list') return wrap(await apiProxy.agentPresets.list(request as never))
409
- if (method === 'session.create') return wrap(await apiProxy.sessions.create(request as never))
410
- if (method === 'session.history') return wrap(await apiProxy.sessions.history(request as never))
411
- if (method === 'session.search') return wrap(await apiProxy.sessions.search(request as never, signal ?? new AbortController().signal))
412
- if (method === 'session.prompt') return wrap(await apiProxy.sessions.prompt(request as never))
413
- if (method === 'session.models') return wrap(await apiProxy.sessions.models(request as never))
414
- if (method === 'session.selectModel') return wrap(await apiProxy.sessions.selectModel(request as never))
415
- if (method === 'session.rename') return wrap(await apiProxy.sessions.rename(request as never))
416
- if (method === 'session.cancel') return wrap(await apiProxy.sessions.cancel(request as never))
417
- throw new Error(`unhandled allowlisted method ${method}`)
418
- }