@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/lib/index.js CHANGED
@@ -1,218 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
- import { dirname, isAbsolute, join } from "node:path";
3
- import { setInterval as setInterval$1, setTimeout as setTimeout$1 } from "node:timers";
4
- import { Service } from "@deepseek-ai/cordis";
2
+ import { dirname, isAbsolute, join, resolve, sep } from "node:path";
3
+ import { setInterval, setTimeout as setTimeout$1 } from "node:timers";
5
4
  import z from "schemastery";
6
5
  import { randomBytes } from "node:crypto";
7
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
8
7
  import { homedir, networkInterfaces } from "node:os";
9
8
  import { isAbsolute as isAbsolute$1, join as join$1 } from "node:path/posix";
9
+ import { Service } from "@deepseek-ai/cordis";
10
10
  import { z as z$1 } from "zod";
11
- import { readFile } from "node:fs/promises";
12
- import { fileURLToPath } from "node:url";
13
- import { gzipSync } from "node:zlib";
14
- import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
15
11
  import http, { request } from "node:http";
16
12
  import { connect } from "node:net";
13
+ import { spawn, spawnSync } from "node:child_process";
17
14
  import { Tunnel, bin, install } from "cloudflared";
18
- import { spawn } from "node:child_process";
19
- //#region ../../node_modules/.pnpm/@deepseek-ai+dsh-settings@0.1.1-rc.2_@deepseek-ai+cordis@4.0.1_@deepseek-ai+dsh-brand@0_02aaf429ec98c58247037e2222c17a8f/node_modules/@deepseek-ai/dsh-settings/lib/index.js
20
- /**
21
- * Structural secret redaction for settings values. `role('secret')` fields are
22
- * removed from a value before it crosses a wire boundary; a sidecar records
23
- * each schema-declared secret position and whether it currently holds a value,
24
- * so a configuration surface can render a write-only input without ever
25
- * receiving the secret itself.
26
- * @module @deepseek-ai/dsh-settings/redact
27
- */
28
- /** Whether a value is a plain data object the walker may recurse into. */
29
- function isRecord$1(value) {
30
- return typeof value === "object" && value !== null && !Array.isArray(value);
31
- }
32
- function walk(node, value, path, secrets) {
33
- if (node === void 0) return value;
34
- if (node.meta?.role === "secret") {
35
- secrets.push({
36
- path,
37
- set: value !== void 0
38
- });
39
- return;
40
- }
41
- switch (node.type) {
42
- case "object": {
43
- const properties = node.dict ?? {};
44
- const source = isRecord$1(value) ? value : void 0;
45
- const rebuilt = {};
46
- if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
47
- if (key in properties) continue;
48
- rebuilt[key] = entry;
49
- }
50
- for (const [key, child] of Object.entries(properties)) {
51
- const stripped = walk(child, source?.[key], [...path, key], secrets);
52
- if (stripped !== void 0) rebuilt[key] = stripped;
53
- }
54
- return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
55
- }
56
- case "dict": {
57
- if (!isRecord$1(value)) return value;
58
- const rebuilt = {};
59
- for (const [key, entry] of Object.entries(value)) {
60
- const stripped = walk(node.inner, entry, [...path, key], secrets);
61
- if (stripped !== void 0) rebuilt[key] = stripped;
62
- }
63
- return rebuilt;
64
- }
65
- case "array":
66
- if (!Array.isArray(value)) return value;
67
- return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
68
- default: return value;
69
- }
70
- }
71
- /**
72
- * Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
73
- * per-namespace sections; plugins register a namespace schema and read the
74
- * resolved value, which layers schema defaults, the registrant's composition
75
- * `base`, and the user document section, in that order.
76
- * @module @deepseek-ai/dsh-settings
77
- */
78
- const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
79
- /**
80
- * Brand a raw string as a {@link SettingsNamespace}.
81
- * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
82
- * @returns the branded namespace.
83
- */
84
- function settingsNamespace(value) {
85
- if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
86
- return value;
87
- }
88
- /**
89
- * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
90
- * Service Definition's single change-detection predicate, exported so the invariant
91
- * companion checks exactly the implementation's relation.
92
- * @param a - one JSON-compatible value.
93
- * @param b - the other JSON-compatible value.
94
- * @returns whether the two values are structurally equal.
95
- */
96
- function deepEqualJson(a, b) {
97
- if (a === b) return true;
98
- if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
99
- if (Array.isArray(a) || Array.isArray(b)) {
100
- if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
101
- return a.every((entry, index) => deepEqualJson(entry, b[index]));
102
- }
103
- const left = a;
104
- const right = b;
105
- const keys = Object.keys(left);
106
- if (keys.length !== Object.keys(right).length) return false;
107
- return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
108
- }
109
- /** Whether a value is a plain data object (not an array, null, or class instance). */
110
- function isPlainObject(value) {
111
- if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
112
- const proto = Object.getPrototypeOf(value);
113
- return proto === Object.prototype || proto === null;
114
- }
115
- /** Apply one path op to a detached section, returning the next section. */
116
- function applyPathOp(section, op) {
117
- const [head, ...rest] = op.path;
118
- if (head === void 0) {
119
- if (op.op === "unset") return {};
120
- if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
121
- return { ...op.value };
122
- }
123
- if (rest.length === 0) {
124
- if (op.op === "set") return {
125
- ...section,
126
- [head]: op.value
127
- };
128
- const { [head]: _removed, ...kept } = section;
129
- return kept;
130
- }
131
- const child = section[head];
132
- if (!isPlainObject(child)) {
133
- if (op.op === "unset") return section;
134
- return {
135
- ...section,
136
- [head]: applyPathOp({}, {
137
- ...op,
138
- path: rest
139
- })
140
- };
141
- }
142
- return {
143
- ...section,
144
- [head]: applyPathOp(child, {
145
- ...op,
146
- path: rest
147
- })
148
- };
149
- }
150
- /**
151
- * Layer `over` onto `under`: plain objects merge recursively, every other
152
- * value (arrays included) replaces the lower layer wholesale. `over` never
153
- * carries `undefined` entries — sections come from parsed documents and write
154
- * snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
155
- * cannot erase lower keys.
156
- */
157
- function mergeLayers(under, over) {
158
- if (over === void 0) return under;
159
- if (!isPlainObject(under) || !isPlainObject(over)) return over;
160
- const merged = { ...under };
161
- for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
162
- return merged;
163
- }
164
- /** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
165
- function deepFreeze(value) {
166
- if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
167
- for (const entry of Object.values(value)) deepFreeze(entry);
168
- return Object.freeze(value);
169
- }
170
- Service.init;
171
- /**
172
- * Value mirror of the `FiberState` members {@link isUnloading} compares
173
- * against: a const enum has no runtime object to import, and the value is
174
- * needed at runtime (same rationale as the CLI boot driver's mirror).
175
- */
176
- const FIBER_DISPOSED = 4;
177
- const FIBER_UNLOADING = 5;
178
- /** Whether the consumer's own fiber is tearing down (not just losing the settings service). */
179
- function isUnloading(ctx) {
180
- const state = ctx.fiber.state;
181
- return state === FIBER_UNLOADING || state === FIBER_DISPOSED;
182
- }
183
- /**
184
- * Install the canonical optional-settings consumer wiring: while a settings
185
- * service exists, register `ns` with the consumer's composition entry as the
186
- * `base` layer and point the source thunk at the resolved scope; when the
187
- * service goes away (disposal, provider reload), fall back to the entry so
188
- * the consumer keeps working exactly as composed. The registration rides the
189
- * scoped fiber, so no settings service ever mounted means none of this runs.
190
- * @param ctx - consumer plugin context owning the wiring.
191
- * @param ns - the consumer-owned settings namespace.
192
- * @param schema - schema resolving the namespace (typically the plugin Config).
193
- * @param entry - the consumer's composition entry config, used as `base`.
194
- * @param hooks - source sink and change notification.
195
- */
196
- function installSettingsSection(ctx, ns, schema, entry, hooks) {
197
- ctx.inject(["settings"], (sctx) => {
198
- const scope = sctx.settings.register(ns, schema, {
199
- base: entry,
200
- ...hooks.validate === void 0 ? {} : { validate: hooks.validate }
201
- });
202
- hooks.setSource(() => scope.get());
203
- sctx.effect(() => () => {
204
- if (isUnloading(ctx)) return;
205
- hooks.setSource(() => entry);
206
- hooks.onChange();
207
- });
208
- hooks.onChange();
209
- scope.watch(() => {
210
- if (isUnloading(ctx)) return;
211
- hooks.onChange();
212
- });
213
- });
214
- }
215
- //#endregion
216
15
  //#region src/pairing.ts
217
16
  /**
218
17
  * Pairing state machine: one active one-time token, a device-session table,
@@ -223,8 +22,12 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
223
22
  * Security invariants:
224
23
  * - One active token at a time; `issue()` replaces it, so a refreshed QR
225
24
  * immediately invalidates the previous link.
226
- * - A token is consumed by the first successful `accept()` — reuse is
227
- * refused with `'used'`.
25
+ * - A token stays re-usable until it expires or is replaced: the first
26
+ * successful `accept()` marks it consumed, but the same link may pair
27
+ * again within the window (each re-accept mints a fresh device session).
28
+ * Mobile flows routinely split across cookie contexts (camera preview to
29
+ * in-app browser to the system browser), and the later context must be
30
+ * able to complete its own pairing from the same link.
228
31
  * - Tokens expire; `accept()` on an expired token is refused like an
229
32
  * unknown one (no oracle for validity).
230
33
  * - `stop()` revokes every device session and clears the token, so paired
@@ -232,8 +35,16 @@ function installSettingsSection(ctx, ns, schema, entry, hooks) {
232
35
  * - `revoke()` drops one device session; idle sessions older than
233
36
  * `idleExpireMs` are deleted on sweep, load, and the next gated request.
234
37
  */
235
- /** Default idle-expiry window: 7 days without heartbeat or a gated request. */
236
- const DEFAULT_IDLE_EXPIRE_MS = 10080 * 60 * 1e3;
38
+ /**
39
+ * Default idle-expiry window: 30 days without a heartbeat or a gated
40
+ * request. The reopen service worker refreshes lastSeenAt on every
41
+ * navigation it serves, so the window only runs out through genuine
42
+ * disuse; 30 days matches the browser-credential lifetimes the surrounding
43
+ * flow was built around, while the effective device lifetime stays shorter
44
+ * than the 365-day cookie because of this sweep. Override per deployment
45
+ * through the idleExpireMs config.
46
+ */
47
+ const DEFAULT_IDLE_EXPIRE_MS = 720 * 60 * 60 * 1e3;
237
48
  /** Cap on the persisted/displayed User-Agent string. */
238
49
  const MAX_USER_AGENT_CHARS = 180;
239
50
  /** Thrown by issue() for an address outside the sampled LAN literals. */
