dsh-remote-bridge 2.5.1 → 2.6.0

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.cjs CHANGED
@@ -152,6 +152,17 @@ window.__ModuleLoader__.load({
152
152
  async revokeDevice(deviceId) {
153
153
  await this.manageJson(`/devices/${encodeURIComponent(deviceId)}`, { method: "DELETE" });
154
154
  }
155
+ /**
156
+ * 一键清理「N 天没动过」的设备。天数由用户填,服务端再夹一次 1..3650。
157
+ *
158
+ * 返回真正删掉的条数 —— 0 也是一次合法结果(「没有可清理的」),界面不该把它当失败。
159
+ */
160
+ async pruneInactiveDevices(olderThanDays) {
161
+ return this.manageJson("/devices/prune", {
162
+ method: "POST",
163
+ body: JSON.stringify({ olderThanDays })
164
+ });
165
+ }
155
166
  async trashItems() {
156
167
  return this.json("/trash");
157
168
  }
@@ -172,9 +183,25 @@ window.__ModuleLoader__.load({
172
183
  async tunnelActivity() {
173
184
  return this.manageJson("/cloudflare/activity");
174
185
  }
175
- /** 创建(或复用)cloudflared 并启动 Quick Tunnel,返回对外地址。 */
176
- async tunnelCreate() {
177
- return this.manageJson("/cloudflare/create", { method: "POST" });
186
+ /**
187
+ * 创建(或复用)cloudflared 并启动 Quick Tunnel,返回对外地址。
188
+ *
189
+ * `withConfig=true`(一键「开隧道并出令牌」):请求体带 `configText:true`,服务端在
190
+ * 隧道**起来之前**的这一次调用里等地址并顺带生成令牌配置文本(见 state.configText)。
191
+ * 必须走这班车:隧道一旦运行,单独的 /manage/config/text 会被管理面判据 0 挡 403。
192
+ * endpointLabel/rootIds/deviceName 可选透传给服务端的配置生成。
193
+ */
194
+ async tunnelCreate(withConfig = false, options = {}) {
195
+ if (withConfig !== true) return this.manageJson("/cloudflare/create", { method: "POST" });
196
+ return this.manageJson("/cloudflare/create", {
197
+ method: "POST",
198
+ body: JSON.stringify({
199
+ configText: true,
200
+ ...options.endpointLabel === void 0 ? {} : { endpointLabel: options.endpointLabel },
201
+ ...options.rootIds === void 0 ? {} : { rootIds: options.rootIds },
202
+ ...options.deviceName === void 0 ? {} : { deviceName: options.deviceName }
203
+ })
204
+ });
178
205
  }
179
206
  /** 停止当前隧道。 */
180
207
  async tunnelStop() {
@@ -236,335 +263,42 @@ window.__ModuleLoader__.load({
236
263
  } catch {}
237
264
  throw new WorkspaceApiError(response.status, code, message);
238
265
  }
239
- //#endregion
240
- //#region src/client/handoff.tsx
241
- /**
242
- * 浏览器半边:把**图片交接消息**里的那个地址渲染成图片本身。
243
- *
244
- * 这半边是从 `dsh-image-paths` 0.3.1 并过来的,接的是同一个契约:纯文本模型收到图片时,
245
- * remote-bridge 会在用户消息里追加两行 ——
246
- *
247
- * 🖼️ 图片 1「shot.png」(当前模型看不到图,已存到 .dsh-images/<sha>.png;要看图,请用识图工具打开下面那行的路径)
248
- * [Image: source: /absolute/path/.dsh-images/<sha>.png]
249
- *
250
- * 本模块只读第一行里那个地址,把它变成一张真的图。第二行是给模型读的机器契约,同一张图
251
- * 不再重复出第二张;而且只扫**用户消息**,上下文注入、助手正文、工具输出里引用的路径
252
- * 一律不碰 —— 否则对话里到处都是 `.dsh-images/…` 的例子,每个都会被渲染成图。
253
- *
254
- * 取图走宿主那条只读路由 `/plugin/dsh-remote-bridge/raw?sessionId=…&name=…`:
255
- * 请求里**只有会话与裸文件名**,目录由宿主按会话工作目录推出,客户端连「读哪个目录」
256
- * 都递不进去。这条路是给 `<img>` 用的 —— 浏览器不会给图片请求加 Authorization 头,
257
- * 所以它独立于版本化 REST,按回环 + 同源 + 文件名白名单 + 字节自证守门。
258
- *
259
- * 座位选的是 Turn 尾部(`conversation.chat.turnTail`):Chat 节点座位会把这个 kind 收进
260
- * 「过程分组」,standard 模式下折叠起来 —— DOM 里有、肉眼看不着。Turn 尾在独立成行的
261
- * 白名单内,任何工作详情模式下都看得见。
262
- */
263
- /** 本插件在 Chat store 里占的节点 kind。 */
264
- const KIND = "dsh-remote-bridge-handoff-gallery";
265
- /** 宿主的只读图片路由。 */
266
- const ROUTE = "/plugin/dsh-remote-bridge/raw";
267
- /** 每轮最多显示几张。 */
268
- const MAX_IMAGES = 8;
269
- /** 长过这个的路径不值得去解析。 */
270
- const MAX_PATH_CHARS = 4096;
271
- /** 图片扩展名:与宿主交接写盘的白名单一致。 */
272
- const IMAGE_EXTENSION = /\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
273
- /**
274
- * 交接行的行首标记:中英两种文案都以它开头。
275
- *
276
- * 判据选「行首标记」而不是「句子里有 已存到」——后者会把用户自己写的句子也算进来,
277
- * 而 dsh-companion 摘除这一行用的也是同一个标记,两边口径一致。
278
- */
279
- const HANDOFF_LINE = /^🖼️\s*(?:图片|Image)\s+\d+/i;
280
266
  /**
281
- * 从交接行里抠出图片地址。
267
+ * @license lucide-react v0.468.0 - ISC
282
268
  *
283
- * 允许「已存到 / 已保存到 / saved to」后面隔一段散文(历史文案里有「工作目录」这种词),
284
- * 所以是「点心号 + 非贪婪」而不是紧邻匹配。取出的是消息里**原样**的那条路径,
285
- * 相对(`.dsh-images/x.png`)与绝对(兜底目录那版)都认。
286
- */
287
- const HANDOFF_PATH = /(?:已(?:保存|存)到|saved to)[^;;\n]*?([A-Za-z0-9_./@~+-][^\s`"'()[\]{}<>|,;:?,。;:、!?]*?\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif))/i;
288
- /** 路径里的文件名(路由只收裸文件名)。 */
289
- function basenameOf(path) {
290
- const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
291
- return cut === -1 ? path : path.slice(cut + 1);
292
- }
293
- /**
294
- * 读出一段文本里的交接图片。
295
- * @param text - 消息的文本内容(已按块拼接)。
296
- * @returns 单个 `{ path, label }`,不是交接消息就是 null。
297
- */
298
- function handoffMention(text) {
299
- if (typeof text !== "string" || text === "") return null;
300
- for (const line of text.split("\n")) {
301
- if (!HANDOFF_LINE.test(line.trim())) continue;
302
- const match = HANDOFF_PATH.exec(line);
303
- if (match === null) continue;
304
- const path = match[1] ?? "";
305
- if (path.length === 0 || path.length > MAX_PATH_CHARS) continue;
306
- if (!IMAGE_EXTENSION.test(path)) continue;
307
- return {
308
- path,
309
- label: basenameOf(path)
310
- };
311
- }
312
- return null;
313
- }
314
- /** 把一条消息的内容块拼成文本。 */
315
- function contentText(content) {
316
- if (!Array.isArray(content)) return "";
317
- const parts = [];
318
- for (const block of content) {
319
- if (block === null || typeof block !== "object") continue;
320
- const candidate = block;
321
- if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
322
- }
323
- return parts.join("\n");
324
- }
325
- /** 一条消息交接出去的那张图,没有就是空数组。 */
326
- function itemsOfMessage(payload) {
327
- if (payload === null || typeof payload !== "object") return [];
328
- const item = handoffMention(contentText(payload.content));
329
- return item === null ? [] : [item];
330
- }
331
- const WRAP_STYLE = {
332
- display: "flex",
333
- flexWrap: "wrap",
334
- alignItems: "flex-start",
335
- gap: 8,
336
- margin: "8px 0 4px"
337
- };
338
- const ITEM_STYLE = {
339
- display: "flex",
340
- flexDirection: "column",
341
- gap: 2,
342
- maxWidth: "100%"
343
- };
344
- const THUMB_STYLE = {
345
- display: "block",
346
- maxHeight: 180,
347
- maxWidth: "100%",
348
- borderRadius: 8,
349
- border: "1px solid rgba(127,127,127,0.28)",
350
- background: "rgba(127,127,127,0.06)",
351
- cursor: "zoom-in"
352
- };
353
- const CAPTION_STYLE = {
354
- display: "block",
355
- maxWidth: 180,
356
- fontFamily: "inherit",
357
- fontSize: 11,
358
- lineHeight: 1.4,
359
- color: "rgba(127,127,127,0.95)",
360
- overflow: "hidden",
361
- textOverflow: "ellipsis",
362
- whiteSpace: "nowrap"
363
- };
364
- const OVERLAY_STYLE = {
365
- position: "fixed",
366
- inset: 0,
367
- zIndex: 1e3,
368
- display: "flex",
369
- alignItems: "center",
370
- justifyContent: "center",
371
- background: "rgba(0,0,0,0.72)",
372
- cursor: "zoom-out"
373
- };
374
- const FULL_STYLE = {
375
- maxWidth: "92vw",
376
- maxHeight: "92vh",
377
- borderRadius: 6,
378
- boxShadow: "0 8px 32px rgba(0,0,0,0.5)"
379
- };
380
- /** 某条地址在这条路由上的 URL。 */
381
- function imageUrl(path, sessionId) {
382
- return `${ROUTE}?sessionId=${encodeURIComponent(sessionId)}&name=${encodeURIComponent(basenameOf(path))}`;
383
- }
384
- /**
385
- * 一张图。取不到的(文件已删、会话读不出、格式不符)自己消失,而不是在轮末留一个碎图标。
269
+ * This source code is licensed under the ISC license.
270
+ * See the LICENSE file in the root directory of this source tree.
386
271
  */
387
- function HandoffImage(props) {
388
- const [failed, setFailed] = (0, react.useState)(false);
389
- const [open, setOpen] = (0, react.useState)(false);
390
- (0, react.useEffect)(() => {
391
- if (!open) return void 0;
392
- const onKey = (event) => {
393
- if (event.key === "Escape") setOpen(false);
394
- };
395
- document.addEventListener("keydown", onKey);
396
- return () => {
397
- document.removeEventListener("keydown", onKey);
398
- };
399
- }, [open]);
400
- if (failed) return null;
401
- const url = imageUrl(props.item.path, props.sessionId);
402
- const thumb = /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
403
- src: url,
404
- alt: props.item.label,
405
- title: props.item.path,
406
- loading: "lazy",
407
- decoding: "async",
408
- referrerPolicy: "no-referrer",
409
- style: THUMB_STYLE,
410
- onError: () => setFailed(true),
411
- onClick: () => setOpen(true)
412
- });
413
- const framed = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
414
- className: "daw-handoff-item",
415
- style: ITEM_STYLE,
416
- children: [thumb, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
417
- className: "daw-handoff-caption",
418
- title: props.item.path,
419
- style: CAPTION_STYLE,
420
- children: props.item.path
421
- })]
422
- });
423
- if (!open) return framed;
424
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [framed, /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
425
- style: OVERLAY_STYLE,
426
- role: "dialog",
427
- "aria-label": props.item.path,
428
- onClick: () => setOpen(false),
429
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
430
- src: url,
431
- alt: props.item.label,
432
- decoding: "async",
433
- referrerPolicy: "no-referrer",
434
- style: FULL_STYLE
435
- })
436
- })] });
437
- }
438
- /** 把本轮各节点里交接出去的图收成一串(按出现顺序去重)。 */
439
- function collectTurnItems(nodes, locations, turn) {
440
- if (nodes === void 0 || locations === void 0 || turn === void 0 || turn === null) return [];
441
- let keys;
442
- try {
443
- keys = locations.getTurn(turn.turn);
444
- } catch {
445
- return [];
446
- }
447
- if (!Array.isArray(keys)) return [];
448
- const seen = /* @__PURE__ */ new Set();
449
- const items = [];
450
- for (const key of keys) {
451
- const node = nodes.get(key);
452
- if (node === void 0 || node === null || node.kind !== KIND) continue;
453
- const list = node.data?.items;
454
- if (!Array.isArray(list)) continue;
455
- for (const item of list) {
456
- if (item === null || typeof item !== "object" || typeof item.path !== "string") continue;
457
- if (seen.has(item.path)) continue;
458
- seen.add(item.path);
459
- items.push({
460
- path: item.path,
461
- label: typeof item.label === "string" ? item.label : basenameOf(item.path)
462
- });
463
- if (items.length >= MAX_IMAGES) return items;
464
- }
465
- }
466
- return items;
467
- }
468
- /**
469
- * Turn 尾部的图集:本轮每条交接消息各自那张图。
470
- *
471
- * 没有会话 id 就什么都不显示 —— 路由需要它来定位目录,猜一个只会 404。
472
- */
473
- function HandoffTurnTail(props) {
474
- const items = collectTurnItems(props.useChat((snapshot) => snapshot.nodes), props.useChat((snapshot) => snapshot.locations), props.turn);
475
- const sessionId = props.sessionId;
476
- if (items.length === 0) return null;
477
- if (typeof sessionId !== "string" || sessionId === "") return null;
478
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
479
- className: "daw-handoff-gallery",
480
- style: WRAP_STYLE,
481
- children: items.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffImage, {
482
- item,
483
- sessionId
484
- }, item.path))
485
- });
486
- }
487
- /**
488
- * 一条「交接消息 → 一个图集节点」的 Definition。
489
- *
490
- * 只匹配 `user/message`:交接说明属于读者自己发的那条消息。助手正文、上下文注入、
491
- * 工具输出里到处都是引用 `.dsh-images/…` 的文本,读它们会把别人的话切成碎图。
492
- *
493
- * 节点自身**不注册渲染器**(Turn 尾负责显示),所以过程分组里不会多出一行、
494
- * verbose 模式下也不会重复一次。
495
- */
496
- const handoffDefinition = {
497
- kind: KIND,
498
- target: "chat",
499
- match(event) {
500
- if (event === null || typeof event !== "object" || event.surfaceOp !== "append") return null;
501
- if (event.type !== "user/message") return null;
502
- return {
503
- id: `m:${String(event.seq)}`,
504
- role: "start"
505
- };
506
- },
507
- start(_context, match) {
508
- return {
509
- items: itemsOfMessage(match.event.data),
510
- anchorSeq: match.event.seq
511
- };
512
- },
513
- update(context) {
514
- return context.state;
515
- },
516
- buildViewNode(context) {
517
- const state = context.state;
518
- if (state === void 0 || !Array.isArray(state.items) || state.items.length === 0) return null;
519
- const start = context.start;
520
- return {
521
- key: context.key,
522
- kind: KIND,
523
- id: context.id,
524
- target: "chat",
525
- anchorSeq: state.anchorSeq,
526
- location: start !== void 0 && start !== null ? start.location : { kind: "unresolved" },
527
- visibility: "visible",
528
- data: { items: state.items }
529
- };
530
- }
531
- };
532
- //#endregion
533
- //#region node_modules/.pnpm/lucide-react@0.468.0_react@18.3.1/node_modules/lucide-react/dist/cjs/lucide-react.js
534
- /**
535
- * @license lucide-react v0.468.0 - ISC
536
- *
537
- * This source code is licensed under the ISC license.
538
- * See the LICENSE file in the root directory of this source tree.
539
- */
540
- var require_lucide_react = /* @__PURE__ */ __commonJSMin(((exports) => {
541
- var react$1 = require("react");
542
- const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
543
- const mergeClasses = (...classes) => classes.filter((className, index, array) => {
544
- return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
545
- }).join(" ").trim();
546
- var defaultAttributes = {
547
- xmlns: "http://www.w3.org/2000/svg",
548
- width: 24,
549
- height: 24,
550
- viewBox: "0 0 24 24",
551
- fill: "none",
552
- stroke: "currentColor",
553
- strokeWidth: 2,
554
- strokeLinecap: "round",
555
- strokeLinejoin: "round"
556
- };
557
- const Icon = react$1.forwardRef(({ color = "currentColor", size = 24, strokeWidth = 2, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
558
- return react$1.createElement("svg", {
559
- ref,
560
- ...defaultAttributes,
561
- width: size,
562
- height: size,
563
- stroke: color,
564
- strokeWidth: absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth,
565
- className: mergeClasses("lucide", className),
566
- ...rest
567
- }, [...iconNode.map(([tag, attrs]) => react$1.createElement(tag, attrs)), ...Array.isArray(children) ? children : [children]]);
272
+ //#endregion
273
+ //#region src/client/i18n.tsx
274
+ var import_lucide_react = (/* @__PURE__ */ __commonJSMin(((exports) => {
275
+ var react$1 = require("react");
276
+ const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
277
+ const mergeClasses = (...classes) => classes.filter((className, index, array) => {
278
+ return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
279
+ }).join(" ").trim();
280
+ var defaultAttributes = {
281
+ xmlns: "http://www.w3.org/2000/svg",
282
+ width: 24,
283
+ height: 24,
284
+ viewBox: "0 0 24 24",
285
+ fill: "none",
286
+ stroke: "currentColor",
287
+ strokeWidth: 2,
288
+ strokeLinecap: "round",
289
+ strokeLinejoin: "round"
290
+ };
291
+ const Icon = react$1.forwardRef(({ color = "currentColor", size = 24, strokeWidth = 2, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
292
+ return react$1.createElement("svg", {
293
+ ref,
294
+ ...defaultAttributes,
295
+ width: size,
296
+ height: size,
297
+ stroke: color,
298
+ strokeWidth: absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth,
299
+ className: mergeClasses("lucide", className),
300
+ ...rest
301
+ }, [...iconNode.map(([tag, attrs]) => react$1.createElement(tag, attrs)), ...Array.isArray(children) ? children : [children]]);
568
302
  });
569
303
  const createLucideIcon = (iconName, iconNode) => {
570
304
  const Component = react$1.forwardRef(({ className, ...props }, ref) => react$1.createElement(Icon, {
@@ -6770,7 +6504,7 @@ window.__ModuleLoader__.load({
6770
6504
  d: "m22 10-7.5 7.5L13 16",
6771
6505
  key: "ke71qq"
6772
6506
  }]]);
6773
- createLucideIcon("Check", [["path", {
6507
+ const Check = createLucideIcon("Check", [["path", {
6774
6508
  d: "M20 6 9 17l-5-5",
6775
6509
  key: "1gmf2c"
6776
6510
  }]]);
@@ -9169,7 +8903,7 @@ window.__ModuleLoader__.load({
9169
8903
  key: "zix9uf"
9170
8904
  }]
9171
8905
  ]);
9172
- createLucideIcon("Copy", [["rect", {
8906
+ const Copy = createLucideIcon("Copy", [["rect", {
9173
8907
  width: "14",
9174
8908
  height: "14",
9175
8909
  x: "8",
@@ -15939,7 +15673,7 @@ window.__ModuleLoader__.load({
15939
15673
  key: "jkujk7"
15940
15674
  }]
15941
15675
  ]);
15942
- createLucideIcon("Languages", [
15676
+ const Languages = createLucideIcon("Languages", [
15943
15677
  ["path", {
15944
15678
  d: "m5 8 6 6",
15945
15679
  key: "1wu5hv"
@@ -30050,16 +29784,17 @@ window.__ModuleLoader__.load({
30050
29784
  key: "durymu"
30051
29785
  }]
30052
29786
  ]);
29787
+ exports.Check = Check;
29788
+ exports.Copy = Copy;
30053
29789
  exports.Download = Download;
30054
29790
  exports.File = File;
30055
29791
  exports.HardDrive = HardDrive;
30056
29792
  exports.Image = Image;
29793
+ exports.Languages = Languages;
30057
29794
  exports.Smartphone = Smartphone;
30058
29795
  exports.Trash2 = Trash2;
30059
29796
  exports.Upload = Upload;
30060
- }));
30061
- //#endregion
30062
- //#region src/client/i18n.tsx
29797
+ })))();
30063
29798
  const STORAGE_KEY = "dsh-workspace-locale";
30064
29799
  const dictionaries = {
30065
29800
  "zh-CN": {
@@ -30235,6 +29970,56 @@ window.__ModuleLoader__.load({
30235
29970
  errorAdminProxyForwarded: "管理面不接受经代理转发的请求。请在本机用 127.0.0.1 或 localhost 直接打开。",
30236
29971
  errorAdminHostNotLoopback: "管理面只在本机回环地址下可用。请用 127.0.0.1 或 localhost 打开本页。",
30237
29972
  errorAdminLoopbackRequired: "此操作只能在本机执行。",
29973
+ tunnelStageLocating: "正在定位本机 cloudflared…",
29974
+ tunnelStageBinaryFound: "已找到本机二进制,直接复用",
29975
+ tunnelStageDownloadGithub: "正在从 GitHub 下载 cloudflared…",
29976
+ tunnelStageDownloadDeb: "正在从官方 deb 仓库下载 cloudflared…",
29977
+ tunnelStageDownloadProgress: "正在下载 cloudflared… {mb}/{all} MB",
29978
+ tunnelStageDownloadUnverified: "镜像源没有可用的校验和,仍在下载但本次不做完整性校验…",
29979
+ tunnelStageUnpackDeb: "解包官方 deb 并提取二进制…",
29980
+ tunnelStageWriteBinary: "写入二进制并赋予执行权限…",
29981
+ tunnelStageStarting: "正在启动 cloudflared 隧道…",
29982
+ tunnelErrorAlreadyRunning: "隧道已经在运行中,请先停止。",
29983
+ tunnelErrorDownload: "下载 cloudflared 失败,请检查网络后重试;如需换源可设置 CLOUDFLARED_MIRROR。",
29984
+ tunnelErrorExited: "cloudflared 已退出(code={code},signal={signal}),未拿到隧道地址。",
29985
+ tunnelStopConfirm: "停止云隧道后,公网临时地址立即失效,正在经隧道连接的手机会断开。确定停止吗?",
29986
+ tunnelStartAndGenConfig: "开启隧道并生成配置",
29987
+ tunnelRestartAndGenConfig: "停止后重开隧道并生成配置",
29988
+ managementLockedWhileTunnel: "云隧道正在运行,管理面已安全锁定。除停止隧道外的配置暂时不可改,停止隧道后自动恢复。",
29989
+ tunnelConfigAuto: "隧道已开启,配置文本已自动生成",
29990
+ tunnelUrlPending: "隧道已启动,但暂时还没拿到公网地址,稍等片刻后点「用隧道地址生成配置」即可。",
29991
+ copyUrl: "复制地址",
29992
+ pairingExpiresAt: "配对码有效期约 {minutes} 分钟,请尽快在手机上输入。",
29993
+ copyPairing: "复制配对码",
29994
+ pairingCreated: "配对码已生成",
29995
+ configCreated: "配置文本已生成",
29996
+ revokeConfirm: "吊销后这台手机的令牌立即失效,必须重新配对才能访问。确定吊销「{name}」吗?",
29997
+ pruneInactiveDays: "清理超过(天)未使用",
29998
+ pruneInactiveAction: "清理 {days} 天未使用设备",
29999
+ pruneInactiveBusy: "正在清理…",
30000
+ pruneInactiveConfirm: "将删除 {days} 天没有使用过的设备(它们需要重新配对才能访问)。最近用过的设备不受影响。继续吗?",
30001
+ pruneInactiveDone: "已清理 {count} 台设备",
30002
+ pruneInactiveNone: "没有需要清理的设备",
30003
+ removeRootConfirm: "移除根目录「{label}」不会删除磁盘上的文件,但所有手机将立刻失去对它的访问权。确定移除吗?",
30004
+ disableRemoteConfirm: "关闭远程访问后,所有手机连接立即断开,需要重新启用并配对。确定关闭吗?",
30005
+ rootAdded: "根目录已添加",
30006
+ rootRemoved: "根目录已移除",
30007
+ remoteDisabled: "远程访问已关闭",
30008
+ permissionsSaved: "权限已保存",
30009
+ deviceRevoked: "设备已吊销",
30010
+ listenerSaved: "监听设置已保存",
30011
+ trashRestored: "已恢复",
30012
+ trashPurged: "已永久删除",
30013
+ tunnelStarted: "云隧道已开启",
30014
+ auditEmpty: "暂无审计事件",
30015
+ auditLoadMore: "加载更多",
30016
+ auditLoading: "正在加载…",
30017
+ trashEmpty: "回收站是空的",
30018
+ deviceLastSeen: "最后使用 {time}",
30019
+ deviceNeverSeen: "从未连接",
30020
+ imageLoadFailed: "图片加载失败",
30021
+ prevImage: "上一张",
30022
+ nextImage: "下一张",
30238
30023
  author: "作者 Github@Hakunm",
30239
30024
  version: "当前版本"
30240
30025
  },
@@ -30321,7 +30106,7 @@ window.__ModuleLoader__.load({
30321
30106
  removeRoot: "Remove root",
30322
30107
  builtinRootBadge: "Default",
30323
30108
  builtinRootHint: "Default directory · cannot be removed",
30324
- builtinRootNotice: "New roots are not granted to already paired phones by default. To share one, open 「设备」 and tick that directory for the phone in question.",
30109
+ builtinRootNotice: "New roots are not granted to already paired phones by default. To share one, open the Devices card and tick that directory for the phone in question.",
30325
30110
  errorRootBuiltin: "This is the current DSH working directory, so it cannot be removed. You can still add other roots above.",
30326
30111
  scopeChatRead: "Read chat",
30327
30112
  scopeChatWrite: "Send chat",
@@ -30411,6 +30196,56 @@ window.__ModuleLoader__.load({
30411
30196
  errorAdminProxyForwarded: "Management endpoints do not accept proxied requests. Open them locally via 127.0.0.1 or localhost.",
30412
30197
  errorAdminHostNotLoopback: "Management endpoints are available only under a loopback address. Open this page via 127.0.0.1 or localhost.",
30413
30198
  errorAdminLoopbackRequired: "This operation is available only from the local machine.",
30199
+ tunnelStageLocating: "Locating the local cloudflared binary…",
30200
+ tunnelStageBinaryFound: "Local binary found, reusing it",
30201
+ tunnelStageDownloadGithub: "Downloading cloudflared from GitHub…",
30202
+ tunnelStageDownloadDeb: "Downloading cloudflared from the official deb repo…",
30203
+ tunnelStageDownloadProgress: "Downloading cloudflared… {mb}/{all} MB",
30204
+ tunnelStageDownloadUnverified: "No checksum is published for this mirror; downloading without an integrity check…",
30205
+ tunnelStageUnpackDeb: "Unpacking the deb and extracting the binary…",
30206
+ tunnelStageWriteBinary: "Writing the binary and marking it executable…",
30207
+ tunnelStageStarting: "Starting the cloudflared tunnel…",
30208
+ tunnelErrorAlreadyRunning: "The tunnel is already running. Stop it first.",
30209
+ tunnelErrorDownload: "Downloading cloudflared failed. Check the network and retry; set CLOUDFLARED_MIRROR to use another source.",
30210
+ tunnelErrorExited: "cloudflared exited (code={code}, signal={signal}) before a tunnel address was obtained.",
30211
+ tunnelStopConfirm: "Stopping the tunnel invalidates the public address immediately and disconnects phones currently connected through it. Stop anyway?",
30212
+ tunnelStartAndGenConfig: "Start tunnel & generate config",
30213
+ tunnelRestartAndGenConfig: "Restart tunnel & generate config",
30214
+ managementLockedWhileTunnel: "The Cloudflare tunnel is running, so the management surface is safely locked. Everything except stopping the tunnel is read-only until you stop it.",
30215
+ tunnelConfigAuto: "Tunnel started; config text generated automatically",
30216
+ tunnelUrlPending: "The tunnel is starting but its public address is not ready yet. Wait a moment, then press “Generate config with tunnel address”.",
30217
+ copyUrl: "Copy address",
30218
+ pairingExpiresAt: "The pairing code is valid for about {minutes} minutes — enter it on the phone soon.",
30219
+ copyPairing: "Copy code",
30220
+ pairingCreated: "Pairing code generated",
30221
+ configCreated: "Config text generated",
30222
+ revokeConfirm: "Revoking invalidates this phone’s token at once; it must pair again to regain access. Revoke “{name}”?",
30223
+ pruneInactiveDays: "Unused for more than (days)",
30224
+ pruneInactiveAction: "Prune devices unused for {days} days",
30225
+ pruneInactiveBusy: "Pruning…",
30226
+ pruneInactiveConfirm: "This deletes devices unused for {days} days (they must pair again to regain access). Recently used devices are untouched. Continue?",
30227
+ pruneInactiveDone: "Pruned {count} device(s)",
30228
+ pruneInactiveNone: "No devices to prune",
30229
+ removeRootConfirm: "Removing “{label}” does not delete files on disk, but every phone immediately loses access to it. Remove it?",
30230
+ disableRemoteConfirm: "Disabling remote access drops every phone connection right away; it must be re-enabled and re-paired. Disable it?",
30231
+ rootAdded: "Root added",
30232
+ rootRemoved: "Root removed",
30233
+ remoteDisabled: "Remote access disabled",
30234
+ permissionsSaved: "Permissions saved",
30235
+ deviceRevoked: "Device revoked",
30236
+ listenerSaved: "Listener settings saved",
30237
+ trashRestored: "Restored",
30238
+ trashPurged: "Permanently deleted",
30239
+ tunnelStarted: "Tunnel started",
30240
+ auditEmpty: "No audit events yet",
30241
+ auditLoadMore: "Load more",
30242
+ auditLoading: "Loading…",
30243
+ trashEmpty: "The recycle bin is empty",
30244
+ deviceLastSeen: "Last seen {time}",
30245
+ deviceNeverSeen: "Never connected",
30246
+ imageLoadFailed: "Image failed to load",
30247
+ prevImage: "Previous image",
30248
+ nextImage: "Next image",
30414
30249
  author: "Author Github@Hakunm",
30415
30250
  version: "Current version"
30416
30251
  }
@@ -30428,68 +30263,545 @@ window.__ModuleLoader__.load({
30428
30263
  } catch {}
30429
30264
  for (const listener of listeners) listener();
30430
30265
  }
30431
- function subscribeWorkspaceLocale(listener) {
30432
- listeners.add(listener);
30433
- return () => listeners.delete(listener);
30266
+ function subscribeWorkspaceLocale(listener) {
30267
+ listeners.add(listener);
30268
+ return () => listeners.delete(listener);
30269
+ }
30270
+ /**
30271
+ * 取一条文案并填占位符。
30272
+ *
30273
+ * **缺键不抛异常**。原来这里是 `dictionaries[locale][key].replace(...)`:任何一个不存在的键
30274
+ * 都会抛 TypeError,而它在 React 渲染路径上 —— 结果不是那一行显示不出原文,而是**整个设置
30275
+ * 面板白屏**。缺一个描述行就把七张卡里的全部设置都搭进去,代价和收益完全不成比例。
30276
+ *
30277
+ * 降级成「回退到另一种语言 → 再回退成键名本身」:最坏情况是某一行露出 `rootsTabDesc`
30278
+ * 这样的字面量,一眼能看出该补哪条,而不是面对一个空白面板。
30279
+ */
30280
+ function translate(locale, key, values = {}) {
30281
+ const fill = (template) => template?.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
30282
+ const primary = fill(dictionaries[locale][key]);
30283
+ if (primary !== void 0) return primary;
30284
+ const text = fill(dictionaries["zh-CN"][key]) ?? key;
30285
+ console.warn(`[dsh-remote-bridge] 缺少文案键 ${String(key)}(locale=${locale}),已降级显示键名`);
30286
+ return text;
30287
+ }
30288
+ /**
30289
+ * 权限码 → 人话。
30290
+ *
30291
+ * 用户要求「中文状态下显示中文,英文显示英文」。文案刻意**用人话而不是协议码**:
30292
+ * `读文件` / `写文件` / `删文件` 自己就看得懂,而 `files.read` 要对照文档才知道指什么 ——
30293
+ * 出问题时用户把屏幕上的字念出来,比报一串码有用得多。
30294
+ *
30295
+ * 键名 `scope${码}`(`chat.read` → `scopeChatRead`):与既有 `rootsTab` 一致的拼法。
30296
+ * 用**显式查表**而不是模板拼 —— 拼错会查不到键,而键查不到时 `translate` 会静默降级成
30297
+ * 露出键名(见上面),界面上会突然冒出一行 `scopeChatread`。白屏那次就是这么来的。
30298
+ */
30299
+ const SCOPE_LABEL_KEYS = {
30300
+ "chat.read": "scopeChatRead",
30301
+ "chat.write": "scopeChatWrite",
30302
+ "files.read": "scopeFilesRead",
30303
+ "files.write": "scopeFilesWrite",
30304
+ "files.delete": "scopeFilesDelete",
30305
+ "settings.read": "scopeSettingsRead",
30306
+ "settings.write": "scopeSettingsWrite"
30307
+ };
30308
+ function scopeLabel(locale, scope) {
30309
+ const key = SCOPE_LABEL_KEYS[scope];
30310
+ if (key === void 0) return scope;
30311
+ return translate(locale, key);
30312
+ }
30313
+ function useWorkspaceI18n() {
30314
+ const locale = (0, react.useSyncExternalStore)(subscribeWorkspaceLocale, getWorkspaceLocale, () => "zh-CN");
30315
+ return {
30316
+ locale,
30317
+ t: (0, react.useCallback)((key, values) => translate(locale, key, values), [locale]),
30318
+ toggle: (0, react.useCallback)(() => setWorkspaceLocale(locale === "zh-CN" ? "en" : "zh-CN"), [locale])
30319
+ };
30320
+ }
30321
+ function LanguageToggle() {
30322
+ const { t, toggle } = useWorkspaceI18n();
30323
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30324
+ className: "daw-icon",
30325
+ type: "button",
30326
+ title: t("switchLanguage"),
30327
+ "aria-label": t("switchLanguage"),
30328
+ onClick: toggle,
30329
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Languages, { size: 16 })
30330
+ });
30331
+ }
30332
+ function readStoredLocale() {
30333
+ try {
30334
+ return globalThis.localStorage?.getItem(STORAGE_KEY) === "en" ? "en" : "zh-CN";
30335
+ } catch {
30336
+ return "zh-CN";
30337
+ }
30338
+ }
30339
+ //#endregion
30340
+ //#region src/client/handoff.tsx
30341
+ /**
30342
+ * 浏览器半边:把**图片交接消息**里的那个地址渲染成图片本身。
30343
+ *
30344
+ * 这半边是从 `dsh-image-paths` 0.3.1 并过来的,接的是同一个契约:纯文本模型收到图片时,
30345
+ * remote-bridge 会在用户消息里追加两行 ——
30346
+ *
30347
+ * 🖼️ 图片 1「shot.png」(当前模型看不到图,已存到 .dsh-images/<sha>.png;要看图,请用识图工具打开下面那行的路径)
30348
+ * [Image: source: /absolute/path/.dsh-images/<sha>.png]
30349
+ *
30350
+ * 本模块只读第一行里那个地址,把它变成一张真的图。第二行是给模型读的机器契约,同一张图
30351
+ * 不再重复出第二张;而且只扫**用户消息**,上下文注入、助手正文、工具输出里引用的路径
30352
+ * 一律不碰 —— 否则对话里到处都是 `.dsh-images/…` 的例子,每个都会被渲染成图。
30353
+ *
30354
+ * 取图走宿主那条只读路由 `/plugin/dsh-remote-bridge/raw?sessionId=…&name=…`:
30355
+ * 请求里**只有会话与裸文件名**,目录由宿主按会话工作目录推出,客户端连「读哪个目录」
30356
+ * 都递不进去。这条路是给 `<img>` 用的 —— 浏览器不会给图片请求加 Authorization 头,
30357
+ * 所以它独立于版本化 REST,按回环 + 同源 + 文件名白名单 + 字节自证守门。
30358
+ *
30359
+ * 座位选的是 Turn 尾部(`conversation.chat.turnTail`):Chat 节点座位会把这个 kind 收进
30360
+ * 「过程分组」,standard 模式下折叠起来 —— DOM 里有、肉眼看不着。Turn 尾在独立成行的
30361
+ * 白名单内,任何工作详情模式下都看得见。
30362
+ */
30363
+ /** 本插件在 Chat store 里占的节点 kind。 */
30364
+ const KIND = "dsh-remote-bridge-handoff-gallery";
30365
+ /** 宿主的只读图片路由。 */
30366
+ const ROUTE = "/plugin/dsh-remote-bridge/raw";
30367
+ /** 每轮最多显示几张。 */
30368
+ const MAX_IMAGES = 8;
30369
+ /** 长过这个的路径不值得去解析。 */
30370
+ const MAX_PATH_CHARS = 4096;
30371
+ /** 图片扩展名:与宿主交接写盘的白名单一致。 */
30372
+ const IMAGE_EXTENSION = /\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
30373
+ /**
30374
+ * 交接行的行首标记:中英两种文案都以它开头。
30375
+ *
30376
+ * 判据选「行首标记」而不是「句子里有 已存到」——后者会把用户自己写的句子也算进来,
30377
+ * 而 dsh-companion 摘除这一行用的也是同一个标记,两边口径一致。
30378
+ */
30379
+ const HANDOFF_LINE = /^🖼️\s*(?:图片|Image)\s+\d+/i;
30380
+ /**
30381
+ * 从交接行里抠出图片地址。
30382
+ *
30383
+ * 允许「已存到 / 已保存到 / saved to」后面隔一段散文(历史文案里有「工作目录」这种词),
30384
+ * 所以是「点心号 + 非贪婪」而不是紧邻匹配。取出的是消息里**原样**的那条路径,
30385
+ * 相对(`.dsh-images/x.png`)与绝对(兜底目录那版)都认。
30386
+ */
30387
+ const HANDOFF_PATH = /(?:已(?:保存|存)到|saved to)[^;;\n]*?([A-Za-z0-9_./@~+-][^\s`"'()[\]{}<>|,;:?,。;:、!?]*?\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif))/i;
30388
+ /** 路径里的文件名(路由只收裸文件名)。 */
30389
+ function basenameOf(path) {
30390
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
30391
+ return cut === -1 ? path : path.slice(cut + 1);
30392
+ }
30393
+ /**
30394
+ * 读出一段文本里的交接图片。
30395
+ * @param text - 消息的文本内容(已按块拼接)。
30396
+ * @returns 单个 `{ path, label }`,不是交接消息就是 null。
30397
+ */
30398
+ function handoffMention(text) {
30399
+ if (typeof text !== "string" || text === "") return null;
30400
+ for (const line of text.split("\n")) {
30401
+ if (!HANDOFF_LINE.test(line.trim())) continue;
30402
+ const match = HANDOFF_PATH.exec(line);
30403
+ if (match === null) continue;
30404
+ const path = match[1] ?? "";
30405
+ if (path.length === 0 || path.length > MAX_PATH_CHARS) continue;
30406
+ if (!IMAGE_EXTENSION.test(path)) continue;
30407
+ return {
30408
+ path,
30409
+ label: basenameOf(path)
30410
+ };
30411
+ }
30412
+ return null;
30413
+ }
30414
+ /** 把一条消息的内容块拼成文本。 */
30415
+ function contentText(content) {
30416
+ if (!Array.isArray(content)) return "";
30417
+ const parts = [];
30418
+ for (const block of content) {
30419
+ if (block === null || typeof block !== "object") continue;
30420
+ const candidate = block;
30421
+ if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
30422
+ }
30423
+ return parts.join("\n");
30424
+ }
30425
+ /** 一条消息交接出去的那张图,没有就是空数组。 */
30426
+ function itemsOfMessage(payload) {
30427
+ if (payload === null || typeof payload !== "object") return [];
30428
+ const item = handoffMention(contentText(payload.content));
30429
+ return item === null ? [] : [item];
30430
+ }
30431
+ const WRAP_STYLE = {
30432
+ display: "flex",
30433
+ flexWrap: "wrap",
30434
+ alignItems: "flex-start",
30435
+ gap: 8,
30436
+ margin: "8px 0 4px"
30437
+ };
30438
+ const ITEM_STYLE = {
30439
+ display: "flex",
30440
+ flexDirection: "column",
30441
+ gap: 2,
30442
+ maxWidth: "100%"
30443
+ };
30444
+ const THUMB_STYLE = {
30445
+ display: "block",
30446
+ maxHeight: 180,
30447
+ maxWidth: "100%",
30448
+ borderRadius: 8,
30449
+ border: "1px solid rgba(127,127,127,0.28)",
30450
+ background: "rgba(127,127,127,0.06)",
30451
+ cursor: "zoom-in"
30452
+ };
30453
+ const CAPTION_STYLE = {
30454
+ display: "block",
30455
+ maxWidth: 180,
30456
+ fontFamily: "inherit",
30457
+ fontSize: 11,
30458
+ lineHeight: 1.4,
30459
+ color: "rgba(127,127,127,0.95)",
30460
+ overflow: "hidden",
30461
+ textOverflow: "ellipsis",
30462
+ whiteSpace: "nowrap"
30463
+ };
30464
+ const OVERLAY_STYLE = {
30465
+ position: "fixed",
30466
+ inset: 0,
30467
+ zIndex: 1e3,
30468
+ display: "flex",
30469
+ alignItems: "center",
30470
+ justifyContent: "center",
30471
+ background: "rgba(0,0,0,0.72)",
30472
+ cursor: "zoom-out"
30473
+ };
30474
+ const FULL_STYLE = {
30475
+ maxWidth: "92vw",
30476
+ maxHeight: "92vh",
30477
+ borderRadius: 6,
30478
+ boxShadow: "0 8px 32px rgba(0,0,0,0.5)"
30479
+ };
30480
+ /** 加载失败的缩略图占位(不再像旧版那样整张消失,只留一个问号让人瞎猜)。 */
30481
+ const THUMB_BROKEN_STYLE = {
30482
+ ...THUMB_STYLE,
30483
+ display: "flex",
30484
+ alignItems: "center",
30485
+ justifyContent: "center",
30486
+ width: 160,
30487
+ height: 120,
30488
+ cursor: "default",
30489
+ fontSize: 12
30490
+ };
30491
+ const LIGHTBOX_NAV_STYLE = {
30492
+ position: "absolute",
30493
+ top: "50%",
30494
+ transform: "translateY(-50%)",
30495
+ zIndex: 1001,
30496
+ width: 44,
30497
+ height: 44,
30498
+ borderRadius: "50%",
30499
+ border: "none",
30500
+ background: "rgba(255,255,255,0.14)",
30501
+ color: "#fff",
30502
+ fontSize: 22,
30503
+ lineHeight: "44px",
30504
+ textAlign: "center",
30505
+ cursor: "pointer",
30506
+ padding: 0
30507
+ };
30508
+ const LIGHTBOX_CLOSE_STYLE = {
30509
+ position: "absolute",
30510
+ top: 16,
30511
+ right: 16,
30512
+ zIndex: 1001,
30513
+ width: 40,
30514
+ height: 40,
30515
+ borderRadius: "50%",
30516
+ border: "none",
30517
+ background: "rgba(255,255,255,0.14)",
30518
+ color: "#fff",
30519
+ fontSize: 20,
30520
+ lineHeight: "40px",
30521
+ textAlign: "center",
30522
+ cursor: "pointer",
30523
+ padding: 0
30524
+ };
30525
+ const LIGHTBOX_FULL_BROKEN_STYLE = {
30526
+ ...FULL_STYLE,
30527
+ display: "flex",
30528
+ alignItems: "center",
30529
+ justifyContent: "center",
30530
+ width: "min(72vw, 560px)",
30531
+ height: "min(60vh, 360px)",
30532
+ background: "rgba(127,127,127,0.18)",
30533
+ color: "rgba(255,255,255,0.92)",
30534
+ fontSize: 14
30535
+ };
30536
+ /** 某条地址在这条路由上的 URL。 */
30537
+ function imageUrl(path, sessionId) {
30538
+ return `${ROUTE}?sessionId=${encodeURIComponent(sessionId)}&name=${encodeURIComponent(basenameOf(path))}`;
30539
+ }
30540
+ /**
30541
+ * 缩略图。取不到的(文件已删、会话读不出、格式不符)显示一块「加载失败」占位,
30542
+ * 而不是像旧版那样整张消失——消失了用户只会以为模型没存图,根本不知道该重试。
30543
+ */
30544
+ function HandoffThumb(props) {
30545
+ const [failed, setFailed] = (0, react.useState)(false);
30546
+ if (failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30547
+ className: "daw-handoff-item",
30548
+ style: ITEM_STYLE,
30549
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30550
+ style: THUMB_BROKEN_STYLE,
30551
+ title: props.item.path,
30552
+ children: props.failedText
30553
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30554
+ className: "daw-handoff-caption",
30555
+ title: props.item.path,
30556
+ style: CAPTION_STYLE,
30557
+ children: props.item.path
30558
+ })]
30559
+ });
30560
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30561
+ className: "daw-handoff-item",
30562
+ style: ITEM_STYLE,
30563
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
30564
+ src: imageUrl(props.item.path, props.sessionId),
30565
+ alt: props.item.label,
30566
+ title: props.item.path,
30567
+ loading: "lazy",
30568
+ decoding: "async",
30569
+ referrerPolicy: "no-referrer",
30570
+ style: THUMB_STYLE,
30571
+ onError: () => setFailed(true),
30572
+ onClick: props.onOpen
30573
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30574
+ className: "daw-handoff-caption",
30575
+ title: props.item.path,
30576
+ style: CAPTION_STYLE,
30577
+ children: props.item.path
30578
+ })]
30579
+ });
30580
+ }
30581
+ /** 灯箱里的大图;换图时靠 key 重挂载来重置 failed。 */
30582
+ function LightboxImage(props) {
30583
+ const [failed, setFailed] = (0, react.useState)(false);
30584
+ if (failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30585
+ style: LIGHTBOX_FULL_BROKEN_STYLE,
30586
+ children: props.failedText
30587
+ });
30588
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
30589
+ src: imageUrl(props.item.path, props.sessionId),
30590
+ alt: props.item.label,
30591
+ decoding: "async",
30592
+ referrerPolicy: "no-referrer",
30593
+ style: FULL_STYLE,
30594
+ onError: () => setFailed(true)
30595
+ });
30596
+ }
30597
+ /**
30598
+ * 图集灯箱:点遮罩空白处关闭,但**点图片本身不再误关**(旧版 img 的点击冒泡到 overlay)。
30599
+ * 多图时左右切换,Esc 关闭。
30600
+ */
30601
+ function HandoffLightbox(props) {
30602
+ const { items, index, onClose, onPrev, onNext } = props;
30603
+ const multiple = items.length > 1;
30604
+ (0, react.useEffect)(() => {
30605
+ const onKey = (event) => {
30606
+ if (event.key === "Escape") onClose();
30607
+ else if (multiple && event.key === "ArrowLeft") onPrev();
30608
+ else if (multiple && event.key === "ArrowRight") onNext();
30609
+ };
30610
+ document.addEventListener("keydown", onKey);
30611
+ return () => {
30612
+ document.removeEventListener("keydown", onKey);
30613
+ };
30614
+ }, [
30615
+ multiple,
30616
+ onClose,
30617
+ onPrev,
30618
+ onNext
30619
+ ]);
30620
+ const item = items[index];
30621
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30622
+ style: {
30623
+ ...OVERLAY_STYLE,
30624
+ cursor: "zoom-out"
30625
+ },
30626
+ role: "dialog",
30627
+ "aria-modal": "true",
30628
+ "aria-label": item.path,
30629
+ onClick: onClose,
30630
+ children: [
30631
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30632
+ type: "button",
30633
+ style: LIGHTBOX_CLOSE_STYLE,
30634
+ "aria-label": "×",
30635
+ onClick: onClose,
30636
+ children: "×"
30637
+ }),
30638
+ multiple && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30639
+ type: "button",
30640
+ style: {
30641
+ ...LIGHTBOX_NAV_STYLE,
30642
+ left: 24
30643
+ },
30644
+ "aria-label": props.prevLabel,
30645
+ title: props.prevLabel,
30646
+ onClick: (e) => {
30647
+ e.stopPropagation();
30648
+ onPrev();
30649
+ },
30650
+ children: "‹"
30651
+ }) : null,
30652
+ multiple && index < items.length - 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30653
+ type: "button",
30654
+ style: {
30655
+ ...LIGHTBOX_NAV_STYLE,
30656
+ right: 24
30657
+ },
30658
+ "aria-label": props.nextLabel,
30659
+ title: props.nextLabel,
30660
+ onClick: (e) => {
30661
+ e.stopPropagation();
30662
+ onNext();
30663
+ },
30664
+ children: "›"
30665
+ }) : null,
30666
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30667
+ style: {
30668
+ position: "relative",
30669
+ cursor: "default"
30670
+ },
30671
+ onClick: (e) => e.stopPropagation(),
30672
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LightboxImage, {
30673
+ item,
30674
+ sessionId: props.sessionId,
30675
+ failedText: props.failedText
30676
+ }, index), multiple ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30677
+ style: {
30678
+ position: "absolute",
30679
+ bottom: -28,
30680
+ left: 0,
30681
+ right: 0,
30682
+ textAlign: "center",
30683
+ color: "rgba(255,255,255,0.85)",
30684
+ fontSize: 12
30685
+ },
30686
+ children: [
30687
+ index + 1,
30688
+ " / ",
30689
+ items.length
30690
+ ]
30691
+ }) : null]
30692
+ })
30693
+ ]
30694
+ });
30695
+ }
30696
+ /** 把本轮各节点里交接出去的图收成一串(按出现顺序去重)。 */
30697
+ function collectTurnItems(nodes, locations, turn) {
30698
+ if (nodes === void 0 || locations === void 0 || turn === void 0 || turn === null) return [];
30699
+ let keys;
30700
+ try {
30701
+ keys = locations.getTurn(turn.turn);
30702
+ } catch {
30703
+ return [];
30704
+ }
30705
+ if (!Array.isArray(keys)) return [];
30706
+ const seen = /* @__PURE__ */ new Set();
30707
+ const items = [];
30708
+ for (const key of keys) {
30709
+ const node = nodes.get(key);
30710
+ if (node === void 0 || node === null || node.kind !== KIND) continue;
30711
+ const list = node.data?.items;
30712
+ if (!Array.isArray(list)) continue;
30713
+ for (const item of list) {
30714
+ if (item === null || typeof item !== "object" || typeof item.path !== "string") continue;
30715
+ if (seen.has(item.path)) continue;
30716
+ seen.add(item.path);
30717
+ items.push({
30718
+ path: item.path,
30719
+ label: typeof item.label === "string" ? item.label : basenameOf(item.path)
30720
+ });
30721
+ if (items.length >= MAX_IMAGES) return items;
30722
+ }
30723
+ }
30724
+ return items;
30434
30725
  }
