dsh-mobile-plugin 0.0.0-stage → 0.1.58

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 +159 -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 +377 -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/client.js ADDED
@@ -0,0 +1,690 @@
1
+ /**
2
+ * dsh-mobile-plugin 的**客户端半边**(在 DSH 桌面 Web 页面里运行)。
3
+ *
4
+ * 它做四件事:
5
+ *
6
+ * 1. **桌面端工作区展开态 → 宿主**。桌面侧边栏「哪些工作区分组是展开的」只记在
7
+ * **浏览器 localStorage**(`dsh.workspace.view.vN`,由 @deepseek-ai/dsh-client-ui-workspace
8
+ * 的 createWorkspaceViewStore 持久化到 dsh-client-store 的 localStorage),
9
+ * 宿主进程、中转和手机都读不到。移动端要与桌面端对齐展开态,就只能由页面里的
10
+ * 这一半把状态读出来交给宿主。
11
+ *
12
+ * 数据方向是单向的:桌面 → 宿主 → 手机。手机上手动折叠/展开只存在 App 本地
13
+ * (AsyncStorage),不会写回这份 localStorage。
14
+ *
15
+ * 2. **设置 →「移动端」**(设置菜单里的顶层一项,不再挂在内置插件页签下)。这一项现在
16
+ * 只有一个东西:一个通往**网页版配置页面**的入口。界面本体(状态、配对、中转配置、
17
+ * 维护、插件更新)全部在宿主吐出的那页 HTML 里(`GET /api/relay/panel`,源码在 React/)。
18
+ *
19
+ * 为什么连「插件更新」都撤掉了:这一半是渲染器直接从 `node_modules` 加载的文件,
20
+ * **不可热替换** —— 从前插件自己那套热更新只换宿主侧的实现代,换不掉它,于是出现过
21
+ * 「更新报成功、界面一动不动」。2026-10 起插件从 npm 分发、更新交给 DSH 的插件市场,
22
+ * 插件自己不再更新任何一个字节,这条缝隙也就没了。所以这里刻意保持「薄」:改版式只需
23
+ * 重装一次插件(市场里点一下),不用再怀疑哪一半是旧的。
24
+ *
25
+ * 3. **桌面端已读 → 宿主**(`startDesktopReadSync`)。桌面侧栏的「已完成待读」绿点
26
+ * 只活在渲染进程内存里(`UiSession.completionUnread`),宿主和手机都读不到。
27
+ * 这一半盯着 `uiSession.sessionStatus`,把「刚从未读集合掉出去」的会话报给
28
+ * `/api/relay/desktop-read`,宿主据此清掉手机端那份完成提醒。
29
+ *
30
+ * 4. **手机端已读 → 桌面端**(`startReadReceiptSync`)。反方向:宿主把手机端读过
31
+ * 哪些会话记成 `sessionId → readAt` 台账,这一半拉下来,对水位前进过的会话做一次
32
+ * 瞬时 `sessions.retain(id, { source: 'mainView' })` + `release()`,借桌面自己的
33
+ * `reconcileStatus()` 把绿点摘掉。见 lib/read-receipt.cjs 的文件头。
34
+ *
35
+ * 写成惰性工厂(`window.__ModuleLoader__.load`)是 DSH 客户端插件的约定:
36
+ * 运行 bundle 只注册工厂,副作用(这里的定时器、页签注册)在物化时才发生。
37
+ * 工厂收到的 `require` 是 DSH 的模块表 require,`react` 是平台 seed word。
38
+ *
39
+ * 本文件**不经任何构建**(package.json 的 files 直接收录 lib 下的 .js),所以不能
40
+ * 写 JSX:统一用 `React.createElement` 的别名 `h`。
41
+ */
42
+
43
+ window.__ModuleLoader__.load({
44
+ id: 'dsh-mobile-plugin',
45
+ factory(require) {
46
+ const {
47
+ createElement: h,
48
+ Fragment,
49
+ useCallback,
50
+ useEffect,
51
+ useRef,
52
+ useState,
53
+ } = require('react');
54
+
55
+ /* ── 样式 ──────────────────────────────────────────────────────────────
56
+ * 用 DSH 的设计 token,跟随明暗主题;前缀 `dshm_` 避免与别的插件撞名。
57
+ * 照抄 @deepseek-ai/dsh-client-ui-settings-plugins 的注入方式:同一
58
+ * data-plugin-css 只注入一次,bundle 被重复物化也不会堆出第二份 <style>。
59
+ *
60
+ * 这里只留这一项用得到的几条:界面本体已经搬去网页版(那边自带一套样式),
61
+ * 留着旧类名只会让人以为这里还画着那些东西。
62
+ */
63
+ const STYLE_ID = 'dsh-mobile-plugin/settings-tab.css';
64
+ const CSS = `
65
+ .dshm_root{display:flex;flex-direction:column;gap:14px;width:100%;max-width:760px;color:var(--dsw-alias-label-primary)}
66
+ .dshm_card{border:.5px solid var(--dsw-alias-settings-card-stroke);border-radius:var(--dsw-radius-xl);background:var(--dsw-alias-settings-card-fill);display:flex;flex-direction:column;gap:10px;padding:14px 16px}
67
+ .dshm_cardTitle{font-size:14px;font-weight:600;line-height:20px;margin:0;display:flex;align-items:center;gap:8px}
68
+ .dshm_heading{font-size:18px;font-weight:600;margin:0}
69
+ .dshm_actions{display:flex;flex-wrap:wrap;gap:8px}
70
+ .dshm_note{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary)}
71
+ .dshm_btn{border:.5px solid var(--dsw-alias-border-l3);border-radius:var(--dsw-radius-sm);background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:13px;line-height:18px;padding:5px 12px;display:inline-flex;align-items:center;gap:6px;text-decoration:none}
72
+ .dshm_btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}
73
+ .dshm_btn:disabled{cursor:default;opacity:.45}
74
+ .dshm_btnPrimary{background:var(--dsw-alias-state-business-primary);border-color:transparent;color:var(--dsw-alias-bg-layer-1)}
75
+ .dshm_error{margin:0;border-radius:var(--dsw-radius-md);background:color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent);color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px;padding:8px 10px}
76
+ `;
77
+ if (typeof document !== 'undefined' && document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) === null) {
78
+ const tag = document.createElement('style');
79
+ tag.dataset.pluginCss = STYLE_ID;
80
+ tag.textContent = CSS;
81
+ document.head.appendChild(tag);
82
+ }
83
+
84
+ /* ── 一:桌面工作区展开态同步(语义与原实现一字未改,只是搬进函数) ── */
85
+ /** 桌面端持久化键:`dsh.workspace.view.v5`(版本号可能上浮,取版本最大的那个)。 */
86
+ const STORAGE_KEY_RE = /^dsh\.workspace\.view\.v(\d+)$/;
87
+ /** 轮询间隔:展开/折叠是低频操作,2s 足够跟手又不费电。 */
88
+ const PUSH_INTERVAL_MS = 2000;
89
+ /** 与宿主 lib/desktop-view.cjs 的上限保持一致,避免无谓的超大请求。 */
90
+ const MAX_KEYS = 512;
91
+ const DESKTOP_VIEW_ENDPOINT = '/api/relay/desktop-view';
92
+
93
+ /** 读桌面端最新的视图状态;读不到返回 null(首次使用、隐私模式、键名变更等)。 */
94
+ function readDesktopView() {
95
+ let bestKey = null;
96
+ let bestVersion = -1;
97
+ for (let index = 0; index < localStorage.length; index += 1) {
98
+ const key = localStorage.key(index);
99
+ const match = key ? STORAGE_KEY_RE.exec(key) : null;
100
+ if (!match) continue;
101
+ const version = Number(match[1]);
102
+ if (version > bestVersion) {
103
+ bestVersion = version;
104
+ bestKey = key;
105
+ }
106
+ }
107
+ if (!bestKey) return null;
108
+ const parsed = JSON.parse(localStorage.getItem(bestKey) || 'null');
109
+ const state = parsed && typeof parsed === 'object' ? parsed : null;
110
+ // 持久化写的是整份 store 状态(groupExpansion 就在顶层);这里容忍将来可能
111
+ // 出现的 { state } / { data } 包装。
112
+ const holder = state && typeof state.groupExpansion === 'object' && state.groupExpansion !== null
113
+ ? state
114
+ : (state && state.state) || (state && state.data);
115
+ const source = holder && typeof holder.groupExpansion === 'object' && holder.groupExpansion !== null
116
+ ? holder.groupExpansion
117
+ : null;
118
+ if (!source) return null;
119
+ const groupExpansion = {};
120
+ let count = 0;
121
+ for (const key of Object.keys(source)) {
122
+ const value = source[key];
123
+ if (typeof value !== 'boolean') continue;
124
+ groupExpansion[key] = value;
125
+ count += 1;
126
+ if (count >= MAX_KEYS) break;
127
+ }
128
+ return { source: bestKey, groupExpansion };
129
+ }
130
+
131
+ function startDesktopViewSync(ctx) {
132
+ ctx.effect(() => {
133
+ let disposed = false;
134
+ let lastBody = '';
135
+ const push = async () => {
136
+ if (disposed) return;
137
+ let view = null;
138
+ try {
139
+ view = readDesktopView();
140
+ } catch {
141
+ view = null;
142
+ }
143
+ if (!view) return;
144
+ const body = JSON.stringify(view);
145
+ // 桌面没动过就不发:2s 轮询的成本压到最低,宿主也不用反复落盘。
146
+ if (body === lastBody) return;
147
+ try {
148
+ const response = await fetch(DESKTOP_VIEW_ENDPOINT, {
149
+ method: 'POST',
150
+ headers: { 'content-type': 'application/json' },
151
+ body,
152
+ credentials: 'same-origin',
153
+ });
154
+ if (response.ok) lastBody = body;
155
+ } catch {
156
+ // 宿主没起来 / 页面正在重载:下一轮再试。
157
+ }
158
+ };
159
+ void push();
160
+ const timer = setInterval(() => {
161
+ void push();
162
+ }, PUSH_INTERVAL_MS);
163
+ return () => {
164
+ disposed = true;
165
+ clearInterval(timer);
166
+ };
167
+ });
168
+ }
169
+
170
+ /* ── 二:控制面调用 ─────────────────────────────────────────────────────
171
+ * 全部走同源 fetch,天然满足宿主的 `localOnly` 守卫(拒绝 cross-site、要求
172
+ * Origin 与 Host 同源)——设置页与宿主同源,正是它被设计成的样子。
173
+ */
174
+ const RELAY = '/api/relay';
175
+
176
+ /** 错误正文有三种形状:字符串、{code,message}、{ok:false,error}。这里拍平成一句话。 */
177
+ function errorText(body, status) {
178
+ const raw = body && typeof body === 'object' ? body.error : body;
179
+ if (typeof raw === 'string' && raw) return raw;
180
+ if (raw && typeof raw === 'object') {
181
+ const message = typeof raw.message === 'string' ? raw.message : '';
182
+ const code = typeof raw.code === 'string' ? raw.code : '';
183
+ if (message && code) return `${code}: ${message}`;
184
+ if (message) return message;
185
+ if (code) return code;
186
+ }
187
+ return `HTTP ${status}`;
188
+ }
189
+
190
+ /**
191
+ * 控制面调用。`path` 既可以是 `/xxx` 这样的**相对控制面的短路径**(推荐,与
192
+ * `postJson('/desktop-read')` 的写法一致),也可以直接甩一个完整的
193
+ * `/api/relay/xxx`——两种都接受。
194
+ *
195
+ * **为什么必须两种都接受(2026-10-10 真机故障)**:这里以前是无条件
196
+ * `${RELAY}${path}`,而绿点那两条同步传进来的偏偏是**已经带 `/api/relay` 的全路径**
197
+ * (`READ_RECEIPT_ENDPOINT` / `DESKTOP_READ_ENDPOINT`),于是真正发出去的是
198
+ * `/api/relay/api/relay/read-receipt` —— 一个不存在的地址。它返回 401/404,被
199
+ * 两条同步各自的 `catch` 静静吞掉,所以「手机上读过桌面不灭灯」「桌面上读过手机
200
+ * 不灭灯」从来就没通过,而且**一条日志都不留**(宿主侧活体台账里那两个计数恒为 0)。
201
+ * 同一文件里的展开态同步之所以一直正常,只是因为它用的是裸 `fetch` + 全路径。
202
+ * 改断言而不是改实现是不行的:守卫测试里的假 fetch 按 `endsWith(endpoint)` 匹配,
203
+ * 而 `/api/relay/api/relay/read-receipt` 也满足这个后缀 —— 测试因此一直是绿的。
204
+ */
205
+ async function call(path, init) {
206
+ const url = /^\/api\//.test(String(path)) ? String(path) : `${RELAY}${path}`;
207
+ let response;
208
+ try {
209
+ response = await fetch(url, {
210
+ credentials: 'same-origin',
211
+ ...(init || {}),
212
+ });
213
+ } catch (error) {
214
+ throw new Error(String((error && error.message) || error));
215
+ }
216
+ const body = await response.json().catch(() => ({}));
217
+ if (!response.ok) throw new Error(errorText(body, response.status));
218
+ return body;
219
+ }
220
+
221
+ const postJson = (path, payload) => call(path, {
222
+ method: 'POST',
223
+ headers: { 'content-type': 'application/json' },
224
+ body: JSON.stringify(payload || {}),
225
+ });
226
+
227
+ /* ── 三:侧边栏未读绿点的双向同步 ───────────────────────────────────────
228
+ * 两端的绿点是**两份互不相干**的状态:
229
+ * - 手机端 = 宿主按设备维度维护的完成提醒(lib/mobile/completion.js),
230
+ * 由 `POST /api/mobile/v1/session/read` 清掉;
231
+ * - 桌面端 = 渲染进程内存里的 `UiSession.completionUnread`,既不落盘也不过服务端。
232
+ * 所以「手机上读过了,桌面还亮着」和「桌面上读过了,手机还亮着」都得靠这一半补。
233
+ *
234
+ * 这两条同步都要桌面端的服务(uiSession / sessions),拿不到就整条不启动:
235
+ * 注册了也只是空转的定时器。`ctx.get` 在 Cordis 上恒有,服务可能比我们晚就位,
236
+ * 所以每个 tick 现取一次,而不是启动时取一次。
237
+ */
238
+ /** 轮询间隔:与展开态同步同频。绿点晚 2s 灭掉无感,但没必要更勤。 */
239
+ const READ_SYNC_INTERVAL_MS = 2000;
240
+ /** 一次上报最多带多少条,与宿主 lib/routes/control-plane.js 的上限一致。 */
241
+ const MAX_READ_IDS = 200;
242
+ const READ_RECEIPT_ENDPOINT = '/api/relay/read-receipt';
243
+ const DESKTOP_READ_ENDPOINT = '/api/relay/desktop-read';
244
+ /** 自检上报的出口与间隔:只为可观测性存在,不做任何状态变更。 */
245
+ const CLIENT_HALF_ENDPOINT = '/api/relay/client-half';
246
+ const CLIENT_HALF_REPORT_MS = 10000;
247
+
248
+ /**
249
+ * 把 sessionStatus 的快照摊成 [id, status] 列表。
250
+ *
251
+ * 快照是 Map,但这里不用 `instanceof Map` / 迭代协议:插件客户端半边在测试里会被
252
+ * 塞进一个跨 realm 的假快照,`instanceof` 会跨 realm 判假。`forEach` 是鸭子类型,
253
+ * 两种 realm 都认。
254
+ */
255
+ function statusEntries(snapshot) {
256
+ const out = [];
257
+ if (!snapshot) return out;
258
+ if (typeof snapshot.forEach === 'function') {
259
+ snapshot.forEach((value, key) => {
260
+ out.push([key, value]);
261
+ });
262
+ return out;
263
+ }
264
+ if (typeof snapshot === 'object') {
265
+ for (const key of Object.keys(snapshot)) out.push([key, snapshot[key]]);
266
+ }
267
+ return out;
268
+ }
269
+
270
+ /**
271
+ * 方向一:桌面端读过了 → 告诉宿主,手机端侧栏灭灯。
272
+ *
273
+ * 两类会话都算「桌面读过了」:
274
+ * 1. **未读绿点刚掉出去**的(用户点了那一行)—— 只报 true → false 的边沿,首帧的
275
+ * 全量未读集合一律不上报(那些还没读,照报会把全部绿点一次清空);会话从快照里
276
+ * 消失(列表还没加载完、会话被删)同样不算读过。
277
+ * 2. **此刻正开在桌面主视图里的那个**(`retainedBy.mainView > 0`)。这类会话在桌面侧
278
+ * 永远不会进未读集合(`observeRunning` 对 mainView 直接跳过、`reconcileStatus`
279
+ * 还会主动删),所以第 1 条那个边沿对它**一次都不会发生** —— 用户盯着它读完,
280
+ * 手机上却一直亮着绿点。2026-10-10 真机截图就是这一格:手机有绿点、桌面没有。
281
+ * 用户明摆着在看它,就该算读过;会话重新跑起来时把标记摘掉,下一轮结束再报一次。
282
+ */
283
+ function startDesktopReadSync(ctx) {
284
+ withClientServices(ctx, ['uiSession'], 'uiSession', 'mobile-plugin: 桌面端已读上报', (scope, isDisposed) => {
285
+ let primed = false;
286
+ let sending = false;
287
+ /** sessionId → 上一轮是否在未读集合里。 */
288
+ const seen = new Map();
289
+ /** 已经替「正开着」的会话报过读的标记;它重新跑起来就摘掉。 */
290
+ const reportedOpen = new Set();
291
+ /** 待上报的会话 id;POST 失败就留着下一轮再发。 */
292
+ const pending = new Set();
293
+
294
+ const flush = async () => {
295
+ if (sending || !pending.size) return;
296
+ const ids = [];
297
+ for (const id of pending) {
298
+ ids.push(id);
299
+ if (ids.length >= MAX_READ_IDS) break;
300
+ }
301
+ sending = true;
302
+ try {
303
+ await postJson(DESKTOP_READ_ENDPOINT, { sessionIds: ids });
304
+ for (const id of ids) pending.delete(id);
305
+ } catch {
306
+ // 宿主没起来 / 页面正在重载:下一轮再试。
307
+ } finally {
308
+ sending = false;
309
+ }
310
+ };
311
+
312
+ /**
313
+ * 清单里每行的 `retainedBy`:「谁正开着它」只在这里。
314
+ *
315
+ * 读的是上面 `captureSessions` 抓回来的那份服务(可选依赖):拿不到就只少
316
+ * 「正开着」这一项增强,第 1 类边沿上报照常工作 —— 所以它不该是硬门禁。
317
+ */
318
+ const retainedRows = () => {
319
+ try {
320
+ const list = currentSessions && currentSessions.list;
321
+ const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
322
+ return (snapshot && snapshot.byId) || {};
323
+ } catch {
324
+ return {};
325
+ }
326
+ };
327
+
328
+ const scan = () => {
329
+ if (isDisposed()) return;
330
+ const uiSession = scope.uiSession;
331
+ const store = uiSession && uiSession.sessionStatus;
332
+ if (!store || typeof store.getSnapshot !== 'function') return;
333
+ let snapshot = null;
334
+ try {
335
+ snapshot = store.getSnapshot();
336
+ } catch {
337
+ return;
338
+ }
339
+ const rows = retainedRows();
340
+ const next = new Map();
341
+ for (const [id, status] of statusEntries(snapshot)) {
342
+ const unread = !!(status && status.completionUnread === true);
343
+ next.set(id, unread);
344
+ if (primed && seen.get(id) === true && !unread) pending.add(id);
345
+ // 第 2 类:正开着的,且不是运行中 —— 报一次就够了,跑起来就重新排队。
346
+ const row = rows[id];
347
+ const open = !!(row && row.retainedBy && Number(row.retainedBy.mainView) > 0);
348
+ if (!open || (status && status.running === true)) {
349
+ reportedOpen.delete(id);
350
+ continue;
351
+ }
352
+ if (reportedOpen.has(id)) continue;
353
+ reportedOpen.add(id);
354
+ pending.add(id);
355
+ }
356
+ seen.clear();
357
+ for (const [id, unread] of next) seen.set(id, unread);
358
+ if (!primed) {
359
+ primed = true;
360
+ return;
361
+ }
362
+ if (pending.size) void flush();
363
+ };
364
+
365
+ scan();
366
+ const timer = setInterval(scan, READ_SYNC_INTERVAL_MS);
367
+ return () => clearInterval(timer);
368
+ });
369
+ }
370
+
371
+ /**
372
+ * 拿到桌面客户端的两个服务:`uiSession`(未读绿点的真源 + 每会话运行态)与
373
+ * `sessions`(会话清单 + `retain`)。
374
+ *
375
+ * **必须用 `ctx.inject([...], child => …)`,不能用 `ctx.get(name)`。** 2026-10-10
376
+ * 真机故障:两条绿点同步通道一条都没在跑 —— 宿主侧的活体台账(`/api/relay/status`
377
+ * 的 `desktopHalf`)显示桌面半边**一次 read-receipt 都没拉过**,而 read-receipt 那条
378
+ * effect 是**无条件 2s 轮询**、`ctx.get` 之后才 fetch,所以只可能是 tick 在服务这一
379
+ * 道 guard 上就返回了。同一个桌面里能正常工作的客户端插件(内置的 conversation /
380
+ * approval、第三方 `dsh-opencode-go`)一律走 `inject`:DSH 自己的插件把服务写进
381
+ * `inject` 数组,opencode-go 用 `ctx.inject([...], child => …)`。`ctx.get` 是「绕过
382
+ * 注入直接取」,在客户端运行时里取不到这两个服务。拿不到就一个定时器都不起(与旧
383
+ * 行为一致),由 lib/client.js 的自检上报把「到底看见了什么」说给宿主听。
384
+ */
385
+ /**
386
+ * 客户端半边的自检状态:只给自检上报用(谁注入了、谁没注入)。
387
+ * 放在这一层的原地变量:两条同步各占一格,拿不到服务时宿主能一眼看出来是**哪一条**
388
+ * 没起来,而不是只看到「两边都没动静」。
389
+ */
390
+ const halfState = { uiSession: false, sessions: false, lastError: '' };
391
+
392
+ function withClientServices(ctx, names, flagKey, label, run) {
393
+ if (!ctx || typeof ctx.inject !== 'function') {
394
+ halfState.lastError = 'ctx.inject 不可用';
395
+ return;
396
+ }
397
+ try {
398
+ ctx.inject(names, (scope) => {
399
+ halfState[flagKey] = true;
400
+ scope.effect(() => {
401
+ let disposed = false;
402
+ const stop = run(scope, () => disposed);
403
+ return () => {
404
+ disposed = true;
405
+ if (typeof stop === 'function') stop();
406
+ };
407
+ }, label);
408
+ });
409
+ } catch (error) {
410
+ halfState.lastError = `注入 ${names.join('+')} 失败:${String((error && error.message) || error).slice(0, 100)}`;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * 把 sessions 服务抓进一个可变引用,供方向一的可选增强(「正开着」判定)使用。
416
+ * 它缺席时宿主侧看不到这条 effect 的标签,自检上报里 `sessions` 那一格也正是 false ——
417
+ * 排查时能一眼看出「不是没读到,是服务没拿到」。
418
+ */
419
+ let currentSessions = null;
420
+ function captureSessions(ctx) {
421
+ withClientServices(ctx, ['sessions'], 'sessions', 'mobile-plugin: 会话清单服务', (scope) => {
422
+ currentSessions = scope.sessions;
423
+ return () => { currentSessions = null; };
424
+ });
425
+ }
426
+
427
+ /**
428
+ * 方向二:手机上读过了 → 在桌面页面里就地清掉绿点。
429
+ *
430
+ * 拉宿主那份 `sessionId → readAt` 台账,对水位前进过的会话做一次瞬时
431
+ * `sessions.retain(id, { source: 'mainView' })` + 同 tick `release()`。桌面自己的
432
+ * `reconcileStatus()` 订阅了列表 store,而 `retain` 里的 `publishRetention` 会同步
433
+ * 抬 `retainedBy.mainView`,于是 `completionUnread.delete(id)` 在 release 之前就已经
434
+ * 发生;release 把引用数降回 0 也不会把绿点加回来(只有 `observeRunning(false)`
435
+ * 且非 mainView 才会加)。整个过程同一个 tick 内完成,React 还没重渲染,看不到抖动。
436
+ *
437
+ * 为什么还要跟 `updatedAt` 比一次:台账只记「读过」不记「读完之后又跑完了一轮」。
438
+ * 若不管这一条,桌面端**重新打开页面**时(内存里的 applied 游标是空的)会把一条
439
+ * 早已被新一轮完成盖过的旧回执重新应用,把本该亮的绿点灭掉。会话的最后活动时间
440
+ * 晚于已读水位,就说明这一轮是读完之后才跑完的,必须跳过。
441
+ */
442
+ function startReadReceiptSync(ctx) {
443
+ withClientServices(ctx, ['sessions'], 'sessions', 'mobile-plugin: 已读回执应用', (scope, isDisposed) => {
444
+ /** sessionId → 已经应用过的最大 readAt(只在本页存活期间有效)。 */
445
+ const applied = new Map();
446
+
447
+ const tick = async () => {
448
+ if (isDisposed()) return;
449
+ const sessions = scope.sessions;
450
+ if (!sessions || typeof sessions.retain !== 'function') return;
451
+ let payload = null;
452
+ try {
453
+ payload = await call(READ_RECEIPT_ENDPOINT);
454
+ } catch {
455
+ return;
456
+ }
457
+ if (isDisposed()) return;
458
+ const receipt = payload && payload.receipt;
459
+ const readAt = receipt && receipt.readAt;
460
+ if (!readAt || typeof readAt !== 'object') return;
461
+ let rows = {};
462
+ try {
463
+ const list = sessions.list;
464
+ const snapshot = list && typeof list.getSnapshot === 'function' ? list.getSnapshot() : null;
465
+ rows = (snapshot && snapshot.byId) || {};
466
+ } catch {
467
+ rows = {};
468
+ }
469
+ for (const id of Object.keys(readAt)) {
470
+ const at = readAt[id];
471
+ if (typeof at !== 'number' || !Number.isFinite(at)) continue;
472
+ if (at <= (applied.get(id) || 0)) continue;
473
+ const row = rows[id];
474
+ const updatedAt = row && typeof row.updatedAt === 'number' ? row.updatedAt : 0;
475
+ // 读完之后这一轮才跑完:回执已经过期,绿点该亮着。
476
+ if (updatedAt > at) {
477
+ applied.set(id, at);
478
+ continue;
479
+ }
480
+ // 先把游标推进再动作:retain 对未知会话会抛错(会话可能已被删除),
481
+ // 那是一次性失败,重试没有意义,不该让整份台账卡在它身上。
482
+ applied.set(id, at);
483
+ try {
484
+ const reference = sessions.retain(id, { source: 'mainView' });
485
+ if (reference && typeof reference.release === 'function') reference.release();
486
+ } catch {
487
+ // 未知/已删除的会话:跳过。
488
+ }
489
+ }
490
+ };
491
+
492
+ void tick();
493
+ const timer = setInterval(() => {
494
+ void tick();
495
+ }, READ_SYNC_INTERVAL_MS);
496
+ return () => clearInterval(timer);
497
+ });
498
+ }
499
+
500
+ /* ── 文案 ──────────────────────────────────────────────────────────── */
501
+ const NS = 'settings.mobileRelay';
502
+ const DICT = {
503
+ zh: {
504
+ nav: '移动端',
505
+ heading: '移动端',
506
+ cardPanel: '配置页面',
507
+ openPanel: '打开配置页面',
508
+ openHint: '设置、配对、插件更新都在这个页面里。它跟着插件一起更新,改版式不用重装,也不用重启 DSH。',
509
+ panelPending: '正在读取页面地址…',
510
+ panelMissing: '宿主还没给出页面地址,稍后再打开这一页试试。',
511
+ loadFailed: '读取宿主状态失败',
512
+ },
513
+ en: {
514
+ nav: 'Mobile',
515
+ heading: 'Mobile',
516
+ cardPanel: 'Configuration page',
517
+ openPanel: 'Open the configuration page',
518
+ openHint: 'Settings, pairing and plugin updates all live on that page. It ships with the plugin, so layout changes need neither a reinstall nor a DSH restart.',
519
+ panelPending: 'Reading the page address…',
520
+ panelMissing: 'The host has not reported a page address yet — reopen this item in a moment.',
521
+ loadFailed: 'Failed to read host status',
522
+ },
523
+ };
524
+
525
+ /* ── 设置项组件 ───────────────────────────────────────────────────────
526
+ * 这一项只干一件事:把人送去网页版(`GET /api/relay/panel`)。
527
+ *
528
+ * 界面本体(状态、配对、中转配置、维护、更新)全部在宿主吐出的那个网页里
529
+ * (源码在 `React/`,产物 `lib/panel/index.html`)。为什么只留一个链接:客户端半边
530
+ * 是渲染器直接读 `node_modules` 里的文件,**不可热替换**,改一次就要完整安装 + 重启
531
+ * DSH;而网页版跟着插件包一起更新,改版式重装一次插件就生效。
532
+ *
533
+ * 更新也不再由插件自己做(2026-10 起):一律走 DSH 的插件市场,插件包从 npm 来。
534
+ * 所以这里那两张卡片(网页入口 + 插件更新)里的更新那张也撤掉了 —— 少一件事就少一次
535
+ * 「半边是旧的」。
536
+ */
537
+ function MobileSettingsSection({ t }) {
538
+ /** null = 还没问到;'' = 问到了但这个宿主没给地址(老版本或还没起来)。 */
539
+ const [panelUrl, setPanelUrl] = useState(null);
540
+ const [error, setError] = useState(null);
541
+
542
+ useEffect(() => {
543
+ let alive = true;
544
+ let timer = null;
545
+ const stop = () => {
546
+ if (timer !== null) {
547
+ clearInterval(timer);
548
+ timer = null;
549
+ }
550
+ };
551
+ /** 只问一次:地址由请求的 host 头算出来,问到就定了。 */
552
+ const load = async () => {
553
+ try {
554
+ const next = await call('/status');
555
+ if (!alive) return;
556
+ setError(null);
557
+ setPanelUrl(typeof next.panelUrl === 'string' ? next.panelUrl : '');
558
+ stop();
559
+ } catch (problem) {
560
+ // 宿主还没挂上控制面路由(刚启动)时问不到,过 3s 再问 —— 不然这一页会永久停在
561
+ // 「正在读取页面地址…」。真的拿到「这个宿主不给我地址」也算答到了,同样停。
562
+ if (!alive) return;
563
+ setError(String((problem && problem.message) || problem));
564
+ }
565
+ };
566
+ void load();
567
+ timer = setInterval(() => {
568
+ if (typeof document !== 'undefined' && document.hidden) return;
569
+ void load();
570
+ }, 3000);
571
+ return () => {
572
+ alive = false;
573
+ stop();
574
+ };
575
+ }, []);
576
+
577
+ const url = typeof panelUrl === 'string' ? panelUrl : '';
578
+
579
+ return h('div', { className: 'dshm_root' }, [
580
+ h('h2', { className: 'dshm_heading' }, t('heading')),
581
+ error ? h('p', { className: 'dshm_error' }, `${t('loadFailed')}: ${error}`) : null,
582
+
583
+ // 用 `<a target="_blank">` 而不是 iframe:DSH 桌面端会把这个链接交给系统浏览器
584
+ // 打开(网页版有自己的地址与刷新节奏,嵌在设置弹窗里反而不好用)。
585
+ // href 必须是宿主给的绝对地址:相对地址在桌面端会被解析成 `dsh-app://`,
586
+ // 而 Electron 只把 http(s) 交给浏览器,其它协议静默丢弃、点了没反应。
587
+ h('section', { className: 'dshm_card', key: 'panel' }, [
588
+ h('h3', { className: 'dshm_cardTitle' }, t('cardPanel')),
589
+ h('div', { className: 'dshm_actions' }, [
590
+ url
591
+ ? h('a', {
592
+ className: 'dshm_btn dshm_btnPrimary',
593
+ href: url,
594
+ target: '_blank',
595
+ rel: 'noreferrer',
596
+ }, t('openPanel'))
597
+ : h('button', {
598
+ type: 'button',
599
+ className: 'dshm_btn',
600
+ disabled: true,
601
+ }, panelUrl === null ? t('panelPending') : t('panelMissing')),
602
+ ]),
603
+ h('p', { className: 'dshm_note' }, t('openHint')),
604
+ ]),
605
+ ]);
606
+ }
607
+
608
+ /**
609
+ * 客户端半边的自检上报:每 10s 把「这一半到底看见了什么」报给宿主。
610
+ *
611
+ * 为什么要它:宿主能数出「那半边没来敲门」(`/api/relay/status` 的 `desktopHalf`),
612
+ * 但数不出「它为什么没来」。2026-10-10 那次故障里,正是这一点让「服务没拿到」和
613
+ * 「服务拿到了但清理失败」两种可能耗掉了一整轮排查 —— 这条通路把答案直接摆出来:
614
+ * 两条同步各自的 `ctx.inject` 到底有没有回调(`uiSession` / `sessions` 两格)、
615
+ * `ctx.get` 能不能看见那两个服务、最后一次错在哪。
616
+ *
617
+ * 刻意**不**依赖任何客户端服务:探测全包在 try/catch 里,它自己要是也挂在服务上,
618
+ * 就永远报不出「服务拿不到」这件事了。
619
+ */
620
+ function startClientHalfReport(ctx) {
621
+ const probe = (name) => {
622
+ if (typeof ctx.get !== 'function') return 'no-ctx.get';
623
+ try {
624
+ const value = ctx.get(name);
625
+ if (!value) return String(value);
626
+ if (name === 'sessions') return typeof value.retain === 'function' ? 'object:retain' : 'object:no-retain';
627
+ return typeof value;
628
+ } catch (error) {
629
+ return `throw:${String((error && error.message) || error).slice(0, 80)}`;
630
+ }
631
+ };
632
+ ctx.effect(() => {
633
+ let disposed = false;
634
+ const report = async () => {
635
+ if (disposed) return;
636
+ try {
637
+ await postJson(CLIENT_HALF_ENDPOINT, {
638
+ loaded: true,
639
+ effectUiSession: halfState.uiSession,
640
+ effectSessions: halfState.sessions,
641
+ getUiSession: probe('uiSession'),
642
+ getSessions: probe('sessions'),
643
+ lastError: halfState.lastError,
644
+ });
645
+ } catch {
646
+ // 宿主没起来:下一条再试。
647
+ }
648
+ };
649
+ // 刻意不在挂载那一刻就报:它是周期自检,第一个采样点等一个间隔,
650
+ // 免得给「页面刚打开时一个请求都不该发」这条既有契约添噪声。
651
+ const timer = setInterval(() => {
652
+ void report();
653
+ }, CLIENT_HALF_REPORT_MS);
654
+ return () => {
655
+ disposed = true;
656
+ clearInterval(timer);
657
+ };
658
+ }, 'mobile-plugin: 客户端半边自检上报');
659
+ }
660
+
661
+ return {
662
+ // slots:注册设置项,由 @deepseek-ai/dsh-client-runtime 提供(它 require
663
+ // dsh-client-ui-slots 的 SlotCore 再以 `super(ctx, "slots")` 暴露服务)。
664
+ // locale:字典,由 @deepseek-ai/dsh-client-locale 提供。
665
+ // 这两个服务名对应 package.json 里 dsh.client.inject 注入的两个包。
666
+ inject: ['slots', 'locale'],
667
+ apply(ctx) {
668
+ ctx.effect(() => ctx.locale.register(NS, DICT), 'mobile-plugin: settings dictionaries');
669
+ const t = ctx.locale.bind(NS);
670
+ startDesktopViewSync(ctx);
671
+ startDesktopReadSync(ctx);
672
+ captureSessions(ctx);
673
+ startReadReceiptSync(ctx);
674
+ startClientHalfReport(ctx);
675
+ // 插槽是「先声明后注册」:宿主可能比我们晚一步声明 settings.section,
676
+ // slots.inject 会等它就位再注册——已装的第三方插件(dsh-opencode-go)也是这么做的。
677
+ // settings.section 是设置菜单里的顶层一项;settings.plugins.tab 是内置插件页里的页签,
678
+ // 这一项原先挂在那里,后来按用户要求搬出来独立成一项。
679
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
680
+ name: 'settings.section',
681
+ id: 'mobile',
682
+ order: 20,
683
+ label: () => t('nav'),
684
+ locale: NS,
685
+ inject: () => ({ t }),
686
+ }, MobileSettingsSection));
687
+ },
688
+ };
689
+ },
690
+ });