dsh-cherry-bridge 0.2.0 → 0.2.2

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/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
- async function request(url, init) {
425
- const response = await fetch(url, {
426
- ...init,
427
- headers: {
428
- "content-type": "application/json",
429
- ...init?.headers ?? {}
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
- const body = await readBody(response);
433
- if (!response.ok) {
434
- const record = body ?? {};
435
- const code = typeof record.error === "string" ? record.error : `http-${response.status}`;
436
- const message = typeof record.message === "string" ? record.message : code;
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, name) {
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
- /** Runs one mutation, mapping a refusal into the card's error line. */
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(cause instanceof Error ? cause.message : String(cause));
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: device.hosts.join(", ") }),
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
- function PairingWizard({ subscribed, progress, codeLifetimeMs = DEFAULT_CODE_LIFETIME_MS, onRunStart, onSettled }) {
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
- var describeCause = (cause) => cause instanceof Error ? cause.message : String(cause);
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(cause instanceof Error && cause.message === EVENT_SOURCE_UNAVAILABLE ? t("app.eventSourceUnavailable") : cause instanceof Error ? cause.message : String(cause));
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(cause instanceof Error ? cause.message : String(cause));
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cherry-bridge",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "packageManager": "npm@10.9.9",