30435
30726
  /**
30436
- * 取一条文案并填占位符。
30437
- *
30438
- * **缺键不抛异常**。原来这里是 `dictionaries[locale][key].replace(...)`:任何一个不存在的键
30439
- * 都会抛 TypeError,而它在 React 渲染路径上 —— 结果不是那一行显示不出原文,而是**整个设置
30440
- * 面板白屏**。缺一个描述行就把七张卡里的全部设置都搭进去,代价和收益完全不成比例。
30727
+ * Turn 尾部的图集:本轮每条交接消息各自那张图。
30441
30728
  *
30442
- * 降级成「回退到另一种语言 → 再回退成键名本身」:最坏情况是某一行露出 `rootsTabDesc`
30443
- * 这样的字面量,一眼能看出该补哪条,而不是面对一个空白面板。
30729
+ * 没有会话 id 就什么都不显示 —— 路由需要它来定位目录,猜一个只会 404。
30444
30730
  */
30445
- function translate(locale, key, values = {}) {
30446
- const fill = (template) => template?.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
30447
- const primary = fill(dictionaries[locale][key]);
30448
- if (primary !== void 0) return primary;
30449
- const text = fill(dictionaries["zh-CN"][key]) ?? key;
30450
- console.warn(`[dsh-remote-bridge] 缺少文案键 ${String(key)}(locale=${locale}),已降级显示键名`);
30451
- return text;
30731
+ function HandoffTurnTail(props) {
30732
+ const items = collectTurnItems(props.useChat((snapshot) => snapshot.nodes), props.useChat((snapshot) => snapshot.locations), props.turn);
30733
+ const sessionId = props.sessionId;
30734
+ const { t } = useWorkspaceI18n();
30735
+ const [activeIndex, setActiveIndex] = (0, react.useState)(null);
30736
+ if (items.length === 0) return null;
30737
+ if (typeof sessionId !== "string" || sessionId === "") return null;
30738
+ const openIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null;
30739
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30740
+ className: "daw-handoff-gallery",
30741
+ style: WRAP_STYLE,
30742
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffThumb, {
30743
+ item,
30744
+ sessionId,
30745
+ failedText: t("imageLoadFailed"),
30746
+ onOpen: () => setActiveIndex(index)
30747
+ }, item.path))
30748
+ }), openIndex !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffLightbox, {
30749
+ items,
30750
+ index: openIndex,
30751
+ sessionId,
30752
+ failedText: t("imageLoadFailed"),
30753
+ prevLabel: t("prevImage"),
30754
+ nextLabel: t("nextImage"),
30755
+ onClose: () => setActiveIndex(null),
30756
+ onPrev: () => setActiveIndex((i) => i === null ? i : Math.max(0, i - 1)),
30757
+ onNext: () => setActiveIndex((i) => i === null ? i : Math.min(items.length - 1, i + 1))
30758
+ }) : null] });
30452
30759
  }
