@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
package/src/gate.ts CHANGED
@@ -6,9 +6,15 @@
6
6
  *
7
7
  * Policy: loopback requests (the desktop) pass without a device identity;
8
8
  * every non-loopback /api request must carry a live, non-revoked device
9
- * cookie. This makes the QR the only way into a LAN-exposed dsh web and
10
- * gives "停止" real teeth: revoked devices 403 on their next request,
11
- * including the mux/SSE stream (which then dies on reconnect).
9
+ * cookie — intended to make the QR the only way into a LAN-exposed dsh web
10
+ * and to give "停止" real teeth for direct /api callers.
11
+ *
12
+ * Cohort reality (0.1.2-alpha.2): NOTHING emits api/gate in the official
13
+ * runtime, so this listener never fires there and direct /api is governed
14
+ * solely by the harness fence + browser-auth cookie. The pairing gate with
15
+ * real teeth lives on the plugin's own /remote channel (remote-api.ts); the
16
+ * listener stays wired for cohort lines that do carry the seam, and the
17
+ * README security model states the gap plainly.
12
18
  */
13
19
 
14
20
  import type { IncomingMessage } from 'node:http'
@@ -100,8 +106,8 @@ export function makeGateListener(
100
106
 
101
107
  /**
102
108
  * Whether a request carries a live, non-revoked paired-device cookie for
103
- * this service. Sibling host routes outside /api (aionui-panel, etc.) use
104
- * the same check via the remoteWebUiPairing service.
109
+ * this service. Sibling host routes outside /api (the right-panel
110
+ * /sidebar/* routes, etc.) use the same check via the remoteWebUiPairing service.
105
111
  * @param service - the pairing service that owns the device table.
106
112
  * @param request - the incoming HTTP request.
107
113
  * @returns true when the cookie names a live session (and lastSeenAt was refreshed).
package/src/index.ts CHANGED
@@ -13,23 +13,27 @@ import { join } from 'node:path'
13
13
  import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
14
14
  import type { IncomingMessage } from 'node:http'
15
15
  import type { Context } from '@deepseek-ai/cordis'
16
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
16
+ import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
17
17
  import z from 'schemastery'
18
18
  import type {} from '@deepseek-ai/dsh-host-webserver'
19
19
  import type {} from '@deepseek-ai/dsh-agent'
20
20
  import type {} from '@deepseek-ai/dsh-commands'
21
+ // Type-only: pulls the host-side Context merge (ctx.connection, whose
22
+ // authenticatedUrl the /pair-accept redirect consumes).
23
+ import type {} from '@deepseek-ai/dsh-client-connection'
21
24
  import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
22
25
  import { dshHome } from './dsh-home.ts'
23
26
  import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
24
27
  import { RemoteWebUiPairing } from './pairing-access.ts'
25
28
  import { isTrustedApiRequest, makeRoutes } from './routes.ts'
26
- import { makeMobileRoutes } from './mobile-routes.ts'
27
- import { makeMobileApiRoutes } from './mobile-api.ts'
28
- import { PendingTracker } from './mobile-pending.ts'
29
- import { makePairedModelCatalogRoutes } from './paired-model-catalog.ts'
30
29
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
30
+ import { startRemotePresencePet, type PresencePetSeam } from './remote-presence-pet.ts'
31
31
  import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
32
32
  import { lanIPv4Addresses } from './lan.ts'
33
+ import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firewall.ts'
34
+ import { lanBindState, writeLanBind } from './lan-bind.ts'
35
+ import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
36
+ import { createInnerAuth } from './inner-auth.ts'
33
37
  import { TunnelManager, type TunnelInfo } from './tunnel.ts'
34
38
  import {
35
39
  checkUpdates,
@@ -54,6 +58,12 @@ declare module '@deepseek-ai/cordis' {
54
58
  * fires this per /api request before bridging to the API proxy on
55
59
  * deployments that carry the pairing/revocation seam; call `next()` to
56
60
  * delegate, return false (without calling it) to veto with 403.
61
+ *
62
+ * Cohort note (0.1.2-alpha.2): the official runtime ships NO emitter
63
+ * for this event, so the listener below never fires there and direct
64
+ * /api stays under the harness fence + browser auth. It is wired anyway
65
+ * so cohort lines that do carry the seam get pairing enforcement on
66
+ * direct /api without a plugin change.
57
67
  */
58
68
  'api/gate'(
59
69
  this: Context,
@@ -68,14 +78,14 @@ declare module '@deepseek-ai/cordis' {
68
78
  export const name = 'remote-web-ui'
69
79
 
70
80
  /** Services required before the pairing surfaces can mount. */
71
- export const inject = ['webServer', 'apiProxy', 'commands', 'agents']
81
+ export const inject = ['webServer', 'typertGateway', 'connection']
72
82
 
73
83
  /**
74
84
  * Settings namespace of the remote-control capability — the section the web
75
85
  * settings surface edits. Spelled here rather than imported: the browser
76
86
  * half spells the same value and must not depend on a Host package.
77
87
  */
78
- export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = settingsNamespace('remote-web-ui')
88
+ export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui' as SettingsNamespace
79
89
 
80
90
  /** Plugin config, validated by the same-named schemastery schema. */
81
91
  export interface Config {
@@ -95,9 +105,14 @@ export interface Config {
95
105
  /**
96
106
  * When true (default), a desktop Web GUI opened at a non-loopback origin
97
107
  * rides the gated `/remote/api` channel and must carry a live paired-device
98
- * cookie — the QR is the only way into remote desktop, and stop() cuts
99
- * paired devices off. Set false to keep the desktop on plain `/api`
100
- * (only useful when that origin is already trusted for `/api`).
108
+ * cookie — the QR is the only way into remote desktop, and stop()/revoke()
109
+ * cut the /remote channel and the pairing cookie off immediately. Scope
110
+ * note for this cohort: direct /api is governed by the harness fence +
111
+ * browser-auth cookie (the api/gate seam has no emitter on 0.1.2-alpha.2),
112
+ * so a harness browser credential a device has already redeemed is not
113
+ * invalidated by stop() — see the README security model. Set false to keep
114
+ * the desktop on plain `/api` (only useful when that origin is already
115
+ * trusted for `/api`).
101
116
  */
102
117
  requirePairingForLan?: boolean
103
118
  /**
@@ -125,12 +140,26 @@ export interface Config {
125
140
  */
126
141
  autoTunnel?: boolean
127
142
  /**
128
- * Mobile composer behavior: when true (default), a plain Enter in the
129
- * phone chat textarea sends the prompt and Shift+Enter inserts a newline.
130
- * When false, plain Enter inserts a newline and only the send button
131
- * sends (Shift+Enter keeps inserting a newline).
143
+ * LAN bind toggle. When the user flips it (true or false) the plugin
144
+ * writes the managed webserver block into the profile patch — true pins
145
+ * the bind default to 0.0.0.0 (an explicit --host flag still wins), false
146
+ * pins it back to 127.0.0.1 — and maintains the matching host firewall
147
+ * rule (Windows netsh; Linux firewalld/ufw/iptables; other platforms
148
+ * report the firewall as unmanaged). The block takes effect when the
149
+ * process next applies the profile — the live patch watcher recomposes on
150
+ * some profile shapes, otherwise on the next start — and the settings
151
+ * card reports the divergence (pendingRestart) instead of guessing.
152
+ * While the toggle has never been set (undefined), the plugin does not
153
+ * touch the patch file at all.
132
154
  */
133
- mobileEnterToSend?: boolean
155
+ lanBind?: boolean
156
+ /**
157
+ * The profile whose cordis.patch.yml the LAN bind toggle manages.
158
+ * Defaults to the DSH_PROFILE environment variable, then "web". Must be
159
+ * a single safe path segment (the DSH_PROFILE env fallback bypasses this
160
+ * schema, so the path builder asserts containment independently).
161
+ */
162
+ profile?: string
134
163
  /** Master switch for the plugin (browser half + host pairing surfaces). */
135
164
  enabled?: boolean
136
165
  }
@@ -145,7 +174,8 @@ export const Config: z<Config> = z.object({
145
174
  publicBaseUrl: z.string(),
146
175
  devicesFile: z.string(),
147
176
  autoTunnel: z.boolean().default(false),
148
- mobileEnterToSend: z.boolean().default(true),
177
+ lanBind: z.boolean(),
178
+ profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
149
179
  enabled: z.boolean().default(true),
150
180
  })
151
181
 
@@ -157,9 +187,12 @@ const SWEEP_INTERVAL_MS = 10_000
157
187
  * which legitimately resolves to `undefined` when unset (the schema keeps it
158
188
  * optional, so `Required` alone would over-narrow it to `string`).
159
189
  */
160
- type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
190
+ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile'>> & {
161
191
  publicBaseUrl: string | undefined
162
192
  devicesFile: string
193
+ /** undefined until the user flips the toggle once; undefined never writes the patch. */
194
+ lanBind: boolean | undefined
195
+ profile: string
163
196
  }
164
197
 
165
198
  /**
@@ -198,7 +231,8 @@ const DEFAULTS: ResolvedConfig = {
198
231
  publicBaseUrl: undefined,
199
232
  devicesFile: defaultDevicesFile(),
200
233
  autoTunnel: false,
201
- mobileEnterToSend: true,
234
+ lanBind: undefined,
235
+ profile: process.env.DSH_PROFILE ?? 'web',
202
236
  enabled: true,
203
237
  }
204
238
 
@@ -220,12 +254,13 @@ function applyImpl(ctx: Context, config?: Config): void {
220
254
  publicBaseUrl: config?.publicBaseUrl,
221
255
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
222
256
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
223
- mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
257
+ lanBind: config?.lanBind,
258
+ profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
224
259
  enabled: config?.enabled ?? DEFAULTS.enabled,
225
260
  }
226
261
  // The live source the pairing service and the gate read: the settings
227
262
  // section once the web settings surface is served, the composition entry
228
- // otherwise (installSettingsSection swaps it when the namespace registers).
263
+ // otherwise (installSection swaps it when the namespace registers).
229
264
  let current: () => Config = () => config ?? {}
230
265
  const resolve = (): ResolvedConfig => {
231
266
  const value = current()
@@ -239,7 +274,8 @@ function applyImpl(ctx: Context, config?: Config): void {
239
274
  publicBaseUrl: value.publicBaseUrl,
240
275
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
241
276
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
242
- mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
277
+ lanBind: value.lanBind,
278
+ profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
243
279
  enabled: value.enabled ?? DEFAULTS.enabled,
244
280
  }
245
281
  }
@@ -247,7 +283,7 @@ function applyImpl(ctx: Context, config?: Config): void {
247
283
 
248
284
  // ── auto tunnel ─────────────────────────────────────────────────────────
249
285
  // The minted public URL becomes the QR base (and the pairing fence's
250
- // trusted host). Phone /api traffic rides the plugin's own /m/api channel,
286
+ // trusted host). Phone /api traffic rides the plugin's own /remote channel,
251
287
  // which is NOT subject to the connection trust fence — so no fence
252
288
  // mutation is needed here (a distributable plugin must not change the
253
289
  // harness's connection plugin).
@@ -277,11 +313,18 @@ function applyImpl(ctx: Context, config?: Config): void {
277
313
  // sampling stance. The QR can only advertise addresses the fence accepts;
278
314
  // every interface gets its own base URL so a multi-homed machine can pick
279
315
  // the network the phone can actually reach.
280
- const lanBases = ctx.webServer.host === '0.0.0.0'
281
- ? lanIPv4Addresses().map(address => ({ address, base: `http://${address}:${String(ctx.webServer.port)}` }))
282
- : []
283
- service.setLanBases(lanBases)
284
- const lanAddresses = lanBases.map(entry => entry.address)
316
+ // A recompose pass can hand the plugin a webServer whose facts have not
317
+ // settled yet (host unset, port undefined); deriving LAN bases from that
318
+ // would advertise :undefined links or clear a working set, so the bases
319
+ // only update when both facts are readable.
320
+ const bindKnown = (ctx.webServer.host === '0.0.0.0' || ctx.webServer.host === '127.0.0.1')
321
+ && Number.isFinite(ctx.webServer.port)
322
+ if (bindKnown) {
323
+ const lanBases = ctx.webServer.host === '0.0.0.0'
324
+ ? lanIPv4Addresses().map(address => ({ address, base: `http://${address}:${String(ctx.webServer.port)}` }))
325
+ : []
326
+ service.setLanBases(lanBases)
327
+ }
285
328
 
286
329
  // Push a committed settings section into the service and gate. The service
287
330
  // config object is read per operation (token mint, touch, sweep), and the
@@ -292,12 +335,6 @@ function applyImpl(ctx: Context, config?: Config): void {
292
335
  // (now vetoing every non-loopback request) instead of opening the fence.
293
336
  let disposeRoutes: (() => void) | undefined
294
337
  let disposeSweep: (() => void) | undefined
295
- // The phone's data channel: pairing routes + the /m page + the /m/api
296
- // proxy (which needs the host ApiProxy service; the plugin injects it).
297
- const apiProxy = ctx.get('apiProxy')
298
- if (apiProxy === undefined) {
299
- console.warn('remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled')
300
- }
301
338
  // ── remote update ────────────────────────────────────────────────────────
302
339
  // The dsh-web self-update surface: probe the npm registry for family
303
340
  // releases and run `pnpm update --latest` in the owning profile. Resolutions
@@ -372,29 +409,90 @@ function applyImpl(ctx: Context, config?: Config): void {
372
409
  })
373
410
  },
374
411
  })
412
+ // LAN-bind facts for the settings card, re-read per request so a hot
413
+ // rebind (the patch watcher recomposes the process) and a fresh toggle
414
+ // round are both reflected without a restart.
415
+ let lastKnownPort: number | undefined
416
+ let lastFirewallApplied: AppliedFirewallState | undefined
417
+ const lanBindStatus = (): Record<string, unknown> => {
418
+ const resolvedNow = resolve()
419
+ let state: { blockPresent: boolean; host?: string; port?: number }
420
+ try {
421
+ state = lanBindState(resolvedNow.profile)
422
+ } catch {
423
+ // An unsafe profile value must not take the whole status endpoint down.
424
+ state = { blockPresent: false }
425
+ }
426
+ const lanOn = state.host === '0.0.0.0'
427
+ const port = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : lastKnownPort
428
+ if (Number.isFinite(ctx.webServer.port)) lastKnownPort = ctx.webServer.port
429
+ const startup = ctx.get('webStartup') as StartupFacts | undefined
430
+ const desiredHost = resolvedNow.lanBind === undefined
431
+ ? undefined
432
+ : desiredBindHost(resolvedNow.lanBind === true, startup?.host)
433
+ return {
434
+ profile: resolvedNow.profile,
435
+ setting: resolvedNow.lanBind ?? null,
436
+ blockHost: state.host ?? null,
437
+ bindHost: ctx.webServer.host,
438
+ port,
439
+ lanUrls: ctx.webServer.host === '0.0.0.0' && port !== undefined
440
+ ? lanIPv4Addresses().map(address => `http://${address}:${String(port)}`)
441
+ : [],
442
+ firewall: port !== undefined ? firewallSummary(port, lanOn) : { ok: true, managed: false },
443
+ platform: process.platform,
444
+ // The running bind does not follow the block on every deployment (the
445
+ // live patch watcher is profile-shape dependent): flag the divergence
446
+ // so the card can ask for a restart instead of looking broken. The
447
+ // comparison uses the effective desired host (a CLI --host wins over
448
+ // the toggle), not the raw toggle.
449
+ pendingRestart: pendingRestartOf(resolvedNow.lanBind, desiredHost, ctx.webServer.host),
450
+ }
451
+ }
452
+ // The process's inner browser credential: the proxied /api re-issues to
453
+ // 127.0.0.1, where the connection route enforces the harness browser-auth
454
+ // cookie (authority-bound; no loopback exemption on this cohort), so a
455
+ // device's own cookie can never satisfy the inner check. The plugin
456
+ // redeems its own launch token once and attaches the cookie to inner
457
+ // requests; the credential is only ever exercised behind the pairing gate
458
+ // in remote-api.ts.
459
+ const innerAuth = createInnerAuth(() => {
460
+ if (!Number.isFinite(ctx.webServer.port)) return undefined
461
+ try {
462
+ return (ctx.connection as { authenticatedUrl?: (base: string) => string }).authenticatedUrl?.(`http://127.0.0.1:${String(ctx.webServer.port)}/`)
463
+ } catch {
464
+ return undefined
465
+ }
466
+ })
467
+ // The official index document for the /pair-app landing, fetched from the
468
+ // inner loopback with the process credential and cached briefly (the shell
469
+ // is static; skins/injections settle right after boot).
470
+ const APP_SHELL_TTL_MS = 30_000
471
+ let appShellCache: { at: number; html: string } | undefined
472
+ const fetchAppShell = async (): Promise<string | undefined> => {
473
+ if (!Number.isFinite(ctx.webServer.port)) return undefined
474
+ if (appShellCache !== undefined && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html
475
+ const cookie = await innerAuth.ready()
476
+ try {
477
+ const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, {
478
+ headers: cookie !== undefined ? { cookie } : undefined,
479
+ })
480
+ if (!response.ok) return undefined
481
+ const html = await response.text()
482
+ appShellCache = { at: Date.now(), html }
483
+ return html
484
+ } catch {
485
+ return undefined
486
+ }
487
+ }
375
488
  const routes = [
376
- ...makeRoutes({ service, lanAddresses, requirePairingForLan: () => resolve().requirePairingForLan }),
377
- ...makeMobileRoutes(),
378
- ...(apiProxy !== undefined
379
- ? makeMobileApiRoutes({
380
- service,
381
- apiProxy,
382
- pendingTracker: new PendingTracker(),
383
- mobileEnterToSend: () => resolve().mobileEnterToSend,
384
- commandDispatcher: ctx.commands !== undefined && ctx.agents !== undefined
385
- ? {
386
- async execute(sessionId, line, signal) {
387
- const agent = ctx.agents.get(sessionId as never)
388
- if (agent === undefined) return { error: 'session-not-found' as const }
389
- const execution = await ctx.commands.execute(agent, line, [], signal)
390
- if (execution === undefined) return { error: 'unknown-command' as const }
391
- return { ok: true as const, result: execution.result }
392
- },
393
- }
394
- : undefined,
395
- })
396
- : []),
397
- ...(apiProxy !== undefined ? makePairedModelCatalogRoutes({ service, apiProxy, lanAddresses }) : []),
489
+ ...makeRoutes({
490
+ service,
491
+ lanAddresses: service.lanAddresses,
492
+ requirePairingForLan: () => resolve().requirePairingForLan,
493
+ lanBindStatus,
494
+ indexDocument: fetchAppShell,
495
+ }),
398
496
  // The remote desktop channel: policy-gated `/remote` prefix that
399
497
  // re-issues fenced paths to loopback (see remote-api.ts). The live
400
498
  // requirePairingForLan is re-read per request, same as the gate listener
@@ -404,6 +502,7 @@ function applyImpl(ctx: Context, config?: Config): void {
404
502
  service,
405
503
  port: ctx.webServer.port,
406
504
  requirePairingForLan: () => resolve().requirePairingForLan,
505
+ auth: innerAuth,
407
506
  }),
408
507
  ...updateRoutes,
409
508
  ]
@@ -411,6 +510,7 @@ function applyImpl(ctx: Context, config?: Config): void {
411
510
  service,
412
511
  port: ctx.webServer.port,
413
512
  requirePairingForLan: () => resolve().requirePairingForLan,
513
+ auth: innerAuth,
414
514
  })
415
515
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
416
516
  ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
@@ -456,21 +556,89 @@ function applyImpl(ctx: Context, config?: Config): void {
456
556
  const initialPostureTimer = nodeSetTimeout(() => { runPostureProbe() }, 5_000)
457
557
  initialPostureTimer.unref()
458
558
  ctx.effect(() => () => { clearTimeout(initialPostureTimer) }, 'remote-web-ui: posture probe boot')
459
- // Sibling plugins (aionui-panel, …) look this up by name. Absent when this
559
+ // Sibling plugins (dsh-better-sidebar, …) look this up by name. Absent when this
460
560
  // plugin is not installed; stop() / enabled=false still refuse cookies.
461
561
  new RemoteWebUiPairing(ctx, (request) => {
462
562
  if (!resolve().enabled) return false
463
563
  return isPairedDeviceRequest(service, request)
464
564
  })
465
565
 
466
- if (lanAddresses.length > 0) {
467
- const urls = lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}/m/`).join(' , ')
468
- console.log(`remote-web-ui: mobile UI reachable on LAN at ${urls}`)
566
+ // Remote-presence to pet-visibility link: while a paired device is online
567
+ // (an active phone mirror), hide the host-global pet through the pet's OWN
568
+ // hide switch; when the last device has been offline for a grace window,
569
+ // show it again (user design; the pet plugin is optional, so the seam is
570
+ // resolved per transition and every failure degrades to a no-op).
571
+ const presencePet = startRemotePresencePet({
572
+ onState: listener => service.onState(listener),
573
+ pet: (): PresencePetSeam | undefined => {
574
+ try {
575
+ return ctx.get('pet') as unknown as PresencePetSeam | undefined
576
+ } catch {
577
+ return undefined
578
+ }
579
+ },
580
+ })
581
+ ctx.effect(() => presencePet, 'remote-web-ui: remote-presence pet visibility')
582
+
583
+ if (service.lanAddresses.length > 0) {
584
+ const urls = service.lanAddresses.map(ip => `http://${ip}:${String(ctx.webServer.port)}`).join(' , ')
585
+ console.log(`remote-web-ui: the paired Web GUI is reachable on LAN at ${urls}`)
586
+ }
587
+ // Cohort honesty (see the README security model): the pairing gate covers
588
+ // the plugin's own /remote channel; direct /api from LAN origins is
589
+ // governed by the harness fence + browser-auth cookie, and this cohort's
590
+ // api/gate seam has no emitter — so stop()/revoke() cannot invalidate a
591
+ // browser credential a device has already redeemed.
592
+ if (ctx.webServer.host === '0.0.0.0') {
593
+ console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
469
594
  }
470
595
 
471
596
  const sync = (): void => {
472
597
  const value = resolve()
473
598
  service.config = pairingConfigOf(value)
599
+ // LAN bind toggle: only write the managed patch block once the user has
600
+ // flipped it (undefined = untouched, never write). Desired host/port come
601
+ // from the CLI flags when given (flags win), else from the toggle and
602
+ // the currently bound port. The block takes effect on the next start, so
603
+ // the re-assert at every boot keeps it in sync with both the toggle and
604
+ // the flags.
605
+ if (value.lanBind !== undefined) {
606
+ const startup = ctx.get('webStartup') as StartupFacts | undefined
607
+ const desiredHost = desiredBindHost(value.lanBind === true, startup?.host)
608
+ const desiredPort = desiredBindPort(startup?.port, Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : undefined)
609
+ if (desiredPort === undefined) {
610
+ console.error('remote-web-ui: cannot assert the lan-bind block — the web server port is not known yet')
611
+ } else {
612
+ try {
613
+ const current = lanBindState(value.profile)
614
+ if (current.host !== desiredHost || current.port !== desiredPort) {
615
+ writeLanBind(desiredHost, desiredPort, value.profile)
616
+ console.log(`remote-web-ui: lan-bind block written for profile ${value.profile} (${desiredHost}:${String(desiredPort)}); it takes effect when the profile next applies (the card reports pendingRestart until the running bind follows)`)
617
+ }
618
+ } catch (error) {
619
+ console.error(`remote-web-ui: failed to write the lan-bind block: ${error instanceof Error ? error.message : String(error)}`)
620
+ }
621
+ }
622
+ // Keep the firewall rule aligned with the toggle (managed platforms
623
+ // only; see firewall.ts). The probes and rule rewrites are blocking
624
+ // subprocesses, so they run only when the desired state actually moved
625
+ // — not on every unrelated settings save. The port is the live bind
626
+ // port; without it there is nothing to align yet.
627
+ const livePort = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : undefined
628
+ if (livePort === undefined) {
629
+ console.error('remote-web-ui: cannot align the host firewall rule — the web server port is not known yet')
630
+ } else {
631
+ const nextFirewall: AppliedFirewallState = { enabled: value.lanBind === true, port: livePort }
632
+ if (firewallActionNeeded(lastFirewallApplied, nextFirewall)) {
633
+ const ruleOk = nextFirewall.enabled ? ensureFirewallRule(nextFirewall.port) : removeFirewallRule(nextFirewall.port)
634
+ if (ruleOk) {
635
+ lastFirewallApplied = nextFirewall
636
+ } else {
637
+ console.error('remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)')
638
+ }
639
+ }
640
+ }
641
+ }
474
642
  // The auto tunnel owns the public base while enabled: the minted URL
475
643
  // lands in the service through the tunnel's phase listener. The manual
476
644
  // publicBaseUrl applies only when the auto tunnel is off.
@@ -545,12 +713,14 @@ function applyImpl(ctx: Context, config?: Config): void {
545
713
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
546
714
  }), 'remote-web-ui: remote channel boot patch')
547
715
 
548
- installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
549
- setSource: (source) => {
550
- current = source
551
- sync()
552
- },
553
- onChange: sync,
716
+ ctx.inject(['settings'], (settingsCtx) => {
717
+ settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
718
+ setSource: (source) => {
719
+ current = source
720
+ sync()
721
+ },
722
+ onChange: sync,
723
+ })
554
724
  })
555
725
  sync()
556
726
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The inner browser credential for the remote desktop channel. The proxied
3
+ * /api re-issues requests to 127.0.0.1, where the connection plugin's route
4
+ * enforces the harness browser-auth cookie — an authority-bound signed cookie
5
+ * with no loopback exemption on this cohort line, so a cookie minted for the
6
+ * device's own authority can never satisfy the inner check. The process
7
+ * therefore redeems its own launch token (the same one-time exchange every
8
+ * paired device's first navigation performs) and attaches the resulting
9
+ * cookie to inner requests. The credential is only ever exercised behind the
10
+ * pairing gate: while requirePairingForLan is on, every proxied call already
11
+ * carried a live paired-device cookie before this module is consulted.
12
+ */
13
+
14
+ /** Browser-auth credential the proxy attaches to loopback-bound requests. */
15
+ export interface InnerAuth {
16
+ /**
17
+ * Resolve the `name=value` cookie pair for inner requests, or undefined
18
+ * when the launch token is unavailable (the proxy then sends without a
19
+ * credential and the inner route answers 401, exactly as before).
20
+ */
21
+ ready(): Promise<string | undefined>
22
+ /** Drop the cached credential; the next ready() re-redeems. */
23
+ invalidate(): void
24
+ }
25
+
26
+ /** The harness browser-auth cookie name prefix (see dsh-client-connection). */
27
+ const BROWSER_AUTH_COOKIE_PREFIX = 'dsh-auth-'
28
+
29
+ /**
30
+ * Build the inner-auth handle.
31
+ * @param launchUrl - the launch-token URL for the inner loopback authority
32
+ * (the connection service's authenticatedUrl of `http://127.0.0.1:<port>/`),
33
+ * or undefined when the connection service or port is unavailable.
34
+ * @param fetchImpl - injectable fetch (tests).
35
+ */
36
+ export function createInnerAuth(
37
+ launchUrl: () => string | undefined,
38
+ fetchImpl: typeof fetch = fetch,
39
+ ): InnerAuth {
40
+ let cached: string | undefined
41
+ let inflight: Promise<string | undefined> | undefined
42
+
43
+ const redeem = async (): Promise<string | undefined> => {
44
+ const url = launchUrl()
45
+ if (url === undefined) return undefined
46
+ try {
47
+ // redirect: 'manual' keeps the 303's set-cookie readable instead of
48
+ // following it; the exchange itself is a plain GET with the token in
49
+ // the query, exactly what a first browser visit performs.
50
+ const response = await fetchImpl(url, { redirect: 'manual' })
51
+ const raw = response.headers.get('set-cookie')
52
+ if (raw === null) return undefined
53
+ // Generated cookie values are base64url and never contain commas, so a
54
+ // comma split followed by a prefix match finds the browser-auth entry
55
+ // even when the exchange sets several cookies.
56
+ const entry = raw
57
+ .split(',')
58
+ .map((part) => part.trim())
59
+ .find((part) => part.startsWith(BROWSER_AUTH_COOKIE_PREFIX))
60
+ if (entry === undefined) return undefined
61
+ const pair = entry.split(';')[0]?.trim()
62
+ return pair !== undefined && pair.includes('=') ? pair : undefined
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ return {
69
+ ready(): Promise<string | undefined> {
70
+ if (cached !== undefined) return Promise.resolve(cached)
71
+ inflight ??= redeem().then((value) => {
72
+ if (value !== undefined) cached = value
73
+ inflight = undefined
74
+ return value
75
+ }, () => {
76
+ inflight = undefined
77
+ return undefined
78
+ })
79
+ return inflight
80
+ },
81
+ invalidate(): void {
82
+ cached = undefined
83
+ },
84
+ }
85
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pure decision helpers shared by the lan-bind sync path (index.ts) and the
3
+ * status path (lanBindStatus). Extracted so the flags-win precedence, the
4
+ * pending-restart divergence check, and the firewall re-apply gate have one
5
+ * implementation and direct unit coverage.
6
+ */
7
+
8
+ /** The two bind hosts the managed block pins. */
9
+ export type LanBindPlanHost = '0.0.0.0' | '127.0.0.1'
10
+
11
+ /** webStartup facts the CLI hands the process (absent unless a flag was passed). */
12
+ export interface StartupFacts {
13
+ host?: string
14
+ port?: number
15
+ }
16
+
17
+ /**
18
+ * The bind host the managed block should pin: an explicit CLI --host wins,
19
+ * otherwise the toggle decides. CLI hosts outside the two managed literals
20
+ * (a pinned specific IP) are not a state the toggle manages — the toggle
21
+ * decides, and the card surfaces the divergence honestly.
22
+ * @param lanBind - the toggle value.
23
+ * @param startupHost - the CLI-provided bind host, when given.
24
+ * @returns the desired bind host literal.
25
+ */
26
+ export function desiredBindHost(lanBind: boolean, startupHost: string | undefined): LanBindPlanHost {
27
+ if (startupHost === '0.0.0.0' || startupHost === '127.0.0.1') return startupHost
28
+ return lanBind ? '0.0.0.0' : '127.0.0.1'
29
+ }
30
+
31
+ /**
32
+ * The port the managed block should pin: the CLI port when given, else the
33
+ * currently bound port. Undefined when neither is readable yet (the caller
34
+ * skips the write and reports why).
35
+ * @param startupPort - the CLI-provided port, when given.
36
+ * @param livePort - the currently bound port, when finite.
37
+ */
38
+ export function desiredBindPort(startupPort: number | undefined, livePort: number | undefined): number | undefined {
39
+ if (typeof startupPort === 'number' && Number.isFinite(startupPort)) return startupPort
40
+ return typeof livePort === 'number' && Number.isFinite(livePort) ? livePort : undefined
41
+ }
42
+
43
+ /**
44
+ * Whether the running bind has not caught up with what the toggle (plus a
45
+ * possibly overriding CLI host) will pin. Compared against the effective
46
+ * desired host — not the raw toggle — so a flag-managed bind is not reported
47
+ * as pending forever.
48
+ * @param lanBind - the toggle value (undefined = untouched, never pending).
49
+ * @param desiredHost - the effective desired host, or undefined when untouched.
50
+ * @param liveHost - the currently bound host.
51
+ */
52
+ export function pendingRestartOf(
53
+ lanBind: boolean | undefined,
54
+ desiredHost: LanBindPlanHost | undefined,
55
+ liveHost: string | undefined,
56
+ ): boolean {
57
+ if (lanBind === undefined || desiredHost === undefined) return false
58
+ return liveHost !== desiredHost
59
+ }
60
+
61
+ /** The last firewall state this process applied. */
62
+ export interface AppliedFirewallState {
63
+ enabled: boolean
64
+ port: number
65
+ }
66
+
67
+ /**
68
+ * Whether the firewall rule must be (re)applied: only when the toggle or the
69
+ * bound port moved since the last application. Keeps unrelated settings
70
+ * saves from spawning netsh/firewall-cmd delete+add churn.
71
+ * @param previous - the state applied earlier by this process, if any.
72
+ * @param next - the state the settings now ask for, if derivable.
73
+ */
74
+ export function firewallActionNeeded(
75
+ previous: AppliedFirewallState | undefined,
76
+ next: AppliedFirewallState | undefined,
77
+ ): boolean {
78
+ if (next === undefined) return false
79
+ if (previous === undefined) return true
80
+ return previous.enabled !== next.enabled || previous.port !== next.port
81
+ }