dsh-cherry-bridge 0.1.11 → 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 +15 -8
- package/README.md +13 -6
- package/dist/index.d.mts +32 -3
- package/dist/index.mjs +1563 -168
- package/docs/e2e-checklist.md +50 -2
- package/lib/client.js +311 -31
- package/package.json +4 -3
- package/src/AGENTS.md +949 -0
- package/src/connection.ts +287 -11
- package/src/deviceStore.ts +518 -6
- package/src/diagnostics.ts +30 -0
- package/src/httpApi.ts +351 -23
- package/src/index.ts +203 -8
- package/src/pairing.ts +504 -112
- package/src/rpc/jsonRpcClient.ts +19 -1
- package/src/subscriptions.ts +10 -2
- package/src/text.ts +158 -0
- package/src/tools.ts +329 -23
- package/ui/AGENTS.md +44 -0
- package/ui/src/App.tsx +26 -10
- package/ui/src/DeviceCard.tsx +141 -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 +49 -0
- package/ui/src/styles.css +24 -0
- package/ui/src/types.ts +16 -1
- package/src/urlHost.ts +0 -15
package/lib/client.js
CHANGED
|
@@ -36,11 +36,21 @@ window.__ModuleLoader__.load({
|
|
|
36
36
|
"device.field.name": "设备名称",
|
|
37
37
|
"device.field.hosts": "主机地址",
|
|
38
38
|
"device.hosts.hint": "多个候选地址用逗号分隔(整体替换)",
|
|
39
|
+
"device.field.ports": "每地址端口",
|
|
40
|
+
"device.ports.hint": "部分候选地址的端口与上方的端口不同;在「地址维护」里改动端口会把所有候选地址改为同一端口。",
|
|
41
|
+
"device.ports.current": "当前端口映射:",
|
|
42
|
+
"device.port.hint": "保持当前值会保留各地址自己的端口;改成新值会把所有候选地址重指到该端口。",
|
|
39
43
|
"device.save.name": "保存名称",
|
|
40
44
|
"device.save.addresses": "保存地址",
|
|
41
45
|
"device.remove.confirm": "确认移除",
|
|
42
46
|
"device.remove.warning": "移除后本机将不再连接该设备,凭据保留在对方一侧。",
|
|
43
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": "请求长时间没有回应,已中止——请稍后重试",
|
|
44
54
|
"device.error.nameRequired": "设备名称不能为空",
|
|
45
55
|
"device.error.hostsRequired": "至少需要一个主机地址",
|
|
46
56
|
"device.error.portInvalid": "端口必须是 1..65535 之间的整数",
|
|
@@ -61,7 +71,10 @@ window.__ModuleLoader__.load({
|
|
|
61
71
|
"wizard.succeeded": "配对成功",
|
|
62
72
|
"wizard.failed": "配对失败:{stage}",
|
|
63
73
|
"wizard.unknownError": "未知错误",
|
|
74
|
+
"app.error.timeout": "请求长时间没有回应,已中止——请确认宿主仍在运行;若反复出现,请重启宿主后再试",
|
|
75
|
+
"wizard.timedOut": "没有等到配对结果——若桌面端已批准,下面的设备列表里会出现该设备;否则请在 Cherry Studio 重新生成配对码后重试",
|
|
64
76
|
"wizard.error.payloadRequired": "请先粘贴扫码页显示的配对信息",
|
|
77
|
+
"wizard.error.timeout": "配对请求迟迟没有回应,已中止——请重试;若反复出现,宿主可能已卡住,重启后再试",
|
|
65
78
|
"wizard.stage.validate": "正在校验配对信息…",
|
|
66
79
|
"wizard.stage.connect": "正在连接 Cherry Studio…",
|
|
67
80
|
"wizard.stage.claim": "正在交换凭据…",
|
|
@@ -99,6 +112,7 @@ window.__ModuleLoader__.load({
|
|
|
99
112
|
"scope.savedPruned": "保存成功,按服务端修剪清除了失效选择:{list}。",
|
|
100
113
|
"scope.reread": "重新读取",
|
|
101
114
|
"scope.saveFailed": "保存失败:{cause}",
|
|
115
|
+
"scope.error.timeout": "请求长时间没有回应,已中止——请稍后重试",
|
|
102
116
|
"scope.serverSaid": "服务端返回 {code}(HTTP {status})。",
|
|
103
117
|
"scope.degraded.install": "隐藏层应用曾失败(install):相关 agent 的隐藏层未生效或无法确认,启用范围对它可能不适用",
|
|
104
118
|
"scope.degraded.workspace-list": "注册表列表当前读不到",
|
|
@@ -136,11 +150,21 @@ window.__ModuleLoader__.load({
|
|
|
136
150
|
"device.field.name": "Device name",
|
|
137
151
|
"device.field.hosts": "Host addresses",
|
|
138
152
|
"device.hosts.hint": "Separate candidate addresses with commas (the whole list is replaced)",
|
|
153
|
+
"device.field.ports": "Ports per address",
|
|
154
|
+
"device.ports.hint": "Some candidate addresses dial a different port than the one above; changing the port in address maintenance moves every candidate address to that one port.",
|
|
155
|
+
"device.ports.current": "Current port map:",
|
|
156
|
+
"device.port.hint": "Keeping the current value preserves each address’s own port; a new value re-points every candidate address.",
|
|
139
157
|
"device.save.name": "Save name",
|
|
140
158
|
"device.save.addresses": "Save addresses",
|
|
141
159
|
"device.remove.confirm": "Confirm removal",
|
|
142
160
|
"device.remove.warning": "This machine will stop connecting to the device. Its credentials stay on the peer.",
|
|
143
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.",
|
|
144
168
|
"device.error.nameRequired": "Device name cannot be empty",
|
|
145
169
|
"device.error.hostsRequired": "At least one host address is required",
|
|
146
170
|
"device.error.portInvalid": "Port must be an integer between 1 and 65535",
|
|
@@ -161,7 +185,10 @@ window.__ModuleLoader__.load({
|
|
|
161
185
|
"wizard.succeeded": "Paired",
|
|
162
186
|
"wizard.failed": "Pairing failed: {stage}",
|
|
163
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.",
|
|
164
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.",
|
|
165
192
|
"wizard.stage.validate": "Validating the pairing payload…",
|
|
166
193
|
"wizard.stage.connect": "Connecting to Cherry Studio…",
|
|
167
194
|
"wizard.stage.claim": "Exchanging credentials…",
|
|
@@ -199,6 +226,7 @@ window.__ModuleLoader__.load({
|
|
|
199
226
|
"scope.savedPruned": "Saved. The server pruned these stale selections: {list}.",
|
|
200
227
|
"scope.reread": "Reload",
|
|
201
228
|
"scope.saveFailed": "Save failed: {cause}",
|
|
229
|
+
"scope.error.timeout": "The request stopped answering and was abandoned. Try again in a moment.",
|
|
202
230
|
"scope.serverSaid": "The server answered {code} (HTTP {status}).",
|
|
203
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",
|
|
204
232
|
"scope.degraded.workspace-list": "The workspace registry list is currently unreadable",
|
|
@@ -403,6 +431,33 @@ window.__ModuleLoader__.load({
|
|
|
403
431
|
this.name = "ApiError";
|
|
404
432
|
}
|
|
405
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;
|
|
406
461
|
/** Reads a JSON body without throwing on an empty (204) or non-JSON response. */
|
|
407
462
|
async function readBody(response) {
|
|
408
463
|
const text = await response.text();
|
|
@@ -413,22 +468,36 @@ window.__ModuleLoader__.load({
|
|
|
413
468
|
return text;
|
|
414
469
|
}
|
|
415
470
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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);
|
|
422
493
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
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);
|
|
430
500
|
}
|
|
431
|
-
return body;
|
|
432
501
|
}
|
|
433
502
|
/** `GET /devices` — the full card list, states projected server-side. */
|
|
434
503
|
async function listDevices() {
|
|
@@ -441,15 +510,19 @@ window.__ModuleLoader__.load({
|
|
|
441
510
|
* It deliberately does NOT wait for approval: the 6-digit code and the verdict
|
|
442
511
|
* arrive as `pairing-progress` SSE frames, which is why the caller must already
|
|
443
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.
|
|
444
516
|
*/
|
|
445
|
-
async function startPairing(payload,
|
|
517
|
+
async function startPairing(payload, options = {}) {
|
|
518
|
+
const { name } = options;
|
|
446
519
|
return await request(`/cherry-remote/api/pair`, {
|
|
447
520
|
method: "POST",
|
|
448
521
|
body: JSON.stringify(name === void 0 || name === "" ? { payload } : {
|
|
449
522
|
payload,
|
|
450
523
|
name
|
|
451
524
|
})
|
|
452
|
-
}) ?? {};
|
|
525
|
+
}, options.timeoutMs) ?? {};
|
|
453
526
|
}
|
|
454
527
|
/** `PATCH /devices/:id` — `hosts` is whole-replace; omitted fields are untouched. */
|
|
455
528
|
async function patchDevice(deviceId, patch) {
|
|
@@ -458,6 +531,35 @@ window.__ModuleLoader__.load({
|
|
|
458
531
|
body: JSON.stringify(patch)
|
|
459
532
|
});
|
|
460
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
|
+
}
|
|
461
563
|
/** `DELETE /devices/:id` — 204, no body. */
|
|
462
564
|
async function removeDevice(deviceId) {
|
|
463
565
|
await request(`${API_BASE}/devices/${encodeURIComponent(deviceId)}`, { method: "DELETE" });
|
|
@@ -511,11 +613,35 @@ window.__ModuleLoader__.load({
|
|
|
511
613
|
* `onSaved`, so the card shows what the archive holds rather than what the
|
|
512
614
|
* browser hoped for. The live `state` keeps coming from SSE; the patch response
|
|
513
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.
|
|
514
626
|
*/
|
|
515
627
|
/** Splits the comma-separated host box into the whole-replace list the API wants. */
|
|
516
628
|
function parseHosts(raw) {
|
|
517
629
|
return raw.split(",").map((entry) => entry.trim()).filter((entry) => entry !== "");
|
|
518
630
|
}
|
|
631
|
+
/** The dial port for one host: its own stored port, else the record-level one. */
|
|
632
|
+
function dialPort(device, host) {
|
|
633
|
+
return device.ports?.[host] ?? device.port;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* `host:port` as one readable address.
|
|
637
|
+
*
|
|
638
|
+
* An IPv6 literal contains colons, so `fe80::1:4343` reads as an ambiguous
|
|
639
|
+
* host; stored hosts are bare (`normalizeHost` strips the brackets), so the
|
|
640
|
+
* brackets are added here, for display only.
|
|
641
|
+
*/
|
|
642
|
+
function formatAddress(host, port) {
|
|
643
|
+
return host.includes(":") ? `[${host}]:${port}` : `${host}:${port}`;
|
|
644
|
+
}
|
|
519
645
|
function DeviceCard({ device, state, onSaved, onRemoved }) {
|
|
520
646
|
const t = useT();
|
|
521
647
|
const [editor, setEditor] = (0, react.useState)("none");
|
|
@@ -559,18 +685,59 @@ window.__ModuleLoader__.load({
|
|
|
559
685
|
device.port,
|
|
560
686
|
editor
|
|
561
687
|
]);
|
|
562
|
-
/**
|
|
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
|
+
*/
|
|
563
697
|
const run = async (action) => {
|
|
564
698
|
setBusy(true);
|
|
565
699
|
setError(void 0);
|
|
566
700
|
try {
|
|
567
701
|
await action();
|
|
568
702
|
} catch (cause) {
|
|
569
|
-
setError(
|
|
703
|
+
setError(describeCause(cause));
|
|
570
704
|
} finally {
|
|
571
705
|
setBusy(false);
|
|
572
706
|
}
|
|
573
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
|
+
};
|
|
574
741
|
const toggleEnabled = () => {
|
|
575
742
|
const enabled = !device.enabled;
|
|
576
743
|
run(async () => {
|
|
@@ -615,6 +782,15 @@ window.__ModuleLoader__.load({
|
|
|
615
782
|
onRemoved(device.deviceId);
|
|
616
783
|
});
|
|
617
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 ?? [];
|
|
792
|
+
const mixedPorts = device.hosts.some((host) => dialPort(device, host) !== device.port);
|
|
793
|
+
const addressList = device.hosts.map((host) => formatAddress(host, dialPort(device, host))).join(", ");
|
|
618
794
|
const label = state === void 0 ? t("device.state.unknown") : t(STATE_KEYS[state] ?? state);
|
|
619
795
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
620
796
|
className: "cherry-card",
|
|
@@ -678,9 +854,30 @@ window.__ModuleLoader__.load({
|
|
|
678
854
|
className: "cherry-card-facts",
|
|
679
855
|
children: [
|
|
680
856
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.addresses") }),
|
|
681
|
-
/* @__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
|
+
}) })] }),
|
|
682
875
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.port") }),
|
|
683
876
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: device.port }),
|
|
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", {
|
|
878
|
+
className: "cherry-mono",
|
|
879
|
+
children: addressList
|
|
880
|
+
})] }),
|
|
684
881
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: t("device.field.id") }),
|
|
685
882
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", {
|
|
686
883
|
className: "cherry-mono",
|
|
@@ -688,6 +885,14 @@ window.__ModuleLoader__.load({
|
|
|
688
885
|
})
|
|
689
886
|
]
|
|
690
887
|
}),
|
|
888
|
+
forgottenHosts.length > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
889
|
+
className: "cherry-hint",
|
|
890
|
+
children: t("device.forgotten.hint")
|
|
891
|
+
}),
|
|
892
|
+
mixedPorts && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
893
|
+
className: "cherry-hint",
|
|
894
|
+
children: t("device.ports.hint")
|
|
895
|
+
}),
|
|
691
896
|
editor === "rename" && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
|
|
692
897
|
className: "cherry-inline-form",
|
|
693
898
|
onSubmit: saveName,
|
|
@@ -725,6 +930,17 @@ window.__ModuleLoader__.load({
|
|
|
725
930
|
className: "cherry-hint",
|
|
726
931
|
children: t("device.hosts.hint")
|
|
727
932
|
}),
|
|
933
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
934
|
+
className: "cherry-hint",
|
|
935
|
+
children: [
|
|
936
|
+
t("device.ports.current"),
|
|
937
|
+
" ",
|
|
938
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
939
|
+
className: "cherry-mono",
|
|
940
|
+
children: addressList
|
|
941
|
+
})
|
|
942
|
+
]
|
|
943
|
+
}),
|
|
728
944
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
|
|
729
945
|
htmlFor: `port-${device.deviceId}`,
|
|
730
946
|
children: t("device.field.port")
|
|
@@ -735,6 +951,10 @@ window.__ModuleLoader__.load({
|
|
|
735
951
|
value: portText,
|
|
736
952
|
onChange: (event) => setPortText(event.target.value)
|
|
737
953
|
}),
|
|
954
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
955
|
+
className: "cherry-hint",
|
|
956
|
+
children: t("device.port.hint")
|
|
957
|
+
}),
|
|
738
958
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
739
959
|
type: "submit",
|
|
740
960
|
disabled: busy,
|
|
@@ -788,7 +1008,28 @@ window.__ModuleLoader__.load({
|
|
|
788
1008
|
*/
|
|
789
1009
|
/** The countdown the transient code display gets, in seconds. */
|
|
790
1010
|
var DEFAULT_CODE_LIFETIME_MS = 12e4;
|
|
791
|
-
|
|
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 }) {
|
|
792
1033
|
const t = useT();
|
|
793
1034
|
const [payload, setPayload] = (0, react.useState)("");
|
|
794
1035
|
const [claimId, setClaimId] = (0, react.useState)(void 0);
|
|
@@ -797,6 +1038,7 @@ window.__ModuleLoader__.load({
|
|
|
797
1038
|
const [code, setCode] = (0, react.useState)(void 0);
|
|
798
1039
|
const [busy, setBusy] = (0, react.useState)(false);
|
|
799
1040
|
const [verdict, setVerdict] = (0, react.useState)(void 0);
|
|
1041
|
+
const [timedOut, setTimedOut] = (0, react.useState)(false);
|
|
800
1042
|
const stage = progress?.stage;
|
|
801
1043
|
const frameClaimId = progress?.claimId;
|
|
802
1044
|
(0, react.useEffect)(() => {
|
|
@@ -811,6 +1053,7 @@ window.__ModuleLoader__.load({
|
|
|
811
1053
|
}
|
|
812
1054
|
if (TERMINAL_STAGES.includes(stage)) {
|
|
813
1055
|
setCode(void 0);
|
|
1056
|
+
setTimedOut(false);
|
|
814
1057
|
setVerdict(stage);
|
|
815
1058
|
setPhase(stage === "approved" ? "done" : "failed");
|
|
816
1059
|
setBusy(false);
|
|
@@ -828,6 +1071,21 @@ window.__ModuleLoader__.load({
|
|
|
828
1071
|
const timer = setTimeout(() => setCode(void 0), codeLifetimeMs);
|
|
829
1072
|
return () => clearTimeout(timer);
|
|
830
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
|
+
]);
|
|
831
1089
|
const submit = (event) => {
|
|
832
1090
|
event.preventDefault();
|
|
833
1091
|
const trimmed = payload.trim();
|
|
@@ -840,12 +1098,13 @@ window.__ModuleLoader__.load({
|
|
|
840
1098
|
setPhase("submitting");
|
|
841
1099
|
setClaimId(void 0);
|
|
842
1100
|
setVerdict(void 0);
|
|
1101
|
+
setTimedOut(false);
|
|
843
1102
|
onRunStart?.();
|
|
844
1103
|
startPairing(trimmed).then((result) => {
|
|
845
1104
|
setClaimId(result.claimId);
|
|
846
1105
|
setPhase("waiting");
|
|
847
1106
|
}, (cause) => {
|
|
848
|
-
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));
|
|
849
1108
|
setPhase("idle");
|
|
850
1109
|
setBusy(false);
|
|
851
1110
|
});
|
|
@@ -929,7 +1188,7 @@ window.__ModuleLoader__.load({
|
|
|
929
1188
|
phase === "failed" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
930
1189
|
className: "cherry-error",
|
|
931
1190
|
role: "alert",
|
|
932
|
-
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 ?? ""]) })
|
|
933
1192
|
}),
|
|
934
1193
|
error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
935
1194
|
className: "cherry-error",
|
|
@@ -1059,7 +1318,15 @@ window.__ModuleLoader__.load({
|
|
|
1059
1318
|
* code" checkable by eye.
|
|
1060
1319
|
*/
|
|
1061
1320
|
var SAVE_FAILED_KEY = "scope.saveFailed";
|
|
1062
|
-
|
|
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);
|
|
1063
1330
|
/**
|
|
1064
1331
|
* D11's empty state for the current draft, or `undefined` when nothing may be
|
|
1065
1332
|
* claimed yet.
|
|
@@ -1120,7 +1387,7 @@ window.__ModuleLoader__.load({
|
|
|
1120
1387
|
if (!live) return;
|
|
1121
1388
|
seedFromDocument(response);
|
|
1122
1389
|
}, (cause) => {
|
|
1123
|
-
if (live) setLoadError(describeCause(cause));
|
|
1390
|
+
if (live) setLoadError(describeCause$1(t, cause));
|
|
1124
1391
|
});
|
|
1125
1392
|
return () => {
|
|
1126
1393
|
live = false;
|
|
@@ -1190,7 +1457,7 @@ window.__ModuleLoader__.load({
|
|
|
1190
1457
|
if (code === "scope-version-unsupported") setVersionBlocked(true);
|
|
1191
1458
|
setFailure({
|
|
1192
1459
|
code: code ?? "request-failed",
|
|
1193
|
-
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) }),
|
|
1194
1461
|
detail: status === void 0 ? void 0 : t("scope.serverSaid", {
|
|
1195
1462
|
code,
|
|
1196
1463
|
status
|
|
@@ -1228,7 +1495,7 @@ window.__ModuleLoader__.load({
|
|
|
1228
1495
|
setFailure(void 0);
|
|
1229
1496
|
}, (cause) => {
|
|
1230
1497
|
setBusy(false);
|
|
1231
|
-
setLoadError(describeCause(cause));
|
|
1498
|
+
setLoadError(describeCause$1(t, cause));
|
|
1232
1499
|
});
|
|
1233
1500
|
}, [client, seedFromDocument]);
|
|
1234
1501
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
|
|
@@ -1455,6 +1722,18 @@ window.__ModuleLoader__.load({
|
|
|
1455
1722
|
* before this task the literal here rendered in English on a zh page.
|
|
1456
1723
|
*/
|
|
1457
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
|
+
}
|
|
1458
1737
|
/** Parses one SSE frame payload, ignoring a malformed one rather than crashing. */
|
|
1459
1738
|
function parseFrame(data) {
|
|
1460
1739
|
try {
|
|
@@ -1463,7 +1742,7 @@ window.__ModuleLoader__.load({
|
|
|
1463
1742
|
return;
|
|
1464
1743
|
}
|
|
1465
1744
|
}
|
|
1466
|
-
function App({ eventSourceFactory = defaultEventSourceFactory, codeLifetimeMs, scopeClient }) {
|
|
1745
|
+
function App({ eventSourceFactory = defaultEventSourceFactory, codeLifetimeMs, runLifetimeMs, scopeClient }) {
|
|
1467
1746
|
const t = useT();
|
|
1468
1747
|
const [devices, setDevices] = (0, react.useState)([]);
|
|
1469
1748
|
const [states, setStates] = (0, react.useState)({});
|
|
@@ -1476,7 +1755,7 @@ window.__ModuleLoader__.load({
|
|
|
1476
1755
|
try {
|
|
1477
1756
|
source = eventSourceFactory(`${API_BASE}/events`);
|
|
1478
1757
|
} catch (cause) {
|
|
1479
|
-
setError(
|
|
1758
|
+
setError(describeCause(t, cause));
|
|
1480
1759
|
return;
|
|
1481
1760
|
}
|
|
1482
1761
|
const onDeviceState = (event) => {
|
|
@@ -1508,7 +1787,7 @@ window.__ModuleLoader__.load({
|
|
|
1508
1787
|
setListed(true);
|
|
1509
1788
|
setError(void 0);
|
|
1510
1789
|
}, (cause) => {
|
|
1511
|
-
setError(
|
|
1790
|
+
setError(describeCause(t, cause));
|
|
1512
1791
|
setListed(true);
|
|
1513
1792
|
});
|
|
1514
1793
|
}, []);
|
|
@@ -1557,6 +1836,7 @@ window.__ModuleLoader__.load({
|
|
|
1557
1836
|
subscribed,
|
|
1558
1837
|
progress,
|
|
1559
1838
|
...codeLifetimeMs === void 0 ? {} : { codeLifetimeMs },
|
|
1839
|
+
...runLifetimeMs === void 0 ? {} : { runLifetimeMs },
|
|
1560
1840
|
onRunStart: startRun,
|
|
1561
1841
|
onSettled
|
|
1562
1842
|
}),
|
|
@@ -1569,7 +1849,7 @@ window.__ModuleLoader__.load({
|
|
|
1569
1849
|
id: "cherry-devices-title",
|
|
1570
1850
|
children: t("app.devices.heading")
|
|
1571
1851
|
}),
|
|
1572
|
-
sorted.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1852
|
+
sorted.length === 0 && error === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
1573
1853
|
className: "cherry-hint",
|
|
1574
1854
|
children: listed ? t("app.devices.empty") : t("app.devices.loading")
|
|
1575
1855
|
}),
|
|
@@ -1589,7 +1869,7 @@ window.__ModuleLoader__.load({
|
|
|
1589
1869
|
}
|
|
1590
1870
|
//#endregion
|
|
1591
1871
|
//#region ui/src/styles.css?inline
|
|
1592
|
-
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";
|
|
1593
1873
|
//#endregion
|
|
1594
1874
|
//#region ui/src/main.tsx
|
|
1595
1875
|
/** The slot a settings page registers into, per the shell's settings contract. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cherry-bridge",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"private": false,
|
|
6
6
|
"packageManager": "npm@10.9.9",
|
|
@@ -65,8 +65,9 @@
|
|
|
65
65
|
"prepublishOnly": "npm run typecheck && npm test && npm run build && npm run build:client && npm pack --dry-run"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@cherrystudio/remote-protocol": "0.
|
|
69
|
-
"@cherrystudio/remote-transport": "0.2.
|
|
68
|
+
"@cherrystudio/remote-protocol": "0.7.0",
|
|
69
|
+
"@cherrystudio/remote-transport": "0.2.5",
|
|
70
|
+
"@libp2p/peer-id": "6.0.15",
|
|
70
71
|
"ws": "8.22.0",
|
|
71
72
|
"zod": "4.3.4"
|
|
72
73
|
},
|