dsh-mobile-plugin 0.0.0-stage → 0.1.57

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 (87) hide show
  1. package/README.md +128 -2
  2. package/config/agent-presets/standard/agent.cordis.yml +170 -0
  3. package/config/agent-presets/standard/preset.yml +3 -0
  4. package/cordis.mobile-host.yml +48 -0
  5. package/cordis.patch.yml +21 -0
  6. package/dsh-mobile-credentials/lib/credentials.js +135 -0
  7. package/dsh-mobile-credentials/package.json +13 -0
  8. package/host.js +128 -0
  9. package/lib/auto-archive.cjs +98 -0
  10. package/lib/client.js +690 -0
  11. package/lib/desktop-half-stats.cjs +101 -0
  12. package/lib/desktop-view.cjs +109 -0
  13. package/lib/host-guard.cjs +230 -0
  14. package/lib/host-patches.cjs +46 -0
  15. package/lib/http/package.json +3 -0
  16. package/lib/http/routing.js +84 -0
  17. package/lib/impl.js +406 -0
  18. package/lib/mirror/pusher.cjs +706 -0
  19. package/lib/mirror-snapshot.cjs +39 -0
  20. package/lib/mobile/agent-presets.js +182 -0
  21. package/lib/mobile/attachments.js +116 -0
  22. package/lib/mobile/auth.js +78 -0
  23. package/lib/mobile/completion.js +114 -0
  24. package/lib/mobile/credentials.js +150 -0
  25. package/lib/mobile/directory-browse.js +295 -0
  26. package/lib/mobile/envelope.js +51 -0
  27. package/lib/mobile/events.js +293 -0
  28. package/lib/mobile/history-derive.js +608 -0
  29. package/lib/mobile/host-bridge/endpoints.js +189 -0
  30. package/lib/mobile/host-bridge/event-synthesis.js +232 -0
  31. package/lib/mobile/host-bridge/shims.js +218 -0
  32. package/lib/mobile/host-bridge/transport.js +168 -0
  33. package/lib/mobile/host-bridge.js +71 -0
  34. package/lib/mobile/host-settings.js +282 -0
  35. package/lib/mobile/idempotency.js +78 -0
  36. package/lib/mobile/jobs.js +97 -0
  37. package/lib/mobile/local-images.js +153 -0
  38. package/lib/mobile/package.json +3 -0
  39. package/lib/mobile/plugins.js +149 -0
  40. package/lib/mobile/questions.js +71 -0
  41. package/lib/mobile/references.js +209 -0
  42. package/lib/mobile/rpc.js +76 -0
  43. package/lib/mobile/session-cache.js +263 -0
  44. package/lib/mobile/session-goal.js +44 -0
  45. package/lib/mobile/session-log.js +94 -0
  46. package/lib/mobile/session-mirror.js +177 -0
  47. package/lib/mobile/session-models.js +153 -0
  48. package/lib/mobile/session-presets.js +52 -0
  49. package/lib/mobile/session-search.js +65 -0
  50. package/lib/mobile/session-send.js +278 -0
  51. package/lib/mobile/session-snapshot.js +142 -0
  52. package/lib/mobile/sessions.js +462 -0
  53. package/lib/mobile/skills.js +80 -0
  54. package/lib/mobile/stream.js +268 -0
  55. package/lib/mobile/subagents.js +265 -0
  56. package/lib/mobile/workspaces.js +214 -0
  57. package/lib/panel/index.html +33 -0
  58. package/lib/read-receipt.cjs +124 -0
  59. package/lib/reconnect-scheduler.cjs +37 -0
  60. package/lib/relay/core.js +77 -0
  61. package/lib/relay/lan-network.cjs +85 -0
  62. package/lib/relay/lan-proxy.js +593 -0
  63. package/lib/relay/package.json +3 -0
  64. package/lib/relay/pairing-policy.js +45 -0
  65. package/lib/relay/port-allocator.cjs +60 -0
  66. package/lib/relay/preview.js +374 -0
  67. package/lib/relay/protocol.js +177 -0
  68. package/lib/relay/status.js +145 -0
  69. package/lib/relay/tunnel.js +721 -0
  70. package/lib/routes/control-plane.js +311 -0
  71. package/lib/routes/mobile-api.js +208 -0
  72. package/lib/routes/package.json +3 -0
  73. package/lib/session-cleanup.cjs +153 -0
  74. package/lib/shared/credentials-entry.js +48 -0
  75. package/lib/shared/dshtoken.js +62 -0
  76. package/lib/shared/fresh-request.js +35 -0
  77. package/lib/shared/http-util.js +71 -0
  78. package/lib/shared/list-revision.js +51 -0
  79. package/lib/shared/list-signal.js +87 -0
  80. package/lib/shared/package.json +3 -0
  81. package/lib/shared/paths.js +27 -0
  82. package/lib/shared/session-signal.js +110 -0
  83. package/lib/shared/workspace-guard.js +127 -0
  84. package/lib/sidecar-facade-worker.cjs +353 -0
  85. package/lib/sidecar-facade.cjs +141 -0
  86. package/lib/sidecar-lifecycle.cjs +246 -0
  87. package/package.json +74 -4