30453
30760
  /**
30454
- * 权限码 → 人话。
30761
+ * 一条「交接消息 → 一个图集节点」的 Definition。
30455
30762
  *
30456
- * 用户要求「中文状态下显示中文,英文显示英文」。文案刻意**用人话而不是协议码**:
30457
- * `读文件` / `写文件` / `删文件` 自己就看得懂,而 `files.read` 要对照文档才知道指什么 ——
30458
- * 出问题时用户把屏幕上的字念出来,比报一串码有用得多。
30763
+ * 只匹配 `user/message`:交接说明属于读者自己发的那条消息。助手正文、上下文注入、
30764
+ * 工具输出里到处都是引用 `.dsh-images/…` 的文本,读它们会把别人的话切成碎图。
30459
30765
  *
30460
- * 键名 `scope${码}`(`chat.read` → `scopeChatRead`):与既有 `rootsTab` 一致的拼法。
30461
- * 用**显式查表**而不是模板拼 —— 拼错会查不到键,而键查不到时 `translate` 会静默降级成
30462
- * 露出键名(见上面),界面上会突然冒出一行 `scopeChatread`。白屏那次就是这么来的。
30766
+ * 节点自身**不注册渲染器**(Turn 尾负责显示),所以过程分组里不会多出一行、
30767
+ * verbose 模式下也不会重复一次。
30463
30768
  */
