dsh-cindy-host 0.1.20

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 (82) hide show
  1. package/CHANGELOG.md +462 -0
  2. package/LICENSE +21 -0
  3. package/README.md +178 -0
  4. package/cordis.patch.yml +6 -0
  5. package/doc/architecture.md +34 -0
  6. package/doc/cindy-android-verify.md +362 -0
  7. package/doc/cindy-mobile-freeze-handoff.md +124 -0
  8. package/doc/cindy-phone-link.md +2902 -0
  9. package/doc/desktop-runtime-fix-20260930.md +69 -0
  10. package/doc/development-plan.md +19 -0
  11. package/doc/file-peer-apply-recovery-20260921.md +35 -0
  12. package/doc/file-peer-installed-hotfix-20260921.md +53 -0
  13. package/doc/file-peer-minimal-apply-20260921.md +78 -0
  14. package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
  15. package/doc/file-peer-phone-export-observation-20260921.md +75 -0
  16. package/doc/file-peer-product-contract-audit-20260921.md +106 -0
  17. package/doc/file-peer-root-cause-20260921.md +53 -0
  18. package/doc/file-transfer.md +50 -0
  19. package/doc/host-performance-20260930.md +91 -0
  20. package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
  21. package/doc/new-session-sync-fix-20260930.md +59 -0
  22. package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
  23. package/doc/projection-read-probe.md +77 -0
  24. package/doc/publishing.md +259 -0
  25. package/doc/releasing.md +149 -0
  26. package/doc/research.md +24 -0
  27. package/doc/session-discovery-2026-09-20.md +67 -0
  28. package/lib/client.js +763 -0
  29. package/package.json +84 -0
  30. package/src/auth-session.js +189 -0
  31. package/src/authorization-policy.js +59 -0
  32. package/src/cindy-channels.js +1707 -0
  33. package/src/cindy-login-flow.js +159 -0
  34. package/src/cindy-login.js +80 -0
  35. package/src/cindy-message-row.js +432 -0
  36. package/src/cindy-session-row.js +171 -0
  37. package/src/console-sink.js +6 -0
  38. package/src/contracts.js +26 -0
  39. package/src/credential-store.js +128 -0
  40. package/src/demo.js +9 -0
  41. package/src/dsh-active-sessions.js +59 -0
  42. package/src/dsh-host-source.js +62 -0
  43. package/src/dsh-message-fold.js +608 -0
  44. package/src/dsh-plugin.js +1932 -0
  45. package/src/dsh-session-source.js +529 -0
  46. package/src/file-peer-protocol.js +7 -0
  47. package/src/file-peer-rtc.js +41 -0
  48. package/src/fixture-source.js +13 -0
  49. package/src/host-approvals.js +329 -0
  50. package/src/host-attachments.js +249 -0
  51. package/src/host-authorization.js +51 -0
  52. package/src/host-demo.js +3 -0
  53. package/src/host-export-jobs.js +356 -0
  54. package/src/host-export-owner.js +62 -0
  55. package/src/host-file-browser.js +400 -0
  56. package/src/host-file-ice.js +63 -0
  57. package/src/host-file-peer.js +289 -0
  58. package/src/host-file-reference.js +16 -0
  59. package/src/host-files.js +148 -0
  60. package/src/host-frame-budget.js +310 -0
  61. package/src/host-goals.js +284 -0
  62. package/src/host-history-view-limits.js +22 -0
  63. package/src/host-history-view.js +417 -0
  64. package/src/host-input-queue.js +505 -0
  65. package/src/host-media-fetch.js +511 -0
  66. package/src/host-media.js +678 -0
  67. package/src/host-models.js +231 -0
  68. package/src/host-palette.js +111 -0
  69. package/src/host-push-channels.js +37 -0
  70. package/src/host-reconnect.js +54 -0
  71. package/src/host-routes.js +266 -0
  72. package/src/host-settings.js +41 -0
  73. package/src/host-status.js +216 -0
  74. package/src/host-transfer-diagnostics.js +50 -0
  75. package/src/host.js +2497 -0
  76. package/src/login.js +31 -0
  77. package/src/logout.js +3 -0
  78. package/src/projection-read-model-sink.js +6 -0
  79. package/src/projection.js +43 -0
  80. package/src/session-flags.js +160 -0
  81. package/src/session-publisher.js +123 -0
  82. package/src/session-read-model.js +6 -0
