dsh-cherry-bridge 0.2.0 → 0.2.1
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/README.en.md +7 -7
- package/README.md +6 -5
- package/dist/index.d.mts +1 -0
- package/dist/index.mjs +668 -35
- package/docs/e2e-checklist.md +49 -1
- package/lib/client.js +264 -31
- package/package.json +1 -1
- package/src/AGENTS.md +103 -14
- package/src/connection.ts +174 -2
- package/src/deviceStore.ts +314 -1
- package/src/httpApi.ts +234 -27
- package/src/index.ts +13 -0
- package/src/tools.ts +148 -9
- package/ui/AGENTS.md +21 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +106 -4
- package/ui/src/PairingWizard.tsx +80 -5
- package/ui/src/ToolScopePanel.tsx +19 -6
- package/ui/src/api.ts +111 -17
- package/ui/src/i18n.ts +39 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +7 -0
package/docs/e2e-checklist.md
CHANGED
|
@@ -46,6 +46,11 @@
|
|
|
46
46
|
**权威来源**;实时 `interaction.updated` 流是**加速器/保鲜器**——它让插件不必为每张卡都
|
|
47
47
|
回查一次,但**不保证**送达,也不构成资格依据。任何一张卡都必须仅凭 list 快照可应答。
|
|
48
48
|
|
|
49
|
+
**上游实证**:2.1.9 之前,桌面的通知发送链会被一次失败**永久毒死**——`RemoteConnection.send()`
|
|
50
|
+
把可 rejected 的 Promise 直接存回链头,此后该连接上的 `interaction.updated` 等通知静默停发,
|
|
51
|
+
而请求-应答不受影响(应答不走这条链)。2.1.9 由 PR #22593 修复。这就是「实时流只是加速器、
|
|
52
|
+
list 快照才是权威」的实证来源:一条健康的连接随时可能不再推送任何通知。
|
|
53
|
+
|
|
49
54
|
**手动核验步骤**(与 `CONTRACT_DIFFERENCES[1].checklistStep` 同源):
|
|
50
55
|
|
|
51
56
|
- [ ] approval-card-not-realtime: trigger a tool call on the real desktop that raises an approval card; confirm `cherry_interactions` finds it through `agent.interactions.list` even when no `interaction.updated` event arrived, and that `cherry_respond` still accepts the listed snapshot.
|
|
@@ -159,6 +164,14 @@ schema 已允许 `interrupted`,所以客户端**必须**处理,但当前服
|
|
|
159
164
|
- [ ] 设置页同时渲染两张设备卡片,状态灯随各自连接独立变化。
|
|
160
165
|
- [ ] 停用其中一台 → 该设备转 `disabled` 且套接字被断开;再启用 → 从档案重建连接。
|
|
161
166
|
- [ ] `cherry_unpair` 移除一台 → 另一台连接与订阅不受影响。
|
|
167
|
+
- [ ] 设置页「忘记 / 恢复」:对某台设备的一个候选地址点「**忘记**」→ 该地址从「地址」行消失、
|
|
168
|
+
出现在「**已忘记地址**」行;`GET /devices` 的 `forgottenHosts` 含它,且**重启桌面或重连后
|
|
169
|
+
该地址不会被自动学回来**(`cherry_devices` 的 `action: "forget"` 是同一个写入)。
|
|
170
|
+
点「**恢复**」→ 标记消失,桌面下次广播该地址时重新加入;只剩最后一个候选地址时点「忘记」
|
|
171
|
+
→ 页面显示「不能忘记最后一个候选地址」而**不是** `last-address:` 前缀的服务端原文。
|
|
172
|
+
另外核一条**冻结**规则:打开「地址维护」后,卡片上的「忘记 / 恢复」应变为**不可点**——那是
|
|
173
|
+
因为该表单整体替换 `hosts`,它的草稿不会被卡片重新播种,编辑器开着时点「忘记」会被下一次
|
|
174
|
+
「保存地址」连同标记一起覆盖掉;关掉编辑器即恢复可点。
|
|
162
175
|
|
|
163
176
|
### F. 工具启用范围(`/scope`)
|
|
164
177
|
|
|
@@ -269,7 +282,7 @@ http://127.0.0.1:23023/cherry-remote/api/scope`(宿主换过端口就换端口
|
|
|
269
282
|
- [ ] 该端点同样受信任 fence 约束:非回环 Host、非同源 Origin、`sec-fetch-site: cross-site`
|
|
270
283
|
一律 403;无 Origin 的非浏览器客户端放行。
|
|
271
284
|
- [ ] 全流程日志不含 token / invitationSecret / accessToken / 消息文本 / part 文本。
|
|
272
|
-
- [ ] **上游版本核验**(桌面端为 2.1.4–2.1.
|
|
285
|
+
- [ ] **上游版本核验**(桌面端为 2.1.4–2.1.10 或已比对过的更新版本;依据见
|
|
273
286
|
src/AGENTS.md「Protocol basis」):
|
|
274
287
|
- [ ] 桌面端**升级后无需重新配对**:设备档案直接连上,`desktopIdentity` 未变。
|
|
275
288
|
- [ ] 桌面端为 2.1.5+ 且会话里有**来自手机上传的附件**时:该 part 为 `kind:"file"`
|
|
@@ -284,8 +297,43 @@ http://127.0.0.1:23023/cherry-remote/api/scope`(宿主换过端口就换端口
|
|
|
284
297
|
`data` part(任务事件等)不是附件,原样透传。
|
|
285
298
|
- [ ] `cherry_send` 传空白文本(如 `" "`)→ 本地返回 `text-empty`,对端未收到
|
|
286
299
|
`agent.messages.send`。
|
|
300
|
+
- [ ] 桌面端为 2.1.8+ 且 hello 广播了 `agentMessageSummariesVersion: 1` 时:
|
|
301
|
+
`GET /cherry-remote/api/diagnostics` 顶层出现 `messageSummaries: 1`,且一次
|
|
302
|
+
`cherry_read` 正常返回消息与 parts(消息行走分页摘要,请求带 `partsMode:"paged"`,
|
|
303
|
+
part 经 `agent.parts.list` 按 messageId + revision 枚举)。桌面端未广播时该字段
|
|
304
|
+
**不出现**,读路径回落到字节安全的分页尺寸。
|
|
287
305
|
- [ ] 桌面端广播了本插件不认识的支持字段时,`cherry_devices` 的 limits 投影仍只有
|
|
288
306
|
4 个已知键(heartbeatMs/subscriptions/pageItems/inFlightRequests),连接不被拒。
|
|
289
307
|
- [ ] `package.json` 中 `@cherrystudio/remote-protocol`、`@cherrystudio/remote-transport`
|
|
290
308
|
为精确版本(无 `^`)。
|
|
291
309
|
- [ ] `npm test` 全绿(含 `tests/contract/contract.test.ts`)。
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## 第四部分:机器可验证项实测记录
|
|
314
|
+
|
|
315
|
+
A–G 各节要真人 + Electron 端批准,无法被 CI 化;下表只记录其中**不需要人工点击、agent
|
|
316
|
+
在本机就能断言**的那部分,用于跨版本比对「哪些已经被证过」。带勾选框的条目仍是唯一判据。
|
|
317
|
+
|
|
318
|
+
### 0.2.0(2026-10-27 · DSH profile `web` · 桌面端 ISV-ZYX 2.1.8)
|
|
319
|
+
|
|
320
|
+
| 项 | 结果 | 观测 |
|
|
321
|
+
| --- | --- | --- |
|
|
322
|
+
| 装机生效 | 通过 | 包 mtime `18:49:52` 早于宿主 PID 1 启动 `18:50:00`;装机 `dist/index.mjs`、`dist/index.d.mts`、`lib/client.js`、`locale/zh.json` 与发行 tarball sha256 全同 |
|
|
323
|
+
| 设备档案迁移 `schemaVersion` 2→4 | 通过 | `devices.json` 为 `schemaVersion: 4`,逐设备 `security` / `ports` 两个映射就位;设备**未重新配对**,重启后自行连上 |
|
|
324
|
+
| `GET /cherry-remote/api/devices` 新字段 | 通过 | 返回 `security` 与 `ports`(0.1.11 没有这两个键) |
|
|
325
|
+
| 分页摘要能力 | 通过 | `GET …/diagnostics` 顶层 `messageSummaries: 1`;`cherry_read` 正常返回历史会话,parts 由 `agent.parts.list` 回填 |
|
|
326
|
+
| 坏 pin 解析期拒绝(#153) | 通过 | `desktopIdentity: "not-a-peer-id"` → `invalid-payload`/`payload field "desktopIdentity" is not a peer id`;`connections`/`sockets`/`connectionsBuilt` 保持 1/1/1,设备数不变,**零 socket** |
|
|
327
|
+
| 失败分型 + 裁决有界(#142 / #148) | 通过 | 3 个 193 字符主机 → `unreachable`,每段形如 `…:9400: websocket: getaddrinfo ENOTFOUND … (+147 chars)`:分型在、端口保留、段 ≤240 |
|
|
328
|
+
| 进度行有界(#152) | 通过 | 宿主日志 `pairing stage validate (hosts: h0.… (+2 more))` 整行 251 字符 |
|
|
329
|
+
|
|
330
|
+
**本轮未覆盖**(环境不可达,**不是通过**):
|
|
331
|
+
|
|
332
|
+
- **#141 拨号总预算**:需要黑洞地址让每次拨号烧满 `connectTimeoutMs`,实测要挂满 30 s;
|
|
333
|
+
由集成测试的 `dialBudgetMs: 0` 用例覆盖(断言 `connectionCount === 0` 且每个候选各出现一次)。
|
|
334
|
+
- **#133 `wsTls`**:被测环境是内网 `ws://`、没有 TLS 前置,配置该键只会被忽略;由
|
|
335
|
+
`tests/helpers/tls/dualPeer.ts` 的自签 TLS 握手用例覆盖。
|
|
336
|
+
- **#140 设置页 per-host 端口行**:该行**仅在**各主机端口与记录级 `port` 不一致时渲染,
|
|
337
|
+
被测设备是单主机同端口;需要造一条混合端口记录才能走。
|
|
338
|
+
- **#161 `failureText`**:要制造一个 peer 可达的 caught failure(如 ack 失败)才会出现,
|
|
339
|
+
本轮 `recentErrors` 为空。
|
package/lib/client.js
CHANGED
|
@@ -45,6 +45,12 @@ window.__ModuleLoader__.load({
|
|
|
45
45
|
"device.remove.confirm": "确认移除",
|
|
46
46
|
"device.remove.warning": "移除后本机将不再连接该设备,凭据保留在对方一侧。",
|
|
47
47
|
"device.cancel": "取消",
|
|
48
|
+
"device.forget": "忘记",
|
|
49
|
+
"device.remember": "恢复",
|
|
50
|
+
"device.field.forgotten": "已忘记地址",
|
|
51
|
+
"device.forgotten.hint": "已忘记的地址不会再被连接;「恢复」只解除标记,桌面下次广播该地址时会重新加入。",
|
|
52
|
+
"device.error.lastAddress": "不能忘记最后一个候选地址——先补一个地址,再忘记这个",
|
|
53
|
+
"device.error.timeout": "请求长时间没有回应,已中止——请稍后重试",
|
|
48
54
|
"device.error.nameRequired": "设备名称不能为空",
|
|
49
55
|
"device.error.hostsRequired": "至少需要一个主机地址",
|
|
50
56
|
"device.error.portInvalid": "端口必须是 1..65535 之间的整数",
|
|
@@ -65,7 +71,10 @@ window.__ModuleLoader__.load({
|
|
|
65
71
|
"wizard.succeeded": "配对成功",
|
|
66
72
|
"wizard.failed": "配对失败:{stage}",
|
|
67
73
|
"wizard.unknownError": "未知错误",
|
|
74
|
+
"app.error.timeout": "请求长时间没有回应,已中止——请确认宿主仍在运行;若反复出现,请重启宿主后再试",
|
|
75
|
+
"wizard.timedOut": "没有等到配对结果——若桌面端已批准,下面的设备列表里会出现该设备;否则请在 Cherry Studio 重新生成配对码后重试",
|
|
68
76
|
"wizard.error.payloadRequired": "请先粘贴扫码页显示的配对信息",
|
|
77
|
+
"wizard.error.timeout": "配对请求迟迟没有回应,已中止——请重试;若反复出现,宿主可能已卡住,重启后再试",
|
|
69
78
|
"wizard.stage.validate": "正在校验配对信息…",
|
|
70
79
|
"wizard.stage.connect": "正在连接 Cherry Studio…",
|
|
71
80
|
"wizard.stage.claim": "正在交换凭据…",
|
|
@@ -103,6 +112,7 @@ window.__ModuleLoader__.load({
|
|
|
103
112
|
"scope.savedPruned": "保存成功,按服务端修剪清除了失效选择:{list}。",
|
|
104
113
|
"scope.reread": "重新读取",
|
|
105
114
|
"scope.saveFailed": "保存失败:{cause}",
|
|
115
|
+
"scope.error.timeout": "请求长时间没有回应,已中止——请稍后重试",
|
|
106
116
|
"scope.serverSaid": "服务端返回 {code}(HTTP {status})。",
|
|
107
117
|
"scope.degraded.install": "隐藏层应用曾失败(install):相关 agent 的隐藏层未生效或无法确认,启用范围对它可能不适用",
|
|
108
118
|
"scope.degraded.workspace-list": "注册表列表当前读不到",
|
|
@@ -149,6 +159,12 @@ window.__ModuleLoader__.load({
|
|
|
149
159
|
"device.remove.confirm": "Confirm removal",
|
|
150
160
|
"device.remove.warning": "This machine will stop connecting to the device. Its credentials stay on the peer.",
|
|
151
161
|
"device.cancel": "Cancel",
|
|
162
|
+
"device.forget": "Forget",
|
|
163
|
+
"device.remember": "Restore",
|
|
164
|
+
"device.field.forgotten": "Forgotten addresses",
|
|
165
|
+
"device.forgotten.hint": "Forgotten addresses are never dialed. Restore only lifts the mark — the address comes back the next time the desktop advertises it.",
|
|
166
|
+
"device.error.lastAddress": "The last candidate address cannot be forgotten. Add one first.",
|
|
167
|
+
"device.error.timeout": "The request stopped answering and was abandoned. Try again in a moment.",
|
|
152
168
|
"device.error.nameRequired": "Device name cannot be empty",
|
|
153
169
|
"device.error.hostsRequired": "At least one host address is required",
|
|
154
170
|
"device.error.portInvalid": "Port must be an integer between 1 and 65535",
|
|
@@ -169,7 +185,10 @@ window.__ModuleLoader__.load({
|
|
|
169
185
|
"wizard.succeeded": "Paired",
|
|
170
186
|
"wizard.failed": "Pairing failed: {stage}",
|
|
171
187
|
"wizard.unknownError": "Unknown error",
|
|
188
|
+
"app.error.timeout": "The request stopped answering and was abandoned. Check that the host is still running; if it keeps happening, restart it and try again.",
|
|
189
|
+
"wizard.timedOut": "No pairing result arrived. If the desktop approved it, the device appears in the list below; otherwise generate a new pairing code in Cherry Studio and try again.",
|
|
172
190
|
"wizard.error.payloadRequired": "Paste the pairing payload shown on the scan page first",
|
|
191
|
+
"wizard.error.timeout": "The pairing request stopped answering and was abandoned. Try again; if it keeps happening the host may be stuck — restart it and retry.",
|
|
173
192
|
"wizard.stage.validate": "Validating the pairing payload…",
|
|
174
193
|
"wizard.stage.connect": "Connecting to Cherry Studio…",
|
|
175
194
|
"wizard.stage.claim": "Exchanging credentials…",
|
|
@@ -207,6 +226,7 @@ window.__ModuleLoader__.load({
|
|
|
207
226
|
"scope.savedPruned": "Saved. The server pruned these stale selections: {list}.",
|
|
208
227
|
"scope.reread": "Reload",
|
|
209
228
|
"scope.saveFailed": "Save failed: {cause}",
|
|
229
|
+
"scope.error.timeout": "The request stopped answering and was abandoned. Try again in a moment.",
|
|
210
230
|
"scope.serverSaid": "The server answered {code} (HTTP {status}).",
|
|
211
231
|
"scope.degraded.install": "Applying a hidden layer failed earlier (install): the affected agent’s hidden layer is not in effect or cannot be confirmed, so the tool scope may not apply to it",
|
|
212
232
|
"scope.degraded.workspace-list": "The workspace registry list is currently unreadable",
|
|
@@ -411,6 +431,33 @@ window.__ModuleLoader__.load({
|
|
|
411
431
|
this.name = "ApiError";
|
|
412
432
|
}
|
|
413
433
|
};
|
|
434
|
+
/**
|
|
435
|
+
* The marker a request that never answered throws with.
|
|
436
|
+
*
|
|
437
|
+
* Internal token, NEVER copy: each surface renders its own sentence for it —
|
|
438
|
+
* `app.error.timeout`, `wizard.error.timeout`, `scope.error.timeout` — and this
|
|
439
|
+
* string only travels from `request()` to the catch block that recognizes it, the
|
|
440
|
+
* same split as `app.eventSourceUnavailable` (see `App.tsx`). That is what keeps
|
|
441
|
+
* an English literal off a Chinese page.
|
|
442
|
+
*/
|
|
443
|
+
var REQUEST_TIMEOUT = "cherry-remote.request-timeout";
|
|
444
|
+
/**
|
|
445
|
+
* How long ANY call on this page may stay open, in milliseconds.
|
|
446
|
+
*
|
|
447
|
+
* None of these calls is slow: they are same-origin requests to the plugin's own
|
|
448
|
+
* HTTP face, and the one that looks slowest — a pairing START — does no I/O
|
|
449
|
+
* before it answers (`src/httpApi.ts` `startPairing` writes its 202 at once).
|
|
450
|
+
* A request still open after this long is a WEDGED transport, not a busy server,
|
|
451
|
+
* and it must not be allowed to strand a control: without a ceiling the pairing
|
|
452
|
+
* wizard's submit button (issue #173), a device card's buttons, the scope panel's
|
|
453
|
+
* save and the device list's "loading" state each wait for the life of the page
|
|
454
|
+
* (issue #180).
|
|
455
|
+
*
|
|
456
|
+
* So the ceiling lives in `request()`, where every call passes, rather than on
|
|
457
|
+
* one call site. Each surface turns the {@link REQUEST_TIMEOUT} marker into its
|
|
458
|
+
* own sentence.
|
|
459
|
+
*/
|
|
460
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 1e4;
|
|
414
461
|
/** Reads a JSON body without throwing on an empty (204) or non-JSON response. */
|
|
415
462
|
async function readBody(response) {
|
|
416
463
|
const text = await response.text();
|
|
@@ -421,22 +468,36 @@ window.__ModuleLoader__.load({
|
|
|
421
468
|
return text;
|
|
422
469
|
}
|
|
423
470
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
471
|
+
/**
|
|
472
|
+
* One bounded request. `timeoutMs` exists so a test can prove the ceiling
|
|
473
|
+
* without waiting for it; production always takes {@link DEFAULT_REQUEST_TIMEOUT_MS}.
|
|
474
|
+
*/
|
|
475
|
+
async function request(url, init, timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
476
|
+
const controller = new AbortController();
|
|
477
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
478
|
+
try {
|
|
479
|
+
const response = await fetch(url, {
|
|
480
|
+
...init,
|
|
481
|
+
headers: {
|
|
482
|
+
"content-type": "application/json",
|
|
483
|
+
...init?.headers ?? {}
|
|
484
|
+
},
|
|
485
|
+
signal: controller.signal
|
|
486
|
+
});
|
|
487
|
+
const body = await readBody(response);
|
|
488
|
+
if (!response.ok) {
|
|
489
|
+
const record = body ?? {};
|
|
490
|
+
const code = typeof record.error === "string" ? record.error : `http-${response.status}`;
|
|
491
|
+
const message = typeof record.message === "string" ? record.message : code;
|
|
492
|
+
throw new ApiError(response.status, code, message);
|
|
430
493
|
}
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
throw new ApiError(response.status, code, message);
|
|
494
|
+
return body;
|
|
495
|
+
} catch (cause) {
|
|
496
|
+
if (controller.signal.aborted) throw new Error(REQUEST_TIMEOUT);
|
|
497
|
+
throw cause;
|
|
498
|
+
} finally {
|
|
499
|
+
clearTimeout(timer);
|
|
438
500
|
}
|
|
439
|
-
return body;
|
|
440
501
|
}
|
|
441
502
|
/** `GET /devices` — the full card list, states projected server-side. */
|
|
442
503
|
async function listDevices() {
|
|
@@ -449,15 +510,19 @@ window.__ModuleLoader__.load({
|
|
|
449
510
|
* It deliberately does NOT wait for approval: the 6-digit code and the verdict
|
|
450
511
|
* arrive as `pairing-progress` SSE frames, which is why the caller must already
|
|
451
512
|
* be subscribed when this resolves.
|
|
513
|
+
*
|
|
514
|
+
* Bounded like every other call (see {@link DEFAULT_REQUEST_TIMEOUT_MS});
|
|
515
|
+
* `timeoutMs` exists only so a test can prove the ceiling without waiting.
|
|
452
516
|
*/
|
|
453
|
-
async function startPairing(payload,
|
|
517
|
+
async function startPairing(payload, options = {}) {
|
|
518
|
+
const { name } = options;
|
|
454
519
|
return await request(`/cherry-remote/api/pair`, {
|
|
455
520
|
method: "POST",
|
|
456
521
|
body: JSON.stringify(name === void 0 || name === "" ? { payload } : {
|
|
457
522
|
payload,
|
|
458
523
|
name
|
|
459
524
|
})
|
|
460
|
-
}) ?? {};
|
|
525
|
+
}, options.timeoutMs) ?? {};
|
|
461
526
|
}
|
|
462
527
|
/** `PATCH /devices/:id` — `hosts` is whole-replace; omitted fields are untouched. */
|
|
463
528
|
async function patchDevice(deviceId, patch) {
|
|
@@ -466,6 +531,35 @@ window.__ModuleLoader__.load({
|
|
|
466
531
|
body: JSON.stringify(patch)
|
|
467
532
|
});
|
|
468
533
|
}
|
|
534
|
+
/**
|
|
535
|
+
* `POST /devices/:id/forget` — stop dialing these addresses AND record them so
|
|
536
|
+
* the desktop cannot advertise them back (issue #185).
|
|
537
|
+
*
|
|
538
|
+
* The answer is the SAME record shape {@link patchDevice} returns, which is why
|
|
539
|
+
* the card hands either body to the same `onSaved`. The action is its own
|
|
540
|
+
* request on purpose: folding it into the PATCH would make "replace the dial
|
|
541
|
+
* list" and "forget one address" one body with an ordering to define.
|
|
542
|
+
*/
|
|
543
|
+
async function forgetHosts(deviceId, hosts) {
|
|
544
|
+
return await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}/forget`, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
body: JSON.stringify({ hosts })
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* `POST /devices/:id/remember` — lift the marks.
|
|
551
|
+
*
|
|
552
|
+
* It does NOT dial the address again: the record's `hosts` is untouched, and
|
|
553
|
+
* the address returns when the desktop next advertises it. Putting it back into
|
|
554
|
+
* the dial list immediately stays the PATCH's job (that PATCH clears the mark in
|
|
555
|
+
* the same write).
|
|
556
|
+
*/
|
|
557
|
+
async function rememberHosts(deviceId, hosts) {
|
|
558
|
+
return await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}/remember`, {
|
|
559
|
+
method: "POST",
|
|
560
|
+
body: JSON.stringify({ hosts })
|
|
561
|
+
});
|
|
562
|
+
}
|
|
469
563
|
/** `DELETE /devices/:id` — 204, no body. */
|
|
470
564
|
async function removeDevice(deviceId) {
|
|
471
565
|
await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}`, { method: "DELETE" });
|
|
@@ -519,6 +613,16 @@ window.__ModuleLoader__.load({
|
|
|
519
613
|
* `onSaved`, so the card shows what the archive holds rather than what the
|
|
520
614
|
* browser hoped for. The live `state` keeps coming from SSE; the patch response
|
|
521
615
|
* only seeds it.
|
|
616
|
+
*
|
|
617
|
+
* The addresses row is also where an address is FORGOTTEN (issue #204), and the
|
|
618
|
+
* marks it leaves are listed under their own label with a 恢复 each. One click
|
|
619
|
+
* is one committed action, never a draft: the two controls are INERT while the
|
|
620
|
+
* address editor is open, because that form PATCHes the whole host list and its
|
|
621
|
+
* draft is unconditionally sacred (see the resync effect) — a mark chosen
|
|
622
|
+
* underneath it would be re-sent by the next 保存地址, and the PATCH that names a
|
|
623
|
+
* forgotten host clears its mark in the same write. Freezing the controls is
|
|
624
|
+
* what keeps "commit or discard the edit first" true; it is not a stylistic
|
|
625
|
+
* preference.
|
|
522
626
|
*/
|
|
523
627
|
/** Splits the comma-separated host box into the whole-replace list the API wants. */
|
|
524
628
|
function parseHosts(raw) {
|
|
@@ -581,18 +685,59 @@ window.__ModuleLoader__.load({
|
|
|
581
685
|
device.port,
|
|
582
686
|
editor
|
|
583
687
|
]);
|
|
584
|
-
/**
|
|
688
|
+
/**
|
|
689
|
+
* Runs one mutation, mapping a refusal into the card's error line.
|
|
690
|
+
*
|
|
691
|
+
* A request that never answered arrives as `api.ts`'s internal marker — the
|
|
692
|
+
* page-wide ceiling (issue #180) — and a marker must never render. Every card
|
|
693
|
+
* action is a bounded call (停用 / 重命名 / 移除 are PATCH or DELETE), so this
|
|
694
|
+
* surface translates it like the other three do instead of echoing
|
|
695
|
+
* `cherry-remote.request-timeout` onto the page.
|
|
696
|
+
*/
|
|
585
697
|
const run = async (action) => {
|
|
586
698
|
setBusy(true);
|
|
587
699
|
setError(void 0);
|
|
588
700
|
try {
|
|
589
701
|
await action();
|
|
590
702
|
} catch (cause) {
|
|
591
|
-
setError(
|
|
703
|
+
setError(describeCause(cause));
|
|
592
704
|
} finally {
|
|
593
705
|
setBusy(false);
|
|
594
706
|
}
|
|
595
707
|
};
|
|
708
|
+
/**
|
|
709
|
+
* One refusal for a cause, so the two markers and the one translated code
|
|
710
|
+
* cannot drift between this card's arms.
|
|
711
|
+
*
|
|
712
|
+
* `ApiError` renders as `<code>: <server message>`, which is right for a
|
|
713
|
+
* diagnostic and wrong for `last-address`: forgetting the only candidate is a
|
|
714
|
+
* thing the operator can fix here, and the server's own prose is English on a
|
|
715
|
+
* Chinese page. Everything else keeps the server's verdict verbatim — that is
|
|
716
|
+
* what tells `invalid-payload` from `unreachable`.
|
|
717
|
+
*/
|
|
718
|
+
const describeCause = (cause) => {
|
|
719
|
+
if (cause instanceof ApiError && cause.code === "last-address") return t("device.error.lastAddress");
|
|
720
|
+
if (cause instanceof Error && cause.message === "cherry-remote.request-timeout") return t("device.error.timeout");
|
|
721
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
722
|
+
};
|
|
723
|
+
/** Forgets ONE address: the store drops it and records the mark in one write. */
|
|
724
|
+
const forget = (host) => {
|
|
725
|
+
run(async () => {
|
|
726
|
+
onSaved(await forgetHosts(device.deviceId, [host]));
|
|
727
|
+
});
|
|
728
|
+
};
|
|
729
|
+
/**
|
|
730
|
+
* Lifts ONE mark. The server also drops the pooled connection on a change, so
|
|
731
|
+
* the very handshake that follows re-learns the address if the desktop still
|
|
732
|
+
* advertises it — without that, a desktop that stays connected would not
|
|
733
|
+
* re-handshake for as long as the link holds, and this button would look
|
|
734
|
+
* inert.
|
|
735
|
+
*/
|
|
736
|
+
const remember = (host) => {
|
|
737
|
+
run(async () => {
|
|
738
|
+
onSaved(await rememberHosts(device.deviceId, [host]));
|
|
739
|
+
});
|
|
740
|
+
};
|
|
596
741
|
const toggleEnabled = () => {
|
|
597
742
|
const enabled = !device.enabled;
|
|
598
743
|
run(async () => {
|
|
@@ -637,6 +782,13 @@ window.__ModuleLoader__.load({
|
|
|
637
782
|
onRemoved(device.deviceId);
|
|
638
783
|
});
|
|
639
784
|
};
|
|
785
|
+
/**
|
|
786
|
+
* A defensive empty list, the same choice `dialPort` makes for the required
|
|
787
|
+
* `ports` map: `GET /devices` always emits the key, but this card is one row
|
|
788
|
+
* in a list, and a record missing one field must not take the whole page down
|
|
789
|
+
* with a render throw — it shows a card without the mark instead.
|
|
790
|
+
*/
|
|
791
|
+
const forgottenHosts = device.forgottenHosts ?? [];
|
|
640
792
|
const mixedPorts = device.hosts.some((host) => dialPort(device, host) !== device.port);
|
|
641
793
|
const addressList = device.hosts.map((host) => formatAddress(host, dialPort(device, host))).join(", ");
|
|
642
794
|
const label = state === void 0 ? t("device.state.unknown") : t(STATE_KEYS[state] ?? state);
|
|
@@ -702,7 +854,24 @@ window.__ModuleLoader__.load({
|
|
|
702
854
|
className: "cherry-card-facts",
|
|
703
855
|
children: [
|
|
704
856
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.addresses") }),
|
|
705
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children:
|
|
857
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
858
|
+
className: "cherry-address-list",
|
|
859
|
+
children: device.hosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: host }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
860
|
+
type: "button",
|
|
861
|
+
onClick: () => forget(host),
|
|
862
|
+
disabled: busy || editor === "address",
|
|
863
|
+
children: t("device.forget")
|
|
864
|
+
})] }, host))
|
|
865
|
+
}) }),
|
|
866
|
+
forgottenHosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.forgotten") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("ul", {
|
|
867
|
+
className: "cherry-address-list",
|
|
868
|
+
children: forgottenHosts.map((host) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: host }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
869
|
+
type: "button",
|
|
870
|
+
onClick: () => remember(host),
|
|
871
|
+
disabled: busy || editor === "address",
|
|
872
|
+
children: t("device.remember")
|
|
873
|
+
})] }, host))
|
|
874
|
+
}) })] }),
|
|
706
875
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.port") }),
|
|
707
876
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: device.port }),
|
|
708
877
|
mixedPorts && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.ports") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", {
|
|
@@ -716,6 +885,10 @@ window.__ModuleLoader__.load({
|
|
|
716
885
|
})
|
|
717
886
|
]
|
|
718
887
|
}),
|
|
888
|
+
forgottenHosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
889
|
+
className: "cherry-hint",
|
|
890
|
+
children: t("device.forgotten.hint")
|
|
891
|
+
}),
|
|
719
892
|
mixedPorts && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
720
893
|
className: "cherry-hint",
|
|
721
894
|
children: t("device.ports.hint")
|
|
@@ -835,7 +1008,28 @@ window.__ModuleLoader__.load({
|
|
|
835
1008
|
*/
|
|
836
1009
|
/** The countdown the transient code display gets, in seconds. */
|
|
837
1010
|
var DEFAULT_CODE_LIFETIME_MS = 12e4;
|
|
838
|
-
|
|
1011
|
+
/**
|
|
1012
|
+
* How long one run may sit in `waiting` before the wizard gives the form back.
|
|
1013
|
+
*
|
|
1014
|
+
* The server publishes a terminal stage on EVERY outcome (`src/httpApi.ts`
|
|
1015
|
+
* `startPairing`), but the event stream carries no replay, so a single dropped
|
|
1016
|
+
* frame loses the verdict for good — and nothing else clears `busy`: only a
|
|
1017
|
+
* terminal frame or a refused POST does, and there is no cancel control. Without
|
|
1018
|
+
* this bound a lost frame parks the wizard on 「配对进行中」 until a page reload.
|
|
1019
|
+
*
|
|
1020
|
+
* The bound must be comfortably past a REALISTIC live run: `src/pairing.ts`
|
|
1021
|
+
* bounds the candidate walk to 30 s (`dialBudgetMs`) and the claim/poll phase to
|
|
1022
|
+
* a FRESH 120 s anchored at the claim, so 150 s plus 30 s of margin.
|
|
1023
|
+
*
|
|
1024
|
+
* It is NOT past a pathological one, and it says so rather than pretending: two
|
|
1025
|
+
* round trips inside that walk (`hello`, then the claim) are bounded only by
|
|
1026
|
+
* JsonRpcClient's 60 s default request timeout, so a peer that stalls each just
|
|
1027
|
+
* under it can keep a run legitimately working for ~270 s. A premature alarm is
|
|
1028
|
+
* acceptable because it is not a verdict — it points at the device list, and a
|
|
1029
|
+
* terminal frame arriving later replaces it (see `claimId` below).
|
|
1030
|
+
*/
|
|
1031
|
+
var DEFAULT_RUN_LIFETIME_MS = 18e4;
|
|
1032
|
+
function PairingWizard({ subscribed, progress, codeLifetimeMs = DEFAULT_CODE_LIFETIME_MS, runLifetimeMs = DEFAULT_RUN_LIFETIME_MS, onRunStart, onSettled }) {
|
|
839
1033
|
const t = useT();
|
|
840
1034
|
const [payload, setPayload] = (0, react.useState)("");
|
|
841
1035
|
const [claimId, setClaimId] = (0, react.useState)(void 0);
|
|
@@ -844,6 +1038,7 @@ window.__ModuleLoader__.load({
|
|
|
844
1038
|
const [code, setCode] = (0, react.useState)(void 0);
|
|
845
1039
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
846
1040
|
const [verdict, setVerdict] = (0, react.useState)(void 0);
|
|
1041
|
+
const [timedOut, setTimedOut] = (0, react.useState)(false);
|
|
847
1042
|
const stage = progress?.stage;
|
|
848
1043
|
const frameClaimId = progress?.claimId;
|
|
849
1044
|
(0, react.useEffect)(() => {
|
|
@@ -858,6 +1053,7 @@ window.__ModuleLoader__.load({
|
|
|
858
1053
|
}
|
|
859
1054
|
if (TERMINAL_STAGES.includes(stage)) {
|
|
860
1055
|
setCode(void 0);
|
|
1056
|
+
setTimedOut(false);
|
|
861
1057
|
setVerdict(stage);
|
|
862
1058
|
setPhase(stage === "approved" ? "done" : "failed");
|
|
863
1059
|
setBusy(false);
|
|
@@ -875,6 +1071,21 @@ window.__ModuleLoader__.load({
|
|
|
875
1071
|
const timer = setTimeout(() => setCode(void 0), codeLifetimeMs);
|
|
876
1072
|
return () => clearTimeout(timer);
|
|
877
1073
|
}, [code, codeLifetimeMs]);
|
|
1074
|
+
(0, react.useEffect)(() => {
|
|
1075
|
+
if (phase !== "waiting") return void 0;
|
|
1076
|
+
const timer = setTimeout(() => {
|
|
1077
|
+
setCode(void 0);
|
|
1078
|
+
setTimedOut(true);
|
|
1079
|
+
setPhase("failed");
|
|
1080
|
+
setBusy(false);
|
|
1081
|
+
onSettled?.();
|
|
1082
|
+
}, runLifetimeMs);
|
|
1083
|
+
return () => clearTimeout(timer);
|
|
1084
|
+
}, [
|
|
1085
|
+
phase,
|
|
1086
|
+
runLifetimeMs,
|
|
1087
|
+
onSettled
|
|
1088
|
+
]);
|
|
878
1089
|
const submit = (event) => {
|
|
879
1090
|
event.preventDefault();
|
|
880
1091
|
const trimmed = payload.trim();
|
|
@@ -887,12 +1098,13 @@ window.__ModuleLoader__.load({
|
|
|
887
1098
|
setPhase("submitting");
|
|
888
1099
|
setClaimId(void 0);
|
|
889
1100
|
setVerdict(void 0);
|
|
1101
|
+
setTimedOut(false);
|
|
890
1102
|
onRunStart?.();
|
|
891
1103
|
startPairing(trimmed).then((result) => {
|
|
892
1104
|
setClaimId(result.claimId);
|
|
893
1105
|
setPhase("waiting");
|
|
894
1106
|
}, (cause) => {
|
|
895
|
-
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1107
|
+
setError(cause instanceof Error && cause.message === "cherry-remote.request-timeout" ? t("wizard.error.timeout") : cause instanceof Error ? cause.message : String(cause));
|
|
896
1108
|
setPhase("idle");
|
|
897
1109
|
setBusy(false);
|
|
898
1110
|
});
|
|
@@ -976,7 +1188,7 @@ window.__ModuleLoader__.load({
|
|
|
976
1188
|
phase === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
977
1189
|
className: "cherry-error",
|
|
978
1190
|
role: "alert",
|
|
979
|
-
children: t("wizard.failed", { stage: STAGE_KEYS[verdict ?? ""] === void 0 ? t("wizard.unknownError") : t(STAGE_KEYS[verdict ?? ""]) })
|
|
1191
|
+
children: timedOut ? t("wizard.timedOut") : t("wizard.failed", { stage: STAGE_KEYS[verdict ?? ""] === void 0 ? t("wizard.unknownError") : t(STAGE_KEYS[verdict ?? ""]) })
|
|
980
1192
|
}),
|
|
981
1193
|
error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
982
1194
|
className: "cherry-error",
|
|
@@ -1106,7 +1318,15 @@ window.__ModuleLoader__.load({
|
|
|
1106
1318
|
* code" checkable by eye.
|
|
1107
1319
|
*/
|
|
1108
1320
|
var SAVE_FAILED_KEY = "scope.saveFailed";
|
|
1109
|
-
|
|
1321
|
+
/**
|
|
1322
|
+
* The panel's own description of a failure.
|
|
1323
|
+
*
|
|
1324
|
+
* A request that never answered arrives as `api.ts`'s internal marker (the
|
|
1325
|
+
* page-wide ceiling, issue #180), which must become a sentence before it reaches
|
|
1326
|
+
* any of the three places this feeds — the read error, the save failure and the
|
|
1327
|
+
* re-read error. Everything else is the server's own message, interpolated.
|
|
1328
|
+
*/
|
|
1329
|
+
var describeCause$1 = (t, cause) => cause instanceof Error && cause.message === "cherry-remote.request-timeout" ? t("scope.error.timeout") : cause instanceof Error ? cause.message : String(cause);
|
|
1110
1330
|
/**
|
|
1111
1331
|
* D11's empty state for the current draft, or `undefined` when nothing may be
|
|
1112
1332
|
* claimed yet.
|
|
@@ -1167,7 +1387,7 @@ window.__ModuleLoader__.load({
|
|
|
1167
1387
|
if (!live) return;
|
|
1168
1388
|
seedFromDocument(response);
|
|
1169
1389
|
}, (cause) => {
|
|
1170
|
-
if (live) setLoadError(describeCause(cause));
|
|
1390
|
+
if (live) setLoadError(describeCause$1(t, cause));
|
|
1171
1391
|
});
|
|
1172
1392
|
return () => {
|
|
1173
1393
|
live = false;
|
|
@@ -1237,7 +1457,7 @@ window.__ModuleLoader__.load({
|
|
|
1237
1457
|
if (code === "scope-version-unsupported") setVersionBlocked(true);
|
|
1238
1458
|
setFailure({
|
|
1239
1459
|
code: code ?? "request-failed",
|
|
1240
|
-
label: code === void 0 ? t(SAVE_FAILED_KEY, { cause: describeCause(cause) }) : t(SAVE_FAILURE_KEYS[code] ?? SAVE_FAILED_KEY, { cause: describeCause(cause) }),
|
|
1460
|
+
label: code === void 0 ? t(SAVE_FAILED_KEY, { cause: describeCause$1(t, cause) }) : t(SAVE_FAILURE_KEYS[code] ?? SAVE_FAILED_KEY, { cause: describeCause$1(t, cause) }),
|
|
1241
1461
|
detail: status === void 0 ? void 0 : t("scope.serverSaid", {
|
|
1242
1462
|
code,
|
|
1243
1463
|
status
|
|
@@ -1275,7 +1495,7 @@ window.__ModuleLoader__.load({
|
|
|
1275
1495
|
setFailure(void 0);
|
|
1276
1496
|
}, (cause) => {
|
|
1277
1497
|
setBusy(false);
|
|
1278
|
-
setLoadError(describeCause(cause));
|
|
1498
|
+
setLoadError(describeCause$1(t, cause));
|
|
1279
1499
|
});
|
|
1280
1500
|
}, [client, seedFromDocument]);
|
|
1281
1501
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
@@ -1502,6 +1722,18 @@ window.__ModuleLoader__.load({
|
|
|
1502
1722
|
* before this task the literal here rendered in English on a zh page.
|
|
1503
1723
|
*/
|
|
1504
1724
|
var EVENT_SOURCE_UNAVAILABLE = "event-source-unavailable";
|
|
1725
|
+
/**
|
|
1726
|
+
* One description for a cause, shared by the two catches below so they cannot
|
|
1727
|
+
* drift. Two markers get the dictionary's own sentence — the missing
|
|
1728
|
+
* `EventSource` and a request that never answered (`api.ts`'s ceiling) — because
|
|
1729
|
+
* both are INTERNAL tokens that must never render; everything else is the
|
|
1730
|
+
* foreign message, interpolated verbatim as data.
|
|
1731
|
+
*/
|
|
1732
|
+
function describeCause(t, cause) {
|
|
1733
|
+
if (cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE) return t("app.eventSourceUnavailable");
|
|
1734
|
+
if (cause instanceof Error && cause.message === "cherry-remote.request-timeout") return t("app.error.timeout");
|
|
1735
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
1736
|
+
}
|
|
1505
1737
|
/** Parses one SSE frame payload, ignoring a malformed one rather than crashing. */
|
|
1506
1738
|
function parseFrame(data) {
|
|
1507
1739
|
try {
|
|
@@ -1510,7 +1742,7 @@ window.__ModuleLoader__.load({
|
|
|
1510
1742
|
return;
|
|
1511
1743
|
}
|
|
1512
1744
|
}
|
|
1513
|
-
function App({ eventSourceFactory = defaultEventSourceFactory, codeLifetimeMs, scopeClient }) {
|
|
1745
|
+
function App({ eventSourceFactory = defaultEventSourceFactory, codeLifetimeMs, runLifetimeMs, scopeClient }) {
|
|
1514
1746
|
const t = useT();
|
|
1515
1747
|
const [devices, setDevices] = (0, react.useState)([]);
|
|
1516
1748
|
const [states, setStates] = (0, react.useState)({});
|
|
@@ -1523,7 +1755,7 @@ window.__ModuleLoader__.load({
|
|
|
1523
1755
|
try {
|
|
1524
1756
|
source = eventSourceFactory(`${API_BASE}/events`);
|
|
1525
1757
|
} catch (cause) {
|
|
1526
|
-
setError(
|
|
1758
|
+
setError(describeCause(t, cause));
|
|
1527
1759
|
return;
|
|
1528
1760
|
}
|
|
1529
1761
|
const onDeviceState = (event) => {
|
|
@@ -1555,7 +1787,7 @@ window.__ModuleLoader__.load({
|
|
|
1555
1787
|
setListed(true);
|
|
1556
1788
|
setError(void 0);
|
|
1557
1789
|
}, (cause) => {
|
|
1558
|
-
setError(
|
|
1790
|
+
setError(describeCause(t, cause));
|
|
1559
1791
|
setListed(true);
|
|
1560
1792
|
});
|
|
1561
1793
|
}, []);
|
|
@@ -1604,6 +1836,7 @@ window.__ModuleLoader__.load({
|
|
|
1604
1836
|
subscribed,
|
|
1605
1837
|
progress,
|
|
1606
1838
|
...codeLifetimeMs === void 0 ? {} : { codeLifetimeMs },
|
|
1839
|
+
...runLifetimeMs === void 0 ? {} : { runLifetimeMs },
|
|
1607
1840
|
onRunStart: startRun,
|
|
1608
1841
|
onSettled
|
|
1609
1842
|
}),
|
|
@@ -1616,7 +1849,7 @@ window.__ModuleLoader__.load({
|
|
|
1616
1849
|
id: "cherry-devices-title",
|
|
1617
1850
|
children: t("app.devices.heading")
|
|
1618
1851
|
}),
|
|
1619
|
-
sorted.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1852
|
+
sorted.length === 0 && error === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1620
1853
|
className: "cherry-hint",
|
|
1621
1854
|
children: listed ? t("app.devices.empty") : t("app.devices.loading")
|
|
1622
1855
|
}),
|
|
@@ -1636,7 +1869,7 @@ window.__ModuleLoader__.load({
|
|
|
1636
1869
|
}
|
|
1637
1870
|
//#endregion
|
|
1638
1871
|
//#region ui/src/styles.css?inline
|
|
1639
|
-
var styles_default = "/**\n * The settings page's own styles.\n *\n * Hand-written CSS, no UI framework (ruling 6). The palette follows the DSH web\n * client's dark-friendly aesthetic: it leans on `currentColor` and a few\n * neutral surface variables so the page reads correctly on both a dark and a\n * light shell, and the five state colours stay distinguishable (and are always paired\n * with a text label — colour alone is never the status).\n */\n.cherry-settings {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 16px;\n color: inherit;\n font-size: 13px;\n line-height: 1.6;\n}\n\n.cherry-header h2 {\n margin: 0 0 4px;\n font-size: 16px;\n}\n\n.cherry-panel {\n border: 1px solid color-mix(in srgb, currentColor 18%, transparent);\n border-radius: 10px;\n padding: 14px 16px;\n background: color-mix(in srgb, currentColor 4%, transparent);\n}\n\n.cherry-panel h3 {\n margin: 0 0 8px;\n font-size: 14px;\n}\n\n.cherry-hint {\n margin: 4px 0;\n opacity: 0.7;\n font-size: 12px;\n}\n\n.cherry-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n}\n\n/* --- pairing wizard ------------------------------------------------------ */\n\n.cherry-steps {\n margin: 8px 0 12px;\n padding-left: 20px;\n opacity: 0.85;\n}\n\n.cherry-pair-form {\n display: flex;\n flex-direction: column;\n gap: 6px;\n align-items: flex-start;\n}\n\n.cherry-pair-form textarea,\n.cherry-inline-form input {\n width: 100%;\n box-sizing: border-box;\n padding: 6px 8px;\n border-radius: 6px;\n border: 1px solid color-mix(in srgb, currentColor 25%, transparent);\n background: color-mix(in srgb, currentColor 6%, transparent);\n color: inherit;\n font: inherit;\n}\n\n.cherry-progress {\n margin-top: 10px;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px dashed color-mix(in srgb, currentColor 25%, transparent);\n}\n\n.cherry-code {\n margin-top: 10px;\n padding: 12px;\n border-radius: 8px;\n text-align: center;\n border: 1px solid color-mix(in srgb, currentColor 30%, transparent);\n}\n\n.cherry-code-digits {\n display: block;\n margin-top: 6px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 26px;\n letter-spacing: 0.3em;\n}\n\n.cherry-ok {\n color: #3fb950;\n}\n\n.cherry-error {\n color: #f85149;\n}\n\n/* --- enablement scope panel ---------------------------------------------- */\n\n/* All three visible as a group, never as one combined verdict: the mode radio\n pair, the workspace ticks and the save that commits them (§6.4). */\n.cherry-scope-modes {\n display: flex;\n flex-wrap: wrap;\n gap: 14px;\n margin: 8px 0;\n padding: 6px 10px;\n border: 1px dashed color-mix(in srgb, currentColor 20%, transparent);\n border-radius: 8px;\n}\n\n.cherry-scope-modes label {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n}\n\n.cherry-scope-degraded,\n.cherry-scope-options,\n.cherry-scope-stale ul,\n.cherry-scope-unmatched ul {\n margin: 6px 0;\n padding-left: 18px;\n}\n\n.cherry-scope-degraded li,\n.cherry-scope-options li {\n margin: 2px 0;\n}\n\n.cherry-scope-options label {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n}\n\n.cherry-scope-option-title {\n font-weight: 600;\n}\n\n/* The two diagnostic boxes are deliberately separate surfaces: a deleted\n workspace and an unmatched cwd are different answers (§6.1/§6.4). */\n.cherry-scope-stale,\n.cherry-scope-unmatched {\n margin-top: 10px;\n padding: 8px 10px;\n border-radius: 8px;\n border: 1px dashed color-mix(in srgb, currentColor 25%, transparent);\n}\n\n.cherry-scope-save {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 12px;\n padding-top: 10px;\n border-top: 1px dashed color-mix(in srgb, currentColor 18%, transparent);\n}\n\n/* --- device cards -------------------------------------------------------- */\n\n.cherry-cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 12px;\n margin: 8px 0 0;\n padding: 0;\n list-style: none;\n}\n\n.cherry-card {\n border: 1px solid color-mix(in srgb, currentColor 15%, transparent);\n border-radius: 10px;\n padding: 12px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.cherry-card-head {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.cherry-card-title {\n display: flex;\n flex-direction: column;\n min-width: 0;\n flex: 1;\n}\n\n.cherry-state-label {\n font-size: 11px;\n opacity: 0.7;\n}\n\n.cherry-card-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n.cherry-card-facts {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 2px 10px;\n margin: 0;\n font-size: 12px;\n}\n\n.cherry-card-facts dt {\n opacity: 0.6;\n}\n\n.cherry-card-facts dd {\n margin: 0;\n overflow-wrap: anywhere;\n}\n\n.cherry-inline-form {\n display: flex;\n flex-direction: column;\n gap: 6px;\n align-items: flex-start;\n padding-top: 6px;\n border-top: 1px dashed color-mix(in srgb, currentColor 18%, transparent);\n}\n\n/* SCOPED DELIBERATELY. This stylesheet is injected into the HOST page's\n document head, not a sandboxed frame, so a bare `button {}` rule restyles\n every button in the host UI — a plugin must not do that. Every top-level\n selector in this file is therefore rooted at a plugin-owned class in the\n `cherry-` namespace: the element rules here at the plugin's own\n `.cherry-settings` container, and the rest at their own component classes\n (`.cherry-panel`, `.cherry-card`, `.cherry-state`, …). None of them matches\n anything outside the markup this plugin renders. */\n.cherry-settings button {\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid color-mix(in srgb, currentColor 25%, transparent);\n background: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n font: inherit;\n cursor: pointer;\n}\n\n.cherry-settings button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n/* --- five-state light ---------------------------------------------------- */\n\n.cherry-state {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex: none;\n}\n\n.cherry-state-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--cherry-state-color, #8b949e);\n box-shadow: 0 0 6px var(--cherry-state-color, transparent);\n}\n\n.cherry-state[data-state='connected'] {\n --cherry-state-color: #3fb950;\n}\n\n.cherry-state[data-state='connecting'] {\n --cherry-state-color: #d29922;\n}\n\n.cherry-state[data-state='offline'] {\n --cherry-state-color: #8b949e;\n}\n\n.cherry-state[data-state='disabled'] {\n --cherry-state-color: #6e7681;\n}\n\n.cherry-state[data-state='identity-mismatch'] {\n --cherry-state-color: #f85149;\n}\n\n.cherry-state[data-state='unknown'] {\n --cherry-state-color: #484f58;\n}\n";
|
|
1872
|
+
var styles_default = "/**\n * The settings page's own styles.\n *\n * Hand-written CSS, no UI framework (ruling 6). The palette follows the DSH web\n * client's dark-friendly aesthetic: it leans on `currentColor` and a few\n * neutral surface variables so the page reads correctly on both a dark and a\n * light shell, and the five state colours stay distinguishable (and are always paired\n * with a text label — colour alone is never the status).\n */\n.cherry-settings {\n display: flex;\n flex-direction: column;\n gap: 20px;\n padding: 16px;\n color: inherit;\n font-size: 13px;\n line-height: 1.6;\n}\n\n.cherry-header h2 {\n margin: 0 0 4px;\n font-size: 16px;\n}\n\n.cherry-panel {\n border: 1px solid color-mix(in srgb, currentColor 18%, transparent);\n border-radius: 10px;\n padding: 14px 16px;\n background: color-mix(in srgb, currentColor 4%, transparent);\n}\n\n.cherry-panel h3 {\n margin: 0 0 8px;\n font-size: 14px;\n}\n\n.cherry-hint {\n margin: 4px 0;\n opacity: 0.7;\n font-size: 12px;\n}\n\n.cherry-mono {\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 12px;\n}\n\n/* --- pairing wizard ------------------------------------------------------ */\n\n.cherry-steps {\n margin: 8px 0 12px;\n padding-left: 20px;\n opacity: 0.85;\n}\n\n.cherry-pair-form {\n display: flex;\n flex-direction: column;\n gap: 6px;\n align-items: flex-start;\n}\n\n.cherry-pair-form textarea,\n.cherry-inline-form input {\n width: 100%;\n box-sizing: border-box;\n padding: 6px 8px;\n border-radius: 6px;\n border: 1px solid color-mix(in srgb, currentColor 25%, transparent);\n background: color-mix(in srgb, currentColor 6%, transparent);\n color: inherit;\n font: inherit;\n}\n\n.cherry-progress {\n margin-top: 10px;\n padding: 10px 12px;\n border-radius: 8px;\n border: 1px dashed color-mix(in srgb, currentColor 25%, transparent);\n}\n\n.cherry-code {\n margin-top: 10px;\n padding: 12px;\n border-radius: 8px;\n text-align: center;\n border: 1px solid color-mix(in srgb, currentColor 30%, transparent);\n}\n\n.cherry-code-digits {\n display: block;\n margin-top: 6px;\n font-family: ui-monospace, SFMono-Regular, Menlo, monospace;\n font-size: 26px;\n letter-spacing: 0.3em;\n}\n\n.cherry-ok {\n color: #3fb950;\n}\n\n.cherry-error {\n color: #f85149;\n}\n\n/* --- enablement scope panel ---------------------------------------------- */\n\n/* All three visible as a group, never as one combined verdict: the mode radio\n pair, the workspace ticks and the save that commits them (§6.4). */\n.cherry-scope-modes {\n display: flex;\n flex-wrap: wrap;\n gap: 14px;\n margin: 8px 0;\n padding: 6px 10px;\n border: 1px dashed color-mix(in srgb, currentColor 20%, transparent);\n border-radius: 8px;\n}\n\n.cherry-scope-modes label {\n display: inline-flex;\n align-items: center;\n gap: 4px;\n}\n\n.cherry-scope-degraded,\n.cherry-scope-options,\n.cherry-scope-stale ul,\n.cherry-scope-unmatched ul {\n margin: 6px 0;\n padding-left: 18px;\n}\n\n.cherry-scope-degraded li,\n.cherry-scope-options li {\n margin: 2px 0;\n}\n\n.cherry-scope-options label {\n display: inline-flex;\n align-items: baseline;\n gap: 8px;\n}\n\n.cherry-scope-option-title {\n font-weight: 600;\n}\n\n/* The two diagnostic boxes are deliberately separate surfaces: a deleted\n workspace and an unmatched cwd are different answers (§6.1/§6.4). */\n.cherry-scope-stale,\n.cherry-scope-unmatched {\n margin-top: 10px;\n padding: 8px 10px;\n border-radius: 8px;\n border: 1px dashed color-mix(in srgb, currentColor 25%, transparent);\n}\n\n.cherry-scope-save {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: 10px;\n margin-top: 12px;\n padding-top: 10px;\n border-top: 1px dashed color-mix(in srgb, currentColor 18%, transparent);\n}\n\n/* --- device cards -------------------------------------------------------- */\n\n.cherry-cards {\n display: grid;\n grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));\n gap: 12px;\n margin: 8px 0 0;\n padding: 0;\n list-style: none;\n}\n\n.cherry-card {\n border: 1px solid color-mix(in srgb, currentColor 15%, transparent);\n border-radius: 10px;\n padding: 12px;\n display: flex;\n flex-direction: column;\n gap: 8px;\n}\n\n.cherry-card-head {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n\n.cherry-card-title {\n display: flex;\n flex-direction: column;\n min-width: 0;\n flex: 1;\n}\n\n.cherry-state-label {\n font-size: 11px;\n opacity: 0.7;\n}\n\n.cherry-card-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 6px;\n}\n\n.cherry-card-facts {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 2px 10px;\n margin: 0;\n font-size: 12px;\n}\n\n.cherry-card-facts dt {\n opacity: 0.6;\n}\n\n.cherry-card-facts dd {\n margin: 0;\n overflow-wrap: anywhere;\n}\n\n/* A per-host action list (issue #204): the address, then its own control. The\n buttons stay small so a record with a dozen candidates stays ONE fact row\n instead of a column of chrome. */\n.cherry-address-list {\n display: flex;\n flex-wrap: wrap;\n gap: 4px 10px;\n margin: 0;\n padding: 0;\n list-style: none;\n}\n\n.cherry-address-list li {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n\n.cherry-address-list button {\n padding: 0 6px;\n font-size: 11px;\n line-height: 1.6;\n}\n\n.cherry-inline-form {\n display: flex;\n flex-direction: column;\n gap: 6px;\n align-items: flex-start;\n padding-top: 6px;\n border-top: 1px dashed color-mix(in srgb, currentColor 18%, transparent);\n}\n\n/* SCOPED DELIBERATELY. This stylesheet is injected into the HOST page's\n document head, not a sandboxed frame, so a bare `button {}` rule restyles\n every button in the host UI — a plugin must not do that. Every top-level\n selector in this file is therefore rooted at a plugin-owned class in the\n `cherry-` namespace: the element rules here at the plugin's own\n `.cherry-settings` container, and the rest at their own component classes\n (`.cherry-panel`, `.cherry-card`, `.cherry-state`, …). None of them matches\n anything outside the markup this plugin renders. */\n.cherry-settings button {\n padding: 4px 10px;\n border-radius: 6px;\n border: 1px solid color-mix(in srgb, currentColor 25%, transparent);\n background: color-mix(in srgb, currentColor 8%, transparent);\n color: inherit;\n font: inherit;\n cursor: pointer;\n}\n\n.cherry-settings button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n/* --- five-state light ---------------------------------------------------- */\n\n.cherry-state {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 14px;\n height: 14px;\n flex: none;\n}\n\n.cherry-state-dot {\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: var(--cherry-state-color, #8b949e);\n box-shadow: 0 0 6px var(--cherry-state-color, transparent);\n}\n\n.cherry-state[data-state='connected'] {\n --cherry-state-color: #3fb950;\n}\n\n.cherry-state[data-state='connecting'] {\n --cherry-state-color: #d29922;\n}\n\n.cherry-state[data-state='offline'] {\n --cherry-state-color: #8b949e;\n}\n\n.cherry-state[data-state='disabled'] {\n --cherry-state-color: #6e7681;\n}\n\n.cherry-state[data-state='identity-mismatch'] {\n --cherry-state-color: #f85149;\n}\n\n.cherry-state[data-state='unknown'] {\n --cherry-state-color: #484f58;\n}\n";
|
|
1640
1873
|
//#endregion
|
|
1641
1874
|
//#region ui/src/main.tsx
|
|
1642
1875
|
/** The slot a settings page registers into, per the shell's settings contract. */
|