dsh-remote-bridge 2.5.1 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.cjs CHANGED
@@ -172,9 +172,25 @@ window.__ModuleLoader__.load({
172
172
  async tunnelActivity() {
173
173
  return this.manageJson("/cloudflare/activity");
174
174
  }
175
- /** 创建(或复用)cloudflared 并启动 Quick Tunnel,返回对外地址。 */
176
- async tunnelCreate() {
177
- return this.manageJson("/cloudflare/create", { method: "POST" });
175
+ /**
176
+ * 创建(或复用)cloudflared 并启动 Quick Tunnel,返回对外地址。
177
+ *
178
+ * `withConfig=true`(一键「开隧道并出令牌」):请求体带 `configText:true`,服务端在
179
+ * 隧道**起来之前**的这一次调用里等地址并顺带生成令牌配置文本(见 state.configText)。
180
+ * 必须走这班车:隧道一旦运行,单独的 /manage/config/text 会被管理面判据 0 挡 403。
181
+ * endpointLabel/rootIds/deviceName 可选透传给服务端的配置生成。
182
+ */
183
+ async tunnelCreate(withConfig = false, options = {}) {
184
+ if (withConfig !== true) return this.manageJson("/cloudflare/create", { method: "POST" });
185
+ return this.manageJson("/cloudflare/create", {
186
+ method: "POST",
187
+ body: JSON.stringify({
188
+ configText: true,
189
+ ...options.endpointLabel === void 0 ? {} : { endpointLabel: options.endpointLabel },
190
+ ...options.rootIds === void 0 ? {} : { rootIds: options.rootIds },
191
+ ...options.deviceName === void 0 ? {} : { deviceName: options.deviceName }
192
+ })
193
+ });
178
194
  }
179
195
  /** 停止当前隧道。 */
180
196
  async tunnelStop() {
@@ -236,335 +252,42 @@ window.__ModuleLoader__.load({
236
252
  } catch {}
237
253
  throw new WorkspaceApiError(response.status, code, message);
238
254
  }
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
- /**
281
- * 从交接行里抠出图片地址。
282
- *
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
- * 一张图。取不到的(文件已删、会话读不出、格式不符)自己消失,而不是在轮末留一个碎图标。
386
- */
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
255
  /**
469
- * Turn 尾部的图集:本轮每条交接消息各自那张图。
256
+ * @license lucide-react v0.468.0 - ISC
470
257
  *
471
- * 没有会话 id 就什么都不显示 —— 路由需要它来定位目录,猜一个只会 404。
258
+ * This source code is licensed under the ISC license.
259
+ * See the LICENSE file in the root directory of this source tree.
472
260
  */
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]]);
261
+ //#endregion
262
+ //#region src/client/i18n.tsx
263
+ var import_lucide_react = (/* @__PURE__ */ __commonJSMin(((exports) => {
264
+ var react$1 = require("react");
265
+ const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
266
+ const mergeClasses = (...classes) => classes.filter((className, index, array) => {
267
+ return Boolean(className) && className.trim() !== "" && array.indexOf(className) === index;
268
+ }).join(" ").trim();
269
+ var defaultAttributes = {
270
+ xmlns: "http://www.w3.org/2000/svg",
271
+ width: 24,
272
+ height: 24,
273
+ viewBox: "0 0 24 24",
274
+ fill: "none",
275
+ stroke: "currentColor",
276
+ strokeWidth: 2,
277
+ strokeLinecap: "round",
278
+ strokeLinejoin: "round"
279
+ };
280
+ const Icon = react$1.forwardRef(({ color = "currentColor", size = 24, strokeWidth = 2, absoluteStrokeWidth, className = "", children, iconNode, ...rest }, ref) => {
281
+ return react$1.createElement("svg", {
282
+ ref,
283
+ ...defaultAttributes,
284
+ width: size,
285
+ height: size,
286
+ stroke: color,
287
+ strokeWidth: absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth,
288
+ className: mergeClasses("lucide", className),
289
+ ...rest
290
+ }, [...iconNode.map(([tag, attrs]) => react$1.createElement(tag, attrs)), ...Array.isArray(children) ? children : [children]]);
568
291
  });
569
292
  const createLucideIcon = (iconName, iconNode) => {
570
293
  const Component = react$1.forwardRef(({ className, ...props }, ref) => react$1.createElement(Icon, {
@@ -6770,7 +6493,7 @@ window.__ModuleLoader__.load({
6770
6493
  d: "m22 10-7.5 7.5L13 16",
6771
6494
  key: "ke71qq"
6772
6495
  }]]);
6773
- createLucideIcon("Check", [["path", {
6496
+ const Check = createLucideIcon("Check", [["path", {
6774
6497
  d: "M20 6 9 17l-5-5",
6775
6498
  key: "1gmf2c"
6776
6499
  }]]);
@@ -9169,7 +8892,7 @@ window.__ModuleLoader__.load({
9169
8892
  key: "zix9uf"
9170
8893
  }]
9171
8894
  ]);
9172
- createLucideIcon("Copy", [["rect", {
8895
+ const Copy = createLucideIcon("Copy", [["rect", {
9173
8896
  width: "14",
9174
8897
  height: "14",
9175
8898
  x: "8",
@@ -15939,7 +15662,7 @@ window.__ModuleLoader__.load({
15939
15662
  key: "jkujk7"
15940
15663
  }]
15941
15664
  ]);
15942
- createLucideIcon("Languages", [
15665
+ const Languages = createLucideIcon("Languages", [
15943
15666
  ["path", {
15944
15667
  d: "m5 8 6 6",
15945
15668
  key: "1wu5hv"
@@ -23108,7 +22831,7 @@ window.__ModuleLoader__.load({
23108
22831
  key: "1o3tu8"
23109
22832
  }]
23110
22833
  ]);
23111
- createLucideIcon("ScrollText", [
22834
+ const ScrollText = createLucideIcon("ScrollText", [
23112
22835
  ["path", {
23113
22836
  d: "M15 12h-5",
23114
22837
  key: "r7krc0"
@@ -30050,16 +29773,18 @@ window.__ModuleLoader__.load({
30050
29773
  key: "durymu"
30051
29774
  }]
30052
29775
  ]);
29776
+ exports.Check = Check;
29777
+ exports.Copy = Copy;
30053
29778
  exports.Download = Download;
30054
29779
  exports.File = File;
30055
29780
  exports.HardDrive = HardDrive;
30056
29781
  exports.Image = Image;
29782
+ exports.Languages = Languages;
29783
+ exports.ScrollText = ScrollText;
30057
29784
  exports.Smartphone = Smartphone;
30058
29785
  exports.Trash2 = Trash2;
30059
29786
  exports.Upload = Upload;
30060
- }));
30061
- //#endregion
30062
- //#region src/client/i18n.tsx
29787
+ })))();
30063
29788
  const STORAGE_KEY = "dsh-workspace-locale";
30064
29789
  const dictionaries = {
30065
29790
  "zh-CN": {
@@ -30235,6 +29960,50 @@ window.__ModuleLoader__.load({
30235
29960
  errorAdminProxyForwarded: "管理面不接受经代理转发的请求。请在本机用 127.0.0.1 或 localhost 直接打开。",
30236
29961
  errorAdminHostNotLoopback: "管理面只在本机回环地址下可用。请用 127.0.0.1 或 localhost 打开本页。",
30237
29962
  errorAdminLoopbackRequired: "此操作只能在本机执行。",
29963
+ tunnelStageLocating: "正在定位本机 cloudflared…",
29964
+ tunnelStageBinaryFound: "已找到本机二进制,直接复用",
29965
+ tunnelStageDownloadGithub: "正在从 GitHub 下载 cloudflared…",
29966
+ tunnelStageDownloadDeb: "正在从官方 deb 仓库下载 cloudflared…",
29967
+ tunnelStageDownloadProgress: "正在下载 cloudflared… {mb}/{all} MB",
29968
+ tunnelStageDownloadUnverified: "镜像源没有可用的校验和,仍在下载但本次不做完整性校验…",
29969
+ tunnelStageUnpackDeb: "解包官方 deb 并提取二进制…",
29970
+ tunnelStageWriteBinary: "写入二进制并赋予执行权限…",
29971
+ tunnelStageStarting: "正在启动 cloudflared 隧道…",
29972
+ tunnelErrorAlreadyRunning: "隧道已经在运行中,请先停止。",
29973
+ tunnelErrorDownload: "下载 cloudflared 失败,请检查网络后重试;如需换源可设置 CLOUDFLARED_MIRROR。",
29974
+ tunnelErrorExited: "cloudflared 已退出(code={code},signal={signal}),未拿到隧道地址。",
29975
+ tunnelStopConfirm: "停止云隧道后,公网临时地址立即失效,正在经隧道连接的手机会断开。确定停止吗?",
29976
+ tunnelStartAndGenConfig: "开启隧道并生成配置",
29977
+ tunnelRestartAndGenConfig: "停止后重开隧道并生成配置",
29978
+ managementLockedWhileTunnel: "云隧道正在运行,管理面已安全锁定。除停止隧道外的配置暂时不可改,停止隧道后自动恢复。",
29979
+ tunnelConfigAuto: "隧道已开启,配置文本已自动生成",
29980
+ tunnelUrlPending: "隧道已启动,但暂时还没拿到公网地址,稍等片刻后点「用隧道地址生成配置」即可。",
29981
+ copyUrl: "复制地址",
29982
+ pairingExpiresAt: "配对码有效期约 {minutes} 分钟,请尽快在手机上输入。",
29983
+ copyPairing: "复制配对码",
29984
+ pairingCreated: "配对码已生成",
29985
+ configCreated: "配置文本已生成",
29986
+ revokeConfirm: "吊销后这台手机的令牌立即失效,必须重新配对才能访问。确定吊销「{name}」吗?",
29987
+ removeRootConfirm: "移除根目录「{label}」不会删除磁盘上的文件,但所有手机将立刻失去对它的访问权。确定移除吗?",
29988
+ disableRemoteConfirm: "关闭远程访问后,所有手机连接立即断开,需要重新启用并配对。确定关闭吗?",
29989
+ rootAdded: "根目录已添加",
29990
+ rootRemoved: "根目录已移除",
29991
+ remoteDisabled: "远程访问已关闭",
29992
+ permissionsSaved: "权限已保存",
29993
+ deviceRevoked: "设备已吊销",
29994
+ listenerSaved: "监听设置已保存",
29995
+ trashRestored: "已恢复",
29996
+ trashPurged: "已永久删除",
29997
+ tunnelStarted: "云隧道已开启",
29998
+ auditEmpty: "暂无审计事件",
29999
+ auditLoadMore: "加载更多",
30000
+ auditLoading: "正在加载…",
30001
+ trashEmpty: "回收站是空的",
30002
+ deviceLastSeen: "最后使用 {time}",
30003
+ deviceNeverSeen: "从未连接",
30004
+ imageLoadFailed: "图片加载失败",
30005
+ prevImage: "上一张",
30006
+ nextImage: "下一张",
30238
30007
  author: "作者 Github@Hakunm",
30239
30008
  version: "当前版本"
30240
30009
  },
@@ -30321,7 +30090,7 @@ window.__ModuleLoader__.load({
30321
30090
  removeRoot: "Remove root",
30322
30091
  builtinRootBadge: "Default",
30323
30092
  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.",
30093
+ 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
30094
  errorRootBuiltin: "This is the current DSH working directory, so it cannot be removed. You can still add other roots above.",
30326
30095
  scopeChatRead: "Read chat",
30327
30096
  scopeChatWrite: "Send chat",
@@ -30411,6 +30180,50 @@ window.__ModuleLoader__.load({
30411
30180
  errorAdminProxyForwarded: "Management endpoints do not accept proxied requests. Open them locally via 127.0.0.1 or localhost.",
30412
30181
  errorAdminHostNotLoopback: "Management endpoints are available only under a loopback address. Open this page via 127.0.0.1 or localhost.",
30413
30182
  errorAdminLoopbackRequired: "This operation is available only from the local machine.",
30183
+ tunnelStageLocating: "Locating the local cloudflared binary…",
30184
+ tunnelStageBinaryFound: "Local binary found, reusing it",
30185
+ tunnelStageDownloadGithub: "Downloading cloudflared from GitHub…",
30186
+ tunnelStageDownloadDeb: "Downloading cloudflared from the official deb repo…",
30187
+ tunnelStageDownloadProgress: "Downloading cloudflared… {mb}/{all} MB",
30188
+ tunnelStageDownloadUnverified: "No checksum is published for this mirror; downloading without an integrity check…",
30189
+ tunnelStageUnpackDeb: "Unpacking the deb and extracting the binary…",
30190
+ tunnelStageWriteBinary: "Writing the binary and marking it executable…",
30191
+ tunnelStageStarting: "Starting the cloudflared tunnel…",
30192
+ tunnelErrorAlreadyRunning: "The tunnel is already running. Stop it first.",
30193
+ tunnelErrorDownload: "Downloading cloudflared failed. Check the network and retry; set CLOUDFLARED_MIRROR to use another source.",
30194
+ tunnelErrorExited: "cloudflared exited (code={code}, signal={signal}) before a tunnel address was obtained.",
30195
+ tunnelStopConfirm: "Stopping the tunnel invalidates the public address immediately and disconnects phones currently connected through it. Stop anyway?",
30196
+ tunnelStartAndGenConfig: "Start tunnel & generate config",
30197
+ tunnelRestartAndGenConfig: "Restart tunnel & generate config",
30198
+ 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.",
30199
+ tunnelConfigAuto: "Tunnel started; config text generated automatically",
30200
+ tunnelUrlPending: "The tunnel is starting but its public address is not ready yet. Wait a moment, then press “Generate config with tunnel address”.",
30201
+ copyUrl: "Copy address",
30202
+ pairingExpiresAt: "The pairing code is valid for about {minutes} minutes — enter it on the phone soon.",
30203
+ copyPairing: "Copy code",
30204
+ pairingCreated: "Pairing code generated",
30205
+ configCreated: "Config text generated",
30206
+ revokeConfirm: "Revoking invalidates this phone’s token at once; it must pair again to regain access. Revoke “{name}”?",
30207
+ removeRootConfirm: "Removing “{label}” does not delete files on disk, but every phone immediately loses access to it. Remove it?",
30208
+ disableRemoteConfirm: "Disabling remote access drops every phone connection right away; it must be re-enabled and re-paired. Disable it?",
30209
+ rootAdded: "Root added",
30210
+ rootRemoved: "Root removed",
30211
+ remoteDisabled: "Remote access disabled",
30212
+ permissionsSaved: "Permissions saved",
30213
+ deviceRevoked: "Device revoked",
30214
+ listenerSaved: "Listener settings saved",
30215
+ trashRestored: "Restored",
30216
+ trashPurged: "Permanently deleted",
30217
+ tunnelStarted: "Tunnel started",
30218
+ auditEmpty: "No audit events yet",
30219
+ auditLoadMore: "Load more",
30220
+ auditLoading: "Loading…",
30221
+ trashEmpty: "The recycle bin is empty",
30222
+ deviceLastSeen: "Last seen {time}",
30223
+ deviceNeverSeen: "Never connected",
30224
+ imageLoadFailed: "Image failed to load",
30225
+ prevImage: "Previous image",
30226
+ nextImage: "Next image",
30414
30227
  author: "Author Github@Hakunm",
30415
30228
  version: "Current version"
30416
30229
  }
@@ -30420,76 +30233,553 @@ window.__ModuleLoader__.load({
30420
30233
  function getWorkspaceLocale() {
30421
30234
  return currentLocale;
30422
30235
  }
30423
- function setWorkspaceLocale(locale) {
30424
- if (locale === currentLocale) return;
30425
- currentLocale = locale;
30236
+ function setWorkspaceLocale(locale) {
30237
+ if (locale === currentLocale) return;
30238
+ currentLocale = locale;
30239
+ try {
30240
+ globalThis.localStorage?.setItem(STORAGE_KEY, locale);
30241
+ } catch {}
30242
+ for (const listener of listeners) listener();
30243
+ }
30244
+ function subscribeWorkspaceLocale(listener) {
30245
+ listeners.add(listener);
30246
+ return () => listeners.delete(listener);
30247
+ }
30248
+ /**
30249
+ * 取一条文案并填占位符。
30250
+ *
30251
+ * **缺键不抛异常**。原来这里是 `dictionaries[locale][key].replace(...)`:任何一个不存在的键
30252
+ * 都会抛 TypeError,而它在 React 渲染路径上 —— 结果不是那一行显示不出原文,而是**整个设置
30253
+ * 面板白屏**。缺一个描述行就把七张卡里的全部设置都搭进去,代价和收益完全不成比例。
30254
+ *
30255
+ * 降级成「回退到另一种语言 → 再回退成键名本身」:最坏情况是某一行露出 `rootsTabDesc`
30256
+ * 这样的字面量,一眼能看出该补哪条,而不是面对一个空白面板。
30257
+ */
30258
+ function translate(locale, key, values = {}) {
30259
+ const fill = (template) => template?.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
30260
+ const primary = fill(dictionaries[locale][key]);
30261
+ if (primary !== void 0) return primary;
30262
+ const text = fill(dictionaries["zh-CN"][key]) ?? key;
30263
+ console.warn(`[dsh-remote-bridge] 缺少文案键 ${String(key)}(locale=${locale}),已降级显示键名`);
30264
+ return text;
30265
+ }
30266
+ /**
30267
+ * 权限码 → 人话。
30268
+ *
30269
+ * 用户要求「中文状态下显示中文,英文显示英文」。文案刻意**用人话而不是协议码**:
30270
+ * `读文件` / `写文件` / `删文件` 自己就看得懂,而 `files.read` 要对照文档才知道指什么 ——
30271
+ * 出问题时用户把屏幕上的字念出来,比报一串码有用得多。
30272
+ *
30273
+ * 键名 `scope${码}`(`chat.read` → `scopeChatRead`):与既有 `rootsTab` 一致的拼法。
30274
+ * 用**显式查表**而不是模板拼 —— 拼错会查不到键,而键查不到时 `translate` 会静默降级成
30275
+ * 露出键名(见上面),界面上会突然冒出一行 `scopeChatread`。白屏那次就是这么来的。
30276
+ */
30277
+ const SCOPE_LABEL_KEYS = {
30278
+ "chat.read": "scopeChatRead",
30279
+ "chat.write": "scopeChatWrite",
30280
+ "files.read": "scopeFilesRead",
30281
+ "files.write": "scopeFilesWrite",
30282
+ "files.delete": "scopeFilesDelete",
30283
+ "settings.read": "scopeSettingsRead",
30284
+ "settings.write": "scopeSettingsWrite"
30285
+ };
30286
+ function scopeLabel(locale, scope) {
30287
+ const key = SCOPE_LABEL_KEYS[scope];
30288
+ if (key === void 0) return scope;
30289
+ return translate(locale, key);
30290
+ }
30291
+ function useWorkspaceI18n() {
30292
+ const locale = (0, react.useSyncExternalStore)(subscribeWorkspaceLocale, getWorkspaceLocale, () => "zh-CN");
30293
+ return {
30294
+ locale,
30295
+ t: (0, react.useCallback)((key, values) => translate(locale, key, values), [locale]),
30296
+ toggle: (0, react.useCallback)(() => setWorkspaceLocale(locale === "zh-CN" ? "en" : "zh-CN"), [locale])
30297
+ };
30298
+ }
30299
+ function LanguageToggle() {
30300
+ const { t, toggle } = useWorkspaceI18n();
30301
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30302
+ className: "daw-icon",
30303
+ type: "button",
30304
+ title: t("switchLanguage"),
30305
+ "aria-label": t("switchLanguage"),
30306
+ onClick: toggle,
30307
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Languages, { size: 16 })
30308
+ });
30309
+ }
30310
+ function readStoredLocale() {
30311
+ try {
30312
+ return globalThis.localStorage?.getItem(STORAGE_KEY) === "en" ? "en" : "zh-CN";
30313
+ } catch {
30314
+ return "zh-CN";
30315
+ }
30316
+ }
30317
+ //#endregion
30318
+ //#region src/client/handoff.tsx
30319
+ /**
30320
+ * 浏览器半边:把**图片交接消息**里的那个地址渲染成图片本身。
30321
+ *
30322
+ * 这半边是从 `dsh-image-paths` 0.3.1 并过来的,接的是同一个契约:纯文本模型收到图片时,
30323
+ * remote-bridge 会在用户消息里追加两行 ——
30324
+ *
30325
+ * 🖼️ 图片 1「shot.png」(当前模型看不到图,已存到 .dsh-images/<sha>.png;要看图,请用识图工具打开下面那行的路径)
30326
+ * [Image: source: /absolute/path/.dsh-images/<sha>.png]
30327
+ *
30328
+ * 本模块只读第一行里那个地址,把它变成一张真的图。第二行是给模型读的机器契约,同一张图
30329
+ * 不再重复出第二张;而且只扫**用户消息**,上下文注入、助手正文、工具输出里引用的路径
30330
+ * 一律不碰 —— 否则对话里到处都是 `.dsh-images/…` 的例子,每个都会被渲染成图。
30331
+ *
30332
+ * 取图走宿主那条只读路由 `/plugin/dsh-remote-bridge/raw?sessionId=…&name=…`:
30333
+ * 请求里**只有会话与裸文件名**,目录由宿主按会话工作目录推出,客户端连「读哪个目录」
30334
+ * 都递不进去。这条路是给 `<img>` 用的 —— 浏览器不会给图片请求加 Authorization 头,
30335
+ * 所以它独立于版本化 REST,按回环 + 同源 + 文件名白名单 + 字节自证守门。
30336
+ *
30337
+ * 座位选的是 Turn 尾部(`conversation.chat.turnTail`):Chat 节点座位会把这个 kind 收进
30338
+ * 「过程分组」,standard 模式下折叠起来 —— DOM 里有、肉眼看不着。Turn 尾在独立成行的
30339
+ * 白名单内,任何工作详情模式下都看得见。
30340
+ */
30341
+ /** 本插件在 Chat store 里占的节点 kind。 */
30342
+ const KIND = "dsh-remote-bridge-handoff-gallery";
30343
+ /** 宿主的只读图片路由。 */
30344
+ const ROUTE = "/plugin/dsh-remote-bridge/raw";
30345
+ /** 每轮最多显示几张。 */
30346
+ const MAX_IMAGES = 8;
30347
+ /** 长过这个的路径不值得去解析。 */
30348
+ const MAX_PATH_CHARS = 4096;
30349
+ /** 图片扩展名:与宿主交接写盘的白名单一致。 */
30350
+ const IMAGE_EXTENSION = /\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif)$/i;
30351
+ /**
30352
+ * 交接行的行首标记:中英两种文案都以它开头。
30353
+ *
30354
+ * 判据选「行首标记」而不是「句子里有 已存到」——后者会把用户自己写的句子也算进来,
30355
+ * 而 dsh-companion 摘除这一行用的也是同一个标记,两边口径一致。
30356
+ */
30357
+ const HANDOFF_LINE = /^🖼️\s*(?:图片|Image)\s+\d+/i;
30358
+ /**
30359
+ * 从交接行里抠出图片地址。
30360
+ *
30361
+ * 允许「已存到 / 已保存到 / saved to」后面隔一段散文(历史文案里有「工作目录」这种词),
30362
+ * 所以是「点心号 + 非贪婪」而不是紧邻匹配。取出的是消息里**原样**的那条路径,
30363
+ * 相对(`.dsh-images/x.png`)与绝对(兜底目录那版)都认。
30364
+ */
30365
+ const HANDOFF_PATH = /(?:已(?:保存|存)到|saved to)[^;;\n]*?([A-Za-z0-9_./@~+-][^\s`"'()[\]{}<>|,;:?,。;:、!?]*?\.(?:png|jpe?g|webp|gif|heic|heif|bmp|avif))/i;
30366
+ /** 路径里的文件名(路由只收裸文件名)。 */
30367
+ function basenameOf(path) {
30368
+ const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
30369
+ return cut === -1 ? path : path.slice(cut + 1);
30370
+ }
30371
+ /**
30372
+ * 读出一段文本里的交接图片。
30373
+ * @param text - 消息的文本内容(已按块拼接)。
30374
+ * @returns 单个 `{ path, label }`,不是交接消息就是 null。
30375
+ */
30376
+ function handoffMention(text) {
30377
+ if (typeof text !== "string" || text === "") return null;
30378
+ for (const line of text.split("\n")) {
30379
+ if (!HANDOFF_LINE.test(line.trim())) continue;
30380
+ const match = HANDOFF_PATH.exec(line);
30381
+ if (match === null) continue;
30382
+ const path = match[1] ?? "";
30383
+ if (path.length === 0 || path.length > MAX_PATH_CHARS) continue;
30384
+ if (!IMAGE_EXTENSION.test(path)) continue;
30385
+ return {
30386
+ path,
30387
+ label: basenameOf(path)
30388
+ };
30389
+ }
30390
+ return null;
30391
+ }
30392
+ /** 把一条消息的内容块拼成文本。 */
30393
+ function contentText(content) {
30394
+ if (!Array.isArray(content)) return "";
30395
+ const parts = [];
30396
+ for (const block of content) {
30397
+ if (block === null || typeof block !== "object") continue;
30398
+ const candidate = block;
30399
+ if (candidate.type === "text" && typeof candidate.text === "string") parts.push(candidate.text);
30400
+ }
30401
+ return parts.join("\n");
30402
+ }
30403
+ /** 一条消息交接出去的那张图,没有就是空数组。 */
30404
+ function itemsOfMessage(payload) {
30405
+ if (payload === null || typeof payload !== "object") return [];
30406
+ const item = handoffMention(contentText(payload.content));
30407
+ return item === null ? [] : [item];
30408
+ }
30409
+ const WRAP_STYLE = {
30410
+ display: "flex",
30411
+ flexWrap: "wrap",
30412
+ alignItems: "flex-start",
30413
+ gap: 8,
30414
+ margin: "8px 0 4px"
30415
+ };
30416
+ const ITEM_STYLE = {
30417
+ display: "flex",
30418
+ flexDirection: "column",
30419
+ gap: 2,
30420
+ maxWidth: "100%"
30421
+ };
30422
+ const THUMB_STYLE = {
30423
+ display: "block",
30424
+ maxHeight: 180,
30425
+ maxWidth: "100%",
30426
+ borderRadius: 8,
30427
+ border: "1px solid rgba(127,127,127,0.28)",
30428
+ background: "rgba(127,127,127,0.06)",
30429
+ cursor: "zoom-in"
30430
+ };
30431
+ const CAPTION_STYLE = {
30432
+ display: "block",
30433
+ maxWidth: 180,
30434
+ fontFamily: "inherit",
30435
+ fontSize: 11,
30436
+ lineHeight: 1.4,
30437
+ color: "rgba(127,127,127,0.95)",
30438
+ overflow: "hidden",
30439
+ textOverflow: "ellipsis",
30440
+ whiteSpace: "nowrap"
30441
+ };
30442
+ const OVERLAY_STYLE = {
30443
+ position: "fixed",
30444
+ inset: 0,
30445
+ zIndex: 1e3,
30446
+ display: "flex",
30447
+ alignItems: "center",
30448
+ justifyContent: "center",
30449
+ background: "rgba(0,0,0,0.72)",
30450
+ cursor: "zoom-out"
30451
+ };
30452
+ const FULL_STYLE = {
30453
+ maxWidth: "92vw",
30454
+ maxHeight: "92vh",
30455
+ borderRadius: 6,
30456
+ boxShadow: "0 8px 32px rgba(0,0,0,0.5)"
30457
+ };
30458
+ /** 加载失败的缩略图占位(不再像旧版那样整张消失,只留一个问号让人瞎猜)。 */
30459
+ const THUMB_BROKEN_STYLE = {
30460
+ ...THUMB_STYLE,
30461
+ display: "flex",
30462
+ alignItems: "center",
30463
+ justifyContent: "center",
30464
+ width: 160,
30465
+ height: 120,
30466
+ cursor: "default",
30467
+ fontSize: 12
30468
+ };
30469
+ const LIGHTBOX_NAV_STYLE = {
30470
+ position: "absolute",
30471
+ top: "50%",
30472
+ transform: "translateY(-50%)",
30473
+ zIndex: 1001,
30474
+ width: 44,
30475
+ height: 44,
30476
+ borderRadius: "50%",
30477
+ border: "none",
30478
+ background: "rgba(255,255,255,0.14)",
30479
+ color: "#fff",
30480
+ fontSize: 22,
30481
+ lineHeight: "44px",
30482
+ textAlign: "center",
30483
+ cursor: "pointer",
30484
+ padding: 0
30485
+ };
30486
+ const LIGHTBOX_CLOSE_STYLE = {
30487
+ position: "absolute",
30488
+ top: 16,
30489
+ right: 16,
30490
+ zIndex: 1001,
30491
+ width: 40,
30492
+ height: 40,
30493
+ borderRadius: "50%",
30494
+ border: "none",
30495
+ background: "rgba(255,255,255,0.14)",
30496
+ color: "#fff",
30497
+ fontSize: 20,
30498
+ lineHeight: "40px",
30499
+ textAlign: "center",
30500
+ cursor: "pointer",
30501
+ padding: 0
30502
+ };
30503
+ const LIGHTBOX_FULL_BROKEN_STYLE = {
30504
+ ...FULL_STYLE,
30505
+ display: "flex",
30506
+ alignItems: "center",
30507
+ justifyContent: "center",
30508
+ width: "min(72vw, 560px)",
30509
+ height: "min(60vh, 360px)",
30510
+ background: "rgba(127,127,127,0.18)",
30511
+ color: "rgba(255,255,255,0.92)",
30512
+ fontSize: 14
30513
+ };
30514
+ /** 某条地址在这条路由上的 URL。 */
30515
+ function imageUrl(path, sessionId) {
30516
+ return `${ROUTE}?sessionId=${encodeURIComponent(sessionId)}&name=${encodeURIComponent(basenameOf(path))}`;
30517
+ }
30518
+ /**
30519
+ * 缩略图。取不到的(文件已删、会话读不出、格式不符)显示一块「加载失败」占位,
30520
+ * 而不是像旧版那样整张消失——消失了用户只会以为模型没存图,根本不知道该重试。
30521
+ */
30522
+ function HandoffThumb(props) {
30523
+ const [failed, setFailed] = (0, react.useState)(false);
30524
+ if (failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30525
+ className: "daw-handoff-item",
30526
+ style: ITEM_STYLE,
30527
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30528
+ style: THUMB_BROKEN_STYLE,
30529
+ title: props.item.path,
30530
+ children: props.failedText
30531
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30532
+ className: "daw-handoff-caption",
30533
+ title: props.item.path,
30534
+ style: CAPTION_STYLE,
30535
+ children: props.item.path
30536
+ })]
30537
+ });
30538
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30539
+ className: "daw-handoff-item",
30540
+ style: ITEM_STYLE,
30541
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
30542
+ src: imageUrl(props.item.path, props.sessionId),
30543
+ alt: props.item.label,
30544
+ title: props.item.path,
30545
+ loading: "lazy",
30546
+ decoding: "async",
30547
+ referrerPolicy: "no-referrer",
30548
+ style: THUMB_STYLE,
30549
+ onError: () => setFailed(true),
30550
+ onClick: props.onOpen
30551
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
30552
+ className: "daw-handoff-caption",
30553
+ title: props.item.path,
30554
+ style: CAPTION_STYLE,
30555
+ children: props.item.path
30556
+ })]
30557
+ });
30558
+ }
30559
+ /** 灯箱里的大图;换图时靠 key 重挂载来重置 failed。 */
30560
+ function LightboxImage(props) {
30561
+ const [failed, setFailed] = (0, react.useState)(false);
30562
+ if (failed) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30563
+ style: LIGHTBOX_FULL_BROKEN_STYLE,
30564
+ children: props.failedText
30565
+ });
30566
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
30567
+ src: imageUrl(props.item.path, props.sessionId),
30568
+ alt: props.item.label,
30569
+ decoding: "async",
30570
+ referrerPolicy: "no-referrer",
30571
+ style: FULL_STYLE,
30572
+ onError: () => setFailed(true)
30573
+ });
30574
+ }
30575
+ /**
30576
+ * 图集灯箱:点遮罩空白处关闭,但**点图片本身不再误关**(旧版 img 的点击冒泡到 overlay)。
30577
+ * 多图时左右切换,Esc 关闭。
30578
+ */
30579
+ function HandoffLightbox(props) {
30580
+ const { items, index, onClose, onPrev, onNext } = props;
30581
+ const multiple = items.length > 1;
30582
+ (0, react.useEffect)(() => {
30583
+ const onKey = (event) => {
30584
+ if (event.key === "Escape") onClose();
30585
+ else if (multiple && event.key === "ArrowLeft") onPrev();
30586
+ else if (multiple && event.key === "ArrowRight") onNext();
30587
+ };
30588
+ document.addEventListener("keydown", onKey);
30589
+ return () => {
30590
+ document.removeEventListener("keydown", onKey);
30591
+ };
30592
+ }, [
30593
+ multiple,
30594
+ onClose,
30595
+ onPrev,
30596
+ onNext
30597
+ ]);
30598
+ const item = items[index];
30599
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30600
+ style: {
30601
+ ...OVERLAY_STYLE,
30602
+ cursor: "zoom-out"
30603
+ },
30604
+ role: "dialog",
30605
+ "aria-modal": "true",
30606
+ "aria-label": item.path,
30607
+ onClick: onClose,
30608
+ children: [
30609
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30610
+ type: "button",
30611
+ style: LIGHTBOX_CLOSE_STYLE,
30612
+ "aria-label": "×",
30613
+ onClick: onClose,
30614
+ children: "×"
30615
+ }),
30616
+ multiple && index > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30617
+ type: "button",
30618
+ style: {
30619
+ ...LIGHTBOX_NAV_STYLE,
30620
+ left: 24
30621
+ },
30622
+ "aria-label": props.prevLabel,
30623
+ title: props.prevLabel,
30624
+ onClick: (e) => {
30625
+ e.stopPropagation();
30626
+ onPrev();
30627
+ },
30628
+ children: "‹"
30629
+ }) : null,
30630
+ multiple && index < items.length - 1 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30631
+ type: "button",
30632
+ style: {
30633
+ ...LIGHTBOX_NAV_STYLE,
30634
+ right: 24
30635
+ },
30636
+ "aria-label": props.nextLabel,
30637
+ title: props.nextLabel,
30638
+ onClick: (e) => {
30639
+ e.stopPropagation();
30640
+ onNext();
30641
+ },
30642
+ children: "›"
30643
+ }) : null,
30644
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30645
+ style: {
30646
+ position: "relative",
30647
+ cursor: "default"
30648
+ },
30649
+ onClick: (e) => e.stopPropagation(),
30650
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(LightboxImage, {
30651
+ item,
30652
+ sessionId: props.sessionId,
30653
+ failedText: props.failedText
30654
+ }, index), multiple ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
30655
+ style: {
30656
+ position: "absolute",
30657
+ bottom: -28,
30658
+ left: 0,
30659
+ right: 0,
30660
+ textAlign: "center",
30661
+ color: "rgba(255,255,255,0.85)",
30662
+ fontSize: 12
30663
+ },
30664
+ children: [
30665
+ index + 1,
30666
+ " / ",
30667
+ items.length
30668
+ ]
30669
+ }) : null]
30670
+ })
30671
+ ]
30672
+ });
30673
+ }
30674
+ /** 把本轮各节点里交接出去的图收成一串(按出现顺序去重)。 */
30675
+ function collectTurnItems(nodes, locations, turn) {
30676
+ if (nodes === void 0 || locations === void 0 || turn === void 0 || turn === null) return [];
30677
+ let keys;
30426
30678
  try {
30427
- globalThis.localStorage?.setItem(STORAGE_KEY, locale);
30428
- } catch {}
30429
- for (const listener of listeners) listener();
30430
- }
30431
- function subscribeWorkspaceLocale(listener) {
30432
- listeners.add(listener);
30433
- return () => listeners.delete(listener);
30679
+ keys = locations.getTurn(turn.turn);
30680
+ } catch {
30681
+ return [];
30682
+ }
30683
+ if (!Array.isArray(keys)) return [];
30684
+ const seen = /* @__PURE__ */ new Set();
30685
+ const items = [];
30686
+ for (const key of keys) {
30687
+ const node = nodes.get(key);
30688
+ if (node === void 0 || node === null || node.kind !== KIND) continue;
30689
+ const list = node.data?.items;
30690
+ if (!Array.isArray(list)) continue;
30691
+ for (const item of list) {
30692
+ if (item === null || typeof item !== "object" || typeof item.path !== "string") continue;
30693
+ if (seen.has(item.path)) continue;
30694
+ seen.add(item.path);
30695
+ items.push({
30696
+ path: item.path,
30697
+ label: typeof item.label === "string" ? item.label : basenameOf(item.path)
30698
+ });
30699
+ if (items.length >= MAX_IMAGES) return items;
30700
+ }
30701
+ }
30702
+ return items;
30434
30703
  }
30435
30704
  /**
30436
- * 取一条文案并填占位符。
30437
- *
30438
- * **缺键不抛异常**。原来这里是 `dictionaries[locale][key].replace(...)`:任何一个不存在的键
30439
- * 都会抛 TypeError,而它在 React 渲染路径上 —— 结果不是那一行显示不出原文,而是**整个设置
30440
- * 面板白屏**。缺一个描述行就把七张卡里的全部设置都搭进去,代价和收益完全不成比例。
30705
+ * Turn 尾部的图集:本轮每条交接消息各自那张图。
30441
30706
  *
30442
- * 降级成「回退到另一种语言 → 再回退成键名本身」:最坏情况是某一行露出 `rootsTabDesc`
30443
- * 这样的字面量,一眼能看出该补哪条,而不是面对一个空白面板。
30707
+ * 没有会话 id 就什么都不显示 —— 路由需要它来定位目录,猜一个只会 404。
30444
30708
  */
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;
30709
+ function HandoffTurnTail(props) {
30710
+ const items = collectTurnItems(props.useChat((snapshot) => snapshot.nodes), props.useChat((snapshot) => snapshot.locations), props.turn);
30711
+ const sessionId = props.sessionId;
30712
+ const { t } = useWorkspaceI18n();
30713
+ const [activeIndex, setActiveIndex] = (0, react.useState)(null);
30714
+ if (items.length === 0) return null;
30715
+ if (typeof sessionId !== "string" || sessionId === "") return null;
30716
+ const openIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null;
30717
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30718
+ className: "daw-handoff-gallery",
30719
+ style: WRAP_STYLE,
30720
+ children: items.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffThumb, {
30721
+ item,
30722
+ sessionId,
30723
+ failedText: t("imageLoadFailed"),
30724
+ onOpen: () => setActiveIndex(index)
30725
+ }, item.path))
30726
+ }), openIndex !== null ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(HandoffLightbox, {
30727
+ items,
30728
+ index: openIndex,
30729
+ sessionId,
30730
+ failedText: t("imageLoadFailed"),
30731
+ prevLabel: t("prevImage"),
30732
+ nextLabel: t("nextImage"),
30733
+ onClose: () => setActiveIndex(null),
30734
+ onPrev: () => setActiveIndex((i) => i === null ? i : Math.max(0, i - 1)),
30735
+ onNext: () => setActiveIndex((i) => i === null ? i : Math.min(items.length - 1, i + 1))
30736
+ }) : null] });
30452
30737
  }
30453
30738
  /**
30454
- * 权限码 → 人话。
30739
+ * 一条「交接消息 → 一个图集节点」的 Definition。
30455
30740
  *
30456
- * 用户要求「中文状态下显示中文,英文显示英文」。文案刻意**用人话而不是协议码**:
30457
- * `读文件` / `写文件` / `删文件` 自己就看得懂,而 `files.read` 要对照文档才知道指什么 ——
30458
- * 出问题时用户把屏幕上的字念出来,比报一串码有用得多。
30741
+ * 只匹配 `user/message`:交接说明属于读者自己发的那条消息。助手正文、上下文注入、
30742
+ * 工具输出里到处都是引用 `.dsh-images/…` 的文本,读它们会把别人的话切成碎图。
30459
30743
  *
30460
- * 键名 `scope${码}`(`chat.read` → `scopeChatRead`):与既有 `rootsTab` 一致的拼法。
30461
- * 用**显式查表**而不是模板拼 —— 拼错会查不到键,而键查不到时 `translate` 会静默降级成
30462
- * 露出键名(见上面),界面上会突然冒出一行 `scopeChatread`。白屏那次就是这么来的。
30744
+ * 节点自身**不注册渲染器**(Turn 尾负责显示),所以过程分组里不会多出一行、
30745
+ * verbose 模式下也不会重复一次。
30463
30746
  */
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";
30747
+ const handoffDefinition = {
30748
+ kind: KIND,
30749
+ target: "chat",
30750
+ match(event) {
30751
+ if (event === null || typeof event !== "object" || event.surfaceOp !== "append") return null;
30752
+ if (event.type !== "user/message") return null;
30753
+ return {
30754
+ id: `m:${String(event.seq)}`,
30755
+ role: "start"
30756
+ };
30757
+ },
30758
+ start(_context, match) {
30759
+ return {
30760
+ items: itemsOfMessage(match.event.data),
30761
+ anchorSeq: match.event.seq
30762
+ };
30763
+ },
30764
+ update(context) {
30765
+ return context.state;
30766
+ },
30767
+ buildViewNode(context) {
30768
+ const state = context.state;
30769
+ if (state === void 0 || !Array.isArray(state.items) || state.items.length === 0) return null;
30770
+ const start = context.start;
30771
+ return {
30772
+ key: context.key,
30773
+ kind: KIND,
30774
+ id: context.id,
30775
+ target: "chat",
30776
+ anchorSeq: state.anchorSeq,
30777
+ location: start !== void 0 && start !== null ? start.location : { kind: "unresolved" },
30778
+ visibility: "visible",
30779
+ data: { items: state.items }
30780
+ };
30491
30781
  }
30492
- }
30782
+ };
30493
30783
  //#endregion
30494
30784
  //#region src/client/styles.ts
30495
30785
  const STYLE_ID = "dsh-workspace-style";
@@ -30505,16 +30795,33 @@ window.__ModuleLoader__.load({
30505
30795
  * 类型检查、单测、构建全都发现不了,只有肉眼看渲染或量计算样式才抓得到。
30506
30796
  */
30507
30797
  const WORKSPACE_CSS = `
30508
- .daw-version{font:600 11px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--daw-muted);white-space:nowrap}
30509
30798
  .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}
30799
+ .daw-root *,.daw-settings-section *{box-sizing:border-box;letter-spacing:0}
30800
+ /* 输入控件基线(UI-04):此前只有 :focus-visible 一条,非聚焦态全靠浏览器默认(2px inset/Arial/灰底),
30801
+ 深色令牌下实测白底黑字与面板割裂。这里给齐边框/底/字/内边距,聚焦态只换描边。 */
30802
+ .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}
30803
+ .daw-input::placeholder{color:var(--dsw-alias-label-tertiary,var(--daw-muted))}
30804
+ .daw-input:focus-visible{border-color:var(--dsw-alias-brand-primary,var(--daw-accent));outline:none}
30805
+ .daw-input:disabled{opacity:.55;cursor:not-allowed}.daw-icon{width:30px;padding:0}
30512
30806
  .daw-icon:hover{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}
30513
30807
  .daw-icon.danger{color:var(--daw-danger);border-color:transparent;background:transparent}
30514
30808
  .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}
30809
+ /* 普通命令按钮基线(UI-04):旧版写死 background:#fff/border:#cbd2d8,深色令牌下成了白块黑字。
30810
+ 全部改走 DSH 令牌;.primary 沿用面板底色做次按钮、真正的主操作由调用方加 .primary 配合。 */
30811
+ .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}
30812
+ .daw-command:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30813
+ .daw-command:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:1px}
30814
+ /* 禁用态(UI-05):此前无任何 :disabled 样式,只剩浏览器默认的 30% 文字且仍是箭头手型。 */
30815
+ .daw-command:disabled{opacity:.5;cursor:not-allowed}
30816
+ .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}
30817
+ .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))}
30818
+ .daw-command.danger{color:var(--daw-danger)}
30819
+ .daw-command.danger:hover:not(:disabled){border-color:var(--daw-danger)}
30820
+ /* 图标+文字的小复制按钮:图标和文字对齐,窄屏只露图标也不改变按钮高度。 */
30821
+ .daw-command.icon-as-command{display:inline-flex;align-items:center;gap:6px;padding:0 10px;white-space:nowrap}
30822
+ .daw-copy-label{font-size:12px}
30823
+ .daw-empty{padding:14px 2px;text-align:center}
30824
+ .daw-admin{overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}.daw-admin h3{font-size:14px;margin:0 0 12px}
30518
30825
  .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
30826
  .daw-card:hover{border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30520
30827
  .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 +30836,17 @@ window.__ModuleLoader__.load({
30529
30836
  .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
30837
  .daw-list-title .daw-pill{margin-left:8px;vertical-align:middle}
30531
30838
  .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}}
30839
+ .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}
30840
+ /* 成功反馈条(UI-07):与 .daw-warning 同形,但走成功色,操作成功不再静默。 */
30841
+ .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}
30842
+ .daw-admin-topbar{display:flex;justify-content:flex-end;margin-bottom:8px}
30843
+ .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}
30844
+ .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}
30845
+ /* 隧道/监听进行中的流动条(UI-08):percent=null 时铺一段高光在轨道里循环滑动,
30846
+ 不再把「未知进度」伪装成恒定 30%。 */
30847
+ .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}
30848
+ @keyframes daw-indeterminate{0%{transform:translateX(-120%)}100%{transform:translateX(280%)}}
30849
+ @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
30850
  `;
30534
30851
  function installWorkspaceStyles() {
30535
30852
  if (document.getElementById(STYLE_ID) !== null) return;
@@ -30566,7 +30883,6 @@ window.__ModuleLoader__.load({
30566
30883
  }];
30567
30884
  //#endregion
30568
30885
  //#region src/client/workspace.tsx
30569
- var import_lucide_react = require_lucide_react();
30570
30886
  /** What the settings-page launcher opens: authorized roots, the first section. */
30571
30887
  const DEFAULT_PANEL_SECTION = "roots";
30572
30888
  /**
@@ -30579,9 +30895,37 @@ window.__ModuleLoader__.load({
30579
30895
  function tabLabelKey(tab) {
30580
30896
  return tab === "app-download" ? "appDownloadTab" : `${tab}Tab`;
30581
30897
  }
30582
- /** 云隧道进度条宽度:有确定百分比则用之,否则给 30% 作「流动中」占位。 */
30583
- function pct(a) {
30584
- return `${a.percent ?? 30}%`;
30898
+ /** 配对码剩余分钟:向上取整 —— 显示「约 9 分钟」比「约 8 分钟」在最后一刻更诚实。 */
30899
+ function pairingMinutesLeft(expiresAt, now) {
30900
+ return Math.max(0, Math.ceil((expiresAt - now) / 6e4));
30901
+ }
30902
+ /**
30903
+ * 通用复制小按钮:成功后短暂换成对勾 + 「已复制」,2s 自动复位;
30904
+ * 非安全上下文(HTTP/LAN)拿不到 Clipboard API 时退化为选中文本提示,见 copyText 的调用方。
30905
+ */
30906
+ function CopyButton(props) {
30907
+ const [state, setState] = (0, react.useState)("idle");
30908
+ const [inFlight, setInFlight] = (0, react.useState)(false);
30909
+ const label = state === "done" ? props.copiedText : state === "manual" ? props.manualText ?? props.title : props.title;
30910
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
30911
+ type: "button",
30912
+ className: `daw-command icon-as-command${props.className === void 0 ? "" : ` ${props.className}`}`,
30913
+ title: props.title,
30914
+ "aria-label": props.title,
30915
+ disabled: inFlight || state !== "idle",
30916
+ onClick: () => {
30917
+ if (inFlight) return;
30918
+ setInFlight(true);
30919
+ props.copyText(props.text).then((kind) => {
30920
+ setState(kind === "done" ? "done" : "manual");
30921
+ window.setTimeout(() => setState("idle"), kind === "done" ? 2e3 : 2500);
30922
+ }).finally(() => setInFlight(false));
30923
+ },
30924
+ 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", {
30925
+ className: "daw-copy-label",
30926
+ children: label
30927
+ })]
30928
+ });
30585
30929
  }
30586
30930
  /**
30587
30931
  * 卡片头部的描述行。与 `tabLabelKey` 同理:唯一含连字符的分区显式映射。
@@ -30721,9 +31065,52 @@ window.__ModuleLoader__.load({
30721
31065
  const [openCards, setOpenCards] = (0, react.useState)(() => /* @__PURE__ */ new Set([props.initialOpen]));
30722
31066
  const [imageSaved, setImageSaved] = (0, react.useState)(false);
30723
31067
  const [error, setError] = (0, react.useState)();
31068
+ const [notice, setNotice] = (0, react.useState)();
30724
31069
  const [tunnel, setTunnel] = (0, react.useState)();
30725
31070
  const [tunnelBusy, setTunnelBusy] = (0, react.useState)(false);
30726
31071
  const [activity, setActivity] = (0, react.useState)();
31072
+ const [auditLimit, setAuditLimit] = (0, react.useState)(50);
31073
+ const [auditLoading, setAuditLoading] = (0, react.useState)(false);
31074
+ const [now, setNow] = (0, react.useState)(() => Date.now());
31075
+ const notify = (0, react.useCallback)((key, params) => {
31076
+ setNotice(params === void 0 ? {
31077
+ id: Date.now() + Math.random(),
31078
+ key
31079
+ } : {
31080
+ id: Date.now() + Math.random(),
31081
+ key,
31082
+ params
31083
+ });
31084
+ }, []);
31085
+ (0, react.useEffect)(() => {
31086
+ if (notice === void 0) return;
31087
+ const timer = window.setTimeout(() => setNotice(void 0), 3500);
31088
+ return () => window.clearTimeout(timer);
31089
+ }, [notice]);
31090
+ (0, react.useEffect)(() => {
31091
+ const timer = window.setInterval(() => setNow(Date.now()), 3e4);
31092
+ return () => window.clearInterval(timer);
31093
+ }, []);
31094
+ const copyText = (0, react.useCallback)(async (text) => {
31095
+ const clipboard = globalThis.navigator?.clipboard;
31096
+ if (clipboard !== void 0) try {
31097
+ await clipboard.writeText(text);
31098
+ return "done";
31099
+ } catch {}
31100
+ try {
31101
+ const node = document.createElement("textarea");
31102
+ node.value = text;
31103
+ node.style.position = "fixed";
31104
+ node.style.top = "-9999px";
31105
+ node.setAttribute("readonly", "");
31106
+ document.body.append(node);
31107
+ node.select();
31108
+ const ok = document.execCommand("copy");
31109
+ node.remove();
31110
+ if (ok) return "done";
31111
+ } catch {}
31112
+ return "manual";
31113
+ }, []);
30727
31114
  const isOpen = (0, react.useCallback)((card) => openCards.has(card), [openCards]);
30728
31115
  const toggleCard = (0, react.useCallback)((card) => {
30729
31116
  setOpenCards((previous) => {
@@ -30733,6 +31120,9 @@ window.__ModuleLoader__.load({
30733
31120
  });
30734
31121
  }, []);
30735
31122
  const refresh = (0, react.useCallback)(async () => {
31123
+ try {
31124
+ setTunnel(await props.api.tunnelStatus());
31125
+ } catch {}
30736
31126
  try {
30737
31127
  const next = await props.api.status();
30738
31128
  setStatus(next);
@@ -30744,49 +31134,157 @@ window.__ModuleLoader__.load({
30744
31134
  setImageDraft(settings.subdir);
30745
31135
  setImageSaved(false);
30746
31136
  }
30747
- if (openCards.has("remote")) setTunnel(await props.api.tunnelStatus());
30748
- setError(void 0);
30749
31137
  } catch (cause) {
30750
- setError(cause);
31138
+ if (!(cause instanceof WorkspaceApiError && cause.status === 403 && (cause.code === "TUNNEL_MODE_ADMIN_DISABLED" || cause.code === "ADMIN_PROXY_FORWARDED"))) setError(cause);
30751
31139
  }
30752
31140
  }, [props.api, openCards]);
30753
- const createTunnel = (0, react.useCallback)(() => {
31141
+ const tunnelErrorText = (0, react.useCallback)((s) => {
31142
+ if (s.errorKey !== void 0) return t(s.errorKey, s.errorParams ?? {});
31143
+ return s.error ?? t("listenerFailed");
31144
+ }, [t]);
31145
+ const pollActivityOnce = (0, react.useCallback)(async (active) => {
31146
+ while (active()) {
31147
+ try {
31148
+ const a = await props.api.tunnelActivity();
31149
+ if (!active()) return;
31150
+ setActivity(a.stage === "idle" ? void 0 : a);
31151
+ } catch {}
31152
+ await delay(400);
31153
+ }
31154
+ }, [props.api]);
31155
+ const waitForTunnelUrl = (0, react.useCallback)(async (active) => {
31156
+ for (let attempt = 0; attempt < 40 && active(); attempt += 1) {
31157
+ await delay(500);
31158
+ const s = await props.api.tunnelStatus();
31159
+ if (!active()) return null;
31160
+ setTunnel(s);
31161
+ if (s.errorKey !== void 0 || s.error !== void 0) throw new Error(tunnelErrorText(s));
31162
+ if (s.running && s.url !== null) return s.url;
31163
+ }
31164
+ return null;
31165
+ }, [props.api, tunnelErrorText]);
31166
+ const generateConfigForUrl = (0, react.useCallback)(async (baseUrl, noticeKey = "configCreated") => {
31167
+ setConfigTextBusy(true);
31168
+ setError(void 0);
31169
+ try {
31170
+ const currentRoots = status?.roots ?? [];
31171
+ const value = await props.api.createConfigText({
31172
+ ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) },
31173
+ endpoints: [{
31174
+ label: t("tunnelEndpointLabel"),
31175
+ baseUrl
31176
+ }]
31177
+ });
31178
+ setConfigText(value);
31179
+ notify(noticeKey);
31180
+ return true;
31181
+ } catch (cause) {
31182
+ setError(cause);
31183
+ return false;
31184
+ } finally {
31185
+ setConfigTextBusy(false);
31186
+ }
31187
+ }, [
31188
+ props.api,
31189
+ status,
31190
+ t,
31191
+ notify
31192
+ ]);
31193
+ const createTunnel = (0, react.useCallback)(async (opts = {}) => {
30754
31194
  if (!window.confirm(t("tunnelStartConfirm"))) return;
31195
+ const wantConfig = opts.thenGenConfig === true;
31196
+ let alive = true;
30755
31197
  setTunnelBusy(true);
30756
31198
  setError(void 0);
30757
- setActivity(void 0);
31199
+ setConfigText(void 0);
30758
31200
  setActivity({
30759
31201
  stage: "locating",
30760
- label: "正在准备 cloudflared…",
31202
+ labelKey: "tunnelStageLocating",
30761
31203
  percent: null
30762
31204
  });
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);
31205
+ pollActivityOnce(() => alive);
31206
+ try {
31207
+ const currentRoots = status?.roots ?? [];
31208
+ const created = await props.api.tunnelCreate(wantConfig, { ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) } });
31209
+ setTunnel(created);
30770
31210
  setActivity(void 0);
31211
+ if (created.errorKey !== void 0 || created.error !== void 0) throw new Error(tunnelErrorText(created));
31212
+ if (wantConfig && created.configText !== void 0) {
31213
+ setConfigText(created.configText);
31214
+ await refresh();
31215
+ notify("tunnelConfigAuto");
31216
+ return;
31217
+ }
31218
+ const url = created.url ?? await waitForTunnelUrl(() => alive);
30771
31219
  await refresh();
30772
- }).catch(setError).finally(() => {
30773
- window.clearInterval(poll);
31220
+ if (url === null) {
31221
+ notify("tunnelUrlPending");
31222
+ return;
31223
+ }
31224
+ if (wantConfig) await generateConfigForUrl(url, "tunnelConfigAuto");
31225
+ else notify("tunnelStarted");
31226
+ } catch (cause) {
31227
+ setError(cause);
31228
+ } finally {
31229
+ alive = false;
30774
31230
  setActivity(void 0);
30775
31231
  setTunnelBusy(false);
30776
- });
31232
+ }
30777
31233
  }, [
30778
31234
  props.api,
30779
31235
  refresh,
30780
- t
30781
- ]);
30782
- const stopTunnel = (0, react.useCallback)(() => {
31236
+ status,
31237
+ t,
31238
+ notify,
31239
+ pollActivityOnce,
31240
+ waitForTunnelUrl,
31241
+ tunnelErrorText,
31242
+ generateConfigForUrl
31243
+ ]);
31244
+ const stopTunnel = (0, react.useCallback)(async () => {
31245
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
30783
31246
  setTunnelBusy(true);
30784
31247
  setError(void 0);
30785
- props.api.tunnelStop().then(async (next) => {
31248
+ try {
31249
+ const next = await props.api.tunnelStop();
30786
31250
  setTunnel(next);
30787
31251
  await refresh();
30788
- }).catch(setError).finally(() => setTunnelBusy(false));
30789
- }, [props.api, refresh]);
31252
+ notify("tunnelStopped");
31253
+ } catch (cause) {
31254
+ setError(cause);
31255
+ } finally {
31256
+ setTunnelBusy(false);
31257
+ }
31258
+ }, [
31259
+ props.api,
31260
+ refresh,
31261
+ t,
31262
+ notify
31263
+ ]);
31264
+ const tunnelConfigClick = (0, react.useCallback)(async () => {
31265
+ if (tunnel?.running === true) {
31266
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
31267
+ setTunnelBusy(true);
31268
+ setError(void 0);
31269
+ try {
31270
+ const stopped = await props.api.tunnelStop();
31271
+ setTunnel(stopped);
31272
+ await refresh();
31273
+ } catch (cause) {
31274
+ setError(cause);
31275
+ } finally {
31276
+ setTunnelBusy(false);
31277
+ }
31278
+ await createTunnel({ thenGenConfig: true });
31279
+ } else await createTunnel({ thenGenConfig: true });
31280
+ }, [
31281
+ props.api,
31282
+ tunnel,
31283
+ t,
31284
+ createTunnel,
31285
+ refresh,
31286
+ setError
31287
+ ]);
30790
31288
  (0, react.useEffect)(() => {
30791
31289
  refresh();
30792
31290
  }, [refresh]);
@@ -30796,25 +31294,7 @@ window.__ModuleLoader__.load({
30796
31294
  setListenerPort(String(status.configuredPort));
30797
31295
  }, [status?.configuredHost, status?.configuredPort]);
30798
31296
  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
- ]);
31297
+ const managementLocked = tunnel?.running === true;
30818
31298
  const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEVICE_SCOPES]);
30819
31299
  const toggleScope = (0, react.useCallback)((scope) => {
30820
31300
  setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
@@ -30843,10 +31323,25 @@ window.__ModuleLoader__.load({
30843
31323
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
30844
31324
  className: "daw-admin",
30845
31325
  children: [
31326
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31327
+ className: "daw-admin-topbar",
31328
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LanguageToggle, {})
31329
+ }),
30846
31330
  error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30847
31331
  className: "daw-warning",
31332
+ role: "alert",
30848
31333
  children: messageOf(error, t)
30849
31334
  }),
31335
+ notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31336
+ className: "daw-success",
31337
+ role: "status",
31338
+ children: t(notice.key, notice.params)
31339
+ }),
31340
+ managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31341
+ className: "daw-warning",
31342
+ role: "status",
31343
+ children: t("managementLockedWhileTunnel")
31344
+ }),
30850
31345
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
30851
31346
  tab: "roots",
30852
31347
  nameKey: tabLabelKey("roots"),
@@ -30871,10 +31366,14 @@ window.__ModuleLoader__.load({
30871
31366
  }),
30872
31367
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30873
31368
  className: "daw-command primary",
31369
+ disabled: managementLocked,
31370
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
30874
31371
  onClick: () => {
31372
+ setError(void 0);
30875
31373
  props.api.addRoot(path, label || void 0).then(() => {
30876
31374
  setPath("");
30877
31375
  setLabel("");
31376
+ notify("rootAdded");
30878
31377
  return refresh();
30879
31378
  }).catch(setError);
30880
31379
  },
@@ -30901,8 +31400,15 @@ window.__ModuleLoader__.load({
30901
31400
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30902
31401
  className: "daw-icon danger",
30903
31402
  title: t("removeRoot"),
31403
+ "aria-label": t("removeRoot"),
31404
+ disabled: managementLocked,
30904
31405
  onClick: () => {
30905
- props.api.removeRoot(root.id).then(refresh).catch(setError);
31406
+ if (!window.confirm(t("removeRootConfirm", { label: root.label }))) return;
31407
+ setError(void 0);
31408
+ props.api.removeRoot(root.id).then(() => {
31409
+ notify("rootRemoved");
31410
+ return refresh();
31411
+ }).catch(setError);
30906
31412
  },
30907
31413
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
30908
31414
  })]
@@ -30925,7 +31431,7 @@ window.__ModuleLoader__.load({
30925
31431
  className: "daw-warning",
30926
31432
  children: t("httpWarning")
30927
31433
  }),
30928
- roots.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31434
+ roots.length === 0 && !managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30929
31435
  className: "daw-warning",
30930
31436
  children: t("addRootBeforeRemote")
30931
31437
  }),
@@ -30937,7 +31443,7 @@ window.__ModuleLoader__.load({
30937
31443
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindIp") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
30938
31444
  className: "daw-input",
30939
31445
  value: listenerHost,
30940
- disabled: status?.remoteEnabled,
31446
+ disabled: status?.remoteEnabled || managementLocked,
30941
31447
  spellCheck: false,
30942
31448
  placeholder: "0.0.0.0",
30943
31449
  onChange: (event) => setListenerHost(event.target.value)
@@ -30952,15 +31458,19 @@ window.__ModuleLoader__.load({
30952
31458
  max: 65535,
30953
31459
  inputMode: "numeric",
30954
31460
  value: listenerPort,
30955
- disabled: status?.remoteEnabled,
31461
+ disabled: status?.remoteEnabled || managementLocked,
30956
31462
  onChange: (event) => setListenerPort(event.target.value)
30957
31463
  })]
30958
31464
  }),
30959
31465
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30960
31466
  className: "daw-command",
30961
- disabled: status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
31467
+ disabled: managementLocked || status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
30962
31468
  onClick: () => {
30963
- props.api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(refresh).catch(setError);
31469
+ setError(void 0);
31470
+ props.api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(() => {
31471
+ notify("listenerSaved");
31472
+ return refresh();
31473
+ }).catch(setError);
30964
31474
  },
30965
31475
  children: t("saveListener")
30966
31476
  })
@@ -31008,19 +31518,33 @@ window.__ModuleLoader__.load({
31008
31518
  className: "daw-warning",
31009
31519
  children: t("tunnelSecurityWarning")
31010
31520
  }),
31011
- tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31521
+ tunnel?.errorKey !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31522
+ className: "daw-warning",
31523
+ role: "alert",
31524
+ children: t(tunnel.errorKey, tunnel.errorParams ?? {})
31525
+ }),
31526
+ tunnel?.errorKey === void 0 && tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31012
31527
  className: "daw-warning",
31528
+ role: "alert",
31013
31529
  children: tunnel.error
31014
31530
  }),
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
31531
+ tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31532
+ className: "daw-url-row",
31533
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31534
+ className: "daw-tunnel-url",
31535
+ children: tunnel.url
31536
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31537
+ text: tunnel.url,
31538
+ title: t("copyUrl"),
31539
+ copiedText: t("copied"),
31540
+ manualText: t("copyManual"),
31541
+ copyText
31542
+ })]
31019
31543
  }),
31020
31544
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31021
31545
  className: "daw-command primary",
31022
31546
  style: { marginTop: 10 },
31023
- disabled: tunnelBusy || roots.length === 0,
31547
+ disabled: tunnelBusy || tunnel?.running !== true && roots.length === 0,
31024
31548
  onClick: () => {
31025
31549
  if (tunnel?.running === true) stopTunnel();
31026
31550
  else createTunnel();
@@ -31033,31 +31557,39 @@ window.__ModuleLoader__.load({
31033
31557
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31034
31558
  className: "daw-progress-track",
31035
31559
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31036
- className: "daw-progress-fill",
31037
- style: { width: pct(activity) }
31560
+ className: `daw-progress-fill${activity.percent === null ? " indeterminate" : ""}`,
31561
+ style: activity.percent === null ? void 0 : { width: `${activity.percent}%` }
31038
31562
  })
31039
31563
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31040
31564
  className: "daw-list-meta",
31041
31565
  style: { marginTop: 4 },
31042
- children: activity.label
31566
+ children: activity.labelKey === "" ? "" : t(activity.labelKey, activity.labelParams ?? {})
31043
31567
  })]
31044
31568
  })
31045
31569
  ]
31046
31570
  }),
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))
31571
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("fieldset", {
31572
+ disabled: managementLocked,
31573
+ style: {
31574
+ border: "none",
31575
+ margin: 0,
31576
+ padding: 0
31577
+ },
31578
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31579
+ className: "daw-scope-grid",
31580
+ children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31581
+ className: "daw-check",
31582
+ children: [
31583
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31584
+ type: "checkbox",
31585
+ checked: grantedScopes.includes(scope),
31586
+ onChange: () => toggleScope(scope)
31587
+ }),
31588
+ " ",
31589
+ scopeLabel(locale, scope)
31590
+ ]
31591
+ }, scope))
31592
+ })
31061
31593
  }),
31062
31594
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31063
31595
  className: "daw-list-meta",
@@ -31065,30 +31597,44 @@ window.__ModuleLoader__.load({
31065
31597
  }),
31066
31598
  status?.remoteEnabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31067
31599
  className: "daw-command danger",
31600
+ disabled: managementLocked,
31601
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31068
31602
  onClick: () => {
31069
- props.api.disableRemote().then((result) => poll(result.id)).then(refresh).catch(setError);
31603
+ if (!window.confirm(t("disableRemoteConfirm"))) return;
31604
+ setError(void 0);
31605
+ props.api.disableRemote().then((result) => poll(result.id)).then(() => {
31606
+ notify("remoteDisabled");
31607
+ return refresh();
31608
+ }).catch(setError);
31070
31609
  },
31071
31610
  children: t("disableRemote")
31072
31611
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31073
31612
  className: "daw-command primary",
31074
- disabled: roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31613
+ disabled: managementLocked || roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31075
31614
  onClick: () => {
31615
+ setError(void 0);
31076
31616
  props.api.enableRemote(roots.map((root) => root.id), grantedScopes).then((result) => poll(result.id)).catch(setError);
31077
31617
  },
31078
31618
  children: t("enablePairing")
31079
31619
  }),
31080
- status?.remoteEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31620
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31081
31621
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31082
31622
  className: "daw-command daw-inline-command",
31083
- disabled: grantedScopes.length === 0,
31623
+ disabled: managementLocked || grantedScopes.length === 0,
31624
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31084
31625
  onClick: () => {
31085
- props.api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => setPairing(value)).catch(setError);
31626
+ setError(void 0);
31627
+ props.api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => {
31628
+ setPairing(value);
31629
+ notify("pairingCreated");
31630
+ }).catch(setError);
31086
31631
  },
31087
31632
  children: t("newPairingCode")
31088
31633
  }),
31089
31634
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31090
31635
  className: "daw-command daw-inline-command",
31091
- disabled: configTextBusy,
31636
+ disabled: managementLocked || configTextBusy,
31637
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31092
31638
  onClick: () => {
31093
31639
  const manual = configEndpoint.trim();
31094
31640
  setConfigTextBusy(true);
@@ -31101,7 +31647,7 @@ window.__ModuleLoader__.load({
31101
31647
  }] }
31102
31648
  }).then((value) => {
31103
31649
  setConfigText(value);
31104
- return refresh();
31650
+ notify("configCreated");
31105
31651
  }).catch(setError).finally(() => setConfigTextBusy(false));
31106
31652
  },
31107
31653
  children: t("newConfigText")
@@ -31110,21 +31656,34 @@ window.__ModuleLoader__.load({
31110
31656
  className: "daw-command daw-inline-command",
31111
31657
  disabled: configTextBusy || tunnelBusy,
31112
31658
  onClick: () => {
31113
- if (tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0) genTunnelConfig(tunnel.url);
31114
- else createTunnel();
31659
+ tunnelConfigClick();
31115
31660
  },
31116
- children: tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0 ? t("tunnelGenConfigReady") : t("tunnelGenConfigOff")
31661
+ children: tunnel.running !== true ? t("tunnelStartAndGenConfig") : t("tunnelRestartAndGenConfig")
31117
31662
  }),
31118
31663
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31119
31664
  className: "daw-list-meta daw-listener-hint",
31120
31665
  children: t("configTextScopeNote")
31121
31666
  })
31122
31667
  ] }),
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", {
31668
+ pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31669
+ className: "daw-code-row",
31670
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31671
+ className: "daw-code",
31672
+ children: pairing.code
31673
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31674
+ className: "daw-copy-inline",
31675
+ text: pairing.code,
31676
+ title: t("copyPairing"),
31677
+ copiedText: t("copied"),
31678
+ manualText: t("copyManual"),
31679
+ copyText
31680
+ })]
31681
+ }), pairing.expiresAt !== void 0 && pairing.expiresAt > now && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31682
+ className: "daw-list-meta",
31683
+ style: { marginTop: -4 },
31684
+ children: t("pairingExpiresAt", { minutes: pairingMinutesLeft(pairing.expiresAt, now) })
31685
+ })] }),
31686
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31128
31687
  style: {
31129
31688
  marginTop: 12,
31130
31689
  maxWidth: 560
@@ -31134,6 +31693,7 @@ window.__ModuleLoader__.load({
31134
31693
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("configTextManual") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31135
31694
  className: "daw-input",
31136
31695
  value: configEndpoint,
31696
+ disabled: managementLocked,
31137
31697
  spellCheck: false,
31138
31698
  placeholder: t("configTextManualPlaceholder"),
31139
31699
  onChange: (event) => setConfigEndpoint(event.target.value)
@@ -31160,7 +31720,8 @@ window.__ModuleLoader__.load({
31160
31720
  device,
31161
31721
  roots,
31162
31722
  onChanged: refresh,
31163
- onError: setError
31723
+ onError: setError,
31724
+ onNotice: notify
31164
31725
  }, device.id))
31165
31726
  })
31166
31727
  }, "devices"),
@@ -31264,7 +31825,10 @@ window.__ModuleLoader__.load({
31264
31825
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31265
31826
  className: "daw-list-meta",
31266
31827
  children: t("pluginTrashHint")
31267
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31828
+ }), trash.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31829
+ className: "daw-list-meta daw-empty",
31830
+ children: t("trashEmpty")
31831
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31268
31832
  className: "daw-list",
31269
31833
  children: trash.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31270
31834
  className: "daw-list-row",
@@ -31283,14 +31847,22 @@ window.__ModuleLoader__.load({
31283
31847
  })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31284
31848
  className: "daw-command",
31285
31849
  onClick: () => {
31286
- props.api.restoreTrash(item.id).then(refresh).catch(setError);
31850
+ setError(void 0);
31851
+ props.api.restoreTrash(item.id).then(() => {
31852
+ notify("trashRestored");
31853
+ return refresh();
31854
+ }).catch(setError);
31287
31855
  },
31288
31856
  children: t("restore")
31289
31857
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31290
31858
  className: "daw-icon danger",
31291
31859
  title: t("deletePermanently"),
31860
+ "aria-label": t("deletePermanently"),
31292
31861
  onClick: () => {
31293
- if (confirm(t("purgeConfirm"))) props.api.purgeTrash(item.id).then(refresh).catch(setError);
31862
+ if (confirm(t("purgeConfirm"))) props.api.purgeTrash(item.id).then(() => {
31863
+ notify("trashPurged");
31864
+ return refresh();
31865
+ }).catch(setError);
31294
31866
  },
31295
31867
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31296
31868
  })] })]
@@ -31303,9 +31875,12 @@ window.__ModuleLoader__.load({
31303
31875
  descKey: tabDescKey("audit"),
31304
31876
  open: isOpen("audit"),
31305
31877
  onToggle: () => toggleCard("audit"),
31306
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31878
+ children: audit.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31879
+ className: "daw-list-meta daw-empty",
31880
+ children: t("auditEmpty")
31881
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31307
31882
  className: "daw-list",
31308
- children: audit.map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31883
+ children: audit.slice(0, auditLimit).map((item, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31309
31884
  className: "daw-list-row",
31310
31885
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31311
31886
  className: "daw-list-title",
@@ -31323,7 +31898,18 @@ window.__ModuleLoader__.load({
31323
31898
  ]
31324
31899
  })] })
31325
31900
  }, String(item.id ?? index)))
31326
- })
31901
+ }), auditLimit < audit.length && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31902
+ className: "daw-command daw-inline-command",
31903
+ disabled: auditLoading,
31904
+ onClick: () => {
31905
+ setAuditLoading(true);
31906
+ window.setTimeout(() => {
31907
+ setAuditLimit((value) => value + 50);
31908
+ setAuditLoading(false);
31909
+ }, 0);
31910
+ },
31911
+ children: auditLoading ? t("auditLoading") : t("auditLoadMore")
31912
+ })] })
31327
31913
  }, "audit"),
31328
31914
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AppDownloadSection, {
31329
31915
  open: isOpen("app-download"),
@@ -31383,13 +31969,6 @@ window.__ModuleLoader__.load({
31383
31969
  });
31384
31970
  }
31385
31971
  /**
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
31972
  * Shows a freshly minted `DSH1:` config text.
31394
31973
  *
31395
31974
  * The text is a credential carrier, so this panel keeps the "it is a key" reminder visible and lists
@@ -31520,6 +32099,8 @@ window.__ModuleLoader__.load({
31520
32099
  const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
31521
32100
  const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
31522
32101
  const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
32102
+ const shortId = props.device.id.length > 14 ? `${props.device.id.slice(0, 8)}…${props.device.id.slice(-4)}` : props.device.id;
32103
+ const lastSeen = props.device.lastSeenAt === void 0 ? t("deviceNeverSeen") : t("deviceLastSeen", { time: new Date(props.device.lastSeenAt).toLocaleString(locale) });
31523
32104
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31524
32105
  className: "daw-list-row",
31525
32106
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
@@ -31537,7 +32118,12 @@ window.__ModuleLoader__.load({
31537
32118
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31538
32119
  className: "daw-list-meta",
31539
32120
  children: [
31540
- props.device.id,
32121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32122
+ title: props.device.id,
32123
+ children: shortId
32124
+ }),
32125
+ " · ",
32126
+ lastSeen,
31541
32127
  " · ",
31542
32128
  props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
31543
32129
  ]
@@ -31567,7 +32153,10 @@ window.__ModuleLoader__.load({
31567
32153
  editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31568
32154
  className: "daw-command primary",
31569
32155
  onClick: () => {
31570
- props.api.updateDevice(props.device.id, scopes, rootIds).then(props.onChanged).then(() => setEditing(false)).catch(props.onError);
32156
+ props.api.updateDevice(props.device.id, scopes, rootIds).then(props.onChanged).then(() => {
32157
+ setEditing(false);
32158
+ props.onNotice("permissionsSaved");
32159
+ }).catch(props.onError);
31571
32160
  },
31572
32161
  children: t("save")
31573
32162
  }),
@@ -31579,8 +32168,10 @@ window.__ModuleLoader__.load({
31579
32168
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31580
32169
  className: "daw-icon danger",
31581
32170
  title: t("revokeDevice"),
32171
+ "aria-label": t("revokeDevice"),
31582
32172
  onClick: () => {
31583
- props.api.revokeDevice(props.device.id).then(props.onChanged).catch(props.onError);
32173
+ if (!window.confirm(t("revokeConfirm", { name: props.device.name }))) return;
32174
+ props.api.revokeDevice(props.device.id).then(props.onChanged).then(() => props.onNotice("deviceRevoked")).catch(props.onError);
31584
32175
  },
31585
32176
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31586
32177
  })
@@ -31592,6 +32183,7 @@ window.__ModuleLoader__.load({
31592
32183
  if (tab === "devices") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Smartphone, { size: 15 });
31593
32184
  if (tab === "image") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Image, { size: 15 });
31594
32185
  if (tab === "trash") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 });
32186
+ if (tab === "audit") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.ScrollText, { size: 15 });
31595
32187
  if (tab === "remote") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Upload, { size: 15 });
31596
32188
  if (tab === "app-download") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Download, { size: 15 });
31597
32189
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.File, { size: 15 });