30464
- const SCOPE_LABEL_KEYS = {
30465
- "chat.read": "scopeChatRead",
30466
- "chat.write": "scopeChatWrite",
30467
- "files.read": "scopeFilesRead",
30468
- "files.write": "scopeFilesWrite",
30469
- "files.delete": "scopeFilesDelete",
30470
- "settings.read": "scopeSettingsRead",
30471
- "settings.write": "scopeSettingsWrite"
30472
- };
30473
- function scopeLabel(locale, scope) {
30474
- const key = SCOPE_LABEL_KEYS[scope];
30475
- if (key === void 0) return scope;
30476
- return translate(locale, key);
30477
- }
30478
- function useWorkspaceI18n() {
30479
- const locale = (0, react.useSyncExternalStore)(subscribeWorkspaceLocale, getWorkspaceLocale, () => "zh-CN");
30480
- return {
30481
- locale,
30482
- t: (0, react.useCallback)((key, values) => translate(locale, key, values), [locale]),
30483
- toggle: (0, react.useCallback)(() => setWorkspaceLocale(locale === "zh-CN" ? "en" : "zh-CN"), [locale])
30484
- };
30485
- }
30486
- function readStoredLocale() {
30487
- try {
30488
- return globalThis.localStorage?.getItem(STORAGE_KEY) === "en" ? "en" : "zh-CN";
30489
- } catch {
30490
- return "zh-CN";
30769
+ const handoffDefinition = {
30770
+ kind: KIND,
30771
+ target: "chat",
30772
+ match(event) {
30773
+ if (event === null || typeof event !== "object" || event.surfaceOp !== "append") return null;
30774
+ if (event.type !== "user/message") return null;
30775
+ return {
30776
+ id: `m:${String(event.seq)}`,
30777
+ role: "start"
30778
+ };
30779
+ },
30780
+ start(_context, match) {
30781
+ return {
30782
+ items: itemsOfMessage(match.event.data),
30783
+ anchorSeq: match.event.seq
30784
+ };
30785
+ },
30786
+ update(context) {
30787
+ return context.state;
30788
+ },
30789
+ buildViewNode(context) {
30790
+ const state = context.state;
30791
+ if (state === void 0 || !Array.isArray(state.items) || state.items.length === 0) return null;
30792
+ const start = context.start;
30793
+ return {
30794
+ key: context.key,
30795
+ kind: KIND,
30796
+ id: context.id,
30797
+ target: "chat",
30798
+ anchorSeq: state.anchorSeq,
30799
+ location: start !== void 0 && start !== null ? start.location : { kind: "unresolved" },
30800
+ visibility: "visible",
30801
+ data: { items: state.items }
30802
+ };
30491
30803
  }
30492
- }
30804
+ };
30493
30805
  //#endregion
30494
30806
  //#region src/client/styles.ts
30495
30807
  const STYLE_ID = "dsh-workspace-style";
@@ -30505,16 +30817,33 @@ window.__ModuleLoader__.load({
30505
30817
  * 类型检查、单测、构建全都发现不了,只有肉眼看渲染或量计算样式才抓得到。
30506
30818
  */
30507
30819
  const WORKSPACE_CSS = `
30508
- .daw-version{font:600 11px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--daw-muted);white-space:nowrap}
30509
30820
  .daw-root,.daw-settings-section{--daw-bg:var(--dsw-alias-bg-base,var(--dsw-alias-bg-layer-2,#f7f8fa));--daw-panel:var(--dsw-alias-bg-layer-3,var(--dsw-alias-bg-layer-2,#fff));--daw-ink:var(--dsw-alias-label-primary,#18202a);--daw-muted:var(--dsw-alias-label-tertiary,#687381);--daw-line:var(--dsw-alias-border-l2,#dfe4e8);--daw-accent:var(--dsw-alias-brand-primary,#087f8c);--daw-accent-soft:var(--dsw-alias-bg-module-platform,#e5f5f6);--daw-good:var(--dsw-alias-state-success-primary,#2f7d4a);--daw-danger:var(--dsw-alias-state-error-primary,#b33a3a);color:var(--daw-ink);background:transparent;font:13px/1.45 Inter,ui-sans-serif,system-ui,sans-serif;letter-spacing:0;min-width:0}
30510
- .daw-root *{box-sizing:border-box;letter-spacing:0}
30511
- .daw-input:focus-visible{border-color:var(--dsw-alias-brand-primary,var(--daw-accent));outline:none}.daw-icon{width:30px;padding:0}
30821
+ .daw-root *,.daw-settings-section *{box-sizing:border-box;letter-spacing:0}
30822
+ /* 输入控件基线(UI-04):此前只有 :focus-visible 一条,非聚焦态全靠浏览器默认(2px inset/Arial/灰底),
30823
+ 深色令牌下实测白底黑字与面板割裂。这里给齐边框/底/字/内边距,聚焦态只换描边。 */
30824
+ .daw-input{width:100%;min-width:0;height:30px;padding:4px 9px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));border-radius:6px;background:var(--dsw-alias-bg-layer-3,var(--daw-panel));color:var(--dsw-alias-label-primary,var(--daw-ink));font:inherit}
30825
+ .daw-input::placeholder{color:var(--dsw-alias-label-tertiary,var(--daw-muted))}
30826
+ .daw-input:focus-visible{border-color:var(--dsw-alias-brand-primary,var(--daw-accent));outline:none}
30827
+ .daw-input:disabled{opacity:.55;cursor:not-allowed}.daw-icon{width:30px;padding:0}
30512
30828
  .daw-icon:hover{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}
30513
30829
  .daw-icon.danger{color:var(--daw-danger);border-color:transparent;background:transparent}
30514
30830
  .daw-icon:disabled{opacity:.45;cursor:not-allowed}
30515
- .daw-command{padding:0 10px;border-color:#cbd2d8;background:#fff}.daw-command.primary{color:var(--dsw-alias-label-primary,var(--daw-ink));background:var(--dsw-alias-bg-module-platform,var(--daw-panel));border-color:var(--dsw-alias-border-l2,var(--daw-line));font-weight:500}
30516
- .daw-command.primary:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}.daw-command.danger{color:var(--daw-danger)}.daw-filebar{display:flex;align-items:center;gap:6px;border-bottom:1px solid var(--daw-line);padding:4px 8px;min-width:0}.daw-admin{overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}.daw-admin h3{font-size:14px;margin:0 0 12px}
30517
- .daw-cards{display:grid;gap:10px}
30831
+ /* 普通命令按钮基线(UI-04):旧版写死 background:#fff/border:#cbd2d8,深色令牌下成了白块黑字。
30832
+ 全部改走 DSH 令牌;.primary 沿用面板底色做次按钮、真正的主操作由调用方加 .primary 配合。 */
30833
+ .daw-command{height:30px;padding:0 12px;border-radius:6px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-layer-3,var(--daw-panel));color:var(--dsw-alias-label-primary,var(--daw-ink));font:inherit;cursor:pointer}
30834
+ .daw-command:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30835
+ .daw-command:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:1px}
30836
+ /* 禁用态(UI-05):此前无任何 :disabled 样式,只剩浏览器默认的 30% 文字且仍是箭头手型。 */
30837
+ .daw-command:disabled{opacity:.5;cursor:not-allowed}
30838
+ .daw-command.primary{color:var(--dsw-alias-label-primary,var(--daw-ink));background:var(--dsw-alias-bg-module-platform,var(--daw-panel));border-color:var(--dsw-alias-border-l2,var(--daw-line));font-weight:500}
30839
+ .daw-command.primary:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30840
+ .daw-command.danger{color:var(--daw-danger)}
30841
+ .daw-command.danger:hover:not(:disabled){border-color:var(--daw-danger)}
30842
+ /* 图标+文字的小复制按钮:图标和文字对齐,窄屏只露图标也不改变按钮高度。 */
30843
+ .daw-command.icon-as-command{display:inline-flex;align-items:center;gap:6px;padding:0 10px;white-space:nowrap}
30844
+ .daw-copy-label{font-size:12px}
30845
+ .daw-empty{padding:14px 2px;text-align:center}
30846
+ .daw-admin{overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}.daw-admin h3{font-size:14px;margin:0 0 12px}
30518
30847
  .daw-card{border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-layer-3,var(--daw-panel));border-radius:12px;list-style:none;transition:border-color .16s,background .16s}
30519
30848
  .daw-card:hover{border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30520
30849
  .daw-card-open{background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
@@ -30529,7 +30858,17 @@ window.__ModuleLoader__.load({
30529
30858
  .daw-card-body{border-top:1px solid var(--dsw-alias-border-l2,var(--daw-line));margin:0 16px;padding-bottom:8px}.daw-settings-section{display:grid;grid-template-rows:auto minmax(0,1fr);gap:10px;min-width:0;min-height:0}.daw-settings-section>.daw-list-meta{margin:0}.daw-settings-section .daw-admin{padding:14px 2px}.daw-qr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;margin-top:12px}.daw-qr-card{margin:0;display:grid;justify-items:center;gap:8px;padding:16px;border:1px solid var(--daw-line);border-radius:6px;background:var(--daw-panel)}.daw-qr-image{width:180px;height:180px;background:var(--dsw-alias-bg-layer-3,#fff);border-radius:4px}.daw-qr-site{font-weight:600}.daw-qr-url{font:12px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--daw-accent);text-decoration:none;overflow-wrap:anywhere;text-align:center}.daw-qr-url:hover{text-decoration:underline}.daw-form-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(120px,.45fr) auto;gap:8px;margin-bottom:14px}.daw-listener-form{display:grid;grid-template-columns:minmax(180px,1fr) 130px auto;gap:8px;align-items:end}.daw-field{display:grid;gap:5px;color:var(--dsw-alias-label-secondary,var(--daw-muted));font-size:12px}.daw-listener-status{display:flex;flex-wrap:wrap;gap:5px 18px;padding:9px 0;color:var(--daw-muted);font:12px ui-monospace,SFMono-Regular,Consolas,monospace}.daw-listener-hint{margin:0 0 8px}.daw-list{border-top:1px solid var(--daw-line)}.daw-list-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;min-height:48px;border-bottom:1px solid var(--daw-line);padding:7px 2px}.daw-list-title{font-weight:600;overflow-wrap:anywhere}
30530
30859
  .daw-list-title .daw-pill{margin-left:8px;vertical-align:middle}
30531
30860
  .daw-builtin-hint{text-align:right;align-self:center}
30532
- .daw-builtin-notice{margin-top:10px;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}.daw-list-meta{font-size:12px;color:var(--daw-muted);overflow-wrap:anywhere}.daw-scope-grid{display:flex;flex-wrap:wrap;gap:6px 12px;padding:8px 0}.daw-check{display:inline-flex;align-items:center;gap:5px;color:var(--dsw-alias-label-secondary,var(--daw-muted))}.daw-code{font:600 22px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:2px!important;padding:14px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));border-radius:8px;text-align:center;margin:12px 0}.daw-warning{flex:none;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));border-radius:8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));padding:9px 11px;color:var(--daw-muted);margin-bottom:0}.daw-pill{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:1px 8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));color:var(--dsw-alias-label-secondary,var(--daw-muted));font-size:11px;font-weight:500;line-height:17px}.daw-pill.on{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));color:var(--dsw-alias-state-success-primary,var(--daw-good))}.daw-tunnel{margin-top:14px;border-top:1px dashed var(--dsw-alias-border-l2,var(--daw-line));padding-top:12px}.daw-tunnel-head{display:flex;align-items:center;gap:8px;justify-content:space-between}.daw-tunnel-url{font:500 13px ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));border-radius:8px}.daw-tunnel-progress{max-width:360px}.daw-progress-track{height:6px;border-radius:999px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));overflow:hidden}.daw-progress-fill{height:100%;border-radius:999px;background:var(--dsw-alias-state-info-primary,var(--daw-accent,var(--daw-line)));transition:width .25s ease}.daw-inline-row{display:flex;flex-wrap:wrap;gap:8px}.daw-inline-command{margin-left:8px}@mediaa(max-width:720px){.daw-form-row,.daw-listener-form{grid-template-columns:1fr}.daw-admin{padding:12px}.daw-qr-grid{grid-template-columns:1fr}.daw-qr-image{width:150px;height:150px}.daw-inline-command{margin:8px 0 0}}
30861
+ .daw-builtin-notice{margin-top:10px;padding:8px 10px;border-radius:8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}.daw-list-meta{font-size:12px;color:var(--daw-muted);overflow-wrap:anywhere}.daw-scope-grid{display:flex;flex-wrap:wrap;gap:6px 12px;padding:8px 0}.daw-check{display:inline-flex;align-items:center;gap:5px;color:var(--dsw-alias-label-secondary,var(--daw-muted))}.daw-code{font:600 22px ui-monospace,SFMono-Regular,Consolas,monospace;letter-spacing:2px!important;padding:14px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));border-radius:8px;text-align:center;margin:12px 0}.daw-warning{flex:none;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));border-radius:8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));padding:9px 11px;color:var(--daw-muted);margin-bottom:0}.daw-pill{display:inline-flex;align-items:center;gap:5px;border-radius:999px;padding:1px 8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));color:var(--dsw-alias-label-secondary,var(--daw-muted));font-size:11px;font-weight:500;line-height:17px}.daw-pill.on{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));color:var(--dsw-alias-state-success-primary,var(--daw-good))}.daw-tunnel{margin-top:14px;border-top:1px dashed var(--dsw-alias-border-l2,var(--daw-line));padding-top:12px}.daw-tunnel-head{display:flex;align-items:center;gap:8px;justify-content:space-between}.daw-tunnel-url{font:500 13px ui-monospace,SFMono-Regular,Consolas,monospace;overflow-wrap:anywhere;padding:9px 11px;border:1px solid var(--dsw-alias-border-l2,var(--daw-line));background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));border-radius:8px}.daw-tunnel-progress{max-width:360px}.daw-progress-track{height:6px;border-radius:999px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));overflow:hidden}.daw-progress-fill{height:100%;border-radius:999px;background:var(--dsw-alias-state-info-primary,var(--daw-accent,var(--daw-line)));transition:width .25s ease}.daw-inline-command{margin-left:8px}
30862
+ /* 成功反馈条(UI-07):与 .daw-warning 同形,但走成功色,操作成功不再静默。 */
30863
+ .daw-success{flex:none;border:1px solid var(--dsw-alias-state-success-primary,var(--daw-good));border-radius:8px;background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft));padding:9px 11px;color:var(--dsw-alias-state-success-primary,var(--daw-good));margin-bottom:0}
30864
+ .daw-admin-topbar{display:flex;justify-content:flex-end;margin-bottom:8px}
30865
+ .daw-url-row{display:flex;align-items:center;gap:8px;margin-top:8px}.daw-url-row .daw-tunnel-url{flex:1 1 auto;margin:0;min-width:0}
30866
+ .daw-code-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0}.daw-code-row .daw-code{margin:0;flex:1 1 220px;min-width:0}.daw-copy-inline{flex:none}
30867
+ /* 隧道/监听进行中的流动条(UI-08):percent=null 时铺一段高光在轨道里循环滑动,
30868
+ 不再把「未知进度」伪装成恒定 30%。 */
30869
+ .daw-progress-fill.indeterminate{width:40%;background:linear-gradient(90deg,transparent,var(--dsw-alias-state-info-primary,var(--daw-accent,var(--daw-line))),transparent);animation:daw-indeterminate 1.1s ease-in-out infinite}
30870
+ @keyframes daw-indeterminate{0%{transform:translateX(-120%)}100%{transform:translateX(280%)}}
30871
+ @media (max-width:720px){.daw-form-row,.daw-listener-form{grid-template-columns:1fr}.daw-admin{padding:12px}.daw-qr-grid{grid-template-columns:1fr}.daw-qr-image{width:150px;height:150px}.daw-inline-command{margin:8px 0 0}}
30533
30872
  `;
30534
30873
  function installWorkspaceStyles() {
30535
30874
  if (document.getElementById(STYLE_ID) !== null) return;
@@ -30566,7 +30905,6 @@ window.__ModuleLoader__.load({
30566
30905
  }];
30567
30906
  //#endregion
30568
30907
  //#region src/client/workspace.tsx
30569
- var import_lucide_react = require_lucide_react();
30570
30908
  /** What the settings-page launcher opens: authorized roots, the first section. */
30571
30909
  const DEFAULT_PANEL_SECTION = "roots";
30572
30910
  /**
@@ -30579,9 +30917,37 @@ window.__ModuleLoader__.load({
30579
30917
  function tabLabelKey(tab) {
30580
30918
  return tab === "app-download" ? "appDownloadTab" : `${tab}Tab`;
30581
30919
  }
30582
- /** 云隧道进度条宽度:有确定百分比则用之,否则给 30% 作「流动中」占位。 */
30583
- function pct(a) {
30584
- return `${a.percent ?? 30}%`;
30920
+ /** 配对码剩余分钟:向上取整 —— 显示「约 9 分钟」比「约 8 分钟」在最后一刻更诚实。 */
30921
+ function pairingMinutesLeft(expiresAt, now) {
30922
+ return Math.max(0, Math.ceil((expiresAt - now) / 6e4));
30923
+ }
30924
+ /**
30925
+ * 通用复制小按钮:成功后短暂换成对勾 + 「已复制」,2s 自动复位;
30926
+ * 非安全上下文(HTTP/LAN)拿不到 Clipboard API 时退化为选中文本提示,见 copyText 的调用方。
30927
+ */
30928
+ function CopyButton(props) {
30929
+ const [state, setState] = (0, react.useState)("idle");
30930
+ const [inFlight, setInFlight] = (0, react.useState)(false);
30931
+ const label = state === "done" ? props.copiedText : state === "manual" ? props.manualText ?? props.title : props.title;
30932
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
30933
+ type: "button",
30934
+ className: `daw-command icon-as-command${props.className === void 0 ? "" : ` ${props.className}`}`,
30935
+ title: props.title,
30936
+ "aria-label": props.title,
30937
+ disabled: inFlight || state !== "idle",
30938
+ onClick: () => {
30939
+ if (inFlight) return;
30940
+ setInFlight(true);
30941
+ props.copyText(props.text).then((kind) => {
30942
+ setState(kind === "done" ? "done" : "manual");
30943
+ window.setTimeout(() => setState("idle"), kind === "done" ? 2e3 : 2500);
30944
+ }).finally(() => setInFlight(false));
30945
+ },
30946
+ children: [state === "done" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Check, { size: 15 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Copy, { size: 15 }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30947
+ className: "daw-copy-label",
30948
+ children: label
30949
+ })]
30950
+ });
30585
30951
  }
30586
30952
  /**
30587
30953
  * 卡片头部的描述行。与 `tabLabelKey` 同理:唯一含连字符的分区显式映射。
@@ -30600,7 +30966,6 @@ window.__ModuleLoader__.load({
30600
30966
  devices: "devicesTabDesc",
30601
30967
  image: "imageTabDesc",
30602
30968
  trash: "trashTabDesc",
30603
- audit: "auditTabDesc",
30604
30969
  "app-download": "appDownloadTabDesc"
30605
30970
  };
30606
30971
  function tabDescKey(tab) {
@@ -30706,7 +31071,6 @@ window.__ModuleLoader__.load({
30706
31071
  const { locale, t } = useWorkspaceI18n();
30707
31072
  const [status, setStatus] = (0, react.useState)();
30708
31073
  const [trash, setTrash] = (0, react.useState)([]);
30709
- const [audit, setAudit] = (0, react.useState)([]);
30710
31074
  const [path, setPath] = (0, react.useState)("");
30711
31075
  const [label, setLabel] = (0, react.useState)("");
30712
31076
  const [listenerHost, setListenerHost] = (0, react.useState)("0.0.0.0");
@@ -30721,9 +31085,52 @@ window.__ModuleLoader__.load({
30721
31085
  const [openCards, setOpenCards] = (0, react.useState)(() => /* @__PURE__ */ new Set([props.initialOpen]));
30722
31086
  const [imageSaved, setImageSaved] = (0, react.useState)(false);
30723
31087
  const [error, setError] = (0, react.useState)();
31088
+ const [notice, setNotice] = (0, react.useState)();
30724
31089
  const [tunnel, setTunnel] = (0, react.useState)();
30725
31090
  const [tunnelBusy, setTunnelBusy] = (0, react.useState)(false);
30726
31091
  const [activity, setActivity] = (0, react.useState)();
31092
+ const [pruneDays, setPruneDays] = (0, react.useState)(30);
31093
+ const [pruneBusy, setPruneBusy] = (0, react.useState)(false);
31094
+ const [now, setNow] = (0, react.useState)(() => Date.now());
31095
+ const notify = (0, react.useCallback)((key, params) => {
31096
+ setNotice(params === void 0 ? {
31097
+ id: Date.now() + Math.random(),
31098
+ key
31099
+ } : {
31100
+ id: Date.now() + Math.random(),
31101
+ key,
31102
+ params
31103
+ });
31104
+ }, []);
31105
+ (0, react.useEffect)(() => {
31106
+ if (notice === void 0) return;
31107
+ const timer = window.setTimeout(() => setNotice(void 0), 3500);
31108
+ return () => window.clearTimeout(timer);
31109
+ }, [notice]);
31110
+ (0, react.useEffect)(() => {
31111
+ const timer = window.setInterval(() => setNow(Date.now()), 3e4);
31112
+ return () => window.clearInterval(timer);
31113
+ }, []);
31114
+ const copyText = (0, react.useCallback)(async (text) => {
31115
+ const clipboard = globalThis.navigator?.clipboard;
31116
+ if (clipboard !== void 0) try {
31117
+ await clipboard.writeText(text);
31118
+ return "done";
31119
+ } catch {}
31120
+ try {
31121
+ const node = document.createElement("textarea");
31122
+ node.value = text;
31123
+ node.style.position = "fixed";
31124
+ node.style.top = "-9999px";
31125
+ node.setAttribute("readonly", "");
31126
+ document.body.append(node);
31127
+ node.select();
31128
+ const ok = document.execCommand("copy");
31129
+ node.remove();
31130
+ if (ok) return "done";
31131
+ } catch {}
31132
+ return "manual";
31133
+ }, []);
30727
31134
  const isOpen = (0, react.useCallback)((card) => openCards.has(card), [openCards]);
30728
31135
  const toggleCard = (0, react.useCallback)((card) => {
30729
31136
  setOpenCards((previous) => {
@@ -30733,60 +31140,170 @@ window.__ModuleLoader__.load({
30733
31140
  });
30734
31141
  }, []);
30735
31142
  const refresh = (0, react.useCallback)(async () => {
31143
+ try {
31144
+ setTunnel(await props.api.tunnelStatus());
31145
+ } catch {}
30736
31146
  try {
30737
31147
  const next = await props.api.status();
30738
31148
  setStatus(next);
30739
31149
  if (openCards.has("trash")) setTrash((await props.api.trashItems()).items);
30740
- if (openCards.has("audit")) setAudit((await props.api.audit()).items);
30741
31150
  if (openCards.has("image")) {
30742
31151
  const settings = await props.api.imageSettings();
30743
31152
  setImage(settings);
30744
31153
  setImageDraft(settings.subdir);
30745
31154
  setImageSaved(false);
30746
31155
  }
30747
- if (openCards.has("remote")) setTunnel(await props.api.tunnelStatus());
30748
- setError(void 0);
30749
31156
  } catch (cause) {
30750
- setError(cause);
31157
+ if (!(cause instanceof WorkspaceApiError && cause.status === 403 && (cause.code === "TUNNEL_MODE_ADMIN_DISABLED" || cause.code === "ADMIN_PROXY_FORWARDED"))) setError(cause);
30751
31158
  }
30752
31159
  }, [props.api, openCards]);
30753
- const createTunnel = (0, react.useCallback)(() => {
31160
+ const tunnelErrorText = (0, react.useCallback)((s) => {
31161
+ if (s.errorKey !== void 0) return t(s.errorKey, s.errorParams ?? {});
31162
+ return s.error ?? t("listenerFailed");
31163
+ }, [t]);
31164
+ const pollActivityOnce = (0, react.useCallback)(async (active) => {
31165
+ while (active()) {
31166
+ try {
31167
+ const a = await props.api.tunnelActivity();
31168
+ if (!active()) return;
31169
+ setActivity(a.stage === "idle" ? void 0 : a);
31170
+ } catch {}
31171
+ await delay(400);
31172
+ }
31173
+ }, [props.api]);
31174
+ const waitForTunnelUrl = (0, react.useCallback)(async (active) => {
31175
+ for (let attempt = 0; attempt < 40 && active(); attempt += 1) {
31176
+ await delay(500);
31177
+ const s = await props.api.tunnelStatus();
31178
+ if (!active()) return null;
31179
+ setTunnel(s);
31180
+ if (s.errorKey !== void 0 || s.error !== void 0) throw new Error(tunnelErrorText(s));
31181
+ if (s.running && s.url !== null) return s.url;
31182
+ }
31183
+ return null;
31184
+ }, [props.api, tunnelErrorText]);
31185
+ const generateConfigForUrl = (0, react.useCallback)(async (baseUrl, noticeKey = "configCreated") => {
31186
+ setConfigTextBusy(true);
31187
+ setError(void 0);
31188
+ try {
31189
+ const currentRoots = status?.roots ?? [];
31190
+ const value = await props.api.createConfigText({
31191
+ ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) },
31192
+ endpoints: [{
31193
+ label: t("tunnelEndpointLabel"),
31194
+ baseUrl
31195
+ }]
31196
+ });
31197
+ setConfigText(value);
31198
+ notify(noticeKey);
31199
+ return true;
31200
+ } catch (cause) {
31201
+ setError(cause);
31202
+ return false;
31203
+ } finally {
31204
+ setConfigTextBusy(false);
31205
+ }
31206
+ }, [
31207
+ props.api,
31208
+ status,
31209
+ t,
31210
+ notify
31211
+ ]);
31212
+ const createTunnel = (0, react.useCallback)(async (opts = {}) => {
30754
31213
  if (!window.confirm(t("tunnelStartConfirm"))) return;
31214
+ const wantConfig = opts.thenGenConfig === true;
31215
+ let alive = true;
30755
31216
  setTunnelBusy(true);
30756
31217
  setError(void 0);
30757
- setActivity(void 0);
31218
+ setConfigText(void 0);
30758
31219
  setActivity({
30759
31220
  stage: "locating",
30760
- label: "正在准备 cloudflared…",
31221
+ labelKey: "tunnelStageLocating",
30761
31222
  percent: null
30762
31223
  });
30763
- const poll = window.setInterval(() => {
30764
- props.api.tunnelActivity().then((a) => {
30765
- if (a.stage !== "idle") setActivity(a);
30766
- }).catch(() => {});
30767
- }, 350);
30768
- props.api.tunnelCreate().then(async (next) => {
30769
- setTunnel(next);
31224
+ pollActivityOnce(() => alive);
31225
+ try {
31226
+ const currentRoots = status?.roots ?? [];
31227
+ const created = await props.api.tunnelCreate(wantConfig, { ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) } });
31228
+ setTunnel(created);
30770
31229
  setActivity(void 0);
31230
+ if (created.errorKey !== void 0 || created.error !== void 0) throw new Error(tunnelErrorText(created));
31231
+ if (wantConfig && created.configText !== void 0) {
31232
+ setConfigText(created.configText);
31233
+ await refresh();
31234
+ notify("tunnelConfigAuto");
31235
+ return;
31236
+ }
31237
+ const url = created.url ?? await waitForTunnelUrl(() => alive);
30771
31238
  await refresh();
30772
- }).catch(setError).finally(() => {
30773
- window.clearInterval(poll);
31239
+ if (url === null) {
31240
+ notify("tunnelUrlPending");
31241
+ return;
31242
+ }
31243
+ if (wantConfig) await generateConfigForUrl(url, "tunnelConfigAuto");
31244
+ else notify("tunnelStarted");
31245
+ } catch (cause) {
31246
+ setError(cause);
31247
+ } finally {
31248
+ alive = false;
30774
31249
  setActivity(void 0);
30775
31250
  setTunnelBusy(false);
30776
- });
31251
+ }
30777
31252
  }, [
30778
31253
  props.api,
30779
31254
  refresh,
30780
- t
30781
- ]);
30782
- const stopTunnel = (0, react.useCallback)(() => {
31255
+ status,
31256
+ t,
31257
+ notify,
31258
+ pollActivityOnce,
31259
+ waitForTunnelUrl,
31260
+ tunnelErrorText,
31261
+ generateConfigForUrl
31262
+ ]);
31263
+ const stopTunnel = (0, react.useCallback)(async () => {
31264
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
30783
31265
  setTunnelBusy(true);
30784
31266
  setError(void 0);
30785
- props.api.tunnelStop().then(async (next) => {
31267
+ try {
31268
+ const next = await props.api.tunnelStop();
30786
31269
  setTunnel(next);
30787
31270
  await refresh();
30788
- }).catch(setError).finally(() => setTunnelBusy(false));
30789
- }, [props.api, refresh]);
31271
+ notify("tunnelStopped");
31272
+ } catch (cause) {
31273
+ setError(cause);
31274
+ } finally {
31275
+ setTunnelBusy(false);
31276
+ }
31277
+ }, [
31278
+ props.api,
31279
+ refresh,
31280
+ t,
31281
+ notify
31282
+ ]);
31283
+ const tunnelConfigClick = (0, react.useCallback)(async () => {
31284
+ if (tunnel?.running === true) {
31285
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
31286
+ setTunnelBusy(true);
31287
+ setError(void 0);
31288
+ try {
31289
+ const stopped = await props.api.tunnelStop();
31290
+ setTunnel(stopped);
31291
+ await refresh();
31292
+ } catch (cause) {
31293
+ setError(cause);
31294
+ } finally {
31295
+ setTunnelBusy(false);
31296
+ }
31297
+ await createTunnel({ thenGenConfig: true });
31298
+ } else await createTunnel({ thenGenConfig: true });
31299
+ }, [
31300
+ props.api,
31301
+ tunnel,
31302
+ t,
31303
+ createTunnel,
31304
+ refresh,
31305
+ setError
31306
+ ]);
30790
31307
  (0, react.useEffect)(() => {
30791
31308
  refresh();
30792
31309
  }, [refresh]);
