@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
@@ -97,7 +97,6 @@ export interface DeviceFrame {
97
97
  /**
98
98
  * Mint a fresh pairing token (one active token at a time — this invalidates
99
99
  * any previous link).
100
- * @param workspaceId - optional current workspace to deep-link the phone into.
101
100
  * @param address - optional LAN IP literal the QR must be built from (the
102
101
  * default is the first interface); unknown literals refuse with
103
102
  * 'unknown-address'.
@@ -105,12 +104,11 @@ export interface DeviceFrame {
105
104
  * 0.0.0.0), or the forbidden refusal (the loopback-only fence rejected this
106
105
  * origin — the panel is a desktop control endpoint).
107
106
  */
108
- export async function issuePair(workspaceId?: string, address?: string): Promise<IssueResponse> {
107
+ export async function issuePair(address?: string): Promise<IssueResponse> {
109
108
  const response = await fetch('/api/pair/issue', {
110
109
  method: 'POST',
111
110
  headers: { 'content-type': 'application/json' },
112
111
  body: JSON.stringify({
113
- ...(workspaceId !== undefined ? { workspaceId } : {}),
114
112
  ...(address !== undefined ? { address } : {}),
115
113
  }),
116
114
  })
@@ -166,15 +164,11 @@ export async function sendHeartbeat(): Promise<void> {
166
164
  await fetch('/api/pair/heartbeat', { method: 'POST' })
167
165
  }
168
166
 
169
- /** Whether the current page URL carries a pairing token / workspace target. */
170
- export function readPairParams(search: string): { pair?: string; workspace?: string } {
167
+ /** Whether the current page URL carries a pairing token. */
168
+ export function readPairParams(search: string): { pair?: string } {
171
169
  const params = new URLSearchParams(search)
172
170
  const pair = params.get('pair')
173
- const workspace = params.get('workspace')
174
- return {
175
- ...(pair !== null && pair !== '' ? { pair } : {}),
176
- ...(workspace !== null && workspace !== '' ? { workspace } : {}),
177
- }
171
+ return pair !== null && pair !== '' ? { pair } : {}
178
172
  }
179
173
 
180
174
  /**
@@ -188,11 +182,28 @@ export function stripParam(name: string): string {
188
182
  return url.search
189
183
  }
190
184
 
191
- /** Convert an issued `/m/` link into the desktop pairing form. */
192
- export function desktopPairUrl(mobileUrl: string): string {
193
- const url = new URL(mobileUrl)
194
- url.pathname = '/'
195
- return url.href
185
+ /** LAN-bind status frame from the loopback-only card endpoint. */
186
+ export interface LanBindFrame {
187
+ profile: string
188
+ /** The config toggle: true/false once flipped, null while untouched. */
189
+ setting: boolean | null
190
+ /** The bind default the managed patch block currently pins. */
191
+ blockHost: string | null
192
+ /** The live bind of the running web server. */
193
+ bindHost: string
194
+ port: number
195
+ lanUrls: string[]
196
+ firewall: { ok: boolean; managed: boolean; note?: string }
197
+ platform: string
198
+ /** true when the running bind has not caught up with the toggle yet. */
199
+ pendingRestart?: boolean
200
+ }
201
+
202
+ /** Read the LAN-bind facts (loopback-only endpoint). */
203
+ export async function readLanBindStatus(): Promise<LanBindFrame> {
204
+ const response = await fetch('/api/pair/lan-bind')
205
+ if (!response.ok) throw new Error(`remote-web-ui: lan-bind status failed with ${String(response.status)}`)
206
+ return await response.json() as LanBindFrame
196
207
  }
197
208
 
198
209
  /** Human-readable expiry clock, e.g. "10:35". */
@@ -133,6 +133,7 @@ export interface ChannelWindow {
133
133
  HTMLScriptElement?: SrcConstructor
134
134
  HTMLIFrameElement?: SrcConstructor
135
135
  location: { origin: string; href: string }
136
+ sessionStorage?: { getItem(key: string): string | null }
136
137
  }
137
138
 
138
139
  /** Options for {@link installRemoteChannel}. */
@@ -213,6 +214,33 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
213
214
  const sameOrigin = (url: URL): boolean => url.origin === window.location.origin
214
215
  const rewrite = (raw: string): string => rewriteRawUrl(raw, window.location.href, window.location.origin)
215
216
 
217
+ // The cookieless device credential (set by the /pair-app capture script):
218
+ // attaches as a header on gated fetches and as the device query on WS and
219
+ // EventSource handshakes, mirroring the boot patch exactly.
220
+ const device = (() => {
221
+ try {
222
+ return window.sessionStorage?.getItem(RULES.deviceKey) ?? null
223
+ } catch {
224
+ return null
225
+ }
226
+ })()
227
+ const attach = (init?: RequestInit): RequestInit | undefined => {
228
+ if (device === null) return init
229
+ const headers = init?.headers
230
+ if (typeof Headers !== 'undefined' && headers instanceof Headers) {
231
+ try { headers.set(RULES.deviceHeader, device) } catch { /* ignore */ }
232
+ return init
233
+ }
234
+ if (typeof headers === 'object' && headers !== null) {
235
+ return { ...init, headers: { ...(headers as Record<string, string>), [RULES.deviceHeader]: device } } as RequestInit
236
+ }
237
+ return init
238
+ }
239
+ const withDeviceQuery = (url: URL): URL => {
240
+ if (device !== null) url.searchParams.set(RULES.deviceQuery, device)
241
+ return url
242
+ }
243
+
216
244
  const patchedFetch: typeof globalThis.fetch = (input, init) => {
217
245
  const url = new URL(
218
246
  typeof input === 'string' || input instanceof URL ? input.toString() : input.url,
@@ -224,7 +252,7 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
224
252
  const next: RequestInfo | URL = typeof input === 'string' || input instanceof URL
225
253
  ? rewritten.toString()
226
254
  : new Request(rewritten, input)
227
- return Promise.resolve(originalFetch.call(window, next, init)).then(async (response) => {
255
+ return Promise.resolve(originalFetch.call(window, next, attach(init))).then(async (response) => {
228
256
  if (await isUnpairedDenied(response.clone())) options.onUnpaired?.()
229
257
  else options.onPaired?.()
230
258
  return response
@@ -240,7 +268,7 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
240
268
  if (wsOrigin !== '' && wsOrigin === window.location.origin && shouldRewriteWsPath(parsed.pathname)) {
241
269
  const rewritten = new URL(parsed)
242
270
  rewritten.pathname = rewritePath(parsed.pathname)
243
- super(rewritten, protocols)
271
+ super(withDeviceQuery(rewritten), protocols)
244
272
  return
245
273
  }
246
274
  super(url, protocols)
@@ -262,7 +290,7 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
262
290
  if (sameOrigin(parsed) && shouldRewriteFetchPath(parsed.pathname)) {
263
291
  const rewritten = new URL(parsed)
264
292
  rewritten.pathname = rewritePath(parsed.pathname)
265
- super(rewritten, eventSourceInitDict)
293
+ super(withDeviceQuery(rewritten), eventSourceInitDict)
266
294
  return
267
295
  }
268
296
  super(url, eventSourceInitDict)
@@ -8,8 +8,9 @@
8
8
  * card-store pattern.
9
9
  */
10
10
 
11
- import type { SettingsScope, SettingsScopeSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
12
- import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
11
+ import type { SettingsScope, SettingsScopeSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
12
+ import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
13
+ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
13
14
 
14
15
  /** The write one field's staged text performs when the card is saved. */
15
16
  export type FieldWrite =
@@ -23,9 +24,11 @@ export interface FieldSpec {
23
24
  /**
24
25
  * Whether the Host treats this field as a secret and redacts its value from
25
26
  * the read-back (role('secret') in the section schema). Redacted secrets are
26
- * never compared against the draft on save; the field lands when the scope
27
- * reports the write succeeded (its secret-set marker under the bridge), so
28
- * a successful secret save is not misreported as failed.
27
+ * never compared against the draft on save: the Host strips them from every
28
+ * wire view layer, so the settled snapshot carries nothing to read back. A
29
+ * staged secret set is judged by the mutation settling; the rest of its
30
+ * batch, when one exists, is still judged by read-back, and the atomic
31
+ * mutation lands every write or none.
29
32
  */
30
33
  secret?: boolean
31
34
  /** Render a stored value as draft text; the empty string when the section carries none. */
@@ -53,8 +56,8 @@ export interface CardShell {
53
56
  available: boolean
54
57
  /**
55
58
  * Whether the namespace is actually served to this client. False when the
56
- * Host deployment does not expose it (e.g. the official apiproxy settings
57
- * allowlist omits third-party namespaces): the card renders an explanation
59
+ * Host deployment does not expose it (e.g. the owning plugin's settings
60
+ * domain is not mounted): the card renders an explanation
58
61
  * instead of its form, so a missing namespace never looks like a missing
59
62
  * plugin.
60
63
  */
@@ -101,13 +104,17 @@ interface StagedEdit {
101
104
  interface PlannedWrite {
102
105
  /** Field this entry writes. */
103
106
  field: string
104
- /** The durable write this entry performs, described for a batched scope. */
107
+ /** The durable write this entry performs, inside the save's one atomic mutation. */
105
108
  op: BatchedWrite
106
- /** Perform the write and report whether the Host holds the staged value afterwards. */
107
- run: (() => Promise<boolean>) | undefined
109
+ /**
110
+ * Read the settled snapshot back and report whether the Host holds this
111
+ * write's effect. Undefined when the draft is not a value the field
112
+ * accepts: there is nothing to write, and the entry blocks the save.
113
+ */
114
+ judge: (() => boolean) | undefined
108
115
  }
109
116
 
110
- /** One durable write a batched settings scope performs. */
117
+ /** One durable write inside the save's atomic scope mutation. */
111
118
  export interface BatchedWrite {
112
119
  /** Field this entry writes. */
113
120
  field: string
@@ -117,36 +124,6 @@ export interface BatchedWrite {
117
124
  value?: unknown
118
125
  }
119
126
 
120
- /** Per-field outcome of one batched scope write. */
121
- export interface BatchedFieldResult {
122
- /** Field this entry writes. */
123
- field: string
124
- /** Whether the Host accepted this field's write (per the read-back view). */
125
- landed: boolean
126
- }
127
-
128
- /**
129
- * Result of a batched scope write. The bridge scope posts every planned write
130
- * in one /mutate so the Host validate hook judges baseURL+model together; a
131
- * batched refusal fails the whole save rather than per-field.
132
- */
133
- export interface BatchResult {
134
- /** Whether the whole mutate was accepted. */
135
- ok: boolean
136
- /** Per-field success, in the request order (always present when ok). */
137
- fields: BatchedFieldResult[]
138
- /** Host rejection code (mutate refused). */
139
- code?: string
140
- /** Host rejection message (mutate refused). */
141
- message?: string
142
- }
143
-
144
- /** The optional batch surface the bridge scope adds over the SettingsScope contract. */
145
- interface BatchedSettingsScope {
146
- /** Write every operation in one scope mutation, reporting per-field success. */
147
- mutate: (writes: BatchedWrite[]) => Promise<BatchResult>
148
- }
149
-
150
127
  /** Constraints a numeric field's accepted drafts must satisfy, mirroring the host schema. */
151
128
  export interface NumberConstraints {
152
129
  /** The accepted value must be a whole number. */
@@ -188,8 +165,8 @@ export function textField(field: string): FieldSpec {
188
165
  /**
189
166
  * A free-text field the Host treats as a secret and redacts from the read-back
190
167
  * (role('secret') in the section schema). The card still edits it like text,
191
- * but a save never compares the redacted value back and relies on the scope
192
- * reporting the write landed.
168
+ * but a save never compares the redacted value back: the staged set is judged
169
+ * by the mutation settling (see {@link FieldSpec.secret}).
193
170
  */
194
171
  export function secretField(field: string): FieldSpec {
195
172
  return { ...textField(field), secret: true }
@@ -277,7 +254,7 @@ export class CardForm<T> {
277
254
  exposed: snapshot.status === 'ready',
278
255
  writable: snapshot.writable,
279
256
  dirty: plan.length > 0,
280
- invalid: plan.some(item => item.run === undefined),
257
+ invalid: plan.some(item => item.judge === undefined),
281
258
  saving: this.saving,
282
259
  failed: this.failed,
283
260
  ...this.failedReason === undefined ? {} : { failedReason: this.failedReason },
@@ -318,21 +295,25 @@ export class CardForm<T> {
318
295
  }
319
296
 
320
297
  /**
321
- * Write every staged edit, then re-seed from what the Host accepted.
298
+ * Write every staged edit in one atomic scope mutation, then re-seed from
299
+ * what the Host accepted.
322
300
  *
323
- * When the scope carries the optional batch surface (the dsh-web
324
- * bridge scope), every planned write rides one mutation so cross-field
325
- * validate hooks (baseURL+model) judge the batch as a unit instead of
326
- * deadlocking on per-field writes. Otherwise the per-field loop runs.
327
- * A field lands only when the Host reports it held the staged value; a
328
- * landed field's draft is dropped, a failed one stays staged for the user.
329
- * @returns settlement after every write and the read-back.
301
+ * The whole batch rides one mutate, so cross-field validate hooks
302
+ * (baseURL+model) judge it as a unit: the Host either applies every write
303
+ * or refuses the batch. The 0.1.2 scope contract never rejects a refused
304
+ * mutation — the scope recovers with a fresh Host view and resolves — so
305
+ * resolution alone proves nothing: the outcome is judged by reading the
306
+ * settled snapshot back, one planned write at a time, and one missed write
307
+ * fails the whole save. A scope that still rejects on refusal (the dsh-web
308
+ * bridge scope) reports through the same failure path with its rejection
309
+ * message. A save that did not land keeps its drafts, so the user can
310
+ * correct them instead of retyping.
311
+ * @returns settlement after the mutation and the read-back.
330
312
  */
331
313
  async save(): Promise<void> {
332
314
  const plan = this.plan()
333
- const valid = plan.filter(item => item.run !== undefined)
315
+ const valid = plan.filter((item): item is PlannedWrite & { judge: () => boolean } => item.judge !== undefined)
334
316
  if (plan.length === 0 || this.saving || valid.length !== plan.length) return
335
- const plannedWrites = valid.map(item => item.op)
336
317
  // Snapshot the staged entries this save writes, so an edit staged while it
337
318
  // is in flight (which replaces the same key) survives: only delete the key
338
319
  // when the entry is still the one this save started from.
@@ -342,36 +323,34 @@ export class CardForm<T> {
342
323
  this.failed = false
343
324
  this.failedReason = undefined
344
325
  this.publish()
345
- const landed = new Set<string>()
346
- const batch = this.batchedScope()
347
- if (batch !== undefined) {
348
- const result = await batch.mutate(plannedWrites)
349
- if (result.ok) {
350
- for (const field of result.fields) {
351
- if (field.landed) landed.add(field.field)
352
- }
353
- } else {
354
- this.failedReason = result.message
355
- }
356
- } else {
357
- for (const item of valid) {
358
- if (await item.run!()) landed.add(item.field)
359
- }
326
+ // One atomic namespace mutation: the 0.1.2 scope contract takes ordered
327
+ // path operations, so the whole staged batch is validated, persisted, and
328
+ // recovered together — either every write lands or none does.
329
+ const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
330
+ ? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
331
+ : { op: 'unset', path: [item.field] })
332
+ let failedReason: string | undefined
333
+ try {
334
+ await this.scope.mutate(ops)
335
+ } catch (error) {
336
+ failedReason = error instanceof Error ? error.message : String(error)
360
337
  }
338
+ // The 0.1.2 scope resolves even a refused mutation (it recovers with a
339
+ // fresh view instead of throwing), so resolution alone proves nothing:
340
+ // judge every planned write against the settled snapshot. The mutation is
341
+ // atomic, so one missed write fails the whole save and keeps the drafts.
342
+ const landed = failedReason === undefined && valid.every(item => item.judge())
361
343
  for (const [field, before] of pending) {
362
- if (landed.has(field) && this.staged.get(field) === before) this.staged.delete(field)
344
+ if (landed && this.staged.get(field) === before) this.staged.delete(field)
363
345
  }
364
346
  this.saving = false
365
- this.failed = landed.size !== pending.size
347
+ this.failed = !landed
348
+ // A read-back failure carries no server reason: the card surfaces its
349
+ // generic failure copy; a rejecting scope (the bridge) adds its message.
350
+ this.failedReason = failedReason
366
351
  this.publish()
367
352
  }
368
353
 
369
- /** The scope's batch surface when it supports one; undefined conservatively otherwise. */
370
- private batchedScope(): BatchedSettingsScope | undefined {
371
- const candidate = this.scope as unknown as BatchedSettingsScope | undefined
372
- return typeof candidate?.mutate === 'function' ? candidate : undefined
373
- }
374
-
375
354
  /**
376
355
  * Every staged edit a save would write. An entry whose draft is not a value
377
356
  * its field accepts carries no write: the form is still dirty, and the save
@@ -384,33 +363,40 @@ export class CardForm<T> {
384
363
  for (const [field, staged] of this.staged) {
385
364
  const spec = this.specOf(field)
386
365
  if (staged.clear) {
387
- if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
366
+ if (this.stored(field)) plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
388
367
  continue
389
368
  }
390
369
  if (staged.text === spec.format(this.sectionValue(field))) continue
391
370
  const write = spec.parse(staged.text)
392
- if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, run: undefined })
393
- else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, run: () => this.clear(field) })
394
- else plan.push({ field, op: { field, op: 'set', value: write.value }, run: () => this.store(field, write.value) })
371
+ if (write === undefined) plan.push({ field, op: { field, op: 'unset' }, judge: undefined })
372
+ else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' }, judge: () => this.landedUnset(field) })
373
+ else plan.push({ field, op: { field, op: 'set', value: write.value }, judge: () => this.landedSet(field, write.value) })
395
374
  }
396
375
  return plan
397
376
  }
398
377
 
399
- private async clear(field: string): Promise<boolean> {
400
- await this.scope.unset(field)
401
- return !this.stored(field)
402
- }
403
-
404
- private async store(field: string, value: unknown): Promise<boolean> {
405
- await this.scope.set(field, value)
406
- // A redacted secret never appears in the user layer read-back; judging it
407
- // by value would misreport a successful secret save as failed. The bridge
408
- // reports secret writes through its secret-set markers (batch path); on
409
- // the per-field path the scope resolved, so the write is landed.
378
+ /**
379
+ * Read-back judgment for a planned set: the user layer must hold the
380
+ * intended value once the mutation has settled.
381
+ */
382
+ private landedSet(field: string, value: unknown): boolean {
383
+ // A redacted secret never appears in any wire view layer: the Host strips
384
+ // role('secret') fields and reports them through a sidecar the scope
385
+ // snapshot does not expose, so there is nothing to compare the draft
386
+ // against. Settling is the only signal the form has; the rest of the
387
+ // batch, when one exists, still carries the atomic verdict by read-back.
410
388
  if (this.specOf(field).secret) return true
411
389
  return this.userLayer()?.[field] === value
412
390
  }
413
391
 
392
+ /**
393
+ * Read-back judgment for a planned unset: the field must be gone from the
394
+ * user layer once the mutation has settled.
395
+ */
396
+ private landedUnset(field: string): boolean {
397
+ return !this.stored(field)
398
+ }
399
+
414
400
  private stage(field: string, edit: StagedEdit): void {
415
401
  this.staged.set(field, edit)
416
402
  this.failed = false
package/src/dsh-home.ts CHANGED
@@ -39,4 +39,4 @@ export function resolveDshHome(env: NodeJS.ProcessEnv = process.env, home: strin
39
39
  /** Resolve the DSH home directory from the live environment. */
40
40
  export function dshHome(): string {
41
41
  return resolveDshHome()
42
- }
42
+ }
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Host firewall management for the LAN bind toggle: while LAN access is on,
3
+ * keep one inbound allow rule for the bound port; when it turns off, remove
4
+ * the rule. Windows manages the Windows Defender Firewall rule via netsh;
5
+ * Linux uses the first available manager among firewalld / ufw / iptables.
6
+ * On every other platform (macOS included) the plugin reports the firewall
7
+ * as unmanaged: the port usually needs no rule there, and managing pf or the
8
+ * application firewall is out of scope for a distributable plugin.
9
+ *
10
+ * Ported from the dsh-LAN reference implementation (MIT), adapted to this
11
+ * package's structure and test seams.
12
+ */
13
+
14
+ import { spawnSync } from 'node:child_process'
15
+
16
+ /** Rule name shared by every backend so re-runs recreate the same rule. */
17
+ export const FIREWALL_RULE_NAME = 'remote-web-ui (auto)'
18
+
19
+ /** One command execution result (test seam replaces the runner). */
20
+ export interface ToolResult {
21
+ ok: boolean
22
+ out: string
23
+ err: string
24
+ /** true when the binary could not be spawned at all (ENOENT et al). */
25
+ missing?: boolean
26
+ }
27
+
28
+ /** The command runner seam: spawnSync in production, a stub in tests. */
29
+ export type CommandRunner = (cmd: string, args: readonly string[]) => ToolResult
30
+
31
+ /** Production runner: spawn without a shell, capture text, 20s timeout. */
32
+ export const spawnRunner: CommandRunner = (cmd, args) => {
33
+ const result = spawnSync(cmd, args, {
34
+ shell: false,
35
+ encoding: 'utf8',
36
+ windowsHide: process.platform === 'win32',
37
+ timeout: 20_000,
38
+ })
39
+ return {
40
+ ok: result.status === 0,
41
+ out: result.stdout ?? '',
42
+ err: result.stderr ?? '',
43
+ missing: result.error !== undefined,
44
+ }
45
+ }
46
+
47
+ /** A firewall backend manages one allow rule for a TCP port. */
48
+ export interface FirewallBackend {
49
+ /** Short label surfaced in the settings card (netsh, firewalld, ...). */
50
+ readonly label: string
51
+ ruleExists(port: number): boolean
52
+ addRule(port: number): boolean
53
+ removeRule(port: number): boolean
54
+ }
55
+
56
+ function netshBackend(run: CommandRunner): FirewallBackend {
57
+ // `show rule name=` exits 0 even when nothing matches (the localized "no
58
+ // rules match" note), so exit-code-only probes always report the rule as
59
+ // present. The verbose listing echoes the full rule name — the one
60
+ // locale-independent field — and is matched literally instead.
61
+ const show = (): boolean => {
62
+ const result = run('netsh', ['advfirewall', 'firewall', 'show', 'rule', `name=${FIREWALL_RULE_NAME}`, 'verbose'])
63
+ return result.ok && result.out.includes(FIREWALL_RULE_NAME)
64
+ }
65
+ return {
66
+ label: 'netsh',
67
+ ruleExists: show,
68
+ addRule: (port) => run('netsh', [
69
+ 'advfirewall', 'firewall', 'add', 'rule', `name=${FIREWALL_RULE_NAME}`,
70
+ 'dir=in', 'action=allow', 'protocol=TCP', `localport=${String(port)}`, 'profile=private,domain',
71
+ ]).ok,
72
+ removeRule: () => {
73
+ if (!show()) return true
74
+ return run('netsh', ['advfirewall', 'firewall', 'delete', 'rule', `name=${FIREWALL_RULE_NAME}`]).ok
75
+ },
76
+ }
77
+ }
78
+
79
+ function firewalldBackend(run: CommandRunner): FirewallBackend {
80
+ return {
81
+ label: 'firewalld',
82
+ ruleExists: (port) => run('firewall-cmd', ['--permanent', '--query-port', `${String(port)}/tcp`]).ok,
83
+ addRule: (port) => {
84
+ const add = run('firewall-cmd', ['--permanent', '--add-port', `${String(port)}/tcp`])
85
+ const reload = run('firewall-cmd', ['--reload'])
86
+ return add.ok && reload.ok
87
+ },
88
+ removeRule: (port) => {
89
+ const del = run('firewall-cmd', ['--permanent', '--remove-port', `${String(port)}/tcp`])
90
+ const reload = run('firewall-cmd', ['--reload'])
91
+ return del.ok && reload.ok
92
+ },
93
+ }
94
+ }
95
+
96
+ function ufwBackend(run: CommandRunner): FirewallBackend {
97
+ return {
98
+ label: 'ufw',
99
+ ruleExists: (port) => {
100
+ const result = run('ufw', ['status'])
101
+ // Anchored so a superset port's row (1443/tcp) cannot satisfy the
102
+ // probe for a shorter one (443/tcp).
103
+ return result.ok && new RegExp(`(^|\\s)${String(port)}/tcp\\s+ALLOW`, 'i').test(result.out)
104
+ },
105
+ addRule: (port) => run('ufw', ['allow', `${String(port)}/tcp`]).ok,
106
+ removeRule: (port) => run('ufw', ['delete', 'allow', `${String(port)}/tcp`]).ok,
107
+ }
108
+ }
109
+
110
+ function iptablesBackend(run: CommandRunner): FirewallBackend {
111
+ const rule = (port: number): string[] => ['INPUT', '-p', 'tcp', '--dport', String(port), '-j', 'ACCEPT']
112
+ return {
113
+ label: 'iptables',
114
+ ruleExists: (port) => run('iptables', ['-C', ...rule(port)]).ok,
115
+ addRule: (port) => run('iptables', ['-A', ...rule(port)]).ok,
116
+ removeRule: (port) => run('iptables', ['-D', ...rule(port)]).ok,
117
+ }
118
+ }
119
+
120
+ function toolAvailable(run: CommandRunner, cmd: string): boolean {
121
+ try {
122
+ // A missing binary reports through the runner's missing flag (spawnSync
123
+ // sets result.error for ENOENT); any graceful answer counts as present.
124
+ return run(cmd, ['--version']).missing !== true
125
+ } catch {
126
+ return false
127
+ }
128
+ }
129
+
130
+ /**
131
+ * Detect the platform firewall manager. Returns undefined when the platform
132
+ * has no supported manager (macOS, unknown Linux without tools): the port
133
+ * then usually needs no rule and the UI reports "unmanaged".
134
+ */
135
+ export function detectFirewallBackend(
136
+ platform: NodeJS.Platform,
137
+ run: CommandRunner = spawnRunner,
138
+ ): FirewallBackend | undefined {
139
+ if (platform === 'win32') return netshBackend(run)
140
+ if (platform !== 'linux') return undefined
141
+ if (toolAvailable(run, 'firewall-cmd')) {
142
+ // firewalld only counts when its daemon answers; an installed-but-idle
143
+ // firewall-cmd would otherwise claim management it cannot exercise.
144
+ if (run('firewall-cmd', ['--state']).ok) return firewalldBackend(run)
145
+ }
146
+ if (toolAvailable(run, 'ufw')) return ufwBackend(run)
147
+ if (toolAvailable(run, 'iptables')) return iptablesBackend(run)
148
+ return undefined
149
+ }
150
+
151
+ let cachedBackend: { platform: NodeJS.Platform; backend: FirewallBackend | undefined } | undefined
152
+
153
+ /** Cached detection for the running platform (per process). */
154
+ export function firewallBackend(): FirewallBackend | undefined {
155
+ const platform = process.platform
156
+ if (cachedBackend === undefined || cachedBackend.platform !== platform) {
157
+ cachedBackend = { platform, backend: detectFirewallBackend(platform) }
158
+ }
159
+ return cachedBackend.backend
160
+ }
161
+
162
+ /** Firewall state reported to the settings card. */
163
+ export interface FirewallSummary {
164
+ /** true when the rule state matches the LAN toggle (or nothing to manage). */
165
+ ok: boolean
166
+ /** false when no supported firewall manager exists on this platform. */
167
+ managed: boolean
168
+ /** Backend label or an explanation for unmanaged platforms. */
169
+ note?: string
170
+ }
171
+
172
+ /**
173
+ * Delete-and-add: recreating the rule is idempotent and locale-proof (netsh
174
+ * output is localized, so parsing the live rule's port is fragile; the Linux
175
+ * backends follow the same recreate pattern).
176
+ */
177
+ export function ensureFirewallRule(port: number): boolean {
178
+ invalidateFirewallSummary()
179
+ const backend = firewallBackend()
180
+ if (backend === undefined) return true
181
+ backend.removeRule(port)
182
+ return backend.addRule(port)
183
+ }
184
+
185
+ export function removeFirewallRule(port: number): boolean {
186
+ invalidateFirewallSummary()
187
+ const backend = firewallBackend()
188
+ if (backend === undefined) return true
189
+ return backend.removeRule(port)
190
+ }
191
+
192
+ /**
193
+ * Human-readable firewall state for the status endpoint. A missing backend
194
+ * means the platform has nothing to manage; detection stays at the call site
195
+ * so an explicit undefined cannot silently re-probe the real OS mid-test.
196
+ */
197
+ export function computeFirewallSummary(
198
+ port: number,
199
+ lanEnabled: boolean,
200
+ backend: FirewallBackend | undefined,
201
+ ): FirewallSummary {
202
+ if (backend === undefined) return { ok: true, managed: false }
203
+ return { ok: lanEnabled ? backend.ruleExists(port) : !backend.ruleExists(port), managed: true, note: backend.label }
204
+ }
205
+
206
+ /** How long a firewallSummary answer is reused before the backend is probed again. */
207
+ export const FIREWALL_SUMMARY_TTL_MS = 30_000
208
+
209
+ let summaryCache: { key: string; at: number; value: FirewallSummary } | undefined
210
+
211
+ /** Forget the cached summary (rule mutations call this). */
212
+ export function invalidateFirewallSummary(): void {
213
+ summaryCache = undefined
214
+ }
215
+
216
+ /**
217
+ * The settings card polls this every ten seconds; the probes are blocking
218
+ * spawnSync subprocesses on managed platforms, so short-TTL memoization
219
+ * keeps one poll from freezing the host event loop per request.
220
+ */
221
+ export function firewallSummary(port: number, lanEnabled: boolean): FirewallSummary {
222
+ const key = `${String(port)}|${lanEnabled ? '1' : '0'}`
223
+ const now = Date.now()
224
+ if (summaryCache !== undefined && summaryCache.key === key && now - summaryCache.at < FIREWALL_SUMMARY_TTL_MS) {
225
+ return summaryCache.value
226
+ }
227
+ const value = computeFirewallSummary(port, lanEnabled, firewallBackend())
228
+ summaryCache = { key, at: now, value }
229
+ return value
230
+ }