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.
- package/CHANGELOG.md +462 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/cordis.patch.yml +6 -0
- package/doc/architecture.md +34 -0
- package/doc/cindy-android-verify.md +362 -0
- package/doc/cindy-mobile-freeze-handoff.md +124 -0
- package/doc/cindy-phone-link.md +2902 -0
- package/doc/desktop-runtime-fix-20260930.md +69 -0
- package/doc/development-plan.md +19 -0
- package/doc/file-peer-apply-recovery-20260921.md +35 -0
- package/doc/file-peer-installed-hotfix-20260921.md +53 -0
- package/doc/file-peer-minimal-apply-20260921.md +78 -0
- package/doc/file-peer-original-contract-alignment-20260921.md +119 -0
- package/doc/file-peer-phone-export-observation-20260921.md +75 -0
- package/doc/file-peer-product-contract-audit-20260921.md +106 -0
- package/doc/file-peer-root-cause-20260921.md +53 -0
- package/doc/file-transfer.md +50 -0
- package/doc/host-performance-20260930.md +91 -0
- package/doc/neonabyss-apk-file-peer-diagnosis-20260921.md +87 -0
- package/doc/new-session-sync-fix-20260930.md +59 -0
- package/doc/patches/cindy-peer-silence-probe.patch +1112 -0
- package/doc/projection-read-probe.md +77 -0
- package/doc/publishing.md +259 -0
- package/doc/releasing.md +149 -0
- package/doc/research.md +24 -0
- package/doc/session-discovery-2026-09-20.md +67 -0
- package/lib/client.js +763 -0
- package/package.json +84 -0
- package/src/auth-session.js +189 -0
- package/src/authorization-policy.js +59 -0
- package/src/cindy-channels.js +1707 -0
- package/src/cindy-login-flow.js +159 -0
- package/src/cindy-login.js +80 -0
- package/src/cindy-message-row.js +432 -0
- package/src/cindy-session-row.js +171 -0
- package/src/console-sink.js +6 -0
- package/src/contracts.js +26 -0
- package/src/credential-store.js +128 -0
- package/src/demo.js +9 -0
- package/src/dsh-active-sessions.js +59 -0
- package/src/dsh-host-source.js +62 -0
- package/src/dsh-message-fold.js +608 -0
- package/src/dsh-plugin.js +1932 -0
- package/src/dsh-session-source.js +529 -0
- package/src/file-peer-protocol.js +7 -0
- package/src/file-peer-rtc.js +41 -0
- package/src/fixture-source.js +13 -0
- package/src/host-approvals.js +329 -0
- package/src/host-attachments.js +249 -0
- package/src/host-authorization.js +51 -0
- package/src/host-demo.js +3 -0
- package/src/host-export-jobs.js +356 -0
- package/src/host-export-owner.js +62 -0
- package/src/host-file-browser.js +400 -0
- package/src/host-file-ice.js +63 -0
- package/src/host-file-peer.js +289 -0
- package/src/host-file-reference.js +16 -0
- package/src/host-files.js +148 -0
- package/src/host-frame-budget.js +310 -0
- package/src/host-goals.js +284 -0
- package/src/host-history-view-limits.js +22 -0
- package/src/host-history-view.js +417 -0
- package/src/host-input-queue.js +505 -0
- package/src/host-media-fetch.js +511 -0
- package/src/host-media.js +678 -0
- package/src/host-models.js +231 -0
- package/src/host-palette.js +111 -0
- package/src/host-push-channels.js +37 -0
- package/src/host-reconnect.js +54 -0
- package/src/host-routes.js +266 -0
- package/src/host-settings.js +41 -0
- package/src/host-status.js +216 -0
- package/src/host-transfer-diagnostics.js +50 -0
- package/src/host.js +2497 -0
- package/src/login.js +31 -0
- package/src/logout.js +3 -0
- package/src/projection-read-model-sink.js +6 -0
- package/src/projection.js +43 -0
- package/src/session-flags.js +160 -0
- package/src/session-publisher.js +123 -0
- 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
|
+
});
|