@@ -30796,25 +31313,7 @@ window.__ModuleLoader__.load({
30796
31313
  setListenerPort(String(status.configuredPort));
30797
31314
  }, [status?.configuredHost, status?.configuredPort]);
30798
31315
  const roots = status?.roots ?? [];
30799
- const genTunnelConfig = (0, react.useCallback)((baseUrl) => {
30800
- setConfigTextBusy(true);
30801
- setError(void 0);
30802
- props.api.createConfigText({
30803
- ...roots.length === 0 ? {} : { rootIds: roots.map((root) => root.id) },
30804
- endpoints: [{
30805
- label: t("tunnelEndpointLabel"),
30806
- baseUrl
30807
- }]
30808
- }).then((value) => {
30809
- setConfigText(value);
30810
- return refresh();
30811
- }).catch(setError).finally(() => setConfigTextBusy(false));
30812
- }, [
30813
- props.api,
30814
- refresh,
30815
- roots,
30816
- t
30817
- ]);
31316
+ const managementLocked = tunnel?.running === true;
30818
31317
  const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEVICE_SCOPES]);
30819
31318
  const toggleScope = (0, react.useCallback)((scope) => {
30820
31319
  setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
@@ -30843,10 +31342,25 @@ window.__ModuleLoader__.load({
30843
31342
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
30844
31343
  className: "daw-admin",
30845
31344
  children: [
31345
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31346
+ className: "daw-admin-topbar",
31347
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LanguageToggle, {})
31348
+ }),
30846
31349
  error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30847
31350
  className: "daw-warning",
31351
+ role: "alert",
30848
31352
  children: messageOf(error, t)
30849
31353
  }),