@@ -326,7 +137,7 @@ var PairingService = class {
326
137
  /** Drop sessions whose lastSeenAt is older than idleExpireMs. */
327
138
  evictIdle() {
328
139
  const now = this.clock.now();
329
- const limit = this.config.idleExpireMs ?? 6048e5;
140
+ const limit = this.config.idleExpireMs ?? 2592e6;
330
141
  let removed = false;
331
142
  for (const [id, session] of [...this.devices]) if (now - session.lastSeenAt > limit) {
332
143
  this.devices.delete(id);
@@ -439,9 +250,9 @@ var PairingService = class {
439
250
  */
440
251
  accept(token, userAgent) {
441
252
  const record = this.tokens.get(token);
442
- if (record === void 0 || record.consumed || this.stopped || this.clock.now() > record.expiresAt) return {
253
+ if (record === void 0 || this.stopped || this.clock.now() > record.expiresAt) return {
443
254
  ok: false,
444
- code: record?.consumed === true ? "used" : "invalid"
255
+ code: "invalid"
445
256
  };
446
257
  record.consumed = true;
447
258
  const deviceId = this.clock.randomToken();
@@ -577,7 +388,7 @@ var PairingService = class {
577
388
  if (this.stopped) return void 0;
578
389
  const session = this.devices.get(deviceId);
579
390
  if (session === void 0) return void 0;
580
- const limit = this.config.idleExpireMs ?? 6048e5;
391
+ const limit = this.config.idleExpireMs ?? 2592e6;
581
392
  if (this.clock.now() - session.lastSeenAt > limit) {
582
393
  this.devices.delete(deviceId);
583
394
  this.persist();
@@ -767,8 +578,8 @@ function makeGateListener(service, requirePairingForLan = true, enabled = true)
767
578
  }
768
579
  /**
769
580
  * Whether a request carries a live, non-revoked paired-device cookie for
770
- * this service. Sibling host routes outside /api (aionui-panel, etc.) use
771
- * the same check via the remoteWebUiPairing service.
581
+ * this service. Sibling host routes outside /api (the right-panel
582
+ * /sidebar/* routes, etc.) use the same check via the remoteWebUiPairing service.
772
583
  * @param service - the pairing service that owns the device table.
773
584
  * @param request - the incoming HTTP request.
774
585
  * @returns true when the cookie names a live session (and lastSeenAt was refreshed).
@@ -815,23 +626,6 @@ const JSON_HEADERS = {
815
626
  "referrer-policy": "no-referrer"
816
627
  };
817
628
  /**
818
- * Strict bounded body reader: parse a request body of at most maxBytes as
819
- * JSON.
820
- * @throws 'body too large' past the cap, or the JSON.parse error for an
821
- * invalid or empty payload.
822
- */
823
- async function readBoundedJson(req, maxBytes) {
824
- const chunks = [];
825
- let size = 0;
826
- for await (const chunk of req) {
827
- const buffer = chunk;
828
- size += buffer.length;
829
- if (size > maxBytes) throw new Error("body too large");
830
- chunks.push(buffer);
831
- }
832
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
833
- }
834
- /**
835
629
  * Lenient bounded body reader: parse a request body as JSON, or null on an
836
630
  * empty body, invalid JSON, or a body past maxBytes (default 64 KiB).
837
631
  * Overflow destroys the request instead of draining the remainder (no drain
@@ -919,7 +713,20 @@ function isTrustedApiRequest(request, trustedHosts) {
919
713
  }
920
714
  }
921
715
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
922
- const MAX_BODY_BYTES$1 = 4096;
716
+ const MAX_BODY_BYTES = 4096;
717
+ /**
718
+ * The rate-limit bucket key for one accept attempt (pure; unit-tested).
719
+ * The first client-visible XFF hop separates buckets behind the auto-tunnel
720
+ * (every internet client arrives from 127.0.0.1 there) — but only for
721
+ * loopback peers, since a direct LAN client can rotate the header freely.
722
+ * @param socketIp - the socket peer address.
723
+ * @param forwarded - the first XFF hop, already trimmed, if any.
724
+ * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
725
+ */
726
+ function acceptLimitKey(socketIp, forwarded, bucket) {
727
+ if (isLoopbackAddress(socketIp) && forwarded !== void 0 && forwarded !== "") return `${bucket}|${socketIp}|${forwarded}`;
728
+ return `${bucket}|${socketIp}`;
729
+ }
923
730
  /**
924
731
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
925
732
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -938,6 +745,124 @@ function publicHostOf(url) {
938
745
  }
939
746
  /** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
940
747
  const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60;
748
+ /**
749
+ * The cookieless device credential: pass the device id from the /pair-app
750
+ * URL into sessionStorage (and localStorage for tab reloads) before any app
751
+ * script runs - same key the boot patch and the channel gate read. The
752
+ * replaceState to '/' hides the credential URL from the address bar and
753
+ * leaves the SPA at its canonical root path. The reopen service worker is
754
+ * registered in the same breath: later navigations to '/' (history,
755
+ * bookmark, tab restore) must not fall through to the harness index gate,
756
+ * which the cookieless flow can never satisfy.
757
+ */
758
+ const APP_DEVICE_STORAGE_KEY = "dsh-remote-device";
759
+ function appShellCaptureScript(deviceId) {
760
+ const safeId = JSON.stringify(deviceId);
761
+ const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`;
762
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()<\/script>`;
763
+ }
764
+ /** Patch the official index document with the device-capture script. */
765
+ function patchAppShell(html, deviceId) {
766
+ const script = appShellCaptureScript(deviceId);
767
+ const at = html.indexOf("</head>");
768
+ if (at === -1) return script + html;
769
+ return html.slice(0, at) + script + html.slice(at);
770
+ }
771
+ /**
772
+ * The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
773
+ * phone comes back to `/` from history, bookmarks, or tab restore; the
774
+ * harness fallback seat answers that navigation with its browser-auth 401,
775
+ * and the cookieless mobile flow never holds a browser-auth cookie. The
776
+ * worker owns navigations to `/` instead: network-first through /pair-app
777
+ * (which validates the device cookie and refreshes its presence), the
778
+ * cached shell for offline opens, and a pass-through of the original
779
+ * request when the plugin no longer answers. Plain-HTTP LAN origins are not
780
+ * secure contexts, so the worker never registers there — the LAN reopen
781
+ * path stays "scan a fresh QR", which is cheap in-network.
782
+ *
783
+ * Kept as a plain string (same pattern as the capture script): it must load
784
+ * with no build step, run on every JS engine that ships service workers,
785
+ * and be assertable as source in tests. Bump SHELL_CACHE when the storage
786
+ * layout changes so old caches are pruned on activate.
787
+ */
788
+ function appServiceWorkerScript() {
789
+ return `'use strict';
790
+ var SHELL_CACHE = 'dsh-remote-shell-v1';
791
+ var SHELL_KEY = '/dsh-remote-shell';
792
+ var APP_URL = '/pair-app';
793
+ self.addEventListener('install', function (event) {
794
+ event.waitUntil(refreshShell().then(function () { return self.skipWaiting(); }));
795
+ });
796
+ self.addEventListener('activate', function (event) {
797
+ event.waitUntil(self.caches.keys().then(function (names) {
798
+ return Promise.all(names.map(function (name) {
799
+ return name === SHELL_CACHE ? Promise.resolve() : self.caches.delete(name);
800
+ }));
801
+ }).then(function () { return self.clients.claim(); }));
802
+ });
803
+ self.addEventListener('fetch', function (event) {
804
+ var request = event.request;
805
+ if (request.method !== 'GET' || request.mode !== 'navigate') return;
806
+ var path;
807
+ try { path = new URL(request.url).pathname; } catch (error) { return; }
808
+ if (path !== '/') return;
809
+ event.respondWith(reopen(event));
810
+ });
811
+ /* Network-first: a live pairing gets the current shell (the /pair-app check
812
+ refreshes lastSeenAt, so every reopen also keeps the session alive); a
813
+ refused shell passes the original navigation through for the harness's
814
+ own response; an unreachable server falls back to the cached shell. */
815
+ function reopen(event) {
816
+ var request = event.request;
817
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
818
+ if (!response.ok) return fetch(request);
819
+ var copy = response.clone();
820
+ event.waitUntil(self.caches.open(SHELL_CACHE).then(function (cache) {
821
+ return cache.put(SHELL_KEY, copy);
822
+ }));
823
+ return response;
824
+ }, function () {
825
+ return self.caches.match(SHELL_KEY).then(function (cached) {
826
+ return cached !== undefined ? cached : fetch(request);
827
+ });
828
+ });
829
+ }
830
+ /* Best-effort shell warm-up and refresh; never rejects. */
831
+ function refreshShell() {
832
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
833
+ if (!response.ok) return undefined;
834
+ var copy = response.clone();
835
+ return self.caches.open(SHELL_CACHE).then(function (cache) {
836
+ return cache.put(SHELL_KEY, copy);
837
+ });
838
+ }, function () { return undefined; });
839
+ }`;
840
+ }
841
+ /**
842
+ * The dead-end guard for a failed /pair-accept and for a dead reopen (the
843
+ * reopen service worker serves this page at `/` when the pairing no longer
844
+ * passes): a device without a live pairing has no harness browser-auth
845
+ * cookie, so a redirect to bare `/` would land on the harness browser-auth
846
+ * 401 page ("authentication required"), which reads like a broken server.
847
+ * Serve a plain bilingual explanation instead; an already-paired device (live
848
+ * device cookie, browser credential redeemed during its first scan) keeps
849
+ * the old behavior and is sent on to the app.
850
+ */
851
+ function pairingFailurePage() {
852
+ return [
853
+ "<!doctype html><html><head><meta charset=\"utf-8\">",
854
+ "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">",
855
+ "<meta name=\"referrer\" content=\"no-referrer\">",
856
+ "<title>Pairing link invalid</title></head>",
857
+ "<body style=\"font-family:system-ui,sans-serif;padding:24px;max-width:32em;margin:0 auto;line-height:1.6\">",
858
+ "<p><strong>配对已失效,或配对链接已过期。</strong></p>",
859
+ "<p>请在桌面端打开远程控制面板,刷新二维码后重新扫码;重新配对后本机即可恢复访问。</p>",
860
+ "<hr style=\"border:none;border-top:1px solid #ccc;margin:16px 0\">",
861
+ "<p><strong>This pairing has expired or the link is no longer valid.</strong></p>",
862
+ "<p>Open the remote panel on the desktop, refresh the QR code, and scan again — re-pairing restores access on this device.</p>",
863
+ "</body></html>"
864
+ ].join("");
865
+ }
941
866
  /** Route paths (exact matches under /api). */
942
867
  const PAIR_PATHS = {
943
868
  issue: "/api/pair/issue",
@@ -946,7 +871,17 @@ const PAIR_PATHS = {
946
871
  revoke: "/api/pair/revoke",
947
872
  heartbeat: "/api/pair/heartbeat",
948
873
  status: "/api/pair/status",
949
- events: "/api/pair/events"
874
+ events: "/api/pair/events",
875
+ lanBind: "/api/pair/lan-bind",
876
+ /** Top-level accept-and-redirect entry the QR link points at. */
877
+ acceptPage: "/pair-accept",
878
+ /** The cookieless app landing: serves the official shell for a paired device. */
879
+ appPage: "/pair-app",
880
+ /**
881
+ * The reopen service worker (registered by the capture script). Root-level
882
+ * path so its default script-directory scope already covers `/`.
883
+ */
884
+ appServiceWorker: "/pair-app.sw.js"
950
885
  };
951
886
  /**
952
887
  * /api/pair request payload contracts. Each POST endpoint validates its body
@@ -956,10 +891,7 @@ const PAIR_PATHS = {
956
891
  * issue/accept enforce their optional/required fields. Unknown (extra) keys
957
892
  * are tolerated exactly as the previous manual reads ignored them.
958
893
  */
959
- const issuePayloadSchema = z$1.object({
960
- workspaceId: z$1.string().min(1).optional(),
961
- address: z$1.string().min(1).optional()
962
- });
894
+ const issuePayloadSchema = z$1.object({ address: z$1.string().min(1).optional() });
963
895
  const acceptPayloadSchema = z$1.object({ token: z$1.string().default("") });
964
896
  const revokePayloadSchema = z$1.object({ deviceId: z$1.string().min(1) });
965
897
  const pairActionPayloadSchema = z$1.object({}).passthrough();
@@ -1034,10 +966,11 @@ function makeRoutes(deps) {
1034
966
  const events = new PairingEventsStream(service);
1035
967
  /** Loopback-only fence: the desktop panel's control endpoints. */
1036
968
  const loopbackFence = (req) => isTrustedApiRequest(req, []);
1037
- /** Phone-facing fence: loopback, the derived LAN literals, or the configured public host. */
969
+ /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
1038
970
  const lanFence = (req) => {
1039
971
  const publicHost = publicHostOf(service.publicBaseUrl);
1040
- return isTrustedApiRequest(req, publicHost === void 0 ? lanAddresses : [...lanAddresses, publicHost]);
972
+ const bases = service.lanAddresses;
973
+ return isTrustedApiRequest(req, publicHost === void 0 ? bases : [...bases, publicHost]);
1041
974
  };
1042
975
  const requireMethod = (req, res, method) => {
1043
976
  if (req.method === method) return true;
@@ -1049,10 +982,13 @@ function makeRoutes(deps) {
1049
982
  const acceptAttempts = /* @__PURE__ */ new Map();
1050
983
  const ACCEPT_MAX_ATTEMPTS = 10;
1051
984
  const ACCEPT_WINDOW_MS = 3e4;
1052
- const rateLimitAccept = (req) => {
1053
- const socketIp = req.socket?.remoteAddress ?? "unknown";
1054
- const forwarded = typeof req.headers["x-forwarded-for"] === "string" ? (req.headers["x-forwarded-for"].split(",")[0] ?? "").trim() : void 0;
1055
- const ip = forwarded === void 0 || forwarded === "" ? socketIp : socketIp + "|" + forwarded;
985
+ /**
986
+ * @param bucket - the POST /api/pair/accept and the GET /pair-accept flows
987
+ * count separately: a QR re-scan (page navigation) must not consume a
988
+ * brute-force budget that belongs to token guessing (and vice versa).
989
+ */
990
+ const rateLimitAccept = (req, bucket) => {
991
+ const ip = acceptLimitKey(req.socket?.remoteAddress ?? "unknown", typeof req.headers["x-forwarded-for"] === "string" ? (req.headers["x-forwarded-for"].split(",")[0] ?? "").trim() : void 0, bucket);
1056
992
  const nowMs = Date.now();
1057
993
  if (acceptAttempts.size > 256) {
1058
994
  for (const [key, attempt] of acceptAttempts) if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key);
@@ -1078,7 +1014,7 @@ function makeRoutes(deps) {
1078
1014
  return;
1079
1015
  }
1080
1016
  const body = await readJsonBody(req, {
1081
- maxBytes: MAX_BODY_BYTES$1,
1017
+ maxBytes: MAX_BODY_BYTES,
1082
1018
  objectOnly: true
1083
1019
  });
1084
1020
  const payload = parsePairPayload(issuePayloadSchema, body);
@@ -1089,14 +1025,14 @@ function makeRoutes(deps) {
1089
1025
  });
1090
1026
  return;
1091
1027
  }
1092
- const { workspaceId, address } = payload;
1028
+ const { address } = payload;
1093
1029
  try {
1094
- const { token, expiresAt } = service.issue(workspaceId, address);
1030
+ const { token, expiresAt } = service.issue(void 0, address);
1095
1031
  const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
1096
1032
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
1097
1033
  writeJson(res, 200, {
1098
1034
  ok: true,
1099
- url: `${base}/m/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
1035
+ url: `${base}/pair-accept?pair=${token}`,
1100
1036
  token,
1101
1037
  expiresAt,
1102
1038
  lanAddresses: service.lanAddresses,
@@ -1119,7 +1055,7 @@ function makeRoutes(deps) {
1119
1055
  });
1120
1056
  return;
1121
1057
  }
1122
- if (rateLimitAccept(req)) {
1058
+ if (rateLimitAccept(req, "api")) {
1123
1059
  writeJson(res, 429, {
1124
1060
  ok: false,
1125
1061
  code: "rate-limited"
@@ -1127,7 +1063,7 @@ function makeRoutes(deps) {
1127
1063
  return;
1128
1064
  }
1129
1065
  const body = await readJsonBody(req, {
1130
- maxBytes: MAX_BODY_BYTES$1,
1066
+ maxBytes: MAX_BODY_BYTES,
1131
1067
  objectOnly: true
1132
1068
  });
1133
1069
  const payload = parsePairPayload(acceptPayloadSchema, body);
@@ -1162,7 +1098,7 @@ function makeRoutes(deps) {
1162
1098
  return;
1163
1099
  }
1164
1100
  const body = await readJsonBody(req, {
1165
- maxBytes: MAX_BODY_BYTES$1,
1101
+ maxBytes: MAX_BODY_BYTES,
1166
1102
  objectOnly: true
1167
1103
  });
1168
1104
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
@@ -1185,7 +1121,7 @@ function makeRoutes(deps) {
1185
1121
  return;
1186
1122
  }
1187
1123
  const body = await readJsonBody(req, {
1188
- maxBytes: MAX_BODY_BYTES$1,
1124
+ maxBytes: MAX_BODY_BYTES,
1189
1125
  objectOnly: true
1190
1126
  });
1191
1127
  const payload = parsePairPayload(revokePayloadSchema, body);
@@ -1215,7 +1151,7 @@ function makeRoutes(deps) {
1215
1151
  return;
1216
1152
  }
1217
1153
  const body = await readJsonBody(req, {
1218
- maxBytes: MAX_BODY_BYTES$1,
1154
+ maxBytes: MAX_BODY_BYTES,
1219
1155
  objectOnly: true
1220
1156
  });
1221
1157
  if (parsePairPayload(pairActionPayloadSchema, body) === void 0) {
@@ -1272,1138 +1208,208 @@ function makeRoutes(deps) {
1272
1208
  events.open(req, res);
1273
1209
  events.push(service.snapshot());
1274
1210
  };
1275
- return [
1276
- {
1277
- kind: "exact",
1278
- path: PAIR_PATHS.issue,
1279
- handler: handleIssue
1280
- },
1281
- {
1282
- kind: "exact",
1283
- path: PAIR_PATHS.accept,
1284
- handler: handleAccept
1285
- },
1286
- {
1287
- kind: "exact",
1288
- path: PAIR_PATHS.stop,
1289
- handler: handleStop
1290
- },
1291
- {
1292
- kind: "exact",
1293
- path: PAIR_PATHS.revoke,
1294
- handler: handleRevoke
1295
- },
1296
- {
1297
- kind: "exact",
1298
- path: PAIR_PATHS.heartbeat,
1299
- handler: handleHeartbeat
1300
- },
1301
- {
1302
- kind: "exact",
1303
- path: PAIR_PATHS.status,
1304
- handler: handleStatus
1305
- },
1306
- {
1307
- kind: "exact",
1308
- path: PAIR_PATHS.events,
1309
- handler: handleEvents
1310
- }
1311
- ];
1312
- }
1313
- //#endregion
1314
- //#region src/mobile-routes.ts
1315
- /**
1316
- * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
1317
- * standalone phone UI. The page and worker live in the /m/ scope so the PWA
1318
- * can cache only its static shell; the paired-device data channel remains at
1319
- * /m/api and is never handled by the worker.
1320
- */
1321
- const MOBILE_ROOT = "/m/";
1322
- const MOBILE_BUNDLE_URL = "/m/mobile.js";
1323
- const MOBILE_MANIFEST_URL = "/m/manifest.webmanifest";
1324
- const MOBILE_WORKER_URL = "/m/service-worker.js";
1325
- const MOBILE_OFFLINE_URL = "/m/offline.html";
1326
- /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
1327
- function mobileBundlePath() {
1328
- return fileURLToPath(new URL("../lib/mobile.js", import.meta.url));
1329
- }
1330
- /** A package asset available to the host after a registry or git install. */
1331
- function mobileAssetPath(name) {
1332
- return fileURLToPath(new URL("../assets/" + name, import.meta.url));
1333
- }
1334
- /** The mobile page shell: self-contained, with no document-level user data. */
1335
- function pageHtml(bundleUrl) {
1336
- return [
1337
- "<!doctype html>",
1338
- "<html lang=\"zh-CN\">",
1339
- "<head>",
1340
- "<meta charset=\"utf-8\">",
1341
- "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover\">",
1342
- "<meta name=\"theme-color\" content=\"#f3f5f9\">",
1343
- "<meta name=\"referrer\" content=\"no-referrer\">",
1344
- "<meta name=\"mobile-web-app-capable\" content=\"yes\">",
1345
- "<meta name=\"apple-mobile-web-app-capable\" content=\"yes\">",
1346
- "<meta name=\"apple-mobile-web-app-title\" content=\"DSH Remote\">",
1347
- "<link rel=\"manifest\" href=\"/m/manifest.webmanifest\">",
1348
- "<link rel=\"apple-touch-icon\" href=\"/m/apple-touch-icon.png\">",
1349
- "<title>远程访问</title>",
1350
- "</head>",
1351
- "<body>",
1352
- "<div id=\"root\"></div>",
1353
- "<script type=\"module\" src=\"" + bundleUrl + "\"><\/script>",
1354
- "</body>",
1355
- "</html>"
1356
- ].join("");
1357
- }
1358
- /** The installable app identity and icon declarations for the /m scope. */
1359
- function manifestJson() {
1360
- return JSON.stringify({
1361
- id: MOBILE_ROOT,
1362
- name: "DSH Remote",
1363
- short_name: "DSH Remote",
1364
- start_url: MOBILE_ROOT,
1365
- scope: MOBILE_ROOT,
1366
- display: "standalone",
1367
- background_color: "#151424",
1368
- theme_color: "#f3f5f9",
1369
- icons: [{
1370
- src: "/m/icon-192.png",
1371
- sizes: "192x192",
1372
- type: "image/png",
1373
- purpose: "any"
1374
- }, {
1375
- src: "/m/icon-512.png",
1376
- sizes: "512x512",
1377
- type: "image/png",
1378
- purpose: "any maskable"
1379
- }]
1380
- }, null, 2);
1381
- }
1382
- /** A static fallback that contains no paired-device, session, or workspace data. */
1383
- function offlineHtml() {
1384
- return [
1385
- "<!doctype html>",
1386
- "<html lang=\"zh-CN\">",
1387
- "<head>",
1388
- "<meta charset=\"utf-8\">",
1389
- "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1, viewport-fit=cover\">",
1390
- "<meta name=\"theme-color\" content=\"#151424\">",
1391
- "<title>DSH Remote</title>",
1392
- "<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>",
1393
- "</head>",
1394
- "<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>",
1395
- "</html>"
1396
- ].join("");
1397
- }
1398
- /** Send a small static UTF-8 body with revalidation headers. */
1399
- function writeStatic(res, status, type, body) {
1400
- res.writeHead(status, {
1401
- "content-type": type + "; charset=utf-8",
1402
- "cache-control": "no-cache",
1403
- "referrer-policy": "no-referrer"
1404
- });
1405
- res.end(body);
1406
- }
1407
- /** Send the worker with the same scope it registers for, without HTTP caching. */
1408
- function writeServiceWorker(res, body) {
1409
- res.writeHead(200, {
1410
- "content-type": "text/javascript; charset=utf-8",
1411
- "cache-control": "no-cache",
1412
- "referrer-policy": "no-referrer",
1413
- "service-worker-allowed": MOBILE_ROOT
1414
- });
1415
- res.end(body);
1416
- }
1417
- /** Send a PNG body without decoding it to UTF-8. */
1418
- function writePng(res, status, body) {
1419
- res.writeHead(status, {
1420
- "content-type": "image/png",
1421
- "cache-control": "public, max-age=31536000, immutable",
1422
- "referrer-policy": "no-referrer"
1423
- });
1424
- res.end(body);
1425
- }
1426
- /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
1427
- function redirectToMobileRoot(req, res) {
1428
- const url = new URL(req.url ?? "/m", "http://x");
1429
- res.writeHead(308, {
1430
- location: MOBILE_ROOT + url.search,
1431
- "cache-control": "no-store",
1432
- "referrer-policy": "no-referrer"
1433
- });
1434
- res.end();
1435
- }
1436
- /**
1437
- * Build the mobile page routes.
1438
- * @returns Exact routes for the canonical page, static shell, and PWA assets.
1439
- */
1440
- function makeMobileRoutes() {
1441
- const handlePage = (_req, res) => {
1442
- writeStatic(res, 200, "text/html", pageHtml(MOBILE_BUNDLE_URL));
1443
- };
1444
- const handleManifest = (_req, res) => {
1445
- writeStatic(res, 200, "application/manifest+json", manifestJson());
1446
- };
1447
- const handleOffline = (_req, res) => {
1448
- writeStatic(res, 200, "text/html", offlineHtml());
1449
- };
1450
- let bundleBody;
1451
- let bundleGzip;
1452
- const handleBundle = async (req, res) => {
1453
- if (bundleBody === void 0) {
1454
- const path = mobileBundlePath();
1455
- if (!existsSync(path)) {
1456
- writeStatic(res, 503, "text/plain", "mobile bundle not built: run pnpm --filter @linxin666/dsh-remote-web-ui build");
1457
- return;
1458
- }
1459
- try {
1460
- bundleBody = await readFile(path, "utf8");
1461
- bundleGzip = gzipSync(Buffer.from(bundleBody, "utf8"));
1462
- } catch {
1463
- writeStatic(res, 500, "text/plain", "failed to read the mobile bundle");
1464
- return;
1465
- }
1466
- }
1467
- const acceptEncoding = req.headers["accept-encoding"] ?? "";
1468
- if (typeof acceptEncoding === "string" && acceptEncoding.includes("gzip") && bundleGzip !== void 0) {
1469
- res.writeHead(200, {
1470
- "content-type": "text/javascript; charset=utf-8",
1471
- "content-encoding": "gzip",
1472
- "cache-control": "no-cache",
1473
- vary: "Accept-Encoding",
1474
- "referrer-policy": "no-referrer"
1475
- });
1476
- res.end(bundleGzip);
1477
- } else {
1478
- res.writeHead(200, {
1479
- "content-type": "text/javascript; charset=utf-8",
1480
- "cache-control": "no-cache",
1481
- vary: "Accept-Encoding",
1482
- "referrer-policy": "no-referrer"
1483
- });
1484
- res.end(bundleBody);
1485
- }
1486
- };
1487
- let workerBody;
1488
- const handleWorker = async (_req, res) => {
1489
- if (workerBody === void 0) {
1490
- const path = mobileAssetPath("mobile-service-worker.js");
1491
- if (!existsSync(path)) {
1492
- writeStatic(res, 503, "text/plain", "mobile service worker not found");
1493
- return;
1494
- }
1495
- try {
1496
- workerBody = await readFile(path, "utf8");
1497
- } catch {
1498
- writeStatic(res, 500, "text/plain", "failed to read the mobile service worker");
1499
- return;
1500
- }
1501
- }
1502
- writeServiceWorker(res, workerBody);
1503
- };
1504
- const makeIconHandler = (asset) => async (_req, res) => {
1505
- const path = mobileAssetPath(asset);
1506
- if (!existsSync(path)) {
1507
- writeStatic(res, 404, "text/plain", asset + " not found");
1508
- return;
1509
- }
1510
- try {
1511
- writePng(res, 200, await readFile(path));
1512
- } catch {
1513
- writeStatic(res, 500, "text/plain", "failed to read " + asset);
1514
- }
1515
- };
1516
- return [
1517
- {
1518
- kind: "exact",
1519
- path: "/m",
1520
- handler: redirectToMobileRoot
1521
- },
1522
- {
1523
- kind: "exact",
1524
- path: MOBILE_ROOT,
1525
- handler: handlePage
1526
- },
1527
- {
1528
- kind: "exact",
1529
- path: MOBILE_BUNDLE_URL,
1530
- handler: handleBundle
1531
- },
1532
- {
1533
- kind: "exact",
1534
- path: MOBILE_MANIFEST_URL,
1535
- handler: handleManifest
1536
- },
1537
- {
1538
- kind: "exact",
1539
- path: MOBILE_WORKER_URL,
1540
- handler: handleWorker
1541
- },
1542
- {
1543
- kind: "exact",
1544
- path: MOBILE_OFFLINE_URL,
1545
- handler: handleOffline
1546
- },
1547
- {
1548
- kind: "exact",
1549
- path: "/m/apple-touch-icon.png",
1550
- handler: makeIconHandler("apple-touch-icon.png")
1551
- },
1552
- {
1553
- kind: "exact",
1554
- path: "/m/icon-192.png",
1555
- handler: makeIconHandler("mobile-icon-192.png")
1556
- },
1557
- {
1558
- kind: "exact",
1559
- path: "/m/icon-512.png",
1560
- handler: makeIconHandler("mobile-icon-512.png")
1561
- }
1562
- ];
1563
- }
1564
- //#endregion
1565
- //#region src/mobile-api.ts
1566
- /**
1567
- * Methods the phone surface may call. Everything else is refused HERE — but
1568
- * note the paired-device cookie also passes the global api/gate for the full
1569
- * ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
1570
- * the allowlist only constrains this /m/api proxy, not the cookie's reach.
1571
- * stop() revokes every device; the loopback panel can also revoke one
1572
- * device at a time.
1573
- */
1574
- const MOBILE_ALLOWLIST = /* @__PURE__ */ new Set([
1575
- "host.listDirectory",
1576
- "workspace.create",
1577
- "workspace.list",
1578
- "agentPreset.list",
1579
- "session.create",
1580
- "session.list",
1581
- "session.history",
1582
- "session.search",
1583
- "session.prompt",
1584
- "session.models",
1585
- "session.selectModel",
1586
- "session.rename",
1587
- "session.cancel"
1588
- ]);
1589
- /**
1590
- * Locally answered display-preference method (the phone's read-only
1591
- * surface preferences; never proxied to the host ApiProxy and never a
1592
- * settings-domain write).
1593
- */
1594
- const MOBILE_PREFERENCES_METHOD = "mobile.preferences";
1595
- const MOBILE_PENDING_METHOD = "mobile.pending";
1596
- const MOBILE_RESPOND_METHOD = "mobile.respond";
1597
- /** Execute a slash command through the host command registry (#1125). */
1598
- const MOBILE_COMMAND_METHOD = "mobile.command";
1599
- /** One session.list page (thin phones load incrementally). */
1600
- const SESSION_PAGE_SIZE = 20;
1601
- /** SSE keep-alive ping cadence for the live mux stream (single connection). */
1602
- const DEFAULT_EVENTS_HEARTBEAT_MS = 15e3;
1603
- /** Encode one list position as an opaque continuation cursor. */
1604
- function sessionListCursor(updatedAt, sessionId) {
1605
- return `${updatedAt}:${sessionId}`;
1606
- }
1607
- /** Parse a cursor; malformed cursors mean "start over" (safe failure mode). */
1608
- function parseSessionListCursor(cursor) {
1609
- const separator = cursor.indexOf(":");
1610
- if (separator < 0) return void 0;
1611
- const updatedAt = Number(cursor.slice(0, separator));
1612
- if (!Number.isFinite(updatedAt)) return void 0;
1613
- return {
1614
- updatedAt,
1615
- sessionId: cursor.slice(separator + 1)
1616
- };
1617
- }
1618
- /** Whether a row comes strictly after the cursor position. */
1619
- function afterCursor(row, position) {
1620
- return row.updatedAt < position.updatedAt || row.updatedAt === position.updatedAt && row.sessionId > position.sessionId;
1621
- }
1622
- /** Mobile API route paths. */
1623
- const MOBILE_API_PATHS = { events: "/m/api/events.mux" };
1624
- /** The mobile-api prefix (every other path under it is a method name). */
1625
- const MOBILE_API_PREFIX = "/m/api";
1626
- /** Method extraction: the prefix plus one slash. */
1627
- const MOBILE_API_METHOD_PREFIX = `${MOBILE_API_PREFIX}/`;
1628
- /**
1629
- * Build the mobile data-channel routes.
1630
- * @param deps - pairing service + apiProxy.
1631
- * @returns the routes to register on webServer.
1632
- */
1633
- function makeMobileApiRoutes(deps) {
1634
- const { service, apiProxy, mobileEnterToSend } = deps;
1635
- const eventsHeartbeatMs = deps.eventsHeartbeatMs ?? DEFAULT_EVENTS_HEARTBEAT_MS;
1636
- /**
1637
- * Refresh the paired device's presence and report whether it is live.
1638
- * The mobile surface (unlike the desktop Web UI) has no `/api/pair/heartbeat`
1639
- * sender, so any activity on the mobile channel — a gated RPC, or the live
1640
- * SSE stream staying open — must count as presence. Without this, an
1641
- * idle-but-connected phone ages past `offlineAfterMs` and the desktop panel
1642
- * wrongly reports it as disconnected.
1643
- */
1644
- const touchDeviceFor = (req) => {
1645
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1646
- if (deviceId === void 0) return false;
1647
- return service.touchDevice(deviceId);
1648
- };
1649
- /** The phone gate: a live paired-device cookie, or nothing else proceeds. */
1650
- const gateOk = (req) => {
1651
- return touchDeviceFor(req);
1652
- };
1653
- const handleMethod = async (req, res) => {
1654
- if (req.method !== "POST") {
1655
- res.writeHead(405);
1656
- res.end();
1657
- return;
1658
- }
1659
- if (!gateOk(req)) {
1660
- writeJson(res, 403, {
1661
- ok: false,
1662
- error: {
1663
- code: "unpaired",
1664
- message: "mobile session is not paired"
1665
- }
1666
- });
1667
- return;
1668
- }
1669
- const pathname = new URL(req.url ?? "/", "http://x").pathname;
1670
- if (!pathname.startsWith(MOBILE_API_METHOD_PREFIX)) {
1671
- writeJson(res, 404, {
1672
- ok: false,
1673
- error: {
1674
- code: "not-found",
1675
- message: "unknown mobile api path"
1676
- }
1677
- });
1678
- return;
1679
- }
1680
- const method = pathname.slice(MOBILE_API_METHOD_PREFIX.length);
1681
- const local = method === MOBILE_PREFERENCES_METHOD || method === MOBILE_PENDING_METHOD || method === MOBILE_RESPOND_METHOD || method === MOBILE_COMMAND_METHOD;
1682
- if (!MOBILE_ALLOWLIST.has(method) && !local) {
1683
- writeJson(res, 403, {
1684
- ok: false,
1685
- error: {
1686
- code: "forbidden",
1687
- message: `method ${method} is not exposed to the mobile surface`
1688
- }
1689
- });
1690
- return;
1691
- }
1692
- let envelope;
1693
- try {
1694
- envelope = await readBoundedJson(req, 64 * 1024);
1695
- } catch {
1696
- writeJson(res, 400, {
1697
- ok: false,
1698
- error: {
1699
- code: "bad-request",
1700
- message: "invalid json body"
1701
- }
1702
- });
1703
- return;
1704
- }
1705
- const parsed = envelope;
1706
- const rpcId = typeof parsed?.rpcId === "string" ? parsed.rpcId : "";
1707
- if (rpcId === "") {
1708
- writeJson(res, 400, {
1709
- ok: false,
1710
- error: {
1711
- code: "bad-request",
1712
- message: "missing rpcId"
1713
- }
1714
- });
1715
- return;
1716
- }
1717
- if (local) {
1718
- if (method === MOBILE_PREFERENCES_METHOD) writeJson(res, 200, {
1719
- type: "server-response",
1720
- rpcId,
1721
- result: {
1722
- ok: true,
1723
- value: { mobileEnterToSend: mobileEnterToSend() }
1724
- }
1725
- });
1726
- else if (method === MOBILE_PENDING_METHOD) {
1727
- const payload = parsed.payload;
1728
- writeJson(res, 200, {
1729
- type: "server-response",
1730
- rpcId,
1731
- result: {
1732
- ok: true,
1733
- value: deps.pendingTracker.pending(payload?.sessionId)
1734
- }
1735
- });
1736
- } else if (method === MOBILE_RESPOND_METHOD) {
1737
- const payload = parsed.payload;
1738
- try {
1739
- writeJson(res, 200, {
1740
- type: "server-response",
1741
- rpcId,
1742
- result: {
1743
- ok: true,
1744
- value: await apiProxy.respond({
1745
- type: "client-response",
1746
- rpcId: RpcId(payload.rpcId),
1747
- result: {
1748
- ok: true,
1749
- value: payload.response
1750
- }
1751
- })
1752
- }
1753
- });
1754
- } catch (error) {
1755
- writeJson(res, 200, {
1756
- type: "server-response",
1757
- rpcId,
1758
- result: {
1759
- ok: false,
1760
- error: {
1761
- code: "internal",
1762
- message: error instanceof Error ? error.message : String(error)
1763
- }
1764
- }
1765
- });
1766
- }
1767
- } else if (method === MOBILE_COMMAND_METHOD) {
1768
- const payload = parsed.payload;
1769
- const sessionId = payload?.sessionId;
1770
- const line = payload?.line;
1771
- if (typeof sessionId !== "string" || typeof line !== "string") writeJson(res, 200, {
1772
- type: "server-response",
1773
- rpcId,
1774
- result: {
1775
- ok: false,
1776
- error: {
1777
- code: "bad-request",
1778
- message: "missing sessionId or line"
1779
- }
1780
- }
1781
- });
1782
- else if (deps.commandDispatcher === void 0) writeJson(res, 200, {
1783
- type: "server-response",
1784
- rpcId,
1785
- result: {
1786
- ok: false,
1787
- error: {
1788
- code: "unavailable",
1789
- message: "command dispatcher is not available"
1790
- }
1791
- }
1792
- });
1793
- else try {
1794
- const abort = new AbortController();
1795
- res.on("close", () => {
1796
- if (!res.writableEnded) abort.abort();
1797
- });
1798
- const outcome = await deps.commandDispatcher.execute(sessionId, line, abort.signal);
1799
- if ("error" in outcome) writeJson(res, 200, {
1800
- type: "server-response",
1801
- rpcId,
1802
- result: {
1803
- ok: false,
1804
- error: {
1805
- code: outcome.error,
1806
- message: outcome.error
1807
- }
1808
- }
1809
- });
1810
- else writeJson(res, 200, {
1811
- type: "server-response",
1812
- rpcId,
1813
- result: {
1814
- ok: true,
1815
- value: {
1816
- matched: true,
1817
- result: outcome.result
1818
- }
1819
- }
1820
- });
1821
- } catch (error) {
1822
- writeJson(res, 200, {
1823
- type: "server-response",
1824
- rpcId,
1825
- result: {
1826
- ok: false,
1827
- error: {
1828
- code: "internal",
1829
- message: error instanceof Error ? error.message : String(error)
1830
- }
1831
- }
1832
- });
1833
- }
1834
- }
1835
- return;
1836
- }
1837
- try {
1838
- const abort = new AbortController();
1839
- res.on("close", () => {
1840
- if (!res.writableEnded) abort.abort();
1841
- });
1842
- writeJson(res, 200, await dispatch(apiProxy, method, parsed?.payload, rpcId, abort.signal));
1843
- } catch (error) {
1844
- writeJson(res, 200, {
1845
- type: "server-response",
1846
- rpcId,
1847
- result: {
1848
- ok: false,
1849
- error: {
1850
- code: "internal",
1851
- message: error instanceof Error ? error.message : String(error)
1852
- }
1853
- }
1854
- });
1855
- }
1856
- };
1857
- /** Bridge the host mux stream over SSE: one `data:` frame per mux frame. */
1858
- const handleEvents = async (req, res) => {
1859
- if (req.method !== "GET") {
1860
- res.writeHead(405);
1861
- res.end();
1862
- return;
1863
- }
1864
- if (!gateOk(req)) {
1211
+ /** LAN-bind facts for the settings card; loopback-only, read per request. */
1212
+ const handleLanBind = (req, res) => {
1213
+ if (!requireMethod(req, res, "GET")) return;
1214
+ if (!loopbackFence(req)) {
1865
1215
  writeJson(res, 403, {
1866
1216
  ok: false,
1867
- error: {
1868
- code: "unpaired",
1869
- message: "mobile session is not paired"
1870
- }
1217
+ code: "forbidden"
1871
1218
  });
1872
1219
  return;
1873
1220
  }
1874
- res.writeHead(200, {
1875
- "content-type": "text/event-stream; charset=utf-8",
1876
- "cache-control": "no-cache",
1877
- connection: "keep-alive"
1878
- });
1879
- const controller = new AbortController();
1880
- let closed = false;
1881
- const heartbeat = setInterval(() => {
1882
- if (closed) return;
1883
- touchDeviceFor(req);
1884
- try {
1885
- res.write(": ping\n\n");
1886
- } catch {}
1887
- }, eventsHeartbeatMs);
1888
- const onClose = () => {
1889
- if (closed) return;
1890
- closed = true;
1891
- controller.abort();
1892
- clearInterval(heartbeat);
1893
- };
1894
- res.on("close", onClose);
1895
- req.on("close", onClose);
1896
- try {
1897
- const frames = apiProxy.events.mux({
1898
- rpcId: RpcId(`mobile-mux-${Date.now().toString(36)}`),
1899
- payload: {}
1900
- }, controller.signal);
1901
- for await (const frame of frames) {
1902
- if (closed) break;
1903
- deps.pendingTracker.onFrame(frame);
1904
- res.write(`data: ${JSON.stringify(frame)}\n\n`);
1905
- }
1906
- } catch {} finally {
1907
- controller.abort();
1908
- clearInterval(heartbeat);
1909
- }
1910
- if (!closed) res.end();
1911
- };
1912
- return [{
1913
- kind: "prefix",
1914
- path: MOBILE_API_PREFIX,
1915
- handler: handleMethod
1916
- }, {
1917
- kind: "exact",
1918
- path: MOBILE_API_PATHS.events,
1919
- handler: handleEvents
1920
- }];
1921
- }
1922
- /** Dispatch one allowlisted method through the host ApiProxy. */
1923
- async function dispatch(apiProxy, method, payload, rpcId, signal) {
1924
- const request = {
1925
- rpcId: RpcId(rpcId),
1926
- payload
1927
- };
1928
- if (method === "session.list") {
1929
- const full = await apiProxy.sessions.list(request);
1930
- if (!full.result.ok) return {
1931
- type: "server-response",
1932
- rpcId,
1933
- result: full.result
1934
- };
1935
- const items = full.result.value.items;
1936
- const cursor = payload?.cursor;
1937
- items.sort((a, b) => b.updatedAt - a.updatedAt || (a.sessionId < b.sessionId ? -1 : a.sessionId > b.sessionId ? 1 : 0));
1938
- const position = cursor === void 0 ? void 0 : parseSessionListCursor(cursor);
1939
- const from = position === void 0 ? 0 : items.findIndex((row) => afterCursor(row, position));
1940
- const start = from < 0 ? items.length : from;
1941
- const page = items.slice(start, start + SESSION_PAGE_SIZE);
1942
- const last = page[page.length - 1];
1943
- const nextCursor = last !== void 0 && start + page.length < items.length ? sessionListCursor(last.updatedAt, last.sessionId) : void 0;
1944
- return {
1945
- type: "server-response",
1946
- rpcId,
1947
- result: {
1948
- ok: true,
1949
- value: {
1950
- items: page,
1951
- hasMore: nextCursor !== void 0,
1952
- ...nextCursor !== void 0 ? { nextCursor } : {}
1953
- }
1954
- }
1955
- };
1956
- }
1957
- const wrap = (response) => ({
1958
- type: "server-response",
1959
- rpcId,
1960
- result: response.result
1961
- });
1962
- if (method === "workspace.list") return wrap(await apiProxy.workspace.list(request));
1963
- if (method === "workspace.create") return wrap(await apiProxy.workspace.create(request));
1964
- if (method === "host.listDirectory") return wrap(await apiProxy.host.listDirectory(request, signal ?? new AbortController().signal));
1965
- if (method === "agentPreset.list") return wrap(await apiProxy.agentPresets.list(request));
1966
- if (method === "session.create") return wrap(await apiProxy.sessions.create(request));
1967
- if (method === "session.history") return wrap(await apiProxy.sessions.history(request));
1968
- if (method === "session.search") return wrap(await apiProxy.sessions.search(request, signal ?? new AbortController().signal));
1969
- if (method === "session.prompt") return wrap(await apiProxy.sessions.prompt(request));
1970
- if (method === "session.models") return wrap(await apiProxy.sessions.models(request));
1971
- if (method === "session.selectModel") return wrap(await apiProxy.sessions.selectModel(request));
1972
- if (method === "session.rename") return wrap(await apiProxy.sessions.rename(request));
1973
- if (method === "session.cancel") return wrap(await apiProxy.sessions.cancel(request));
1974
- throw new Error(`unhandled allowlisted method ${method}`);
1975
- }
1976
- //#endregion
1977
- //#region src/mobile-pending.ts
1978
- var PendingTracker = class {
1979
- sessions = /* @__PURE__ */ new Map();
1980
- /** Process one mux frame and update the pending state. */
1981
- onFrame(frame) {
1982
- const payload = frame.payload;
1983
- if (payload.type === "approval/requested") this._getOrInit(payload.sessionId).approvals.set(payload.approvalId, {
1984
- rpcId: frame.rpcId,
1985
- approvalId: payload.approvalId,
1986
- toolName: payload.toolName,
1987
- callId: payload.callId,
1988
- reason: payload.reason
1989
- });
1990
- else if (payload.type === "approval/resolved") {
1991
- const state = this.sessions.get(payload.sessionId);
1992
- if (state) state.approvals.delete(payload.approvalId);
1993
- } else if (payload.type === "question/requested") this._getOrInit(payload.sessionId).questions.push({
1994
- rpcId: frame.rpcId,
1995
- questions: payload.questions
1996
- });
1997
- else if (payload.type === "question/resolved") {
1998
- const state = this.sessions.get(payload.sessionId);
1999
- if (state) state.questions = state.questions.filter((q) => q.rpcId !== payload.questionRpcId);
2000
- }
2001
- }
2002
- _getOrInit(sessionId) {
2003
- let state = this.sessions.get(sessionId);
2004
- if (!state) {
2005
- state = {
2006
- approvals: /* @__PURE__ */ new Map(),
2007
- questions: []
2008
- };
2009
- this.sessions.set(sessionId, state);
2010
- }
2011
- return state;
2012
- }
2013
- /** Query pending items for a session. */
2014
- pending(sessionId) {
2015
- const state = this.sessions.get(sessionId);
2016
- if (!state) return {
2017
- approvals: [],
2018
- questions: []
2019
- };
2020
- return {
2021
- approvals: Array.from(state.approvals.values()),
2022
- questions: state.questions
2023
- };
2024
- }
2025
- /** Clear all pending state for a session. */
2026
- clear(sessionId) {
2027
- this.sessions.delete(sessionId);
2028
- }
2029
- };
2030
- //#endregion
2031
- //#region src/paired-model-catalog.ts
2032
- const MAX_BODY_BYTES = 16 * 1024;
2033
- const MAX_IDENTIFIER_LENGTH = 160;
2034
- const MAX_DISPLAY_NAME_LENGTH = 240;
2035
- const MAX_TOKEN_LIMIT = 1e7;
2036
- const REASONING_EFFORTS = [
2037
- "off",
2038
- "minimal",
2039
- "low",
2040
- "medium",
2041
- "high",
2042
- "xhigh",
2043
- "max"
2044
- ];
2045
- const stringField = (max) => z$1.string().trim().min(1).max(max).refine((value) => !/[\r\n\0]/.test(value), "must not contain a newline or NUL");
2046
- const providerSchema = z$1.object({ provider: stringField(MAX_IDENTIFIER_LENGTH) }).strict();
2047
- const modelSchema = z$1.object({
2048
- id: stringField(MAX_IDENTIFIER_LENGTH),
2049
- name: stringField(MAX_DISPLAY_NAME_LENGTH).optional(),
2050
- contextWindow: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
2051
- maxTokens: z$1.number().int().safe().positive().max(MAX_TOKEN_LIMIT).optional(),
2052
- reasoningEfforts: z$1.array(z$1.enum(REASONING_EFFORTS)).min(1).refine((efforts) => efforts.every((effort, index) => index === 0 || REASONING_EFFORTS.indexOf(efforts[index - 1]) < REASONING_EFFORTS.indexOf(effort)), "reasoning efforts must be unique and in canonical order").optional()
2053
- }).strict();
2054
- const upsertSchema = z$1.object({
2055
- provider: stringField(MAX_IDENTIFIER_LENGTH),
2056
- model: modelSchema
2057
- }).strict();
2058
- const PAIRED_MODEL_CATALOG_PATHS = {
2059
- catalog: "/api/pair/model-catalog",
2060
- discover: "/api/pair/model-catalog/discover",
2061
- upsert: "/api/pair/model-catalog/upsert"
2062
- };
2063
- let rpcSequence = 0;
2064
- function request$1(payload) {
2065
- rpcSequence += 1;
2066
- return {
2067
- rpcId: `paired-model-catalog-${Date.now().toString(36)}-${rpcSequence.toString(36)}`,
2068
- payload
2069
- };
2070
- }
2071
- function isRecord(value) {
2072
- return typeof value === "object" && value !== null && !Array.isArray(value);
2073
- }
2074
- function valueOf(response) {
2075
- return response.result.ok ? response.result.value : void 0;
2076
- }
2077
- function errorCode(response) {
2078
- return response.result.ok ? void 0 : response.result.error.code;
2079
- }
2080
- function reject(res, status, error) {
2081
- writeJson(res, status, { error });
2082
- }
2083
- function publicProviderId(value) {
2084
- const normalized = value.replace(/[\r\n\0]/g, " ").trim().slice(0, MAX_IDENTIFIER_LENGTH);
2085
- return normalized === "" ? "unknown" : normalized;
2086
- }
2087
- function publicCatalog(value) {
2088
- return {
2089
- groups: value.groups.map((group) => ({
2090
- id: group.id,
2091
- name: group.name,
2092
- models: group.models.map((model) => ({
2093
- id: model.id,
2094
- name: model.name,
2095
- ...model.description === void 0 ? {} : { description: model.description },
2096
- ...model.reasoning === void 0 ? {} : { reasoning: model.reasoning }
2097
- }))
2098
- })),
2099
- failures: value.failures.map((failure) => ({
2100
- id: failure.id,
2101
- name: failure.name,
2102
- message: `model catalog unavailable for provider ${publicProviderId(failure.id)}`
2103
- }))
2104
- };
2105
- }
2106
- function publicCandidates(models) {
2107
- return models.map((model) => ({
2108
- id: model.id,
2109
- ...model.name === void 0 ? {} : { name: model.name },
2110
- ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
2111
- ...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
2112
- }));
2113
- }
2114
- function fieldsOf(model, includeId) {
2115
- const fields = {
2116
- ...includeId ? { id: model.id } : {},
2117
- ...model.name === void 0 ? {} : { name: model.name },
2118
- ...model.contextWindow === void 0 ? {} : { contextWindow: model.contextWindow },
2119
- ...model.maxTokens === void 0 ? {} : { maxTokens: model.maxTokens }
2120
- };
2121
- if (model.reasoningEfforts !== void 0) fields.reasoningEfforts = Object.fromEntries(model.reasoningEfforts.map((effort) => [effort, effort === "off" ? null : effort]));
2122
- return fields;
2123
- }
2124
- function groupFor(catalog, provider) {
2125
- if (catalog.failures.some((failure) => failure.id === provider)) return void 0;
2126
- return catalog.groups.find((group) => group.id === provider);
2127
- }
2128
- async function readCatalog(apiProxy) {
2129
- try {
2130
- return valueOf(await apiProxy.llm.models(request$1({})));
2131
- } catch {
2132
- return;
2133
- }
2134
- }
2135
- /** Resolve one exact active llm-pi-ai provider address, never an input-supplied path. */
2136
- async function eligibleProvider(apiProxy, requested) {
2137
- let providersResponse;
2138
- let settingsResponse;
2139
- try {
2140
- providersResponse = await apiProxy.llm.providers(request$1({}));
2141
- settingsResponse = await apiProxy.settings.describe(request$1({}));
2142
- } catch {
2143
- return {
2144
- status: 502,
2145
- error: "model catalog capability is unavailable"
2146
- };
2147
- }
2148
- const providers = valueOf(providersResponse);
2149
- const settings = valueOf(settingsResponse);
2150
- if (providers === void 0 || settings === void 0) return {
2151
- status: 502,
2152
- error: "model catalog capability is unavailable"
2153
- };
2154
- const route = providers.providers.find((candidate) => candidate.provider === requested);
2155
- if (route === void 0) return {
2156
- status: 404,
2157
- error: `unknown provider ${requested}`
2158
- };
2159
- if (!settings.writable || !route.active || route.settingsNs !== "llm-pi-ai" || route.settingsPath.length !== 2 || route.settingsPath[0] !== "providers" || route.settingsPath[1] !== requested) return {
2160
- status: 403,
2161
- error: `provider ${requested} is not eligible for the paired model catalog`
2162
- };
2163
- const namespace = settings.namespaces.find((candidate) => candidate.ns === "llm-pi-ai");
2164
- const root = namespace !== void 0 && isRecord(namespace.value) ? namespace.value : void 0;
2165
- const providersValue = root !== void 0 && isRecord(root.providers) ? root.providers : void 0;
2166
- const profile = providersValue !== void 0 && isRecord(providersValue[requested]) ? providersValue[requested] : void 0;
2167
- if (namespace === void 0 || profile === void 0) return {
2168
- status: 403,
2169
- error: `provider ${requested} is not eligible for the paired model catalog`
2170
- };
2171
- return { provider: {
2172
- provider: requested,
2173
- displayName: route.displayName,
2174
- namespace,
2175
- profile
2176
- } };
2177
- }
2178
- function validIdentifier(value) {
2179
- const parsed = stringField(MAX_IDENTIFIER_LENGTH).safeParse(value);
2180
- return parsed.success && parsed.data === value;
2181
- }
2182
- function configuredModels(profile) {
2183
- if (!Object.hasOwn(profile, "models")) return { inherited: true };
2184
- if (!Array.isArray(profile.models)) return void 0;
2185
- if (profile.models.length === 0) return { inherited: true };
2186
- const values = [];
2187
- for (const entry of profile.models) {
2188
- if (!isRecord(entry) || !validIdentifier(entry.id)) return void 0;
2189
- values.push({ ...entry });
2190
- }
2191
- return {
2192
- inherited: false,
2193
- values
2194
- };
2195
- }
2196
- function configuredOverrides(profile) {
2197
- if (!Object.hasOwn(profile, "modelOverrides")) return {
2198
- present: false,
2199
- values: /* @__PURE__ */ new Map()
2200
- };
2201
- if (!isRecord(profile.modelOverrides)) return void 0;
2202
- const values = /* @__PURE__ */ new Map();
2203
- for (const [id, value] of Object.entries(profile.modelOverrides)) {
2204
- if (!validIdentifier(id) || !isRecord(value)) return void 0;
2205
- values.set(id, { ...value });
2206
- }
2207
- return {
2208
- present: true,
2209
- values
2210
- };
2211
- }
2212
- function updatedModels(existing, model) {
2213
- const next = existing.map((entry) => ({ ...entry }));
2214
- const index = next.findIndex((entry) => entry.id === model.id);
2215
- const patch = fieldsOf(model, true);
2216
- if (index >= 0) next[index] = {
2217
- ...next[index],
2218
- ...patch
2219
- };
2220
- else next.push(patch);
2221
- return next;
2222
- }
2223
- function mutationPlan(profile, provider, model, catalog) {
2224
- const configured = configuredModels(profile);
2225
- const overrides = configuredOverrides(profile);
2226
- if (configured === void 0 || overrides === void 0) return void 0;
2227
- const modelsPath = [
2228
- "providers",
2229
- provider,
2230
- "models"
2231
- ];
2232
- const overridesPath = [
2233
- "providers",
2234
- provider,
2235
- "modelOverrides"
2236
- ];
2237
- if (!configured.inherited) {
2238
- if (overrides.values.size > 0) return void 0;
2239
- const ops = [{
2240
- op: "set",
2241
- path: modelsPath,
2242
- value: updatedModels(configured.values, model)
2243
- }];
2244
- if (overrides.present) ops.push({
2245
- op: "unset",
2246
- path: overridesPath
2247
- });
2248
- return { ops };
2249
- }
2250
- const group = groupFor(catalog, provider);
2251
- if (group?.models.some((entry) => entry.id === model.id) === true) {
2252
- const existing = overrides.values.get(model.id) ?? {};
2253
- return { ops: [{
2254
- op: "set",
2255
- path: [...overridesPath, model.id],
2256
- value: {
2257
- ...existing,
2258
- ...fieldsOf(model, false)
2259
- }
2260
- }] };
2261
- }
2262
- if (group === void 0) return void 0;
2263
- const materialized = [];
2264
- const materializedIds = /* @__PURE__ */ new Set();
2265
- for (const installed of group.models) {
2266
- materialized.push({
2267
- ...overrides.values.get(installed.id),
2268
- id: installed.id
1221
+ writeJson(res, 200, {
1222
+ ok: true,
1223
+ ...deps.lanBindStatus?.() ?? {}
2269
1224
  });
2270
- materializedIds.add(installed.id);
2271
- }
2272
- for (const [id, override] of overrides.values) if (!materializedIds.has(id)) materialized.push({
2273
- ...override,
2274
- id
2275
- });
2276
- const ops = [{
2277
- op: "set",
2278
- path: modelsPath,
2279
- value: updatedModels(materialized, model)
2280
- }];
2281
- if (overrides.present) ops.push({
2282
- op: "unset",
2283
- path: overridesPath
2284
- });
2285
- return { ops };
2286
- }
2287
- function paired(req, service) {
2288
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
2289
- return deviceId !== void 0 && service.touchDevice(deviceId);
2290
- }
2291
- async function bodyOf(req, schema) {
2292
- try {
2293
- return schema.parse(await readBoundedJson(req, MAX_BODY_BYTES));
2294
- } catch {
2295
- return;
2296
- }
2297
- }
2298
- /** Build the narrow paired-device model catalog routes. */
2299
- function makePairedModelCatalogRoutes(deps) {
2300
- const { service, apiProxy, lanAddresses } = deps;
2301
- /** Same fence as the /api/pair family: loopback, the advertised LAN literals, or the configured public host. */
2302
- const fence = (req) => {
2303
- const publicHost = publicHostOf(service.publicBaseUrl);
2304
- return isTrustedApiRequest(req, publicHost === void 0 ? lanAddresses : [...lanAddresses, publicHost]);
2305
1225
  };
2306
- const gate = (req, res) => {
2307
- if (!fence(req)) {
2308
- req.resume();
2309
- reject(res, 403, "paired model catalog is not reachable for this origin");
2310
- return false;
1226
+ /**
1227
+ * The QR entry: navigate here with ?pair=<token>. Sets the device cookie,
1228
+ * then redirects to the authenticated home (the connection service's
1229
+ * launch-token URL), so a LAN device that has never seen this authority
1230
+ * clears the browser-auth gate and boots the paired official UI in one
1231
+ * chain: /pair-accept → /?token=<launch> → /. A device that is already
1232
+ * authenticated (or loopback) skips straight through the same way.
1233
+ */
1234
+ const handleAcceptPage = async (req, res) => {
1235
+ if (!requireMethod(req, res, "GET")) return;
1236
+ if (!lanFence(req)) {
1237
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
1238
+ res.end("forbidden");
1239
+ return;
2311
1240
  }
2312
- if (!paired(req, service)) {
2313
- req.resume();
2314
- reject(res, 403, "paired model catalog requires a live paired device");
2315
- return false;
1241
+ if (rateLimitAccept(req, "page")) {
1242
+ res.writeHead(429, { "content-type": "text/plain; charset=utf-8" });
1243
+ res.end("rate limited");
1244
+ return;
2316
1245
  }
2317
- return true;
2318
- };
2319
- const catalog = async (req, res) => {
2320
- if (!gate(req, res)) return;
2321
- if (req.method !== "GET") return reject(res, 405, "method not allowed");
2322
- let providersResponse;
2323
- let settingsResponse;
2324
- try {
2325
- providersResponse = await apiProxy.llm.providers(request$1({}));
2326
- settingsResponse = await apiProxy.settings.describe(request$1({}));
2327
- } catch {
2328
- return reject(res, 502, "model catalog capability is unavailable");
1246
+ const token = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("pair") ?? "";
1247
+ const ua = req.headers["user-agent"];
1248
+ const result = token === "" ? {
1249
+ ok: false,
1250
+ code: "invalid"
1251
+ } : service.accept(token, typeof ua === "string" ? ua : void 0);
1252
+ if (!result.ok) {
1253
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
1254
+ if (deviceId !== void 0 && service.hasDevice(deviceId)) {
1255
+ res.writeHead(303, {
1256
+ location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`,
1257
+ "cache-control": "no-store",
1258
+ "referrer-policy": "no-referrer"
1259
+ });
1260
+ res.end();
1261
+ return;
1262
+ }
1263
+ res.writeHead(200, {
1264
+ "content-type": "text/html; charset=utf-8",
1265
+ "cache-control": "no-store",
1266
+ "referrer-policy": "no-referrer"
1267
+ });
1268
+ res.end(pairingFailurePage());
1269
+ return;
2329
1270
  }
2330
- const providers = valueOf(providersResponse);
2331
- const settings = valueOf(settingsResponse);
2332
- if (providers === void 0 || settings === void 0) return reject(res, 502, "model catalog capability is unavailable");
2333
- const namespace = settings.namespaces.find((candidate) => candidate.ns === "llm-pi-ai");
2334
- const providerMap = namespace !== void 0 && isRecord(namespace.value) && isRecord(namespace.value.providers) ? namespace.value.providers : void 0;
2335
- writeJson(res, 200, {
2336
- capability: "paired-model-catalog",
2337
- providers: settings.writable && providerMap !== void 0 ? providers.providers.filter((candidate) => candidate.active && candidate.settingsNs === "llm-pi-ai" && candidate.settingsPath.length === 2 && candidate.settingsPath[0] === "providers" && candidate.settingsPath[1] === candidate.provider && isRecord(providerMap[candidate.provider])).map((candidate) => ({
2338
- provider: candidate.provider,
2339
- displayName: candidate.displayName
2340
- })) : []
1271
+ res.writeHead(303, {
1272
+ location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
1273
+ "cache-control": "no-store",
1274
+ "referrer-policy": "no-referrer",
1275
+ "set-cookie": [`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
2341
1276
  });
1277
+ res.end();
2342
1278
  };
2343
- const discover = async (req, res) => {
2344
- if (!gate(req, res)) return;
2345
- if (req.method !== "POST") return reject(res, 405, "method not allowed");
2346
- const body = await bodyOf(req, providerSchema);
2347
- if (body === void 0) return reject(res, 400, "invalid model catalog request");
2348
- const eligible = await eligibleProvider(apiProxy, body.provider);
2349
- if (eligible.provider === void 0) return reject(res, eligible.status ?? 502, eligible.error ?? "model catalog capability is unavailable");
2350
- try {
2351
- const result = valueOf(await apiProxy.llm.discoverModels(request$1({
2352
- settingsNs: "llm-pi-ai",
2353
- provider: eligible.provider.provider
2354
- })));
2355
- if (result === void 0) return reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`);
2356
- writeJson(res, 200, { models: publicCandidates(result.models) });
2357
- } catch {
2358
- reject(res, 502, `model discovery failed for provider ${eligible.provider.provider}`);
1279
+ /**
1280
+ * The cookieless app landing. A paired device (device query or live
1281
+ * pairing cookie) receives the official index shell patched with the
1282
+ * device-capture script; the shell itself is the official document and
1283
+ * carries no data, so serving it needs only the device credential.
1284
+ */
1285
+ const handleAppPage = async (req, res) => {
1286
+ if (!requireMethod(req, res, "GET")) return;
1287
+ if (!lanFence(req)) {
1288
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
1289
+ res.end("forbidden");
1290
+ return;
1291
+ }
1292
+ if (rateLimitAccept(req, "page")) {
1293
+ res.writeHead(429, { "content-type": "text/plain; charset=utf-8" });
1294
+ res.end("rate limited");
1295
+ return;
2359
1296
  }
1297
+ const device = new URL(req.url ?? "/", "http://pair.invalid").searchParams.get("device") ?? "";
1298
+ const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
1299
+ const id = device !== "" && service.touchDevice(device) ? device : cookieDevice !== void 0 && service.touchDevice(cookieDevice) ? cookieDevice : void 0;
1300
+ if (id === void 0) {
1301
+ res.writeHead(200, {
1302
+ "content-type": "text/html; charset=utf-8",
1303
+ "cache-control": "no-store",
1304
+ "referrer-policy": "no-referrer"
1305
+ });
1306
+ res.end(pairingFailurePage());
1307
+ return;
1308
+ }
1309
+ const html = await deps.indexDocument?.(id).catch(() => void 0);
1310
+ if (html === void 0) {
1311
+ res.writeHead(502, {
1312
+ "content-type": "text/plain; charset=utf-8",
1313
+ "cache-control": "no-store"
1314
+ });
1315
+ res.end("remote device app unavailable");
1316
+ return;
1317
+ }
1318
+ res.writeHead(200, {
1319
+ "content-type": "text/html; charset=utf-8",
1320
+ "cache-control": "no-store",
1321
+ "referrer-policy": "no-referrer",
1322
+ "set-cookie": [`${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`]
1323
+ });
1324
+ res.end(patchAppShell(html, id));
2360
1325
  };
2361
- const upsert = async (req, res) => {
2362
- if (!gate(req, res)) return;
2363
- if (req.method !== "POST") return reject(res, 405, "method not allowed");
2364
- const body = await bodyOf(req, upsertSchema);
2365
- if (body === void 0) return reject(res, 400, "invalid model catalog request");
2366
- const eligible = await eligibleProvider(apiProxy, body.provider);
2367
- if (eligible.provider === void 0) return reject(res, eligible.status ?? 502, eligible.error ?? "model catalog capability is unavailable");
2368
- const before = await readCatalog(apiProxy);
2369
- if (before === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
2370
- const plan = mutationPlan(eligible.provider.profile, body.provider, body.model, before);
2371
- if (plan === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
2372
- let mutation;
2373
- try {
2374
- mutation = await apiProxy.settings.mutate(request$1({
2375
- ns: "llm-pi-ai",
2376
- expectedRevision: eligible.provider.namespace.revision,
2377
- ops: plan.ops
2378
- }));
2379
- } catch {
2380
- return reject(res, 502, `model update failed for provider ${body.provider}`);
1326
+ /**
1327
+ * The reopen service worker script. Same phone-facing fence as the app
1328
+ * page but no rate limit and no device check: the script is inert logic
1329
+ * (no secrets, no data), and browsers re-fetch it on navigations for
1330
+ * update checks, which must not trip a brute-force budget. no-store keeps
1331
+ * the update check meaningful across deploys.
1332
+ */
1333
+ const handleAppServiceWorker = (req, res) => {
1334
+ if (!requireMethod(req, res, "GET")) return;
1335
+ if (!lanFence(req)) {
1336
+ res.writeHead(403, { "content-type": "text/plain; charset=utf-8" });
1337
+ res.end("forbidden");
1338
+ return;
2381
1339
  }
2382
- const code = errorCode(mutation);
2383
- if (code === "settings-conflict") return reject(res, 409, `model update conflicted for provider ${body.provider}`);
2384
- if (code === "settings-rejected") return reject(res, 422, `model update was rejected for provider ${body.provider}`);
2385
- if (code !== void 0) return reject(res, 502, `model update failed for provider ${body.provider}`);
2386
- const after = await readCatalog(apiProxy);
2387
- if (after === void 0) return reject(res, 502, `model catalog is unavailable for provider ${body.provider}`);
2388
- writeJson(res, 200, publicCatalog(after));
1340
+ res.writeHead(200, {
1341
+ "content-type": "text/javascript; charset=utf-8",
1342
+ "cache-control": "no-store",
1343
+ "service-worker-allowed": "/"
1344
+ });
1345
+ res.end(appServiceWorkerScript());
2389
1346
  };
2390
- return [
1347
+ const routes = [
1348
+ {
1349
+ kind: "exact",
1350
+ path: PAIR_PATHS.issue,
1351
+ handler: handleIssue
1352
+ },
1353
+ {
1354
+ kind: "exact",
1355
+ path: PAIR_PATHS.accept,
1356
+ handler: handleAccept
1357
+ },
1358
+ {
1359
+ kind: "exact",
1360
+ path: PAIR_PATHS.stop,
1361
+ handler: handleStop
1362
+ },
1363
+ {
1364
+ kind: "exact",
1365
+ path: PAIR_PATHS.revoke,
1366
+ handler: handleRevoke
1367
+ },
2391
1368
  {
2392
1369
  kind: "exact",
2393
- path: PAIRED_MODEL_CATALOG_PATHS.catalog,
2394
- handler: catalog
1370
+ path: PAIR_PATHS.heartbeat,
1371
+ handler: handleHeartbeat
2395
1372
  },
2396
1373
  {
2397
1374
  kind: "exact",
2398
- path: PAIRED_MODEL_CATALOG_PATHS.discover,
2399
- handler: discover
1375
+ path: PAIR_PATHS.status,
1376
+ handler: handleStatus
2400
1377
  },
2401
1378
  {
2402
1379
  kind: "exact",
2403
- path: PAIRED_MODEL_CATALOG_PATHS.upsert,
2404
- handler: upsert
1380
+ path: PAIR_PATHS.events,
1381
+ handler: handleEvents
2405
1382
  }
2406
1383
  ];
1384
+ if (deps.lanBindStatus !== void 0) routes.push({
1385
+ kind: "exact",
1386
+ path: PAIR_PATHS.lanBind,
1387
+ handler: handleLanBind
1388
+ });
1389
+ routes.push({
1390
+ kind: "exact",
1391
+ path: PAIR_PATHS.acceptPage,
1392
+ handler: handleAcceptPage
1393
+ });
1394
+ if (deps.indexDocument !== void 0) {
1395
+ routes.push({
1396
+ kind: "exact",
1397
+ path: PAIR_PATHS.appPage,
1398
+ handler: handleAppPage
1399
+ });
1400
+ routes.push({
1401
+ kind: "exact",
1402
+ path: PAIR_PATHS.appServiceWorker,
1403
+ handler: handleAppServiceWorker
1404
+ });
1405
+ }
1406
+ return routes;
1407
+ }
1408
+ /** The request origin (https when a tunnel edge says so). */
1409
+ function appOrigin(req) {
1410
+ const proto = req.headers["x-forwarded-proto"] === "https" ? "https" : "http";
1411
+ const origin = `http://${req.headers.host ?? "127.0.0.1"}`;
1412
+ return proto === "https" ? origin.replace("http://", "https://") : origin;
2407
1413
  }
2408
1414
  //#endregion
2409
1415
  //#region src/loopback-proxy.ts
@@ -2429,8 +1435,17 @@ const HTTP_FORWARD_RESPONSE_HEADERS = [
2429
1435
  * @param res - the outer response.
2430
1436
  * @param port - local webServer port.
2431
1437
  * @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
2432
- */
2433
- function proxyLoopbackHttp(req, res, port, upstreamPath) {
1438
+ * @param auth - when given, the process's inner browser-auth credential is
1439
+ * attached so the connection plugin's /api route (fence + browser auth,
1440
+ * authority-bound cookie, no loopback exemption on this cohort) accepts
1441
+ * the re-issued request; a 401 answer invalidates the cached credential.
1442
+ */
1443
+ function proxyLoopbackHttp(req, res, port, upstreamPath, auth) {
1444
+ Promise.resolve(auth?.ready()).catch(() => void 0).then((cookie) => {
1445
+ pipeLoopbackHttp(req, res, port, upstreamPath, auth, typeof cookie === "string" ? cookie : void 0);
1446
+ });
1447
+ }
1448
+ function pipeLoopbackHttp(req, res, port, upstreamPath, auth, cookie) {
2434
1449
  const headers = {
2435
1450
  host: `127.0.0.1:${String(port)}`,
2436
1451
  "sec-fetch-site": "same-origin"
@@ -2441,6 +1456,7 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
2441
1456
  if (typeof contentLength === "string") headers["content-length"] = contentLength;
2442
1457
  const accept = req.headers.accept;
2443
1458
  if (typeof accept === "string") headers.accept = accept;
1459
+ if (cookie !== void 0) headers.cookie = cookie;
2444
1460
  const upstream = request({
2445
1461
  host: "127.0.0.1",
2446
1462
  port,
@@ -2448,6 +1464,7 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
2448
1464
  method: req.method,
2449
1465
  headers
2450
1466
  }, (upstreamRes) => {
1467
+ if (upstreamRes.statusCode === 401 && cookie !== void 0) auth?.invalidate();
2451
1468
  const out = {};
2452
1469
  for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
2453
1470
  const value = upstreamRes.headers[name];
@@ -2478,14 +1495,17 @@ function proxyLoopbackHttp(req, res, port, upstreamPath) {
2478
1495
  * @param head - bytes already read past the handshake.
2479
1496
  * @param port - local webServer port.
2480
1497
  * @param upstreamPath - path + query on 127.0.0.1.
1498
+ * @param cookie - the inner browser-auth credential for the handshake, when
1499
+ * the upstream route enforces it (the gateway event-stream mux does).
2481
1500
  */
2482
- function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
1501
+ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath, cookie) {
2483
1502
  const lines = [
2484
1503
  `GET ${upstreamPath} HTTP/1.1`,
2485
1504
  `Host: 127.0.0.1:${String(port)}`,
2486
1505
  "Upgrade: websocket",
2487
1506
  "Connection: Upgrade"
2488
1507
  ];
1508
+ if (cookie !== void 0) lines.push(`Cookie: ${cookie}`);
2489
1509
  for (const name of WS_FORWARD_HEADERS) {
2490
1510
  const value = req.headers[name];
2491
1511
  if (value === void 0) continue;
@@ -2493,6 +1513,8 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
2493
1513
  }
2494
1514
  const handshake = `${lines.join("\r\n")}\r\n\r\n`;
2495
1515
  const upstream = connect(port, "127.0.0.1");
1516
+ socket.setKeepAlive(true, 2e4);
1517
+ upstream.setKeepAlive(true, 2e4);
2496
1518
  const tearDown = () => {
2497
1519
  upstream.destroy();
2498
1520
  socket.destroy();
@@ -2517,51 +1539,67 @@ function proxyLoopbackUpgrade(req, socket, head, port, upstreamPath) {
2517
1539
  /**
2518
1540
  * Remote desktop channel constants — SDK-independent so tests and the
2519
1541
  * client half can pin them without importing the host SDK graph.
1542
+ *
1543
+ * Access model on the 0.1.2-alpha.2 line: the host /api surface has no
1544
+ * per-method privilege pin — the "configuration plane is local" behavior
1545
+ * lives in the browser (client plugins branch on connection.isLoopback), and
1546
+ * the paired remote desktop flips into host mode via the transport hook
1547
+ * (ownsHost) while every call rides this gated channel as a loopback-shaped
1548
+ * request. A paired device is therefore a full-control credential by
1549
+ * design; the only paths that stay physically local are the control planes
1550
+ * below (pairing control, self-update, plugin install/remove, host power).
2520
1551
  */
2521
1552
  /** Gated mirror of same-origin fenced paths (`/remote` + original pathname). */
2522
1553
  const REMOTE_PREFIX$1 = "/remote";
2523
1554
  /** Connection-plugin method prefix under the gated channel. */
2524
1555
  const REMOTE_API_PREFIX$1 = `${REMOTE_PREFIX$1}/api`;
2525
- /** WebSocket event-stream paths served by the channel (client rewrites to these). */
2526
- const REMOTE_API_PATHS = {
2527
- mux: `${REMOTE_API_PREFIX$1}/events.mux`,
2528
- host: `${REMOTE_API_PREFIX$1}/events.host`
2529
- };
2530
1556
  /**
2531
1557
  * Exact upgrade paths registered on webServer (the SDK matches upgrades by
2532
1558
  * exact path, not prefix). Query strings ride on the request URL.
2533
1559
  */
2534
1560
  const REMOTE_UPGRADE_PATHS = [
2535
- REMOTE_API_PATHS.mux,
2536
- REMOTE_API_PATHS.host,
1561
+ { mux: `${REMOTE_API_PREFIX$1}/remote.mux` }.mux,
2537
1562
  `${REMOTE_PREFIX$1}/sidebar/ws/terminal`,
2538
1563
  `${REMOTE_PREFIX$1}/sidebar/ws/agent-terminals`,
2539
1564
  `${REMOTE_API_PREFIX$1}/dsh-ssh/terminal`
2540
1565
  ];
1566
+ /** Plugin-manager HTTP prefix: install/remove stay physically local. */
1567
+ const PLUGIN_MANAGER_PATH = "/api/plugin-manager";
1568
+ /** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
1569
+ const DESKTOP_LAUNCHER_PATH = "/api/dsh-desktop-launcher";
1570
+ /**
1571
+ * The cookieless device credential: the boot patch reads the device id from
1572
+ * the /pair-app URL, keeps it in sessionStorage, and attaches it to every
1573
+ * gated HTTP call as this header (and to WebSocket upgrades as the `device`
1574
+ * query parameter - WS handshakes cannot carry headers from the Web API).
1575
+ * The channel gate accepts it exactly like the device cookie, so the mobile
1576
+ * flow works even with browser cookies fully blocked; the cookie remains the
1577
+ * primary credential on normal browsers.
1578
+ */
1579
+ const REMOTE_DEVICE_HEADER = "x-dsh-remote-device";
1580
+ const REMOTE_DEVICE_QUERY = "device";
1581
+ /**
1582
+ * Path prefixes that stay physically local even for a paired device. A
1583
+ * paired remote desktop may use the full host API (chat, sessions,
1584
+ * settings, credentials, presets — it is a full-control credential), but it
1585
+ * must not reach the machine-control planes: pairing control itself, the
1586
+ * dsh-web self-update installer, plugin install/remove, and desktop
1587
+ * launcher actions (host shutdown, shortcuts).
1588
+ */
1589
+ const LOCAL_ONLY_PREFIXES = [
1590
+ "/api/pair",
1591
+ "/api/update",
1592
+ PLUGIN_MANAGER_PATH,
1593
+ DESKTOP_LAUNCHER_PATH
1594
+ ];
2541
1595
  /**
2542
- * Loopback-only methods of the host API surface, mirrored from
2543
- * client-connection's `PRIVILEGED_METHODS` (pinned by
2544
- * tests/remote-contract.spec.ts against the installed SDK). They stay
2545
- * unreachable from a paired remote desktop, matching the SDK's own stance
2546
- * that the configuration plane is loopback-same-origin only.
1596
+ * Whether a paired inner path must stay physically local.
1597
+ * @param innerPath - the rewritten inner path (e.g. `/api/session.list`).
1598
+ * @returns a denial message, or undefined when the path may be proxied.
2547
1599
  */
2548
- const LOOPBACK_ONLY_METHODS = /* @__PURE__ */ new Set([
2549
- "agentPreset.read",
2550
- "agentPreset.copy",
2551
- "agentPreset.openDocument",
2552
- "agentPreset.remove",
2553
- "host.pickDirectory",
2554
- "host.openPath",
2555
- "settings.describe",
2556
- "settings.openDocument",
2557
- "settings.update",
2558
- "settings.replace",
2559
- "settings.mutate",
2560
- "credentials.describe",
2561
- "credentials.set",
2562
- "credentials.unset",
2563
- "llm.discoverModels"
2564
- ]);
1600
+ function localOnlyDenial(innerPath) {
1601
+ for (const prefix of LOCAL_ONLY_PREFIXES) if (innerPath === prefix || innerPath.startsWith(`${prefix}/`)) return `${prefix.slice(1)} stays physically local and stays unreachable from a paired remote desktop`;
1602
+ }
2565
1603
  //#endregion
2566
1604
  //#region src/remote-api.ts
2567
1605
  const ALLOWED_METHODS = /* @__PURE__ */ new Set([
@@ -2612,18 +1650,25 @@ function innerPathOf(pathname) {
2612
1650
  return rest;
2613
1651
  }
2614
1652
  /**
2615
- * Whether a paired inner path must stay physically local.
1653
+ * Whether a paired inner path must stay physically local (delegates to the
1654
+ * shared LOCAL_ONLY_PREFIXES table).
2616
1655
  * @returns a denial message, or undefined when the path may be proxied.
2617
1656
  */
2618
1657
  function loopbackOnlyDenial(innerPath) {
2619
- if (innerPath === "/api/pair" || innerPath.startsWith("/api/pair/")) return "pairing endpoints stay loopback-only and stay unreachable from a paired remote desktop";
2620
- if (innerPath === "/api/update" || innerPath.startsWith("/api/update/")) return "update endpoints stay loopback-only and stay unreachable from a paired remote desktop";
2621
- if (innerPath === "/api/plugin-manager" || innerPath.startsWith(`/api/plugin-manager/`)) return "plugin-manager stays loopback-only and stays unreachable from a paired remote desktop";
2622
- if (innerPath === "/api/dsh-desktop-launcher" || innerPath.startsWith(`/api/dsh-desktop-launcher/`)) return "desktop-launcher endpoints stay loopback-only and stay unreachable from a paired remote desktop";
2623
- if (innerPath === "/api/dsh-web-ui-settings" || innerPath.startsWith(`/api/dsh-web-ui-settings/`)) return "settings-bridge endpoints stay loopback-only and stay unreachable from a paired remote desktop";
2624
- if (!innerPath.startsWith("/api/")) return void 0;
2625
- const method = innerPath.slice(5);
2626
- if (method !== "" && !method.includes("/") && LOOPBACK_ONLY_METHODS.has(method)) return `${method} is loopback-only and stays unreachable from a paired remote desktop`;
1658
+ return localOnlyDenial(innerPath);
1659
+ }
1660
+ /**
1661
+ * Resolve a live device credential for a gated HTTP request: the pairing
1662
+ * cookie first, then the cookieless header the boot patch attaches. Unknown
1663
+ * or revoked ids are a no-op - a stale id never re-arms a device.
1664
+ * @returns the touched device id, or undefined when neither credential is live.
1665
+ */
1666
+ function pairedDeviceIdOf(req, service) {
1667
+ const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName);
1668
+ const headerDevice = typeof req.headers["x-dsh-remote-device"] === "string" ? req.headers[REMOTE_DEVICE_HEADER] : void 0;
1669
+ const id = cookieDevice ?? headerDevice;
1670
+ if (id === void 0) return void 0;
1671
+ return service.touchDevice(id) ? id : void 0;
2627
1672
  }
2628
1673
  /**
2629
1674
  * Build the remote desktop channel HTTP routes.
@@ -2634,8 +1679,7 @@ function makeRemoteApiRoutes(deps) {
2634
1679
  const { service, port, requirePairingForLan = true } = deps;
2635
1680
  const handler = (req, res) => {
2636
1681
  if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
2637
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
2638
- if (!(deviceId !== void 0 && service.touchDevice(deviceId))) {
1682
+ if (pairedDeviceIdOf(req, service) === void 0) {
2639
1683
  req.resume();
2640
1684
  envelopeError(res, 403, "invalid-request", "unpaired", "this device is not paired with the desktop");
2641
1685
  return;
@@ -2660,7 +1704,7 @@ function makeRemoteApiRoutes(deps) {
2660
1704
  envelopeError(res, 403, "invalid-request", "forbidden", denied);
2661
1705
  return;
2662
1706
  }
2663
- proxyLoopbackHttp(req, res, port, `${inner}${url.search}`);
1707
+ proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth);
2664
1708
  };
2665
1709
  return [{
2666
1710
  kind: "prefix",
@@ -2693,8 +1737,11 @@ function makeRemoteApiUpgradeRoutes(deps) {
2693
1737
  const { service, port, requirePairingForLan = true } = deps;
2694
1738
  const handlerFor = (fallbackPath) => (req, socket, head) => {
2695
1739
  if (typeof requirePairingForLan === "function" ? requirePairingForLan() : requirePairingForLan) {
2696
- const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
2697
- if (deviceId === void 0 || !service.touchDevice(deviceId)) {
1740
+ let queryDevice;
1741
+ try {
1742
+ queryDevice = new URL(req.url ?? "/", "http://127.0.0.1").searchParams.get("device") ?? void 0;
1743
+ } catch {}
1744
+ if ((pairedDeviceIdOf(req, service) ?? (queryDevice !== void 0 && service.touchDevice(queryDevice) ? queryDevice : void 0)) === void 0) {
2698
1745
  socket.write("HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n");
2699
1746
  socket.destroy();
2700
1747
  return;
@@ -2706,7 +1753,10 @@ function makeRemoteApiUpgradeRoutes(deps) {
2706
1753
  socket.destroy();
2707
1754
  return;
2708
1755
  }
2709
- proxyLoopbackUpgrade(req, socket, head, port, inner);
1756
+ Promise.resolve(deps.auth?.ready()).catch(() => void 0).then((cookie) => {
1757
+ if (socket.destroyed) return;
1758
+ proxyLoopbackUpgrade(req, socket, head, port, inner, typeof cookie === "string" ? cookie : void 0);
1759
+ });
2710
1760
  };
2711
1761
  return REMOTE_UPGRADE_PATHS.map((path) => ({
2712
1762
  path,
@@ -2714,6 +1764,79 @@ function makeRemoteApiUpgradeRoutes(deps) {
2714
1764
  }));
2715
1765
  }
2716
1766
  //#endregion
1767
+ //#region src/remote-presence-pet.ts
1768
+ /** Default restore grace: the presence sweep flips a device offline after
1769
+ * ~25 s, and a phone that was briefly backgrounded should not flicker the
1770
+ * pet; 2 minutes keeps the restore stable. */
1771
+ const DEFAULT_RESTORE_AFTER_MS = 12e4;
1772
+ const nodeTimers = {
1773
+ setTimeout,
1774
+ clearTimeout
1775
+ };
1776
+ /**
1777
+ * Start the presence link. Returns the disposer (withdraws the state
1778
+ * subscription and cancels a pending restore).
1779
+ * @param deps - pairing stream + pet seam (+ test seams).
1780
+ */
1781
+ function startRemotePresencePet(deps) {
1782
+ const restoreAfterMs = deps.restoreAfterMs ?? DEFAULT_RESTORE_AFTER_MS;
1783
+ const timers = deps.timers ?? nodeTimers;
1784
+ let previousOnline = false;
1785
+ let hiddenByRemote = false;
1786
+ let restoreTimer;
1787
+ let disposed = false;
1788
+ const clearRestore = () => {
1789
+ if (restoreTimer !== void 0) {
1790
+ timers.clearTimeout(restoreTimer);
1791
+ restoreTimer = void 0;
1792
+ }
1793
+ };
1794
+ const scheduleRestore = () => {
1795
+ clearRestore();
1796
+ restoreTimer = timers.setTimeout(() => {
1797
+ restoreTimer = void 0;
1798
+ restoreAfterTick();
1799
+ }, restoreAfterMs);
1800
+ };
1801
+ const restoreAfterTick = () => {
1802
+ if (disposed || !hiddenByRemote) return;
1803
+ const pet = deps.pet();
1804
+ if (pet === void 0) return;
1805
+ hiddenByRemote = false;
1806
+ pet.setVisible(true).catch(() => {});
1807
+ };
1808
+ const hideForRemote = () => {
1809
+ if (hiddenByRemote) return;
1810
+ const pet = deps.pet();
1811
+ if (pet === void 0) return;
1812
+ pet.state().then((value) => {
1813
+ if (disposed || hiddenByRemote) return;
1814
+ if (value.display?.visible !== true) return;
1815
+ hiddenByRemote = true;
1816
+ return pet.setVisible(false).catch(() => {
1817
+ hiddenByRemote = false;
1818
+ });
1819
+ }, () => {});
1820
+ };
1821
+ const handle = (snapshot) => {
1822
+ const online = snapshot.phase === "connected" && snapshot.onlineCount > 0;
1823
+ if (online && !previousOnline) {
1824
+ cancelRestoreAndClear();
1825
+ hideForRemote();
1826
+ } else if (!online && previousOnline) scheduleRestore();
1827
+ previousOnline = online;
1828
+ };
1829
+ const cancelRestoreAndClear = () => {
1830
+ clearRestore();
1831
+ };
1832
+ const unsubscribe = deps.onState(handle);
1833
+ return () => {
1834
+ disposed = true;
1835
+ unsubscribe();
1836
+ clearRestore();
1837
+ };
1838
+ }
1839
+ //#endregion
2717
1840
  //#region src/posture.ts
2718
1841
  /**
2719
1842
  * Build the forged Host values to probe: the public base authority (host or
@@ -2845,6 +1968,442 @@ function lanIPv4Addresses() {
2845
1968
  }).map((iface) => iface.address);
2846
1969
  }
2847
1970
  //#endregion
1971
+ //#region src/firewall.ts
1972
+ /**
1973
+ * Host firewall management for the LAN bind toggle: while LAN access is on,
1974
+ * keep one inbound allow rule for the bound port; when it turns off, remove
1975
+ * the rule. Windows manages the Windows Defender Firewall rule via netsh;
1976
+ * Linux uses the first available manager among firewalld / ufw / iptables.
1977
+ * On every other platform (macOS included) the plugin reports the firewall
1978
+ * as unmanaged: the port usually needs no rule there, and managing pf or the
1979
+ * application firewall is out of scope for a distributable plugin.
1980
+ *
1981
+ * Ported from the dsh-LAN reference implementation (MIT), adapted to this
1982
+ * package's structure and test seams.
1983
+ */
1984
+ /** Rule name shared by every backend so re-runs recreate the same rule. */
1985
+ const FIREWALL_RULE_NAME = "remote-web-ui (auto)";
1986
+ /** Production runner: spawn without a shell, capture text, 20s timeout. */
1987
+ const spawnRunner = (cmd, args) => {
1988
+ const result = spawnSync(cmd, args, {
1989
+ shell: false,
1990
+ encoding: "utf8",
1991
+ windowsHide: process.platform === "win32",
1992
+ timeout: 2e4
1993
+ });
1994
+ return {
1995
+ ok: result.status === 0,
1996
+ out: result.stdout ?? "",
1997
+ err: result.stderr ?? "",
1998
+ missing: result.error !== void 0
1999
+ };
2000
+ };
2001
+ function netshBackend(run) {
2002
+ const show = () => {
2003
+ const result = run("netsh", [
2004
+ "advfirewall",
2005
+ "firewall",
2006
+ "show",
2007
+ "rule",
2008
+ `name=${FIREWALL_RULE_NAME}`,
2009
+ "verbose"
2010
+ ]);
2011
+ return result.ok && result.out.includes("remote-web-ui (auto)");
2012
+ };
2013
+ return {
2014
+ label: "netsh",
2015
+ ruleExists: show,
2016
+ addRule: (port) => run("netsh", [
2017
+ "advfirewall",
2018
+ "firewall",
2019
+ "add",
2020
+ "rule",
2021
+ `name=${FIREWALL_RULE_NAME}`,
2022
+ "dir=in",
2023
+ "action=allow",
2024
+ "protocol=TCP",
2025
+ `localport=${String(port)}`,
2026
+ "profile=private,domain"
2027
+ ]).ok,
2028
+ removeRule: () => {
2029
+ if (!show()) return true;
2030
+ return run("netsh", [
2031
+ "advfirewall",
2032
+ "firewall",
2033
+ "delete",
2034
+ "rule",
2035
+ `name=${FIREWALL_RULE_NAME}`
2036
+ ]).ok;
2037
+ }
2038
+ };
2039
+ }
2040
+ function firewalldBackend(run) {
2041
+ return {
2042
+ label: "firewalld",
2043
+ ruleExists: (port) => run("firewall-cmd", [
2044
+ "--permanent",
2045
+ "--query-port",
2046
+ `${String(port)}/tcp`
2047
+ ]).ok,
2048
+ addRule: (port) => {
2049
+ const add = run("firewall-cmd", [
2050
+ "--permanent",
2051
+ "--add-port",
2052
+ `${String(port)}/tcp`
2053
+ ]);
2054
+ const reload = run("firewall-cmd", ["--reload"]);
2055
+ return add.ok && reload.ok;
2056
+ },
2057
+ removeRule: (port) => {
2058
+ const del = run("firewall-cmd", [
2059
+ "--permanent",
2060
+ "--remove-port",
2061
+ `${String(port)}/tcp`
2062
+ ]);
2063
+ const reload = run("firewall-cmd", ["--reload"]);
2064
+ return del.ok && reload.ok;
2065
+ }
2066
+ };
2067
+ }
2068
+ function ufwBackend(run) {
2069
+ return {
2070
+ label: "ufw",
2071
+ ruleExists: (port) => {
2072
+ const result = run("ufw", ["status"]);
2073
+ return result.ok && new RegExp(`(^|\\s)${String(port)}/tcp\\s+ALLOW`, "i").test(result.out);
2074
+ },
2075
+ addRule: (port) => run("ufw", ["allow", `${String(port)}/tcp`]).ok,
2076
+ removeRule: (port) => run("ufw", [
2077
+ "delete",
2078
+ "allow",
2079
+ `${String(port)}/tcp`
2080
+ ]).ok
2081
+ };
2082
+ }
2083
+ function iptablesBackend(run) {
2084
+ const rule = (port) => [
2085
+ "INPUT",
2086
+ "-p",
2087
+ "tcp",
2088
+ "--dport",
2089
+ String(port),
2090
+ "-j",
2091
+ "ACCEPT"
2092
+ ];
2093
+ return {
2094
+ label: "iptables",
2095
+ ruleExists: (port) => run("iptables", ["-C", ...rule(port)]).ok,
2096
+ addRule: (port) => run("iptables", ["-A", ...rule(port)]).ok,
2097
+ removeRule: (port) => run("iptables", ["-D", ...rule(port)]).ok
2098
+ };
2099
+ }
2100
+ function toolAvailable(run, cmd) {
2101
+ try {
2102
+ return run(cmd, ["--version"]).missing !== true;
2103
+ } catch {
2104
+ return false;
2105
+ }
2106
+ }
2107
+ /**
2108
+ * Detect the platform firewall manager. Returns undefined when the platform
2109
+ * has no supported manager (macOS, unknown Linux without tools): the port
2110
+ * then usually needs no rule and the UI reports "unmanaged".
2111
+ */
2112
+ function detectFirewallBackend(platform, run = spawnRunner) {
2113
+ if (platform === "win32") return netshBackend(run);
2114
+ if (platform !== "linux") return void 0;
2115
+ if (toolAvailable(run, "firewall-cmd")) {
2116
+ if (run("firewall-cmd", ["--state"]).ok) return firewalldBackend(run);
2117
+ }
2118
+ if (toolAvailable(run, "ufw")) return ufwBackend(run);
2119
+ if (toolAvailable(run, "iptables")) return iptablesBackend(run);
2120
+ }
2121
+ let cachedBackend;
2122
+ /** Cached detection for the running platform (per process). */
2123
+ function firewallBackend() {
2124
+ const platform = process.platform;
2125
+ if (cachedBackend === void 0 || cachedBackend.platform !== platform) cachedBackend = {
2126
+ platform,
2127
+ backend: detectFirewallBackend(platform)
2128
+ };
2129
+ return cachedBackend.backend;
2130
+ }
2131
+ /**
2132
+ * Delete-and-add: recreating the rule is idempotent and locale-proof (netsh
2133
+ * output is localized, so parsing the live rule's port is fragile; the Linux
2134
+ * backends follow the same recreate pattern).
2135
+ */
2136
+ function ensureFirewallRule(port) {
2137
+ invalidateFirewallSummary();
2138
+ const backend = firewallBackend();
2139
+ if (backend === void 0) return true;
2140
+ backend.removeRule(port);
2141
+ return backend.addRule(port);
2142
+ }
2143
+ function removeFirewallRule(port) {
2144
+ invalidateFirewallSummary();
2145
+ const backend = firewallBackend();
2146
+ if (backend === void 0) return true;
2147
+ return backend.removeRule(port);
2148
+ }
2149
+ /**
2150
+ * Human-readable firewall state for the status endpoint. A missing backend
2151
+ * means the platform has nothing to manage; detection stays at the call site
2152
+ * so an explicit undefined cannot silently re-probe the real OS mid-test.
2153
+ */
2154
+ function computeFirewallSummary(port, lanEnabled, backend) {
2155
+ if (backend === void 0) return {
2156
+ ok: true,
2157
+ managed: false
2158
+ };
2159
+ return {
2160
+ ok: lanEnabled ? backend.ruleExists(port) : !backend.ruleExists(port),
2161
+ managed: true,
2162
+ note: backend.label
2163
+ };
2164
+ }
2165
+ let summaryCache;
2166
+ /** Forget the cached summary (rule mutations call this). */
2167
+ function invalidateFirewallSummary() {
2168
+ summaryCache = void 0;
2169
+ }
2170
+ /**
2171
+ * The settings card polls this every ten seconds; the probes are blocking
2172
+ * spawnSync subprocesses on managed platforms, so short-TTL memoization
2173
+ * keeps one poll from freezing the host event loop per request.
2174
+ */
2175
+ function firewallSummary(port, lanEnabled) {
2176
+ const key = `${String(port)}|${lanEnabled ? "1" : "0"}`;
2177
+ const now = Date.now();
2178
+ if (summaryCache !== void 0 && summaryCache.key === key && now - summaryCache.at < 3e4) return summaryCache.value;
2179
+ const value = computeFirewallSummary(port, lanEnabled, firewallBackend());
2180
+ summaryCache = {
2181
+ key,
2182
+ at: now,
2183
+ value
2184
+ };
2185
+ return value;
2186
+ }
2187
+ //#endregion
2188
+ //#region src/lan-bind.ts
2189
+ /**
2190
+ * LAN bind toggle: the managed cordis patch block that pins the web
2191
+ * server's bind. On = a block binding 0.0.0.0; off = the same block binding
2192
+ * 127.0.0.1. The block takes effect on the next `dsh web` start (the live
2193
+ * patch watcher cannot rebind a running listener, and on this harness line
2194
+ * the user patch layer cannot evaluate webStartup-dependent expressions
2195
+ * reliably - static values are the only dependable form), so the plugin
2196
+ * re-asserts the block at every boot and the settings card reports the
2197
+ * running bind honestly.
2198
+ *
2199
+ * Same-id patch rows REPLACE the row config wholesale, so the block carries
2200
+ * the official web-app webserver row's full config (bind + compression)
2201
+ * with the bind values materialized. The CLI's --host 0.0.0.0 guard stays
2202
+ * intact: deliberate LAN exposure happens through this configuration layer
2203
+ * only. Until the user flips the toggle once, the plugin never touches the
2204
+ * patch file.
2205
+ *
2206
+ * The block discipline (markers, atomic write, strip-and-rewrite) follows
2207
+ * the dsh-LAN reference implementation (MIT).
2208
+ */
2209
+ /** Profile names safe to interpolate into the patch path: one path segment, no traversal. */
2210
+ const PROFILE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
2211
+ const LAN_BIND_BLOCK_BEGIN = "# --- remote-web-ui lan-bind block (managed - do not edit) ---";
2212
+ const LAN_BIND_BLOCK_END = "# --- end remote-web-ui lan-bind block ---";
2213
+ /**
2214
+ * The absolute path of the profile patch file this toggle manages. The value
2215
+ * is config-controlled (and the DSH_PROFILE env fallback bypasses schema
2216
+ * validation entirely), so the path is guarded twice: the profile must be a
2217
+ * single safe path segment, and the resolved file must stay under the
2218
+ * profiles directory.
2219
+ */
2220
+ function profilePatchFile(profile, home = dshHome()) {
2221
+ if (!PROFILE_PATTERN.test(profile)) throw new Error(`remote-web-ui: unsafe lan-bind profile ${JSON.stringify(profile)}`);
2222
+ const file = resolve(join(home, "profiles", profile, "cordis.patch.yml"));
2223
+ const root = resolve(join(home, "profiles")) + sep;
2224
+ if (!file.startsWith(root)) throw new Error(`remote-web-ui: lan-bind profile ${JSON.stringify(profile)} escapes the profiles directory`);
2225
+ return file;
2226
+ }
2227
+ function readPatchContent(file) {
2228
+ if (!existsSync(file)) return "";
2229
+ return readFileSync(file, "utf8");
2230
+ }
2231
+ /** Escape one literal string for embedding into a RegExp pattern. */
2232
+ function escapeRegex(text) {
2233
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
2234
+ }
2235
+ /** Remove the managed block (and its markers) from patch content. */
2236
+ function stripManagedBlock(content) {
2237
+ const begin = escapeRegex(LAN_BIND_BLOCK_BEGIN);
2238
+ const end = escapeRegex(LAN_BIND_BLOCK_END);
2239
+ const pattern = new RegExp(`\\r?\\n?${begin}[\\s\\S]*?${end}\\r?\\n?`, "g");
2240
+ return content.replace(pattern, "\n");
2241
+ }
2242
+ /**
2243
+ * Render the managed block for one bind state. The values are static: the
2244
+ * user patch layer has no reliable lazy service evaluation, and the plugin
2245
+ * re-asserts the block at every boot so CLI flags (--port, --host) win by
2246
+ * rewriting it before the next start.
2247
+ */
2248
+ function managedBlock(host, port) {
2249
+ return [
2250
+ LAN_BIND_BLOCK_BEGIN,
2251
+ "- id: webserver",
2252
+ " name: '@deepseek-ai/dsh-host-webserver'",
2253
+ " config:",
2254
+ ` host: '${host}'`,
2255
+ ` port: ${String(port)}`,
2256
+ " compression: gzip",
2257
+ " compressionLevel: 1",
2258
+ " compressionThresholdBytes: 1024",
2259
+ LAN_BIND_BLOCK_END,
2260
+ ""
2261
+ ].join("\n");
2262
+ }
2263
+ /**
2264
+ * Parse the block's pinned bind out of patch content. A block whose values
2265
+ * were hand-edited reports the literals as-is so the card can surface them
2266
+ * instead of silently claiming one of the two known states.
2267
+ */
2268
+ function managedBindOf(content) {
2269
+ const begin = content.indexOf(LAN_BIND_BLOCK_BEGIN);
2270
+ if (begin === -1) return void 0;
2271
+ const end = content.indexOf(LAN_BIND_BLOCK_END, begin);
2272
+ const block = end === -1 ? content.slice(begin) : content.slice(begin, end);
2273
+ const hostMatch = /host:\s*'([^']+)'/.exec(block);
2274
+ const portMatch = /port:\s*(\d+)/.exec(block);
2275
+ return {
2276
+ host: hostMatch?.[1] ?? "",
2277
+ port: portMatch !== null ? Number(portMatch[1]) : void 0
2278
+ };
2279
+ }
2280
+ /** Full file-level state for the settings card and the boot re-assert. */
2281
+ function lanBindState(profile, home = dshHome()) {
2282
+ const state = managedBindOf(readPatchContent(profilePatchFile(profile, home)));
2283
+ if (state === void 0) return { blockPresent: false };
2284
+ return {
2285
+ blockPresent: true,
2286
+ host: state.host,
2287
+ port: state.port
2288
+ };
2289
+ }
2290
+ /**
2291
+ * Write (or rewrite) the managed block with the given bind. The rest of the
2292
+ * patch file is preserved; the block is rewritten atomically (unique temp
2293
+ * file + rename, so concurrent writers can never rename a half-written
2294
+ * file) with the original file's permissions preserved. A hand-truncated
2295
+ * unterminated block (BEGIN marker without END, which stripManagedBlock
2296
+ * cannot match) is truncated at its BEGIN marker first so the rewrite can
2297
+ * never stack a second webserver row onto the orphan.
2298
+ */
2299
+ function writeLanBind(host, port, profile, home = dshHome()) {
2300
+ const file = profilePatchFile(profile, home);
2301
+ const stripped = stripManagedBlock(readPatchContent(file));
2302
+ const orphanBegin = stripped.indexOf(LAN_BIND_BLOCK_BEGIN);
2303
+ const content = `${(orphanBegin === -1 ? stripped : stripped.slice(0, orphanBegin)).trimEnd()}\n\n${managedBlock(host, port)}`;
2304
+ const mode = existsSync(file) ? statSync(file).mode & 511 : 384;
2305
+ mkdirSync(dirname(file), { recursive: true });
2306
+ const temp = `${file}.remote-web-ui-tmp-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2307
+ writeFileSync(temp, content, { mode });
2308
+ renameSync(temp, file);
2309
+ }
2310
+ //#endregion
2311
+ //#region src/lan-bind-plan.ts
2312
+ /**
2313
+ * The bind host the managed block should pin: an explicit CLI --host wins,
2314
+ * otherwise the toggle decides. CLI hosts outside the two managed literals
2315
+ * (a pinned specific IP) are not a state the toggle manages — the toggle
2316
+ * decides, and the card surfaces the divergence honestly.
2317
+ * @param lanBind - the toggle value.
2318
+ * @param startupHost - the CLI-provided bind host, when given.
2319
+ * @returns the desired bind host literal.
2320
+ */
2321
+ function desiredBindHost(lanBind, startupHost) {
2322
+ if (startupHost === "0.0.0.0" || startupHost === "127.0.0.1") return startupHost;
2323
+ return lanBind ? "0.0.0.0" : "127.0.0.1";
2324
+ }
2325
+ /**
2326
+ * The port the managed block should pin: the CLI port when given, else the
2327
+ * currently bound port. Undefined when neither is readable yet (the caller
2328
+ * skips the write and reports why).
2329
+ * @param startupPort - the CLI-provided port, when given.
2330
+ * @param livePort - the currently bound port, when finite.
2331
+ */
2332
+ function desiredBindPort(startupPort, livePort) {
2333
+ if (typeof startupPort === "number" && Number.isFinite(startupPort)) return startupPort;
2334
+ return typeof livePort === "number" && Number.isFinite(livePort) ? livePort : void 0;
2335
+ }
2336
+ /**
2337
+ * Whether the running bind has not caught up with what the toggle (plus a
2338
+ * possibly overriding CLI host) will pin. Compared against the effective
2339
+ * desired host — not the raw toggle — so a flag-managed bind is not reported
2340
+ * as pending forever.
2341
+ * @param lanBind - the toggle value (undefined = untouched, never pending).
2342
+ * @param desiredHost - the effective desired host, or undefined when untouched.
2343
+ * @param liveHost - the currently bound host.
2344
+ */
2345
+ function pendingRestartOf(lanBind, desiredHost, liveHost) {
2346
+ if (lanBind === void 0 || desiredHost === void 0) return false;
2347
+ return liveHost !== desiredHost;
2348
+ }
2349
+ /**
2350
+ * Whether the firewall rule must be (re)applied: only when the toggle or the
2351
+ * bound port moved since the last application. Keeps unrelated settings
2352
+ * saves from spawning netsh/firewall-cmd delete+add churn.
2353
+ * @param previous - the state applied earlier by this process, if any.
2354
+ * @param next - the state the settings now ask for, if derivable.
2355
+ */
2356
+ function firewallActionNeeded(previous, next) {
2357
+ if (next === void 0) return false;
2358
+ if (previous === void 0) return true;
2359
+ return previous.enabled !== next.enabled || previous.port !== next.port;
2360
+ }
2361
+ //#endregion
2362
+ //#region src/inner-auth.ts
2363
+ /** The harness browser-auth cookie name prefix (see dsh-client-connection). */
2364
+ const BROWSER_AUTH_COOKIE_PREFIX = "dsh-auth-";
2365
+ /**
2366
+ * Build the inner-auth handle.
2367
+ * @param launchUrl - the launch-token URL for the inner loopback authority
2368
+ * (the connection service's authenticatedUrl of `http://127.0.0.1:<port>/`),
2369
+ * or undefined when the connection service or port is unavailable.
2370
+ * @param fetchImpl - injectable fetch (tests).
2371
+ */
2372
+ function createInnerAuth(launchUrl, fetchImpl = fetch) {
2373
+ let cached;
2374
+ let inflight;
2375
+ const redeem = async () => {
2376
+ const url = launchUrl();
2377
+ if (url === void 0) return void 0;
2378
+ try {
2379
+ const raw = (await fetchImpl(url, { redirect: "manual" })).headers.get("set-cookie");
2380
+ if (raw === null) return void 0;
2381
+ const entry = raw.split(",").map((part) => part.trim()).find((part) => part.startsWith(BROWSER_AUTH_COOKIE_PREFIX));
2382
+ if (entry === void 0) return void 0;
2383
+ const pair = entry.split(";")[0]?.trim();
2384
+ return pair !== void 0 && pair.includes("=") ? pair : void 0;
2385
+ } catch {
2386
+ return;
2387
+ }
2388
+ };
2389
+ return {
2390
+ ready() {
2391
+ if (cached !== void 0) return Promise.resolve(cached);
2392
+ inflight ??= redeem().then((value) => {
2393
+ if (value !== void 0) cached = value;
2394
+ inflight = void 0;
2395
+ return value;
2396
+ }, () => {
2397
+ inflight = void 0;
2398
+ });
2399
+ return inflight;
2400
+ },
2401
+ invalidate() {
2402
+ cached = void 0;
2403
+ }
2404
+ };
2405
+ }
2406
+ //#endregion
2848
2407
  //#region src/tunnel.ts
2849
2408
  /**
2850
2409
  * Auto-tunnel manager: spawns a Cloudflare quick tunnel (`cloudflared
@@ -2866,7 +2425,10 @@ async function defaultEnsureBinary() {
2866
2425
  }
2867
2426
  /** Default factory: the cloudflared package's quick tunnel (no account). */
2868
2427
  function defaultFactory(targetUrl) {
2869
- return Tunnel.quick(targetUrl, { "--no-autoupdate": true });
2428
+ return Tunnel.quick(targetUrl, {
2429
+ "--no-autoupdate": true,
2430
+ "--protocol": "http2"
2431
+ });
2870
2432
  }
2871
2433
  /** Node timers. */
2872
2434
  const nodeTimer = {
@@ -3725,12 +3287,14 @@ const REMOTE_CHANNEL_RULES = {
3725
3287
  gitPrefix: "/git/",
3726
3288
  petPrefix: "/pet/",
3727
3289
  wsPaths: [
3728
- "/api/events.mux",
3729
- "/api/events.host",
3290
+ "/api/remote.mux",
3730
3291
  "/sidebar/ws/terminal",
3731
3292
  "/sidebar/ws/agent-terminals",
3732
3293
  "/api/dsh-ssh/terminal"
3733
- ]
3294
+ ],
3295
+ deviceHeader: REMOTE_DEVICE_HEADER,
3296
+ deviceKey: "dsh-remote-device",
3297
+ deviceQuery: REMOTE_DEVICE_QUERY
3734
3298
  };
3735
3299
  /** The window global the boot patch publishes its seat under. */
3736
3300
  const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
@@ -3748,8 +3312,15 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3748
3312
  * (hooks + pending unpaired signal) instead of patching twice.
3749
3313
  *
3750
3314
  * The rewrite decisions are generated from REMOTE_CHANNEL_RULES, the same
3751
- * data the browser patch consumes — the two cannot drift apart. The script
3752
- * self-skips on loopback origins and never throws.
3315
+ * data the browser patch consumes — the two cannot drift apart. On this
3316
+ * 0.1.2-alpha.2 line the "configuration plane is local" behavior lives in
3317
+ * the browser (client plugins branch on connection.isLoopback), so the
3318
+ * script also flips the official UI into host mode by installing the
3319
+ * transport hook `__DSH_TRANSPORT__ = { ownsHost: true }` before the
3320
+ * connection plugin reads it: the paired remote desktop gets the full
3321
+ * settings/credentials/presets surface, and every call still rides the
3322
+ * gated /remote channel. The script self-skips on loopback origins and
3323
+ * never throws.
3753
3324
  * @module @linxin666/dsh-remote-web-ui/remote-channel-boot
3754
3325
  */
3755
3326
  /**
@@ -3760,7 +3331,26 @@ const REMOTE_CHANNEL_BOOT_GLOBAL = "__DSH_REMOTE_CHANNEL_BOOT__";
3760
3331
  function buildRemoteChannelBootScript(rules = REMOTE_CHANNEL_RULES) {
3761
3332
  const json = JSON.stringify(rules);
3762
3333
  const seat = JSON.stringify(REMOTE_CHANNEL_BOOT_GLOBAL);
3763
- return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;var R=" + json + ";function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+\"/\")===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,init)).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat}catch(e){}})();";
3334
+ return "(function(){try{var w=window,loc=w.location,h=loc.hostname;if(h==='localhost'||h==='::1'||/^127(\\.\\d{1,3}){3}$/.test(h))return;try{if(w.__DSH_TRANSPORT__===undefined)w.__DSH_TRANSPORT__={};w.__DSH_TRANSPORT__.ownsHost=true}catch(e){}var R=" + json + ";function rdv(){try{return w.sessionStorage.getItem(R.deviceKey)}catch(e){return null}}function att(init){var dv=rdv();if(dv===null)return init;var h=init&&init.headers;if(typeof Headers!==\"undefined\"&&h instanceof Headers){try{h.set(R.deviceHeader,dv)}catch(e){}return init}if(typeof h===\"object\"&&h!==null){var o={};for(var k in h)o[k]=h[k];o[R.deviceHeader]=dv;return Object.assign({},init,{headers:o})}return init}function sf(p){if(p.indexOf(R.pairPrefix)===0)return false;if(p.indexOf(R.updatePrefix)===0)return false;if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+\"/\")===0)return false;if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+\"/\")===0)return false;if(p.indexOf(R.apiPrefix)===0)return true;if(p.indexOf(R.sidebarPrefix)===0||p===\"/sidebar\")return true;if(p.indexOf(R.gitPrefix)===0||p===\"/git\")return true;if(p.indexOf(R.petPrefix)===0||p===\"/pet\")return true;return false}function sw(p){return R.wsPaths.indexOf(p)!==-1}function rp(p){return R.remotePrefix+p}function so(u){return u.origin===loc.origin}function rr(raw){var u;try{u=new URL(raw,loc.href)}catch(e){return raw}if(u.origin!==loc.origin)return raw;if(!sf(u.pathname))return raw;u.pathname=rp(u.pathname);if(raw.charAt(0)===\"/\"&&raw.charAt(1)!==\"/\")return u.pathname+u.search+u.hash;return u.href}function uc(v){if(typeof v!==\"object\"||v===null)return undefined;var n=v.result;if(typeof n===\"object\"&&n!==null){var e=n.error;if(typeof e===\"object\"&&e!==null&&typeof e.code===\"string\")return e.code}var t=v.error;if(typeof t===\"object\"&&t!==null&&typeof t.code===\"string\")return t.code;return undefined}var seat={onUnpaired:null,onPaired:null,pendingUnpaired:false,restore:function(){}};function denied(res){if(res.status!==403)return false;return res.clone().json().then(function(b){return uc(b)===\"unpaired\"}).catch(function(){return false})}function signal(unpaired){if(unpaired){if(seat.onUnpaired)seat.onUnpaired();else seat.pendingUnpaired=true}else{seat.pendingUnpaired=false;if(seat.onPaired)seat.onPaired()}}var of=w.fetch;w.fetch=function(input,init){var raw=typeof input===\"string\"||input instanceof URL?input.toString():input.url;var url=new URL(raw,loc.href);if(so(url)&&sf(url.pathname)){var next=new URL(url);next.pathname=rp(url.pathname);var target=typeof input===\"string\"||input instanceof URL?next.toString():new Request(next,input);return Promise.resolve(of.call(w,target,att(init))).then(function(res){void Promise.resolve(denied(res)).then(signal);return res})}return of.call(w,input,init)};var OW=w.WebSocket;w.WebSocket=function(url,protocols){var p=new URL(url.toString(),loc.href);var o=p.protocol===\"wss:\"?\"https://\"+p.host:p.protocol===\"ws:\"?\"http://\"+p.host:\"\";if(o!==\"\"&&o===loc.origin&&sw(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return protocols!==undefined?new OW(nx,protocols):new OW(nx)}return protocols!==undefined?new OW(url,protocols):new OW(url)};w.WebSocket.prototype=OW.prototype;w.WebSocket.CONNECTING=OW.CONNECTING;w.WebSocket.OPEN=OW.OPEN;w.WebSocket.CLOSING=OW.CLOSING;w.WebSocket.CLOSED=OW.CLOSED;var OE=w.EventSource;if(OE!==undefined){w.EventSource=function(url,cfg){var p=new URL(url.toString(),loc.href);if(so(p)&&sf(p.pathname)){var nx=new URL(p);nx.pathname=rp(p.pathname);var dvv=rdv();if(dvv!==null)nx.searchParams.set(R.deviceQuery,dvv);return new OE(nx,cfg)}return new OE(url,cfg)};w.EventSource.prototype=OE.prototype}var restores=[];function patchSrc(C){if(C===undefined)return;var d=Object.getOwnPropertyDescriptor(C.prototype,\"src\");if(d===undefined||d.configurable===false||d.set===undefined)return;var os=d.set;Object.defineProperty(C.prototype,\"src\",{configurable:true,enumerable:d.enumerable!==false,get:d.get,set:function(v){os.call(this,rr(String(v)))}});restores.push(function(){Object.defineProperty(C.prototype,\"src\",d)})}patchSrc(w.HTMLImageElement);patchSrc(w.HTMLScriptElement);patchSrc(w.HTMLIFrameElement);seat.restore=function(){w.fetch=of;w.WebSocket=OW;if(OE!==undefined)w.EventSource=OE;for(var i=0;i<restores.length;i++)restores[i]();try{delete w[" + seat + "]}catch(e){w[" + seat + "]=undefined}};w[" + seat + "]=seat" + buildBootWatchdogScript() + "}catch(e){}})();";
3335
+ }
3336
+ /** Boot-watchdog latch: one self-reload per session while the boot is broken. */
3337
+ const BOOT_WATCHDOG_KEY = "dsh-remote-boot-reload";
3338
+ /** Total wait for the app's first conversation surface before the reload. */
3339
+ const BOOT_WATCHDOG_WAIT_MS = 15e3;
3340
+ /** Watchdog poll cadence. */
3341
+ const BOOT_WATCHDOG_POLL_MS = 1e3;
3342
+ /**
3343
+ * Build the boot-watchdog script fragment. A remote boot has no recovery
3344
+ * path of its own: the SPA mounts nothing when a boot-critical request dies
3345
+ * (a tunnel-edge 429 under burst, a dropped stream, a boot-order race), and
3346
+ * the phone then shows a permanently blank shell. The watchdog polls for the
3347
+ * app's conversation surface and reloads once when it never appears; the
3348
+ * sessionStorage latch keeps a genuinely broken deployment from looping,
3349
+ * and a successful boot clears the latch so a later failure can recover.
3350
+ */
3351
+ function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs = BOOT_WATCHDOG_POLL_MS) {
3352
+ const key = JSON.stringify(BOOT_WATCHDOG_KEY);
3353
+ return ";function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector(\"[data-conversation-scroll]\")||w.document.querySelector(\"[data-slot=\\\"conversation\\\"]\")))}catch(e){return false}}function wTick(n){try{if(wBoot()){try{w.sessionStorage.removeItem(" + key + ")}catch(e){}return}if(n<" + waitMs + "){w.setTimeout(function(){wTick(n+" + pollMs + ")}," + pollMs + ");return}var done=false;try{done=w.sessionStorage.getItem(" + key + ")===\"1\"}catch(e){}if(done)return;try{w.sessionStorage.setItem(" + key + ",\"1\")}catch(e){}w.location.reload()}catch(e){}}if(typeof w.setTimeout===\"function\")w.setTimeout(function(){wTick(0)}," + pollMs + ");";
3764
3354
  }
3765
3355
  /** The script this plugin contributes; built once from the live rules. */
3766
3356
  const REMOTE_CHANNEL_BOOT_SCRIPT = buildRemoteChannelBootScript();
@@ -3809,16 +3399,15 @@ const name = "remote-web-ui";
3809
3399
  /** Services required before the pairing surfaces can mount. */
3810
3400
  const inject = [
3811
3401
  "webServer",
3812
- "apiProxy",
3813
- "commands",
3814
- "agents"
3402
+ "typertGateway",
3403
+ "connection"
3815
3404
  ];
3816
3405
  /**
3817
3406
  * Settings namespace of the remote-control capability — the section the web
3818
3407
  * settings surface edits. Spelled here rather than imported: the browser
3819
3408
  * half spells the same value and must not depend on a Host package.
3820
3409
  */
3821
- const REMOTE_WEB_UI_SETTINGS_NAMESPACE = settingsNamespace("remote-web-ui");
3410
+ const REMOTE_WEB_UI_SETTINGS_NAMESPACE = "remote-web-ui";
3822
3411
  const Config = z.object({
3823
3412
  tokenTtlMs: z.number().step(1).min(6e4).default(10 * 6e4),
3824
3413
  offlineAfterMs: z.number().step(1).min(5e3).default(25e3),
@@ -3829,7 +3418,8 @@ const Config = z.object({
3829
3418
  publicBaseUrl: z.string(),
3830
3419
  devicesFile: z.string(),
3831
3420
  autoTunnel: z.boolean().default(false),
3832
- mobileEnterToSend: z.boolean().default(true),
3421
+ lanBind: z.boolean(),
3422
+ profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
3833
3423
  enabled: z.boolean().default(true)
3834
3424
  });
3835
3425
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
@@ -3865,7 +3455,8 @@ const DEFAULTS = {
3865
3455
  publicBaseUrl: void 0,
3866
3456
  devicesFile: defaultDevicesFile(),
3867
3457
  autoTunnel: false,
3868
- mobileEnterToSend: true,
3458
+ lanBind: void 0,
3459
+ profile: process.env.DSH_PROFILE ?? "web",
3869
3460
  enabled: true
3870
3461
  };
3871
3462
  /**
@@ -3885,7 +3476,8 @@ function applyImpl(ctx, config) {
3885
3476
  publicBaseUrl: config?.publicBaseUrl,
3886
3477
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
3887
3478
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
3888
- mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
3479
+ lanBind: config?.lanBind,
3480
+ profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3889
3481
  enabled: config?.enabled ?? DEFAULTS.enabled
3890
3482
  };
3891
3483
  let current = () => config ?? {};
@@ -3901,7 +3493,8 @@ function applyImpl(ctx, config) {
3901
3493
  publicBaseUrl: value.publicBaseUrl,
3902
3494
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
3903
3495
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
3904
- mobileEnterToSend: value.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
3496
+ lanBind: value.lanBind,
3497
+ profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
3905
3498
  enabled: value.enabled ?? DEFAULTS.enabled
3906
3499
  };
3907
3500
  };
@@ -3931,16 +3524,15 @@ function applyImpl(ctx, config) {
3931
3524
  ctx.effect(() => () => {
3932
3525
  tunnel.dispose();
3933
3526
  }, "remote-web-ui: auto tunnel");
3934
- const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
3935
- address,
3936
- base: `http://${address}:${String(ctx.webServer.port)}`
3937
- })) : [];
3938
- service.setLanBases(lanBases);
3939
- const lanAddresses = lanBases.map((entry) => entry.address);
3527
+ if ((ctx.webServer.host === "0.0.0.0" || ctx.webServer.host === "127.0.0.1") && Number.isFinite(ctx.webServer.port)) {
3528
+ const lanBases = ctx.webServer.host === "0.0.0.0" ? lanIPv4Addresses().map((address) => ({
3529
+ address,
3530
+ base: `http://${address}:${String(ctx.webServer.port)}`
3531
+ })) : [];
3532
+ service.setLanBases(lanBases);
3533
+ }
3940
3534
  let disposeRoutes;
3941
3535
  let disposeSweep;
3942
- const apiProxy = ctx.get("apiProxy");
3943
- if (apiProxy === void 0) console.warn("remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled");
3944
3536
  const requireFromHost = createRequire(import.meta.url);
3945
3537
  const resolveAnchorPath = () => resolveAnchorManifest((specifier) => {
3946
3538
  try {
@@ -4006,45 +3598,84 @@ function applyImpl(ctx, config) {
4006
3598
  });
4007
3599
  }
4008
3600
  });
3601
+ let lastKnownPort;
3602
+ let lastFirewallApplied;
3603
+ const lanBindStatus = () => {
3604
+ const resolvedNow = resolve();
3605
+ let state;
3606
+ try {
3607
+ state = lanBindState(resolvedNow.profile);
3608
+ } catch {
3609
+ state = { blockPresent: false };
3610
+ }
3611
+ const lanOn = state.host === "0.0.0.0";
3612
+ const port = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : lastKnownPort;
3613
+ if (Number.isFinite(ctx.webServer.port)) lastKnownPort = ctx.webServer.port;
3614
+ const startup = ctx.get("webStartup");
3615
+ const desiredHost = resolvedNow.lanBind === void 0 ? void 0 : desiredBindHost(resolvedNow.lanBind === true, startup?.host);
3616
+ return {
3617
+ profile: resolvedNow.profile,
3618
+ setting: resolvedNow.lanBind ?? null,
3619
+ blockHost: state.host ?? null,
3620
+ bindHost: ctx.webServer.host,
3621
+ port,
3622
+ lanUrls: ctx.webServer.host === "0.0.0.0" && port !== void 0 ? lanIPv4Addresses().map((address) => `http://${address}:${String(port)}`) : [],
3623
+ firewall: port !== void 0 ? firewallSummary(port, lanOn) : {
3624
+ ok: true,
3625
+ managed: false
3626
+ },
3627
+ platform: process.platform,
3628
+ pendingRestart: pendingRestartOf(resolvedNow.lanBind, desiredHost, ctx.webServer.host)
3629
+ };
3630
+ };
3631
+ const innerAuth = createInnerAuth(() => {
3632
+ if (!Number.isFinite(ctx.webServer.port)) return void 0;
3633
+ try {
3634
+ return ctx.connection.authenticatedUrl?.(`http://127.0.0.1:${String(ctx.webServer.port)}/`);
3635
+ } catch {
3636
+ return;
3637
+ }
3638
+ });
3639
+ const APP_SHELL_TTL_MS = 3e4;
3640
+ let appShellCache;
3641
+ const fetchAppShell = async () => {
3642
+ if (!Number.isFinite(ctx.webServer.port)) return void 0;
3643
+ if (appShellCache !== void 0 && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html;
3644
+ const cookie = await innerAuth.ready();
3645
+ try {
3646
+ const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, { headers: cookie !== void 0 ? { cookie } : void 0 });
3647
+ if (!response.ok) return void 0;
3648
+ const html = await response.text();
3649
+ appShellCache = {
3650
+ at: Date.now(),
3651
+ html
3652
+ };
3653
+ return html;
3654
+ } catch {
3655
+ return;
3656
+ }
3657
+ };
4009
3658
  const routes = [
4010
3659
  ...makeRoutes({
4011
3660
  service,
4012
- lanAddresses,
4013
- requirePairingForLan: () => resolve().requirePairingForLan
3661
+ lanAddresses: service.lanAddresses,
3662
+ requirePairingForLan: () => resolve().requirePairingForLan,
3663
+ lanBindStatus,
3664
+ indexDocument: fetchAppShell
4014
3665
  }),
4015
- ...makeMobileRoutes(),
4016
- ...apiProxy !== void 0 ? makeMobileApiRoutes({
4017
- service,
4018
- apiProxy,
4019
- pendingTracker: new PendingTracker(),
4020
- mobileEnterToSend: () => resolve().mobileEnterToSend,
4021
- commandDispatcher: ctx.commands !== void 0 && ctx.agents !== void 0 ? { async execute(sessionId, line, signal) {
4022
- const agent = ctx.agents.get(sessionId);
4023
- if (agent === void 0) return { error: "session-not-found" };
4024
- const execution = await ctx.commands.execute(agent, line, [], signal);
4025
- if (execution === void 0) return { error: "unknown-command" };
4026
- return {
4027
- ok: true,
4028
- result: execution.result
4029
- };
4030
- } } : void 0
4031
- }) : [],
4032
- ...apiProxy !== void 0 ? makePairedModelCatalogRoutes({
4033
- service,
4034
- apiProxy,
4035
- lanAddresses
4036
- }) : [],
4037
3666
  ...makeRemoteApiRoutes({
4038
3667
  service,
4039
3668
  port: ctx.webServer.port,
4040
- requirePairingForLan: () => resolve().requirePairingForLan
3669
+ requirePairingForLan: () => resolve().requirePairingForLan,
3670
+ auth: innerAuth
4041
3671
  }),
4042
3672
  ...updateRoutes
4043
3673
  ];
4044
3674
  const upgrades = makeRemoteApiUpgradeRoutes({
4045
3675
  service,
4046
3676
  port: ctx.webServer.port,
4047
- requirePairingForLan: () => resolve().requirePairingForLan
3677
+ requirePairingForLan: () => resolve().requirePairingForLan,
3678
+ auth: innerAuth
4048
3679
  });
4049
3680
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
4050
3681
  ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
@@ -4087,13 +3718,50 @@ function applyImpl(ctx, config) {
4087
3718
  if (!resolve().enabled) return false;
4088
3719
  return isPairedDeviceRequest(service, request);
4089
3720
  });
4090
- if (lanAddresses.length > 0) {
4091
- const urls = lanAddresses.map((ip) => `http://${ip}:${String(ctx.webServer.port)}/m/`).join(" , ");
4092
- console.log(`remote-web-ui: mobile UI reachable on LAN at ${urls}`);
3721
+ const presencePet = startRemotePresencePet({
3722
+ onState: (listener) => service.onState(listener),
3723
+ pet: () => {
3724
+ try {
3725
+ return ctx.get("pet");
3726
+ } catch {
3727
+ return;
3728
+ }
3729
+ }
3730
+ });
3731
+ ctx.effect(() => presencePet, "remote-web-ui: remote-presence pet visibility");
3732
+ if (service.lanAddresses.length > 0) {
3733
+ const urls = service.lanAddresses.map((ip) => `http://${ip}:${String(ctx.webServer.port)}`).join(" , ");
3734
+ console.log(`remote-web-ui: the paired Web GUI is reachable on LAN at ${urls}`);
4093
3735
  }
3736
+ if (ctx.webServer.host === "0.0.0.0") 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)");
4094
3737
  const sync = () => {
4095
3738
  const value = resolve();
4096
3739
  service.config = pairingConfigOf(value);
3740
+ if (value.lanBind !== void 0) {
3741
+ const startup = ctx.get("webStartup");
3742
+ const desiredHost = desiredBindHost(value.lanBind === true, startup?.host);
3743
+ const desiredPort = desiredBindPort(startup?.port, Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : void 0);
3744
+ if (desiredPort === void 0) console.error("remote-web-ui: cannot assert the lan-bind block — the web server port is not known yet");
3745
+ else try {
3746
+ const current = lanBindState(value.profile);
3747
+ if (current.host !== desiredHost || current.port !== desiredPort) {
3748
+ writeLanBind(desiredHost, desiredPort, value.profile);
3749
+ 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)`);
3750
+ }
3751
+ } catch (error) {
3752
+ console.error(`remote-web-ui: failed to write the lan-bind block: ${error instanceof Error ? error.message : String(error)}`);
3753
+ }
3754
+ const livePort = Number.isFinite(ctx.webServer.port) ? ctx.webServer.port : void 0;
3755
+ if (livePort === void 0) console.error("remote-web-ui: cannot align the host firewall rule — the web server port is not known yet");
3756
+ else {
3757
+ const nextFirewall = {
3758
+ enabled: value.lanBind === true,
3759
+ port: livePort
3760
+ };
3761
+ if (firewallActionNeeded(lastFirewallApplied, nextFirewall)) if (nextFirewall.enabled ? ensureFirewallRule(nextFirewall.port) : removeFirewallRule(nextFirewall.port)) lastFirewallApplied = nextFirewall;
3762
+ else console.error("remote-web-ui: the host firewall rule could not be updated (admin rights required on managed platforms)");
3763
+ }
3764
+ }
4097
3765
  autoTunnel = value.autoTunnel === true;
4098
3766
  if (autoTunnel) {
4099
3767
  if (value.publicBaseUrl !== void 0) console.warn("remote-web-ui: autoTunnel is on — ignoring the manually configured publicBaseUrl");
@@ -4118,7 +3786,7 @@ function applyImpl(ctx, config) {
4118
3786
  disposeRoutes = void 0;
4119
3787
  }
4120
3788
  if (disposeSweep === void 0 && enabled) disposeSweep = ctx.effect(() => {
4121
- const timer = setInterval$1(() => {
3789
+ const timer = setInterval(() => {
4122
3790
  service.sweep();
4123
3791
  }, SWEEP_INTERVAL_MS);
4124
3792
  timer.unref();
@@ -4148,12 +3816,14 @@ function applyImpl(ctx, config) {
4148
3816
  text: REMOTE_CHANNEL_BOOT_SCRIPT
4149
3817
  });
4150
3818
  }), "remote-web-ui: remote channel boot patch");
4151
- installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
4152
- setSource: (source) => {
4153
- current = source;
4154
- sync();
4155
- },
4156
- onChange: sync
3819
+ ctx.inject(["settings"], (settingsCtx) => {
3820
+ settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
3821
+ setSource: (source) => {
3822
+ current = source;
3823
+ sync();
3824
+ },
3825
+ onChange: sync
3826
+ });
4157
3827
  });
4158
3828
  sync();
4159
3829
  }