package/lib/client.js ADDED
@@ -0,0 +1,763 @@
1
+ /**
2
+ * DSH Web half of the Cindy Host bundle: the 「Cindy 手机连接」 page in Settings.
3
+ *
4
+ * The page is a `settings.section` contribution, not a `settings.plugin.item`
5
+ * card: it carries a connection switch, a Cindy sign-in flow and a device list,
6
+ * and the shell's section slot is exactly the seat for "a feature owns its own
7
+ * settings page" (`dsh-client-ui-settings` slot contract) — adding it required
8
+ * no change to the shell.
9
+ *
10
+ * Hand-written as a Cordis client bundle because this package is distributed
11
+ * outside the DSH repository and has no build step. The envelope below is the
12
+ * one `dsh-client-modules` serves and `@deepseek-ai/dsh-client-ui-settings-general`
13
+ * itself uses: a `window.__ModuleLoader__.load({ id, factory })` registration
14
+ * whose factory returns a CommonJS-shaped module exporting `apply` and `inject`.
15
+ *
16
+ * Everything this page draws comes from the Host: the switch is a settings
17
+ * write, and the connection state is whatever `GET /api/dsh-cindy-host/status`
18
+ * last answered. The page never infers a connection from its own clicks.
19
+ */
20
+ window.__ModuleLoader__.load({
21
+ id: "dsh-cindy-host",
22
+ factory: (require) => {
23
+ var module = { exports: {} };
24
+ var exports = module.exports;
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
26
+
27
+ const react = require("react");
28
+ const h = react.createElement;
29
+
30
+ /** Settings namespace this card owns; the slot is keyed by it. */
31
+ const NAMESPACE = "dsh-cindy-host";
32
+ /** Host API prefix registered by `src/host-routes.js`. */
33
+ const API = "/api/dsh-cindy-host";
34
+ /** How often the card re-reads Host status while mounted. */
35
+ const POLL_MS = 1500;
36
+
37
+ const ZH = !String((typeof navigator !== "undefined" && navigator.language) || "zh").toLowerCase().startsWith("en");
38
+
39
+ const TEXT = ZH
40
+ ? {
41
+ title: "Cindy 手机连接",
42
+ description: "让 Cindy 手机客户端连上这台 DSH Host。",
43
+ switchLabel: "连接手机",
44
+ switchHint: "打开后本机登录 Cindy 并接入 DeviceLink,手机即可在设备列表中找到这台 Host。",
45
+ switchDisabled: "手机连接已关闭;打开开关后才能连接。",
46
+ signedIn: "已登录 Cindy",
47
+ stateDisconnected: "未连接",
48
+ stateAuthenticating: "登录中",
49
+ stateConnecting: "正在重连",
50
+ stateWaiting: "等待手机连接",
51
+ stateConnected: "已连接",
52
+ stateFailed: "连接失败",
53
+ reconnect: "重新连接",
54
+ loginTitle: "登录 Cindy",
55
+ loginHint: "本机还没有 Cindy 登录态。用与手机相同的账号登录。",
56
+ kindPhone: "手机号",
57
+ kindEmail: "邮箱",
58
+ identifierPhone: "手机号",
59
+ identifierEmail: "邮箱地址",
60
+ code: "验证码",
61
+ sendCode: "获取验证码",
62
+ sending: "发送中…",
63
+ codeSent: "验证码已发送",
64
+ signIn: "登录",
65
+ signingIn: "登录中…",
66
+ logout: "退出登录",
67
+ devices: "已连接设备",
68
+ deviceOffline: "离线",
69
+ deviceOnline: "在线",
70
+ unknownPlatform: "未知平台",
71
+ hostDevice: "本机设备",
72
+ noDevices: "还没有手机连上这台 Host。",
73
+ unavailable: "Cindy Host 设置命名空间不可用,请确认插件已在本机加载。",
74
+ statusUnavailable: "读取 Host 状态失败,正在重试…",
75
+ selectAccount: "请选择要登录的账户",
76
+ }
77
+ : {
78
+ title: "Cindy Phone Link",
79
+ description: "Let the Cindy mobile app connect to this DSH Host.",
80
+ switchLabel: "Connect phone",
81
+ switchHint: "Signs this Host in to Cindy and joins DeviceLink so the phone can find it.",
82
+ switchDisabled: "Phone connection is disabled; turn on the switch to connect.",
83
+ signedIn: "Signed in to Cindy",
84
+ stateDisconnected: "Not connected",
85
+ stateAuthenticating: "Signing in",
86
+ stateConnecting: "Reconnecting",
87
+ stateWaiting: "Waiting for phone",
88
+ stateConnected: "Connected",
89
+ stateFailed: "Connection failed",
90
+ reconnect: "Reconnect",
91
+ loginTitle: "Sign in to Cindy",
92
+ loginHint: "This Host has no Cindy session yet. Use the same account as your phone.",
93
+ kindPhone: "Phone",
94
+ kindEmail: "Email",
95
+ identifierPhone: "Phone number",
96
+ identifierEmail: "Email address",
97
+ code: "Verification code",
98
+ sendCode: "Send code",
99
+ sending: "Sending…",
100
+ codeSent: "Verification code sent",
101
+ signIn: "Sign in",
102
+ signingIn: "Signing in…",
103
+ logout: "Sign out",
104
+ devices: "Connected devices",
105
+ deviceOffline: "offline",
106
+ deviceOnline: "online",
107
+ unknownPlatform: "unknown platform",
108
+ hostDevice: "This Host",
109
+ noDevices: "No phone has reached this Host yet.",
110
+ unavailable: "The Cindy Host settings namespace is unavailable on this deployment.",
111
+ statusUnavailable: "Reading Host status failed; retrying…",
112
+ selectAccount: "Choose the account to sign in with",
113
+ };
114
+
115
+ /** Color for one connection state. */
116
+ function stateColor(state) {
117
+ switch (state) {
118
+ case "connected":
119
+ return "var(--dsw-alias-state-success-primary, #3fb950)";
120
+ case "failed":
121
+ return "var(--dsw-alias-state-error-primary, #e5534b)";
122
+ case "connecting":
123
+ case "waiting":
124
+ return "var(--dsw-alias-state-warn-primary, #d29922)";
125
+ default:
126
+ return "var(--dsw-alias-label-tertiary, #8b949e)";
127
+ }
128
+ }
129
+
130
+ function stateText(state, fallback) {
131
+ switch (state) {
132
+ case "disconnected":
133
+ return TEXT.stateDisconnected;
134
+ case "authenticating":
135
+ return TEXT.stateAuthenticating;
136
+ // A lost connection is retried automatically; showing it as a *failure* is what
137
+ // made the phone's owner think the Host was gone and reach for the reconnect
138
+ // button. This state is the honest middle ground (`host-reconnect.js`).
139
+ case "connecting":
140
+ return TEXT.stateConnecting;
141
+ case "waiting":
142
+ return TEXT.stateWaiting;
143
+ case "connected":
144
+ return TEXT.stateConnected;
145
+ case "failed":
146
+ return TEXT.stateFailed;
147
+ default:
148
+ return fallback || TEXT.stateDisconnected;
149
+ }
150
+ }
151
+
152
+ function platformText(platform) {
153
+ if (typeof platform !== "string" || platform === "") return TEXT.unknownPlatform;
154
+ if (platform === "android") return "Android";
155
+ if (platform === "ios") return "iOS";
156
+ if (platform === "darwin") return "macOS";
157
+ if (platform === "win32") return "Windows";
158
+ if (platform === "linux") return "Linux";
159
+ return platform;
160
+ }
161
+
162
+ /**
163
+ * Derive everything the page draws from the two inputs it is allowed to trust:
164
+ * the Host status and the settings snapshot. Pure, so the markup rules are
165
+ * testable without a browser.
166
+ * @param status - last status the Host answered, or null before the first poll.
167
+ * @param snapshot - the {@link settingsSnapshot} the page last built.
168
+ * @returns the view model the page renders.
169
+ */
170
+ function resolveCardView(status, snapshot) {
171
+ const value = (snapshot && snapshot.value) || {};
172
+ const login = (status && status.login) || { authenticated: false, required: false };
173
+ return {
174
+ switchOn: value.transportEnabled === true || value.remoteControlEnabled === true,
175
+ writable: !!snapshot && snapshot.status === "ready" && snapshot.writable !== false,
176
+ unavailable: !!snapshot && snapshot.status === "unavailable",
177
+ state: status ? status.state : "disconnected",
178
+ stateLabel: stateText(status && status.state, status && status.stateLabel),
179
+ message: (status && status.message) || null,
180
+ devices: status && Array.isArray(status.devices) ? status.devices : [],
181
+ login,
182
+ host: (status && status.host) || null,
183
+ showLogin: login.required === true,
184
+ showReconnect: (status ? status.state : "disconnected") === "failed" && login.required !== true,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Move the phone switch through the settings controller.
190
+ *
191
+ * Both spellings move together: `transportEnabled` is the card's own field,
192
+ * `remoteControlEnabled` is what a hand-edited document or the CLI path uses
193
+ * for the same intent. The Host reads the union, so leaving one behind would
194
+ * make the switch disagree with the socket.
195
+ *
196
+ * The controller comes from {@link resolveSettingsController}: it is a
197
+ * `settingsScope` scope on 0.1.x and a `configForms` controller on 0.2, and both
198
+ * expose `set(field, value)` over the same Host settings document. Prefer
199
+ * `mutate(ops)` when available so both flags commit atomically.
200
+ * @param scope - the bound settings controller for this namespace.
201
+ * @param next - the requested switch position.
202
+ */
203
+ async function writeSwitch(scope, next) {
204
+ const fields = ["transportEnabled", "remoteControlEnabled"];
205
+ if (typeof scope.mutate === "function") {
206
+ const accepted = await scope.mutate(fields.map((field) => ({ op: "set", path: [field], value: next })));
207
+ if (accepted === false) throw new Error("Settings write rejected");
208
+ return;
209
+ }
210
+ // Older scopes return void on success; only an explicit false is rejection.
211
+ for (const field of fields) {
212
+ if (await scope.set(field, next) === false) throw new Error("Settings write rejected");
213
+ }
214
+ }
215
+
216
+ /** Read a real status snapshot; API failures must not become 'disconnected'. */
217
+ async function readStatus() {
218
+ const response = await fetch(API + "/status", { headers: { accept: "application/json" } });
219
+ if (!response.ok) throw new Error(TEXT.statusUnavailable);
220
+ const body = await response.json();
221
+ if (!body || body.ok === false || !body.status || typeof body.status !== "object" ||
222
+ Array.isArray(body.status) || typeof body.status.state !== "string" || !body.status.state) {
223
+ throw new Error(TEXT.statusUnavailable);
224
+ }
225
+ return body.status;
226
+ }
227
+
228
+ /** Only expose a small recognizable fragment of the authenticated identifier. */
229
+ function maskIdentifier(identifier) {
230
+ if (typeof identifier !== "string" || !identifier.trim()) return null;
231
+ const value = identifier.trim();
232
+ const at = value.indexOf("@");
233
+ if (at > 0) return value[0] + "***@" + value.slice(at + 1);
234
+ return value.length > 4 ? "***" + value.slice(-4) : "***";
235
+ }
236
+
237
+ /** POST JSON to the Host API and always resolve to a parsed body. */
238
+ async function postJson(path, body) {
239
+ const response = await fetch(API + path, {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json" },
242
+ body: JSON.stringify(body === undefined ? {} : body),
243
+ });
244
+ try {
245
+ return await response.json();
246
+ } catch {
247
+ return { ok: false, message: "Cindy Host 返回了无效响应" };
248
+ }
249
+ }
250
+
251
+ const styles = {
252
+ // A settings page fills the panel's content column, so it draws no card
253
+ // chrome of its own — only the column layout.
254
+ page: {
255
+ display: "flex",
256
+ flexDirection: "column",
257
+ gap: "16px",
258
+ },
259
+ header: { display: "flex", flexDirection: "column", gap: "2px" },
260
+ title: { fontSize: "14px", fontWeight: 600, color: "var(--dsw-alias-label-primary, #e6edf3)", lineHeight: 1.5 },
261
+ description: { fontSize: "12px", color: "var(--dsw-alias-label-tertiary, #8b949e)", lineHeight: 1.5, margin: 0 },
262
+ row: { display: "flex", alignItems: "center", gap: "10px" },
263
+ rowText: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0, flex: 1 },
264
+ label: { fontSize: "13px", fontWeight: 500, color: "var(--dsw-alias-label-primary, #e6edf3)" },
265
+ hint: { fontSize: "12px", color: "var(--dsw-alias-label-tertiary, #8b949e)", lineHeight: 1.5 },
266
+ statusRow: { display: "flex", alignItems: "center", gap: "6px", fontSize: "12px", color: "var(--dsw-alias-label-secondary, #9da7b3)" },
267
+ dot: { width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0 },
268
+ form: { display: "flex", flexDirection: "column", gap: "8px", borderTop: "0.5px solid var(--dsw-alias-border-l2, #30363d)", paddingTop: "12px" },
269
+ field: { display: "flex", flexDirection: "column", gap: "4px" },
270
+ input: {
271
+ height: "32px",
272
+ borderRadius: "8px",
273
+ border: "0.5px solid var(--dsw-alias-border-l4, #484f58)",
274
+ background: "var(--dsw-alias-bg-layer-3, #161b22)",
275
+ color: "var(--dsw-alias-label-primary, #e6edf3)",
276
+ padding: "0 10px",
277
+ fontSize: "13px",
278
+ fontFamily: "inherit",
279
+ },
280
+ button: {
281
+ height: "32px",
282
+ borderRadius: "8px",
283
+ border: "0.5px solid var(--dsw-alias-border-l4, #484f58)",
284
+ background: "var(--dsw-alias-button-primary-fill, #238636)",
285
+ color: "var(--dsw-alias-label-primary-foreground, #ffffff)",
286
+ fontSize: "13px",
287
+ fontFamily: "inherit",
288
+ cursor: "pointer",
289
+ padding: "0 12px",
290
+ },
291
+ buttonGhost: {
292
+ height: "30px",
293
+ borderRadius: "8px",
294
+ border: "0.5px solid var(--dsw-alias-border-l4, #484f58)",
295
+ background: "transparent",
296
+ color: "var(--dsw-alias-label-secondary, #9da7b3)",
297
+ fontSize: "12px",
298
+ fontFamily: "inherit",
299
+ cursor: "pointer",
300
+ padding: "0 10px",
301
+ },
302
+ error: { fontSize: "12px", color: "var(--dsw-alias-state-error-primary, #e5534b)", margin: 0 },
303
+ ok: { fontSize: "12px", color: "var(--dsw-alias-state-success-primary, #3fb950)", margin: 0 },
304
+ deviceList: { display: "flex", flexDirection: "column", gap: "6px", borderTop: "0.5px solid var(--dsw-alias-border-l2, #30363d)", paddingTop: "12px" },
305
+ device: { display: "flex", flexDirection: "column", gap: "2px" },
306
+ deviceName: { fontSize: "13px", color: "var(--dsw-alias-label-primary, #e6edf3)" },
307
+ deviceMeta: { fontSize: "11px", color: "var(--dsw-alias-label-tertiary, #8b949e)", fontFamily: "var(--dsw-font-markdown-code-font-family, monospace)" },
308
+ };
309
+
310
+ /** A checkbox rendered as a small switch; the platform has no shared one here. */
311
+ function Switch({ checked, disabled, onChange, label }) {
312
+ return h(
313
+ "button",
314
+ {
315
+ type: "button",
316
+ role: "switch",
317
+ "aria-checked": checked ? "true" : "false",
318
+ "aria-label": label,
319
+ disabled: disabled === true,
320
+ onClick: () => onChange(!checked),
321
+ style: {
322
+ width: "36px",
323
+ height: "20px",
324
+ borderRadius: "10px",
325
+ border: "none",
326
+ padding: 0,
327
+ cursor: disabled ? "default" : "pointer",
328
+ background: checked ? "var(--dsw-alias-brand-primary, #2f81f7)" : "var(--dsw-alias-border-l4, #484f58)",
329
+ position: "relative",
330
+ flexShrink: 0,
331
+ opacity: disabled ? 0.6 : 1,
332
+ transition: "background 120ms ease",
333
+ },
334
+ },
335
+ h("span", {
336
+ "aria-hidden": "true",
337
+ style: {
338
+ position: "absolute",
339
+ top: "2px",
340
+ left: checked ? "18px" : "2px",
341
+ width: "16px",
342
+ height: "16px",
343
+ borderRadius: "50%",
344
+ background: "var(--dsw-alias-label-primary-foreground, #ffffff)",
345
+ transition: "left 120ms ease",
346
+ },
347
+ }),
348
+ );
349
+ }
350
+
351
+ /** The Cindy sign-in form; shown only while the Host reports it needs one. */
352
+ function LoginForm({ busy, error, notice, accounts, onSendCode, onSignIn, onSelectAccount }) {
353
+ const [kind, setKind] = react.useState("phone");
354
+ const [identifier, setIdentifier] = react.useState("");
355
+ const [code, setCode] = react.useState("");
356
+
357
+ const identifierLabel = kind === "phone" ? TEXT.identifierPhone : TEXT.identifierEmail;
358
+
359
+ return h(
360
+ "div",
361
+ { style: styles.form },
362
+ h("span", { style: styles.label }, TEXT.loginTitle),
363
+ h("span", { style: styles.hint }, TEXT.loginHint),
364
+ h(
365
+ "div",
366
+ { style: styles.field },
367
+ h("span", { style: styles.hint }, TEXT.kindPhone + " / " + TEXT.kindEmail),
368
+ h(
369
+ "div",
370
+ { style: { display: "flex", gap: "6px" } },
371
+ ["phone", "email"].map((value) =>
372
+ h(
373
+ "button",
374
+ {
375
+ key: value,
376
+ type: "button",
377
+ onClick: () => setKind(value),
378
+ style: Object.assign({}, styles.buttonGhost, {
379
+ flex: 1,
380
+ color: kind === value ? "var(--dsw-alias-label-primary, #e6edf3)" : undefined,
381
+ borderColor: kind === value ? "var(--dsw-alias-brand-primary, #2f81f7)" : undefined,
382
+ }),
383
+ },
384
+ value === "phone" ? TEXT.kindPhone : TEXT.kindEmail,
385
+ ),
386
+ ),
387
+ ),
388
+ ),
389
+ h(
390
+ "label",
391
+ { style: styles.field },
392
+ h("span", { style: styles.hint }, identifierLabel),
393
+ h("input", {
394
+ style: styles.input,
395
+ value: identifier,
396
+ inputMode: kind === "phone" ? "tel" : "email",
397
+ autoComplete: kind === "phone" ? "tel" : "email",
398
+ onChange: (event) => setIdentifier(event.target.value),
399
+ disabled: busy,
400
+ }),
401
+ ),
402
+ h(
403
+ "div",
404
+ { style: { display: "flex", gap: "6px", alignItems: "flex-end" } },
405
+ h(
406
+ "label",
407
+ { style: Object.assign({}, styles.field, { flex: 1 }) },
408
+ h("span", { style: styles.hint }, TEXT.code),
409
+ h("input", {
410
+ style: styles.input,
411
+ value: code,
412
+ inputMode: "numeric",
413
+ autoComplete: "one-time-code",
414
+ onChange: (event) => setCode(event.target.value),
415
+ disabled: busy,
416
+ }),
417
+ ),
418
+ h(
419
+ "button",
420
+ {
421
+ type: "button",
422
+ style: Object.assign({}, styles.buttonGhost, { height: "32px" }),
423
+ disabled: busy,
424
+ onClick: () => onSendCode({ kind, identifier }),
425
+ },
426
+ busy ? TEXT.sending : TEXT.sendCode,
427
+ ),
428
+ ),
429
+ accounts && accounts.length > 0
430
+ ? h(
431
+ "div",
432
+ { style: styles.field },
433
+ h("span", { style: styles.hint }, TEXT.selectAccount),
434
+ accounts.map((account) =>
435
+ h(
436
+ "button",
437
+ { key: account.id, type: "button", style: styles.buttonGhost, disabled: busy, onClick: () => onSelectAccount(account.id) },
438
+ account.displayName || account.id,
439
+ ),
440
+ ),
441
+ )
442
+ : null,
443
+ error ? h("p", { style: styles.error }, error) : null,
444
+ notice ? h("p", { style: styles.ok }, notice) : null,
445
+ h(
446
+ "button",
447
+ { type: "button", style: styles.button, disabled: busy, onClick: () => onSignIn({ kind, identifier, code }) },
448
+ busy ? TEXT.signingIn : TEXT.signIn,
449
+ ),
450
+ );
451
+ }
452
+
453
+ /** One device row: a name the user recognizes plus the id they can quote. */
454
+ function DeviceRow({ device }) {
455
+ const online = device.online === true;
456
+ return h(
457
+ "div",
458
+ { style: styles.device },
459
+ h("span", { style: styles.deviceName }, device.name || device.deviceId),
460
+ h(
461
+ "span",
462
+ { style: styles.deviceMeta },
463
+ (online ? TEXT.deviceOnline : TEXT.deviceOffline) + " · " + platformText(device.platform) + " · " + device.deviceId,
464
+ ),
465
+ );
466
+ }
467
+
468
+ /**
469
+ * Build the settings page bound to one settings controller.
470
+ * @param scope - the bound controller for this namespace (see {@link writeSwitch}).
471
+ * @returns the React component the settings section renders.
472
+ */
473
+ function createCindySettingsPage(scope) {
474
+ return function CindyHostSettingsPage() {
475
+ const subscribe = react.useCallback((listener) => scope.subscribe(listener), [scope]);
476
+ const getSnapshot = react.useCallback(() => scope.getSnapshot(), [scope]);
477
+ const scopeSnapshot = react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
478
+
479
+ const [status, setStatus] = react.useState(null);
480
+ const [statusError, setStatusError] = react.useState(false);
481
+ const [refreshKey, setRefreshKey] = react.useState(0);
482
+ const [busy, setBusy] = react.useState(false);
483
+ const [error, setError] = react.useState(null);
484
+ const [notice, setNotice] = react.useState(null);
485
+ const [accounts, setAccounts] = react.useState(null);
486
+ const [loginTicket, setLoginTicket] = react.useState(null);
487
+
488
+ const refresh = react.useCallback(() => setRefreshKey((value) => value + 1), []);
489
+
490
+ react.useEffect(() => {
491
+ let alive = true;
492
+ const tick = async () => {
493
+ try {
494
+ const nextStatus = await readStatus();
495
+ if (!alive) return;
496
+ setStatus(nextStatus);
497
+ setStatusError(false);
498
+ } catch {
499
+ if (alive) setStatusError(true);
500
+ }
501
+ };
502
+ void tick();
503
+ const timer = setInterval(tick, POLL_MS);
504
+ return () => {
505
+ alive = false;
506
+ clearInterval(timer);
507
+ };
508
+ }, [refreshKey]);
509
+
510
+ const setSwitch = react.useCallback(
511
+ async (next) => {
512
+ setError(null);
513
+ try {
514
+ await writeSwitch(scope, next);
515
+ } catch {
516
+ setError("写入设置失败,请重试");
517
+ }
518
+ refresh();
519
+ },
520
+ [scope, refresh],
521
+ );
522
+
523
+ const guard = react.useCallback(async (work) => {
524
+ setBusy(true);
525
+ setError(null);
526
+ try {
527
+ await work();
528
+ } catch {
529
+ setError("请求失败,请重试");
530
+ } finally {
531
+ setBusy(false);
532
+ refresh();
533
+ }
534
+ }, [refresh]);
535
+
536
+ const onSendCode = ({ kind, identifier }) =>
537
+ guard(async () => {
538
+ setNotice(null);
539
+ const result = await postJson("/login/request-code", { kind, identifier });
540
+ if (!result.ok) {
541
+ setError(result.message || "验证码发送失败");
542
+ return;
543
+ }
544
+ setNotice(TEXT.codeSent);
545
+ });
546
+
547
+ const onSignIn = ({ kind, identifier, code }) =>
548
+ guard(async () => {
549
+ setNotice(null);
550
+ setAccounts(null);
551
+ const result = await postJson("/login/verify-code", { kind, identifier, code });
552
+ if (!result.ok) {
553
+ if (result.status === "select_account" && Array.isArray(result.accounts)) {
554
+ setAccounts(result.accounts);
555
+ setLoginTicket(result.loginTicket);
556
+ }
557
+ setError(result.message || "登录失败");
558
+ return;
559
+ }
560
+ setNotice(null);
561
+ });
562
+
563
+ const onSelectAccount = (accountId) =>
564
+ guard(async () => {
565
+ const result = await postJson("/login/select-account", { loginTicket, accountId });
566
+ if (!result.ok) {
567
+ setError(result.message || "账户选择失败");
568
+ return;
569
+ }
570
+ setAccounts(null);
571
+ });
572
+
573
+ const onReconnect = () => guard(async () => {
574
+ const result = await postJson("/reconnect", {});
575
+ if (!result.ok) setError(result.message || "重新连接失败");
576
+ });
577
+
578
+ const onLogout = () => guard(async () => {
579
+ const result = await postJson("/logout", {});
580
+ if (!result.ok) setError(result.message || "退出登录失败");
581
+ });
582
+
583
+ const view = resolveCardView(status, scopeSnapshot);
584
+
585
+ if (view.unavailable) {
586
+ return h("div", { style: styles.page }, h("p", { style: styles.hint }, TEXT.unavailable));
587
+ }
588
+
589
+ return h(
590
+ "div",
591
+ { style: styles.page },
592
+ h(
593
+ "div",
594
+ { style: styles.header },
595
+ h("span", { style: styles.title }, TEXT.title),
596
+ h("p", { style: styles.description }, TEXT.description),
597
+ ),
598
+
599
+ h(
600
+ "div",
601
+ { style: styles.row },
602
+ h(
603
+ "div",
604
+ { style: styles.rowText },
605
+ h("span", { style: styles.label }, TEXT.switchLabel),
606
+ h("span", { style: styles.hint }, TEXT.switchHint),
607
+ ),
608
+ h(Switch, { checked: view.switchOn, disabled: !view.writable || busy, onChange: (next) => void setSwitch(next), label: TEXT.switchLabel }),
609
+ ),
610
+
611
+ scopeSnapshot.status === "ready" && !view.switchOn
612
+ ? h("p", { style: styles.hint }, TEXT.switchDisabled)
613
+ : null,
614
+
615
+ statusError
616
+ ? h("p", { role: "status", style: styles.hint }, TEXT.statusUnavailable)
617
+ : h(
618
+ "div",
619
+ { style: styles.statusRow },
620
+ h("span", { "aria-hidden": "true", style: Object.assign({}, styles.dot, { background: stateColor(view.state) }) }),
621
+ h("span", null, view.stateLabel),
622
+ view.message ? h("span", { style: styles.hint }, "· " + view.message) : null,
623
+ ),
624
+
625
+ view.login.authenticated === true
626
+ ? h(
627
+ "div",
628
+ { style: styles.row },
629
+ h("span", { style: styles.hint }, TEXT.signedIn + (maskIdentifier(view.login.identifier) ? " · " + maskIdentifier(view.login.identifier) : "")),
630
+ h("button", { type: "button", style: styles.buttonGhost, disabled: busy, onClick: onLogout }, TEXT.logout),
631
+ )
632
+ : null,
633
+
634
+ view.showLogin ? h(LoginForm, { busy, error, notice, accounts, onSendCode, onSignIn, onSelectAccount }) : null,
635
+
636
+ view.showReconnect
637
+ ? h(
638
+ "div",
639
+ { style: { display: "flex", gap: "6px" } },
640
+ h("button", { type: "button", style: styles.buttonGhost, disabled: busy, onClick: onReconnect }, TEXT.reconnect),
641
+ )
642
+ : null,
643
+
644
+ error && !view.showLogin ? h("p", { style: styles.error }, error) : null,
645
+
646
+ h(
647
+ "div",
648
+ { style: styles.deviceList },
649
+ h("span", { style: styles.label }, TEXT.devices),
650
+ view.devices.length > 0 ? view.devices.map((device) => h(DeviceRow, { key: device.deviceId, device })) : h("span", { style: styles.hint }, TEXT.noDevices),
651
+ view.host && view.host.deviceId
652
+ ? h(
653
+ "span",
654
+ { style: styles.deviceMeta },
655
+ TEXT.hostDevice + ": " + (view.host.deviceName || "DSH Host") + " · " + view.host.deviceId,
656
+ )
657
+ : null,
658
+ ),
659
+ );
660
+ };
661
+ }
662
+
663
+ /**
664
+ * Services this half needs a provider **for before it may activate**.
665
+ *
666
+ * `slots` only, and that is the whole fix: a module-level `inject` entry is a
667
+ * *blocking* dependency, so listing a service the running DSH does not provide
668
+ * keeps this half permanently pending — it never activates, and 0.2 reports the
669
+ * entry as the reason it refuses to finish starting.
670
+ *
671
+ * It used to be `["slots", "settingsScope"]`, and the settings service changed
672
+ * name between generations:
673
+ *
674
+ * - `0.1.5-rc.2` provides **`settingsScope`** (`dsh-client-ui-settings` client half
675
+ * `super(ctx, "settingsScope")`), whose `bind({ namespace })` returns a scope.
676
+ * - `0.2.0-rc.2` removed it and provides **`configForms`** plus `settingsSchema`;
677
+ * `configForms.get(entryId)` returns the controller for one namespace, with the
678
+ * same `getSnapshot()` / `subscribe()` / `set(field, value)` face.
679
+ *
680
+ * Neither name may live here. {@link apply} resolves whichever one this deployment
681
+ * has through a *non-blocking* `ctx.inject`, so an absent provider costs the
682
+ * settings page and nothing else.
683
+ */
684
+ const inject = ["slots"];
685
+
686
+ /** Nav position: general=0, models=10, plugins=15, agent-presets=20. */
687
+ const SECTION_ORDER = 25;
688
+
689
+ /**
690
+ * Bind the settings page to whichever settings service this DSH generation
691
+ * provides, without ever blocking this half's activation.
692
+ *
693
+ * Both candidate services are tried through `ctx.inject`, which only runs its
694
+ * callback once the service exists and never holds the plugin pending. The two
695
+ * names are mutually exclusive across the generations this bundle supports (0.1.x
696
+ * ships `settingsScope`, 0.2 ships `configForms`), so the first one to arrive wins
697
+ * and `bound` makes a second registration impossible even if a future deployment
698
+ * shipped both.
699
+ *
700
+ * The page is registered on the plugin's own context rather than the provider's, so
701
+ * it outlives a provider that appears and disappears; a controller that loses its
702
+ * backing namespace reports `unavailable` through its own snapshot, which the page
703
+ * already renders as an explanation instead of an empty switch.
704
+ *
705
+ * @param ctx - the plugin context that owns the `slots` service.
706
+ */
707
+ function bindSettingsPage(ctx) {
708
+ let bound = false;
709
+ const adopt = (scope) => {
710
+ if (bound || scope === undefined || scope === null) return;
711
+ bound = true;
712
+ const Page = createCindySettingsPage(scope);
713
+ // `slots.inject`, not a bare `slots.register`: the settings shell declares
714
+ // `settings.section` when ui-settings-general activates, and this bundle
715
+ // may activate before it does. Registering eagerly throws
716
+ // `slot "settings.section" is not declared`; injecting defers the
717
+ // registration until the declaring parent publishes the slot — which is
718
+ // how the shipped sections (general/models/plugins) register too.
719
+ //
720
+ // A dedicated page rather than a `settings.plugin.item` card: this surface
721
+ // owns a connection switch, a sign-in flow and a device list, which is a
722
+ // page's worth of state, and the Plugins tab is for a plugin's preferences.
723
+ ctx.slots.inject("settings.section", () =>
724
+ ctx.slots.register(
725
+ {
726
+ name: "settings.section",
727
+ // `id` is the section key the nav and `only` filtering use; it stays the
728
+ // settings namespace so the page and its Host half name the same thing —
729
+ // on 0.2 that string is also the profile entry id the host projects.
730
+ id: NAMESPACE,
731
+ order: SECTION_ORDER,
732
+ // The shell reads this on every render, so it stays a fresh function
733
+ // over the current locale rather than a frozen string.
734
+ label: () => TEXT.title,
735
+ },
736
+ Page,
737
+ ),
738
+ );
739
+ };
740
+
741
+ // 0.2 and later. `get(entryId)` is the per-namespace controller.
742
+ ctx.inject(["configForms"], (formsCtx) => {
743
+ adopt(formsCtx.configForms?.get?.(NAMESPACE));
744
+ });
745
+ // 0.1.x. `bind({ namespace })` is the per-namespace scope.
746
+ ctx.inject(["settingsScope"], (scopeCtx) => {
747
+ adopt(scopeCtx.settingsScope?.bind?.({ namespace: NAMESPACE }));
748
+ });
749
+ }
750
+
751
+ function apply(ctx) {
752
+ bindSettingsPage(ctx);
753
+ }
754
+
755
+ exports.apply = apply;
756
+ exports.inject = inject;
757
+ // Test seam: the presentation helpers and sub-components, so the markup
758
+ // rules can be rendered and asserted without a browser. Not part of the
759
+ // plugin contract — the runner reads only `apply` and `inject`.
760
+ exports.__internals = { TEXT, NAMESPACE, API, SECTION_ORDER, resolveCardView, bindSettingsPage, stateText, stateColor, platformText, writeSwitch, readStatus, maskIdentifier, Switch, LoginForm, DeviceRow, createCindySettingsPage };
761
+ return module.exports;
762
+ },
763
+ });