@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.
- package/README.i18n.yaml +2 -2
- package/README.md +77 -468
- package/README.zh.md +83 -113
- package/lib/client.js +955 -203
- package/lib/client.js.map +1 -1
- package/lib/index.js +1136 -1466
- package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
- package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
- package/lib/types/client/FooterRemoteEntry.js +1 -1
- package/lib/types/client/RemoteEntry.d.ts +7 -4
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +7 -10
- package/lib/types/client/RemotePanel.d.ts +2 -2
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +56 -3
- package/lib/types/client/deep-link.d.ts +10 -13
- package/lib/types/client/deep-link.d.ts.map +1 -1
- package/lib/types/client/deep-link.js +14 -74
- package/lib/types/client/index.d.ts +2 -6
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +73 -39
- package/lib/types/client/locales.d.ts +26 -12
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +36 -22
- package/lib/types/client/mobile-adapt.d.ts +40 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -0
- package/lib/types/client/mobile-adapt.js +967 -0
- package/lib/types/client/pair-api.d.ts +24 -6
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +9 -14
- package/lib/types/client/remote-channel.d.ts +3 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +35 -3
- package/lib/types/client/settings-form.d.ts +35 -43
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +60 -53
- package/lib/types/firewall.d.ts +75 -0
- package/lib/types/firewall.d.ts.map +1 -0
- package/lib/types/firewall.js +184 -0
- package/lib/types/gate.d.ts +11 -5
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +11 -5
- package/lib/types/index.d.ts +39 -10
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +206 -58
- package/lib/types/inner-auth.d.ts +32 -0
- package/lib/types/inner-auth.d.ts.map +1 -0
- package/lib/types/inner-auth.js +72 -0
- package/lib/types/lan-bind-plan.d.ts +55 -0
- package/lib/types/lan-bind-plan.d.ts.map +1 -0
- package/lib/types/lan-bind-plan.js +60 -0
- package/lib/types/lan-bind.d.ts +69 -0
- package/lib/types/lan-bind.d.ts.map +1 -0
- package/lib/types/lan-bind.js +128 -0
- package/lib/types/loopback-proxy.d.ts +9 -2
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +24 -2
- package/lib/types/pairing.d.ts +15 -3
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +20 -6
- package/lib/types/remote-api.d.ts +30 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +55 -42
- package/lib/types/remote-channel-boot.d.ts +21 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +65 -3
- package/lib/types/remote-channel-rules.d.ts +8 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +9 -2
- package/lib/types/remote-methods.d.ts +45 -11
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +56 -27
- package/lib/types/remote-presence-pet.d.ts +51 -0
- package/lib/types/remote-presence-pet.d.ts.map +1 -0
- package/lib/types/remote-presence-pet.js +101 -0
- package/lib/types/routes.d.ts +76 -4
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +336 -19
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +7 -1
- package/package.json +20 -18
- package/src/client/FooterRemoteEntry.tsx +9 -10
- package/src/client/RemoteEntry.tsx +13 -14
- package/src/client/RemotePanel.tsx +5 -16
- package/src/client/RemoteSettingsCard.tsx +72 -13
- package/src/client/deep-link.ts +14 -75
- package/src/client/index.ts +77 -39
- package/src/client/locales.ts +36 -22
- package/src/client/mobile-adapt.ts +913 -0
- package/src/client/pair-api.ts +26 -15
- package/src/client/remote-channel.ts +31 -3
- package/src/client/settings-form.ts +78 -92
- package/src/dsh-home.ts +1 -1
- package/src/firewall.ts +230 -0
- package/src/gate.ts +11 -5
- package/src/index.ts +235 -65
- package/src/inner-auth.ts +85 -0
- package/src/lan-bind-plan.ts +81 -0
- package/src/lan-bind.ts +145 -0
- package/src/loopback-proxy.ts +34 -0
- package/src/pairing.persist.test.ts +1 -1
- package/src/pairing.ts +20 -6
- package/src/remote-api.ts +67 -45
- package/src/remote-channel-boot.ts +70 -3
- package/src/remote-channel-rules.ts +17 -2
- package/src/remote-methods.ts +58 -27
- package/src/remote-presence-pet.ts +129 -0
- package/src/routes.ts +358 -19
- package/src/tunnel.ts +7 -1
- package/assets/apple-touch-icon.png +0 -0
- package/assets/mobile-icon-192.png +0 -0
- package/assets/mobile-icon-512.png +0 -0
- package/assets/mobile-service-worker.js +0 -63
- package/lib/mobile.js +0 -17621
- package/lib/mobile.js.map +0 -1
- package/lib/types/mobile/PairRequiredView.d.ts +0 -7
- package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.js +0 -28
- package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
- package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
- package/lib/types/mobile/PairRequiredView.test.js +0 -24
- package/lib/types/mobile/api.d.ts +0 -145
- package/lib/types/mobile/api.d.ts.map +0 -1
- package/lib/types/mobile/api.js +0 -103
- package/lib/types/mobile/index.d.ts +0 -10
- package/lib/types/mobile/index.d.ts.map +0 -1
- package/lib/types/mobile/index.js +0 -38
- package/lib/types/mobile/markdown.d.ts +0 -34
- package/lib/types/mobile/markdown.d.ts.map +0 -1
- package/lib/types/mobile/markdown.js +0 -285
- package/lib/types/mobile/markdown.test.d.ts +0 -2
- package/lib/types/mobile/markdown.test.d.ts.map +0 -1
- package/lib/types/mobile/markdown.test.js +0 -79
- package/lib/types/mobile/messages.d.ts +0 -141
- package/lib/types/mobile/messages.d.ts.map +0 -1
- package/lib/types/mobile/messages.js +0 -553
- package/lib/types/mobile/messages.test.d.ts +0 -2
- package/lib/types/mobile/messages.test.d.ts.map +0 -1
- package/lib/types/mobile/messages.test.js +0 -356
- package/lib/types/mobile/mobile-styles.d.ts +0 -6
- package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
- package/lib/types/mobile/mobile-styles.js +0 -1438
- package/lib/types/mobile/mobile-theme.d.ts +0 -22
- package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.js +0 -70
- package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
- package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
- package/lib/types/mobile/mobile-theme.test.js +0 -71
- package/lib/types/mobile/mux.d.ts +0 -128
- package/lib/types/mobile/mux.d.ts.map +0 -1
- package/lib/types/mobile/mux.js +0 -305
- package/lib/types/mobile/mux.test.d.ts +0 -2
- package/lib/types/mobile/mux.test.d.ts.map +0 -1
- package/lib/types/mobile/mux.test.js +0 -267
- package/lib/types/mobile/pairing.d.ts +0 -31
- package/lib/types/mobile/pairing.d.ts.map +0 -1
- package/lib/types/mobile/pairing.js +0 -62
- package/lib/types/mobile/rpc.d.ts +0 -38
- package/lib/types/mobile/rpc.d.ts.map +0 -1
- package/lib/types/mobile/rpc.js +0 -83
- package/lib/types/mobile/theme-toggle.d.ts +0 -12
- package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
- package/lib/types/mobile/theme-toggle.js +0 -26
- package/lib/types/mobile/views/App.d.ts +0 -57
- package/lib/types/mobile/views/App.d.ts.map +0 -1
- package/lib/types/mobile/views/App.js +0 -150
- package/lib/types/mobile/views/App.test.d.ts +0 -2
- package/lib/types/mobile/views/App.test.d.ts.map +0 -1
- package/lib/types/mobile/views/App.test.js +0 -52
- package/lib/types/mobile/views/ChatView.d.ts +0 -56
- package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.js +0 -773
- package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
- package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
- package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/ChatView.test.js +0 -718
- package/lib/types/mobile/views/SessionListView.d.ts +0 -27
- package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.js +0 -139
- package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
- package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/SessionListView.test.js +0 -167
- package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
- package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js +0 -93
- package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
- package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
- package/lib/types/mobile-api.d.ts +0 -65
- package/lib/types/mobile-api.d.ts.map +0 -1
- package/lib/types/mobile-api.js +0 -404
- package/lib/types/mobile-pending.d.ts +0 -38
- package/lib/types/mobile-pending.d.ts.map +0 -1
- package/lib/types/mobile-pending.js +0 -59
- package/lib/types/mobile-pwa.d.ts +0 -6
- package/lib/types/mobile-pwa.d.ts.map +0 -1
- package/lib/types/mobile-pwa.js +0 -17
- package/lib/types/mobile-routes.d.ts +0 -13
- package/lib/types/mobile-routes.d.ts.map +0 -1
- package/lib/types/mobile-routes.js +0 -217
- package/lib/types/paired-model-catalog.d.ts +0 -17
- package/lib/types/paired-model-catalog.d.ts.map +0 -1
- package/lib/types/paired-model-catalog.js +0 -311
- package/src/mobile/PairRequiredView.test.tsx +0 -28
- package/src/mobile/PairRequiredView.tsx +0 -58
- package/src/mobile/api.ts +0 -229
- package/src/mobile/index.tsx +0 -41
- package/src/mobile/markdown.test.ts +0 -91
- package/src/mobile/markdown.ts +0 -291
- package/src/mobile/messages.test.ts +0 -402
- package/src/mobile/messages.ts +0 -677
- package/src/mobile/mobile-styles.ts +0 -1439
- package/src/mobile/mobile-theme.test.ts +0 -84
- package/src/mobile/mobile-theme.ts +0 -76
- package/src/mobile/mux.test.ts +0 -314
- package/src/mobile/mux.ts +0 -336
- package/src/mobile/pairing.ts +0 -77
- package/src/mobile/rpc.ts +0 -90
- package/src/mobile/theme-toggle.tsx +0 -47
- package/src/mobile/views/App.test.tsx +0 -62
- package/src/mobile/views/App.tsx +0 -213
- package/src/mobile/views/ChatView.stream.test.tsx +0 -263
- package/src/mobile/views/ChatView.test.tsx +0 -816
- package/src/mobile/views/ChatView.tsx +0 -1295
- package/src/mobile/views/SessionListView.test.tsx +0 -207
- package/src/mobile/views/SessionListView.tsx +0 -244
- package/src/mobile/views/WorkspaceView.test.tsx +0 -227
- package/src/mobile/views/WorkspaceView.tsx +0 -245
- package/src/mobile-api.ts +0 -418
- package/src/mobile-pending.ts +0 -88
- package/src/mobile-pwa.ts +0 -19
- package/src/mobile-routes.ts +0 -231
- package/src/paired-model-catalog.ts +0 -272
package/src/client/pair-api.ts
CHANGED
|
@@ -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(
|
|
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
|
|
170
|
-
export function readPairParams(search: string): { pair?: 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
|
-
|
|
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
|
-
/**
|
|
192
|
-
export
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
12
|
-
import {
|
|
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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
57
|
-
*
|
|
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,
|
|
107
|
+
/** The durable write this entry performs, inside the save's one atomic mutation. */
|
|
105
108
|
op: BatchedWrite
|
|
106
|
-
/**
|
|
107
|
-
|
|
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
|
|
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
|
|
192
|
-
*
|
|
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.
|
|
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
|
|
298
|
+
* Write every staged edit in one atomic scope mutation, then re-seed from
|
|
299
|
+
* what the Host accepted.
|
|
322
300
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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.
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
|
344
|
+
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
363
345
|
}
|
|
364
346
|
this.saving = false
|
|
365
|
-
this.failed = landed
|
|
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' },
|
|
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' },
|
|
393
|
-
else if (write.kind === 'clear') plan.push({ field, op: { field, op: 'unset' },
|
|
394
|
-
else plan.push({ field, op: { field, op: 'set', value: 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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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
package/src/firewall.ts
ADDED
|
@@ -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
|
+
}
|