31354
+ notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31355
+ className: "daw-success",
31356
+ role: "status",
31357
+ children: t(notice.key, notice.params)
31358
+ }),
31359
+ managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31360
+ className: "daw-warning",
31361
+ role: "status",
31362
+ children: t("managementLockedWhileTunnel")
31363
+ }),
30850
31364
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
30851
31365
  tab: "roots",
30852
31366
  nameKey: tabLabelKey("roots"),
@@ -30871,10 +31385,14 @@ window.__ModuleLoader__.load({
30871
31385
  }),
30872
31386
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30873
31387
  className: "daw-command primary",
31388
+ disabled: managementLocked,
31389
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
30874
31390
  onClick: () => {
31391
+ setError(void 0);
30875
31392
  props.api.addRoot(path, label || void 0).then(() => {
30876
31393
  setPath("");
30877
31394
  setLabel("");
31395
+ notify("rootAdded");
30878
31396
  return refresh();
30879
31397
  }).catch(setError);
30880
31398
  },
@@ -30901,8 +31419,15 @@ window.__ModuleLoader__.load({
30901
31419
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30902
31420
  className: "daw-icon danger",
30903
31421
  title: t("removeRoot"),
31422
+ "aria-label": t("removeRoot"),
31423
+ disabled: managementLocked,
30904
31424
  onClick: () => {
30905
- props.api.removeRoot(root.id).then(refresh).catch(setError);
31425
+ if (!window.confirm(t("removeRootConfirm", { label: root.label }))) return;
31426
+ setError(void 0);
31427
+ props.api.removeRoot(root.id).then(() => {
31428
+ notify("rootRemoved");
31429
+ return refresh();
31430
+ }).catch(setError);
30906
31431
  },
30907
31432
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
30908
31433
  })]