package/lib/impl.js ADDED
@@ -0,0 +1,406 @@
1
+ /**
2
+ * 插件实现(随包发布的基础代):装配路由、移动端 API 网关与 sidecar 生命周期。
3
+ * 只做装配——镜像推送在 `lib/mirror/pusher.cjs`、sidecar 生命周期在
4
+ * `lib/sidecar-lifecycle.cjs`、路由表在 `lib/routes/*`,这些实现不许搬回来
5
+ * (`test/assembly-layer.test.cjs` 钉着)。
6
+ */
7
+ import { createRequire } from 'node:module'
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import z from '@deepseek-ai/schemastery'
12
+
13
+ const require = createRequire(import.meta.url)
14
+ const core = require('./relay/core.js')
15
+ const pairingApproval = require('./relay/pairing-policy.js')
16
+ const { sendJson, sendHtml } = require('./shared/http-util.js')
17
+ // 控制面守卫(localOnly)与移动端守卫(guarded)都住在这里;两套守卫并排放,
18
+ // 「控制面多挡跨站与 method、移动端只挡 token」这个不对称一眼可见。
19
+ const { localOnly, readJson, createGuarded } = require('./http/routing.js')
20
+ const { registerMobileRoutes } = require('./routes/mobile-api.js')
21
+ const { registerControlPlaneRoutes } = require('./routes/control-plane.js')
22
+ const { hostMetadata, createStatusView } = require('./relay/status.js')
23
+ const { authorized } = require('./mobile/auth.js')
24
+ const { createSidecarLifecycle } = require('./sidecar-lifecycle.cjs')
25
+ const { createMirrorPusher } = require('./mirror/pusher.cjs')
26
+ const { randomBytes } = require('node:crypto')
27
+ const { lanUrls, lanCandidates } = require('./relay/lan-network.cjs')
28
+ const { resolvePort } = require('./relay/port-allocator.cjs')
29
+ const { createPreviewService } = require('./relay/preview.js')
30
+ // 本机设备凭据库:/api/relay/revoke-all 必须同时吊销局域网直连的 dshc_ 凭据,
31
+ // 否则「撤销全部授权」只清了中转侧,同网设备仍能用旧凭据直连宿主。
32
+ // 入口按两条盘上布局分别找(嵌在插件包内 / 与插件包同级),见 shared/credentials-entry.js。
33
+ const credentials = require('./shared/credentials-entry.js').credentialsEntry()
34
+ const { createApiProxy } = require('./mobile/host-bridge.js')
35
+ const { installAutoArchive } = require('./auto-archive.cjs')
36
+ const { registerSessionCleanupTool } = require('./session-cleanup.cjs')
37
+ const { activeGuard } = require('./host-guard.cjs')
38
+ // 桌面端侧栏展开态的快照存储。桌面把这份状态只记在浏览器 localStorage 里,宿主必须
39
+ // 有一个落盘处:客户端半边(lib/client.js)上报 → 这里 → 移动端读取。
40
+
41
+ export const name = 'dsh-mobile-plugin'
42
+ // The mobile API runs headless inside the Cordis Host. `webServer` and
43
+ // `sessions` are the services it needs to serve /api/mobile/v1.
44
+ //
45
+ // 0.2.0 移除了 `settings.register(namespace, schema, { base, applies })` 这套 API
46
+ // (`@deepseek-ai/dsh-settings` 现在只导出 SettingsForms / redactSecrets /
47
+ // SettingsConflictError),所以这里不再注入 `settings`,配置改由插件自己的
48
+ // relay.json 存储负责 —— 见下面 createConfigStore。
49
+ //
50
+ // 0.2.0 里也没有 `apiProxy` 这个服务(整个 asar 零命中):宿主 RPC 全部走
51
+ // `ctx.get('typertGateway')` 的 `invoke({ namespace, method, args })`,见
52
+ // mobile/plugins.js、mobile/references.js 里同款封装。留着 apiProxy 只会让插件永远
53
+ // pending —— 服务不存在时 loader 不会激活这一行,于是路由、sidecar、中转全都不出现,
54
+ // 而日志只有一行 "pending (waiting for service: apiProxy)"。
55
+ //
56
+ // 所以 `apiProxy` 是**按运行时二选一**的:
57
+ // 旧运行时(自带 @deepseek-ai/dsh-host-apiproxy)→ 注入它,读宿主那份真服务;
58
+ // 0.2.0 → 不注入,改由 apply 在本 fiber 上
59
+ // provide 兼容桥(fiber.store 里
60
+ // 有,get trap 第一站就命中)。
61
+ // 漏了这两个分支里的任何一个,读 `ctx.apiProxy` 都会抛
62
+ // `cannot get property "apiProxy" without inject`:服务器宿主上 mirror 推送曾整条挂掉。
63
+ export const inject = runtimeProvidesApiProxy()
64
+ ? ['webServer', 'apiProxy', 'sessions']
65
+ : ['webServer', 'sessions']
66
+
67
+ export const REMOTE_CONTROL_SETTINGS_NAMESPACE = 'mobile-remote-control'
68
+ export const RemoteControlSettingsSchema = z.object({
69
+ enabled: z.boolean().default(false),
70
+ relayUrl: z.string().default(''),
71
+ lanEnabled: z.boolean().default(true),
72
+ lanPort: z.number().step(1).min(1024).max(65535).default(18787),
73
+ name: z.string().default(''),
74
+ // Pairing approval policy; see lib/relay/pairing-policy.js for the two modes and
75
+ // why 'code' is not the old popup. Kept in the settings schema so the host UI and
76
+ // /api/relay/config can change it, and so an unknown value is rejected on write.
77
+ pairingPolicy: z.union(pairingApproval.POLICIES.map(value => z.const(value))).default(pairingApproval.DEFAULT_POLICY),
78
+ // 中转注册密钥(对偶于中转的 TUNNEL_REGISTER_SECRET)。桌面版拿不到 shell
79
+ // 环境变量,所以它和其他配置一样存在 relay.json(0600),由 /api/relay/config
80
+ // 在回环上写入;status / pairing / 二维码都不回显它。
81
+ registerSecret: z.string().max(512).default(''),
82
+ })
83
+
84
+
85
+ function configOf(state) {
86
+ return {
87
+ enabled: Boolean(state.enabled),
88
+ relayUrl: typeof state.relayUrl === 'string' ? state.relayUrl : '',
89
+ lanEnabled: Boolean(state.lanEnabled),
90
+ lanPort: Number.isInteger(state.lanPort) ? state.lanPort : 18787,
91
+ name: typeof state.name === 'string' ? state.name : '',
92
+ // The schema rejects anything outside the enum, so in practice this only
93
+ // ever normalises a value written before pairingPolicy existed.
94
+ pairingPolicy: pairingApproval.normalize(state.pairingPolicy),
95
+ // 密钥只往 sidecar 和状态文件走:status() / pairingPayload() 都单独取字段,
96
+ // 不会把它回显给手机、二维码或日志。
97
+ registerSecret: typeof state.registerSecret === 'string' ? state.registerSecret : '',
98
+ }
99
+ }
100
+
101
+ // 0.2.0 之前这份配置注册在宿主 settings 的 `mobile-remote-control` 命名空间里
102
+ // (`ctx.settings.register(ns, schema, { base: configOf(state), applies: 'live' })`),
103
+ // 写入路径只有 `settings.update()` / `settings.watch()`。0.2.0 里那套 API 已经不存在,
104
+ // 于是这里用插件自己的状态文件实现等价的三件事:
105
+ //
106
+ // get() —— 读当前值(就是 relay.json 里那几项)
107
+ // update() —— 按 schema 校验后写回 relay.json(和旧 settings.update 一样拒绝非法值)
108
+ // watch() —— 进程内变更通知(旧的 settings.watch 用来触发 applyServices)
109
+ //
110
+ // 代价:桌面「设置」页里不再出现「远程控制」这一项,宿主侧的设置 UI 也不能改它;
111
+ // 控制面(/api/relay/config、/api/relay/toggle)和手机端读到的值完全不受影响,
112
+ // 因为它们本来就都走这个 store。另一个差别是这里只监听进程内写入,直接在进程外编辑
113
+ // relay.json 需要重启宿主才会生效(旧实现同样不会热读文件)。
114
+ function createConfigStore(state) {
115
+ const listeners = new Set()
116
+ const notify = () => {
117
+ const next = configOf(state)
118
+ for (const listener of [...listeners]) {
119
+ try { listener(next) } catch { /* 单个订阅者出错不该中断其余订阅者 */ }
120
+ }
121
+ }
122
+ return {
123
+ get: () => configOf(state),
124
+ async update(patch) {
125
+ const next = configOf(state)
126
+ for (const [key, value] of Object.entries(patch || {})) {
127
+ if (value === undefined || !(key in next)) continue
128
+ next[key] = value
129
+ }
130
+ let parsed
131
+ try {
132
+ parsed = RemoteControlSettingsSchema(next)
133
+ } catch (error) {
134
+ throw new Error(`invalid remote-control setting: ${error && error.message ? error.message : String(error)}`)
135
+ }
136
+ Object.assign(state, parsed)
137
+ core.saveState(state)
138
+ notify()
139
+ return configOf(state)
140
+ },
141
+ watch(listener) {
142
+ listeners.add(listener)
143
+ return () => { listeners.delete(listener) }
144
+ },
145
+ }
146
+ }
147
+
148
+ function pairingPayload(state, lanPort) {
149
+ const port = Number.isInteger(lanPort) ? lanPort : state.lanPort
150
+ const candidates = state.lanEnabled ? safeLanCandidates(port) : []
151
+ const urls = candidates.map(candidate => candidate.url).filter(Boolean)
152
+ const relayUrl = state.relayUrl || ''
153
+ const payload = {
154
+ type: 'dsh-pair',
155
+ version: 1,
156
+ name: state.name || os.hostname(),
157
+ instanceId: state.instanceId,
158
+ code: state.pairingCode,
159
+ relayUrl,
160
+ lanUrls: urls,
161
+ // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
162
+ // 依次重试而不用先回到公网中转去问。
163
+ lanCandidates: candidates.map(candidate => candidate.url).filter(Boolean),
164
+ }
165
+ return {
166
+ payload,
167
+ text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
168
+ }
169
+ }
170
+
171
+ // 上报给 App 的局域网候选地址。除了 `lanUrls` 里的首个地址,App 还会记住整份
172
+ // 候选列表,换网卡/切网络后依次重试,所以这里尽力给全(`lanInterfaces` 已按
173
+ // 物理网卡优先级排序并剔除 docker/veth/vmnet 这类虚拟接口)。
174
+ function safeLanCandidates(port) {
175
+ try {
176
+ const list = lanCandidates(port)
177
+ return Array.isArray(list) ? list : []
178
+ } catch {
179
+ return []
180
+ }
181
+ }
182
+
183
+ /**
184
+ * 运行时是否自带 `@deepseek-ai/dsh-host-apiproxy`(即 `api-gateway` 那个入口)。
185
+ *
186
+ * 从本模块所在位置解析,而不是读 DSH 版本号:桌面端插件装在
187
+ * `~/.dsh/profiles/<profile>/node_modules/dsh-mobile-plugin` 里,那里只有 `ws`,
188
+ * 一路往上也碰不到 DSH 的依赖树(它在 app.asar 里)→ 解析不到 → 用兼容桥;
189
+ * 无头宿主直接跑在插件包里,包内 `node_modules` 有整套 `@deepseek-ai/*` → 解析得到
190
+ * → 用宿主自己那份。解析失败只是「没有」,不该把插件带崩。
191
+ *
192
+ * 这个函数在模块顶层就被 `export const inject` 用了一次,所以它必须是函数声明
193
+ * (提升),不能写成 `const` 箭头函数 —— 那会撞上 TDZ。
194
+ */
195
+ function runtimeProvidesApiProxy() {
196
+ try {
197
+ require.resolve('@deepseek-ai/dsh-host-apiproxy/package.json')
198
+ return true
199
+ } catch {
200
+ return false
201
+ }
202
+ }
203
+
204
+ function apply(ctx) {
205
+ // 0.2.0 删掉了 `apiProxy` 服务(宿主 RPC 全走 ctx.get('typertGateway')),但插件里
206
+ // 17 个移动端模块历史上都是 `ctx.apiProxy.<ns>.<method>({rpcId,method,payload}, signal)`
207
+ // + `{result:{ok,value}|{ok:false,error}}` 这一套。这里在自己的 fiber 上把同形状的
208
+ // 兼容服务 provide 出去(cordis 的 Context get trap 会先查 fiber.store,所以**在 0.2.0 上**
209
+ // 不用把它加进 inject —— 加进去反而会因为「服务不存在」让这一行永远 pending),
210
+ // 内部由 mobile/host-bridge.js 翻译成 gateway 调用。
211
+ //
212
+ // 不用 `class X extends Service`:cordis 的 loader 只对「模块本身就是类」的插件
213
+ // 走 new(api-gateway 就是这么挂 typertGateway 的),本模块有 apply,走不到那条路。
214
+ //
215
+ // 但**旧运行时(≤ 0.1.x)自带 `apiProxy`**(`@deepseek-ai/dsh-host-apiproxy` 的
216
+ // ApiProxyService,由 dsh-web-app 那一层供给),这时再 provide 一次会让整棵树加载
217
+ // 失败:`service "apiProxy" has been registered at <dsh-mobile-plugin>`,报错挂在
218
+ // 后到的 api-gateway 入口上,服务器宿主曾因此完全起不来。而且旧运行时的桥也翻译不动
219
+ // —— host-bridge.js 是翻译到 0.2.0 的 `ctx.get('typertGateway')` 的。所以按运行时
220
+ // 有没有这个包二选一:有就用宿主那份真 apiProxy,没有才装兼容服务。
221
+ // (不能靠 `ctx.get('apiProxy')` 判断:宿主是后于本插件装载的,那时它还不在。)
222
+ if (!runtimeProvidesApiProxy()) ctx.reflect.provide('apiProxy', createApiProxy(ctx))
223
+
224
+ const state = core.loadState()
225
+ // 0.2.0 之前是 `ctx.settings.register(REMOTE_CONTROL_SETTINGS_NAMESPACE,
226
+ // RemoteControlSettingsSchema, { base: configOf(state), applies: 'live' })`,
227
+ // relay.json 是这个命名空间的种子。现在由 createConfigStore 直接以 relay.json
228
+ // 为准(见那里的说明与代价)。
229
+ const settings = createConfigStore(state)
230
+ Object.assign(state, settings.get())
231
+
232
+ // 局域网直连端口自适应。
233
+ //
234
+ // 18787 这个默认端口在这台机器上已经被 `billion-context` 占着,于是 LAN 代理的
235
+ // listen 必然 EADDRINUSE,手机配对时能看到的局域网候选全是死地址。旧实现只有
236
+ // `lan.state='error'` 一句 truthfully 的报错,没有任何自愈。
237
+ //
238
+ // 这里在第一次同步 sidecar 之前探一次:首选端口空闲就用它(什么都不变),被占就
239
+ // 让内核分一个空闲端口,并把实际端口落到三处——relay.json(下次启动继续用它,
240
+ // 免得二维码/收藏的地址每次重启都变)、下发给 sidecar 的配置、以及状态接口和
241
+ // 配对二维码(否则二维码里带的仍是没在监听的死端口)。
242
+ let lanPortAdjusted = false
243
+ let effectiveLanPort = Number.isInteger(state.lanPort) ? state.lanPort : 18787
244
+ let lanPortProbe = null
245
+ const lanPortInUse = () => (state.lanEnabled && effectiveLanPort > 0 ? effectiveLanPort : state.lanPort)
246
+ const advertisedLanPort = () => (state.lanEnabled ? sidecarLifecycle.getAdvertisedLanPort() : state.lanPort) // 对外报地址用实绑端口,见 lifecycle
247
+ // 每次同步前都跑一次探测,而不是只在启动时探一次:`/api/relay/config` 改了
248
+ // lanPort 之后同样要走这条路(否则新端口被占时又会退回死地址)。并发调用复用
249
+ // 在飞的那次——探测要占一个监听句柄,重复触发没有意义。
250
+ const resolveLanPort = () => {
251
+ if (!state.lanEnabled) { lanPortProbe = null; return Promise.resolve(state.lanPort) }
252
+ if (lanPortProbe) return lanPortProbe
253
+ lanPortProbe = resolvePort(state.lanPort)
254
+ .then(resolved => {
255
+ lanPortAdjusted = Boolean(resolved.adjusted)
256
+ if (resolved.adjusted && resolved.port > 0) {
257
+ state.lanPort = resolved.port
258
+ try { core.saveState(state) } catch { /* 落盘失败只影响下次启动的端口选择 */ }
259
+ }
260
+ effectiveLanPort = Number.isInteger(resolved.port) ? resolved.port : state.lanPort
261
+ return effectiveLanPort
262
+ })
263
+ .catch(() => { effectiveLanPort = state.lanPort; return effectiveLanPort })
264
+ .finally(() => { lanPortProbe = null })
265
+ return lanPortProbe
266
+ }
267
+
268
+ // sidecar 子进程的全部生命周期:起停、配置下发、自重启、健康轮询。
269
+ const sidecarLifecycle = createSidecarLifecycle({
270
+ ctx,
271
+ lanPortInUse,
272
+ resolveLanPort,
273
+ // 本代实现所在的 lib/。默认值能推对,但代际目录里那份推出来的是它自己,
274
+ // 显式传入能让「re-fork 前清的是不是正在跑的那份缓存」不依赖巧合。
275
+ pluginLibDir: path.dirname(fileURLToPath(import.meta.url)),
276
+ // 下发给 worker 的配置:这里的字段集就是 sidecar 的启动契约,改名要同步 worker。
277
+ buildConfig: (lanPort) => ({ enabled: state.enabled, relayUrl: state.relayUrl, instanceId: state.instanceId, pairingCode: state.pairingCode, name: state.name, upstreamPort: ctx.webServer.port, lanPort, lanEnabled: state.lanEnabled, lanSecret: state.lanSecret, registerSecret: state.registerSecret }),
278
+ })
279
+ const { sidecarToken, syncSidecar, restartSidecar, tunnelConnected } = sidecarLifecycle
280
+
281
+ // 镜像推送调度器:会话事件立即推,后台循环每个 tick 补推一个冷会话。
282
+ const mirror = createMirrorPusher({ ctx, tunnelConnected, getSidecar: sidecarLifecycle.getSidecar })
283
+ // 隧道刚连上时补推一次全量:未连通期间每轮推送都被 worker 以 503 拒绝。
284
+ sidecarLifecycle.onTunnelUp(() => { void mirror.pushMirror(); })
285
+
286
+ ctx.effect(() => sidecarLifecycle.start(), `${name}: facade sidecar`)
287
+ ctx.effect(() => mirror.start(), `${name}: mobile mirror sync`)
288
+
289
+ // Keep stale project conversations out of the host's session browser.
290
+ // This is deliberately Host-side so it remains active even with no operator present.
291
+ ctx.effect(() => installAutoArchive(ctx), `${name}: automatic session archive`)
292
+ ctx.effect(() => {
293
+ // Profiles without the optional tools injection still support core remote control.
294
+ try { registerSessionCleanupTool(ctx) } catch (error) {
295
+ if (!String(error).includes('without inject')) throw error
296
+ }
297
+ return undefined
298
+ }, `${name}: session cleanup tool`)
299
+
300
+ const status = createStatusView({
301
+ state,
302
+ webServerPort: ctx.webServer.port,
303
+ getSidecarSnapshot: sidecarLifecycle.getSnapshot,
304
+ lanPortInUse: advertisedLanPort, // 手机该拨的端口:sidecar 实绑值
305
+ getLanPortAdjusted: () => lanPortAdjusted,
306
+ getSidecarState: sidecarLifecycle.getState,
307
+ getSidecarRestarts: sidecarLifecycle.getRestarts,
308
+ getSidecarRestartedAt: sidecarLifecycle.getRestartedAt,
309
+ getSidecarRestartError: sidecarLifecycle.getRestartError,
310
+ getMirrorPush: mirror.getMirrorPush,
311
+ })
312
+ function applyServices() {
313
+ void syncSidecar().catch(() => undefined)
314
+ }
315
+ function registerRoute(route) {
316
+ ctx.effect(() => ctx.webServer.register(route), `${name}: ${route.path}`)
317
+ }
318
+
319
+ registerControlPlaneRoutes({
320
+ registerRoute,
321
+ localOnly,
322
+ readJson,
323
+ sendJson,
324
+ // 面板页(lib/panel/index.html)整页吐给浏览器用;与控制面其它响应一样按注入传,不在这里 require,路由表才保持「只依赖注入项」。
325
+ sendHtml,
326
+ state,
327
+ sidecarToken,
328
+ status,
329
+ settings,
330
+ syncSidecar,
331
+ // ⚠️ 必须传取值器:sidecar 会被自重启整体替换,传实例就是一份永远指向旧 facade 的快照。
332
+ getSidecar: sidecarLifecycle.getSidecar,
333
+ credentials,
334
+ activeGuard,
335
+ resolveLanPort,
336
+ lanPortInUse: advertisedLanPort, // 二维码里的端口也必须是实绑值
337
+ getLanPortAdjusted: () => lanPortAdjusted,
338
+ pairingPayload,
339
+ restartSidecar,
340
+ // 桌面端「我读过了」那条路由(/api/relay/desktop-read)要清掉宿主侧的完成提醒,
341
+ // 需要插件 ctx 才能拿到按设备隔离的状态表。
342
+ ctx,
343
+ })
344
+
345
+
346
+ const guarded = createGuarded(ctx, authorized)
347
+
348
+ // 内置浏览器预览的宿主端:手机按端口在它自己的环回上监听,把内置浏览器写下的原始
349
+ // 字节通过 WebSocket 送到这里,这里与 `127.0.0.1:<port>` 对搬。
350
+ //
351
+ // `protectedPorts` 是取值器而不是快照:局域网端口会在运行中被自适应改掉
352
+ // (见上面的 resolveLanPort),快照会让这道边界跟着失效。DSH 自身的 web 端口
353
+ // (`ctx.webServer.port`,默认 3080)也从这里按身份排掉——预览这条管道刻意不做
354
+ // 「把桌面端 GUI 搬进手机」这件事,所以不允许它连回宿主自己。
355
+ const preview = createPreviewService({
356
+ authorized,
357
+ protectedPorts: () => {
358
+ const ports = new Set([ctx.webServer.port, lanPortInUse()])
359
+ ports.delete(0)
360
+ return ports
361
+ },
362
+ onLog: (message) => ctx.logger?.info?.(`${name}: %s`, message),
363
+ })
364
+ ctx.effect(() => () => preview.close(), `${name}: preview pipes`)
365
+
366
+ // 移动端路由表在 lib/routes/mobile-api.js:那里是一张可以对着 App 侧 apiPaths.ts
367
+ // 核对的「路径 → handler」表,不用在 1000 行装配代码里翻。
368
+ registerMobileRoutes({
369
+ registerRoute,
370
+ // 预览是 WebSocket 升级路由,走的是 webServer 的另一套名字空间;与 registerRoute
371
+ // 一样必须包在 ctx.effect 里,否则插件重载时重复注册会直接抛错。
372
+ registerUpgradeRoute: (route) => {
373
+ ctx.effect(() => ctx.webServer.registerUpgrade(route), `${name}: ${route.path}`)
374
+ },
375
+ preview,
376
+ guarded,
377
+ state,
378
+ hostMetadata,
379
+ // 取值器而非快照对象:snapshot 会被 applySidecarConfig 整体替换,闭包捕获对象会让
380
+ // 连接信息永远停在进程启动那一刻。
381
+ //
382
+ // 曾经这里写的是 `() => sidecarSnapshot` —— 那个标识符在本文件作用域里根本不存在
383
+ // (它只在 lib/sidecar-lifecycle.cjs 内部),于是 GET /api/mobile/v1/connection-info
384
+ // 一进来就 ReferenceError。同一个 apply() 里给 createStatusView 传的是
385
+ // `sidecarLifecycle.getSnapshot`,两处不一致才让这个笔误活了下来。
386
+ getSidecarSnapshot: sidecarLifecycle.getSnapshot,
387
+ safeLanCandidates,
388
+ lanPortInUse: advertisedLanPort, // LAN 候选用实绑端口
389
+ })
390
+ ctx.effect(() => {
391
+ const stopWatching = settings.watch(next => {
392
+ Object.assign(state, next)
393
+ core.saveState(state)
394
+ applyServices()
395
+ })
396
+ applyServices()
397
+ return () => {
398
+ stopWatching()
399
+ // 不在这里 `sidecar.stop()`:facade 的生命周期由上面 `${name}: facade sidecar`
400
+ // 那个 effect 独占。两个 effect 都停一次会让 worker 在 dispose 期间收到
401
+ // 两次 stop,第二次撞上已关闭的进程句柄并抛出未捕获异常。
402
+ }
403
+ }, `${name}: services`)
404
+ }
405
+
406
+ export { apply }