@@ -30925,7 +31450,7 @@ window.__ModuleLoader__.load({
30925
31450
  className: "daw-warning",
30926
31451
  children: t("httpWarning")
30927
31452
  }),
30928
- roots.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31453
+ roots.length === 0 && !managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30929
31454
  className: "daw-warning",
30930
31455
  children: t("addRootBeforeRemote")
30931
31456
  }),
@@ -30937,7 +31462,7 @@ window.__ModuleLoader__.load({
30937
31462
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindIp") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
30938
31463
  className: "daw-input",
30939
31464
  value: listenerHost,
30940
- disabled: status?.remoteEnabled,
31465
+ disabled: status?.remoteEnabled || managementLocked,
30941
31466
  spellCheck: false,
30942
31467
  placeholder: "0.0.0.0",
30943
31468
  onChange: (event) => setListenerHost(event.target.value)
@@ -30952,15 +31477,19 @@ window.__ModuleLoader__.load({
30952
31477
  max: 65535,
30953
31478
  inputMode: "numeric",
30954
31479
  value: listenerPort,
30955
- disabled: status?.remoteEnabled,
31480
+ disabled: status?.remoteEnabled || managementLocked,
30956
31481
  onChange: (event) => setListenerPort(event.target.value)
30957
31482
  })]
30958
31483
  }),
30959
31484
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30960
31485
  className: "daw-command",
30961
- disabled: status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
31486
+ disabled: managementLocked || status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
30962
31487
  onClick: () => {
30963
- props.api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(refresh).catch(setError);
31488
+ setError(void 0);
31489
+ props.api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(() => {
31490
+ notify("listenerSaved");
31491
+ return refresh();
31492
+ }).catch(setError);
30964
31493
  },
30965
31494
  children: t("saveListener")
30966
31495
  })
@@ -31008,19 +31537,33 @@ window.__ModuleLoader__.load({
31008
31537
  className: "daw-warning",
31009
31538
  children: t("tunnelSecurityWarning")
31010
31539
  }),
31011
- tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31540
+ tunnel?.errorKey !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31541
+ className: "daw-warning",
31542
+ role: "alert",
31543
+ children: t(tunnel.errorKey, tunnel.errorParams ?? {})
31544
+ }),
31545
+ tunnel?.errorKey === void 0 && tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31012
31546
  className: "daw-warning",
31547
+ role: "alert",
31013
31548
  children: tunnel.error
31014
31549
  }),
31015
- tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31016
- className: "daw-tunnel-url",
31017
- style: { marginTop: 8 },
31018
- children: tunnel.url
31550
+ tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31551
+ className: "daw-url-row",
31552
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31553
+ className: "daw-tunnel-url",
31554
+ children: tunnel.url
31555
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31556
+ text: tunnel.url,
31557
+ title: t("copyUrl"),
31558
+ copiedText: t("copied"),
31559
+ manualText: t("copyManual"),
31560
+ copyText
31561
+ })]
31019
31562
  }),
31020
31563
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31021
31564
  className: "daw-command primary",
31022
31565
  style: { marginTop: 10 },
31023
- disabled: tunnelBusy || roots.length === 0,
31566
+ disabled: tunnelBusy || tunnel?.running !== true && roots.length === 0,
31024
31567
  onClick: () => {
31025
31568
  if (tunnel?.running === true) stopTunnel();
31026
31569
  else createTunnel();
@@ -31033,31 +31576,39 @@ window.__ModuleLoader__.load({
31033
31576
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31034
31577
  className: "daw-progress-track",
31035
31578
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31036
- className: "daw-progress-fill",
31037
- style: { width: pct(activity) }
31579
+ className: `daw-progress-fill${activity.percent === null ? " indeterminate" : ""}`,
31580
+ style: activity.percent === null ? void 0 : { width: `${activity.percent}%` }
31038
31581
  })
31039
31582
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31040
31583
  className: "daw-list-meta",
31041
31584
  style: { marginTop: 4 },
31042
- children: activity.label
31585
+ children: activity.labelKey === "" ? "" : t(activity.labelKey, activity.labelParams ?? {})
31043
31586
  })]
31044
31587
  })
31045
31588
  ]
31046
31589
  }),
31047
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31048
- className: "daw-scope-grid",
31049
- children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31050
- className: "daw-check",
31051
- children: [
31052
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31053
- type: "checkbox",
31054
- checked: grantedScopes.includes(scope),
31055
- onChange: () => toggleScope(scope)
31056
- }),
31057
- " ",
31058
- scopeLabel(locale, scope)
31059
- ]
31060
- }, scope))
31590
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("fieldset", {
31591
+ disabled: managementLocked,
31592
+ style: {
31593
+ border: "none",
31594
+ margin: 0,
31595
+ padding: 0
31596
+ },
31597
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31598
+ className: "daw-scope-grid",
31599
+ children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31600
+ className: "daw-check",
31601
+ children: [
31602
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31603
+ type: "checkbox",
31604
+ checked: grantedScopes.includes(scope),
31605
+ onChange: () => toggleScope(scope)
31606
+ }),
31607
+ " ",
31608
+ scopeLabel(locale, scope)
31609
+ ]
31610
+ }, scope))
31611
+ })
31061
31612
  }),
31062
31613
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31063
31614
  className: "daw-list-meta",
@@ -31065,30 +31616,44 @@ window.__ModuleLoader__.load({
31065
31616
  }),
31066
31617
  status?.remoteEnabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31067
31618
  className: "daw-command danger",
31619
+ disabled: managementLocked,
31620
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31068
31621
  onClick: () => {
31069
- props.api.disableRemote().then((result) => poll(result.id)).then(refresh).catch(setError);
31622
+ if (!window.confirm(t("disableRemoteConfirm"))) return;
31623
+ setError(void 0);
31624
+ props.api.disableRemote().then((result) => poll(result.id)).then(() => {
31625
+ notify("remoteDisabled");
31626
+ return refresh();
31627
+ }).catch(setError);
31070
31628
  },
31071
31629
  children: t("disableRemote")
31072
31630
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31073
31631
  className: "daw-command primary",
31074
- disabled: roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31632
+ disabled: managementLocked || roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31075
31633
  onClick: () => {
31634
+ setError(void 0);
31076
31635
  props.api.enableRemote(roots.map((root) => root.id), grantedScopes).then((result) => poll(result.id)).catch(setError);
31077
31636
  },
31078
31637
  children: t("enablePairing")
31079
31638
  }),
31080
- status?.remoteEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31639
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31081
31640
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31082
31641
  className: "daw-command daw-inline-command",
31083
- disabled: grantedScopes.length === 0,
31642
+ disabled: managementLocked || grantedScopes.length === 0,
31643
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31084
31644
  onClick: () => {
31085
- props.api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => setPairing(value)).catch(setError);
31645
+ setError(void 0);
31646
+ props.api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => {
31647
+ setPairing(value);
31648
+ notify("pairingCreated");
31649
+ }).catch(setError);
31086
31650
  },
31087
31651
  children: t("newPairingCode")
31088
31652
  }),
31089
31653
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31090
31654
  className: "daw-command daw-inline-command",
31091
- disabled: configTextBusy,
31655
+ disabled: managementLocked || configTextBusy,
31656
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31092
31657
  onClick: () => {
31093
31658
  const manual = configEndpoint.trim();
31094
31659
  setConfigTextBusy(true);
@@ -31101,7 +31666,7 @@ window.__ModuleLoader__.load({
31101
31666
  }] }
31102
31667
  }).then((value) => {
31103
31668
  setConfigText(value);
31104
- return refresh();
31669
+ notify("configCreated");
31105
31670
  }).catch(setError).finally(() => setConfigTextBusy(false));
31106
31671
  },
31107
31672
  children: t("newConfigText")
@@ -31110,21 +31675,34 @@ window.__ModuleLoader__.load({
31110
31675
  className: "daw-command daw-inline-command",
31111
31676
  disabled: configTextBusy || tunnelBusy,
31112
31677
  onClick: () => {
31113
- if (tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0) genTunnelConfig(tunnel.url);
31114
- else createTunnel();
31678
+ tunnelConfigClick();
31115
31679
  },
31116
- children: tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0 ? t("tunnelGenConfigReady") : t("tunnelGenConfigOff")
31680
+ children: tunnel.running !== true ? t("tunnelStartAndGenConfig") : t("tunnelRestartAndGenConfig")
31117
31681
  }),
31118
31682
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31119
31683
  className: "daw-list-meta daw-listener-hint",
31120
31684
  children: t("configTextScopeNote")
31121
31685
  })
31122
31686
  ] }),
31123
- pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31124
- className: "daw-code",
31125
- children: pairing.code
31126
- }),
31127
- status?.remoteEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31687
+ pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31688
+ className: "daw-code-row",
31689
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31690
+ className: "daw-code",
31691
+ children: pairing.code
31692
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31693
+ className: "daw-copy-inline",
31694
+ text: pairing.code,
31695
+ title: t("copyPairing"),
31696
+ copiedText: t("copied"),
31697
+ manualText: t("copyManual"),
31698
+ copyText
31699
+ })]
31700
+ }), pairing.expiresAt !== void 0 && pairing.expiresAt > now && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31701
+ className: "daw-list-meta",
31702
+ style: { marginTop: -4 },
31703
+ children: t("pairingExpiresAt", { minutes: pairingMinutesLeft(pairing.expiresAt, now) })
31704
+ })] }),
31705
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31128
31706
  style: {
31129
31707
  marginTop: 12,
31130
31708
  maxWidth: 560
@@ -31134,6 +31712,7 @@ window.__ModuleLoader__.load({
31134
31712
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("configTextManual") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31135
31713
  className: "daw-input",
31136
31714
  value: configEndpoint,
31715
+ disabled: managementLocked,
31137
31716
  spellCheck: false,
31138
31717
  placeholder: t("configTextManualPlaceholder"),
31139
31718
  onChange: (event) => setConfigEndpoint(event.target.value)
@@ -31147,22 +31726,51 @@ window.__ModuleLoader__.load({
31147
31726
  configText !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigTextPanel, { result: configText })
31148
31727
  ]
31149
31728
  }, "remote"),
31150
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsCard, {
31729
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31151
31730
  tab: "devices",
31152
31731
  nameKey: tabLabelKey("devices"),
31153
31732
  descKey: tabDescKey("devices"),
31154
31733
  open: isOpen("devices"),
31155
31734
  onToggle: () => toggleCard("devices"),
31156
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31735
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31736
+ className: "daw-inline-command",
31737
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31738
+ className: "daw-field",
31739
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pruneInactiveDays") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31740
+ className: "daw-input",
31741
+ type: "number",
31742
+ min: 1,
31743
+ max: 3650,
31744
+ value: pruneDays,
31745
+ disabled: managementLocked || pruneBusy,
31746
+ onChange: (event) => setPruneDays(Math.max(1, Math.min(3650, Number(event.target.value) || 1)))
31747
+ })]
31748
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31749
+ className: "daw-command",
31750
+ disabled: managementLocked || pruneBusy,
31751
+ onClick: () => {
31752
+ if (!confirm(t("pruneInactiveConfirm", { days: String(pruneDays) }))) return;
31753
+ setPruneBusy(true);
31754
+ setError(void 0);
31755
+ props.api.pruneInactiveDevices(pruneDays).then((result) => {
31756
+ if (result.pruned === 0) notify("pruneInactiveNone");
31757
+ else notify("pruneInactiveDone", { count: String(result.pruned) });
31758
+ return refresh();
31759
+ }).catch(setError).finally(() => setPruneBusy(false));
31760
+ },
31761
+ children: pruneBusy ? t("pruneInactiveBusy") : t("pruneInactiveAction", { days: String(pruneDays) })
31762
+ })]
31763
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31157
31764
  className: "daw-list",
31158
31765
  children: (status?.devices ?? []).map((device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceRow, {
31159
31766
  api: props.api,
31160
31767
  device,
31161
31768
  roots,
31162
31769
  onChanged: refresh,
31163
- onError: setError
31770
+ onError: setError,
31771
+ onNotice: notify
31164
31772
  }, device.id))
31165
- })
31773
+ })]
31166
31774
  }, "devices"),
31167
31775
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31168
31776
  tab: "image",
@@ -31264,7 +31872,10 @@ window.__ModuleLoader__.load({
31264
31872
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31265
31873
  className: "daw-list-meta",
31266
31874
  children: t("pluginTrashHint")
31267
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31875
+ }), trash.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31876
+ className: "daw-list-meta daw-empty",
31877
+ children: t("trashEmpty")
31878
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31268
31879
  className: "daw-list",
31269
31880
  children: trash.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31270
31881
  className: "daw-list-row",
@@ -31283,48 +31894,28 @@ window.__ModuleLoader__.load({
31283
31894
  })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31284
31895
  className: "daw-command",
31285
31896
  onClick: () => {
31286
- props.api.restoreTrash(item.id).then(refresh).catch(setError);
31897
+ setError(void 0);
31898
+ props.api.restoreTrash(item.id).then(() => {
31899
+ notify("trashRestored");
31900
+ return refresh();
31901
+ }).catch(setError);
31287
31902
  },
31288
31903
  children: t("restore")
31289
31904
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31290
31905
  className: "daw-icon danger",
31291
31906
  title: t("deletePermanently"),
31907
+ "aria-label": t("deletePermanently"),
31292
31908
  onClick: () => {
31293
- if (confirm(t("purgeConfirm"))) props.api.purgeTrash(item.id).then(refresh).catch(setError);
31909
+ if (confirm(t("purgeConfirm"))) props.api.purgeTrash(item.id).then(() => {
31910
+ notify("trashPurged");
31911
+ return refresh();
31912
+ }).catch(setError);
31294
31913
  },
31295
31914
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31296
31915
  })] })]
31297
31916
  }, item.id))
31298
31917
  })]
31299
31918
  }, "trash"),
31300
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(SettingsCard, {
31301
- tab: "audit",
31302
- nameKey: tabLabelKey("audit"),
31303
- descKey: tabDescKey("audit"),
31304
- open: isOpen("audit"),
31305
- onToggle: () => toggleCard("audit"),
31306
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31307
- className: "daw-list",
31308
- children: audit.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31309
- className: "daw-list-row",
31310
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31311
- className: "daw-list-title",
31312
- children: String(item.action ?? t("event"))
31313
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31314
- className: "daw-list-meta",
31315
- children: [
31316
- new Date(Number(item.time ?? 0)).toLocaleString(locale),
31317
- " · ",
31318
- String(item.actorType ?? ""),
31319
- ":",
31320
- String(item.actorId ?? ""),
31321
- " · ",
31322
- String(item.relativePath ?? "")
31323
- ]
31324
- })] })
31325
- }, String(item.id ?? index)))
31326
- })
31327
- }, "audit"),
31328
31919
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AppDownloadSection, {
31329
31920
  open: isOpen("app-download"),
31330
31921
  onToggle: () => toggleCard("app-download")
@@ -31383,13 +31974,6 @@ window.__ModuleLoader__.load({
31383
31974
  });
31384
31975
  }
31385
31976
  /**
31386
- * Standalone settings overlay kept for the `/dsh-workspace` page (mobile WebView / direct link).
31387
- *
31388
- * The desktop WebUI does **not** use this: its settings live inline inside DSH's own settings page
31389
- * (`PhoneSettingsBody`), so a user never opens a dialog from inside a dialog. This one keeps its own
31390
- * shell because the standalone page has no settings container to render into.
31391
- */
31392
- /**
31393
31977
  * Shows a freshly minted `DSH1:` config text.
31394
31978
  *
31395
31979
  * The text is a credential carrier, so this panel keeps the "it is a key" reminder visible and lists
@@ -31520,6 +32104,8 @@ window.__ModuleLoader__.load({
31520
32104
  const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
31521
32105
  const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
31522
32106
  const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
32107
+ const shortId = props.device.id.length > 14 ? `${props.device.id.slice(0, 8)}…${props.device.id.slice(-4)}` : props.device.id;
32108
+ const lastSeen = props.device.lastSeenAt === void 0 ? t("deviceNeverSeen") : t("deviceLastSeen", { time: new Date(props.device.lastSeenAt).toLocaleString(locale) });
31523
32109
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31524
32110
  className: "daw-list-row",
31525
32111
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
@@ -31537,7 +32123,12 @@ window.__ModuleLoader__.load({
31537
32123
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31538
32124
  className: "daw-list-meta",
31539
32125
  children: [
31540
- props.device.id,
32126
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32127
+ title: props.device.id,
32128
+ children: shortId
32129
+ }),
32130
+ " · ",
32131
+ lastSeen,
31541
32132
  " · ",
31542
32133
  props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
31543
32134
  ]
@@ -31567,7 +32158,10 @@ window.__ModuleLoader__.load({
31567
32158
  editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31568
32159
  className: "daw-command primary",
31569
32160
  onClick: () => {
31570
- props.api.updateDevice(props.device.id, scopes, rootIds).then(props.onChanged).then(() => setEditing(false)).catch(props.onError);
32161
+ props.api.updateDevice(props.device.id, scopes, rootIds).then(props.onChanged).then(() => {
32162
+ setEditing(false);
32163
+ props.onNotice("permissionsSaved");
32164
+ }).catch(props.onError);
31571
32165
  },
31572
32166
  children: t("save")
31573
32167
  }),
@@ -31579,8 +32173,10 @@ window.__ModuleLoader__.load({
31579
32173
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31580
32174
  className: "daw-icon danger",
31581
32175
  title: t("revokeDevice"),
32176
+ "aria-label": t("revokeDevice"),
31582
32177
  onClick: () => {
31583
- props.api.revokeDevice(props.device.id).then(props.onChanged).catch(props.onError);
32178
+ if (!window.confirm(t("revokeConfirm", { name: props.device.name }))) return;
32179
+ props.api.revokeDevice(props.device.id).then(props.onChanged).then(() => props.onNotice("deviceRevoked")).catch(props.onError);
31584
32180
  },
31585
32181
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31586
32182
  })