dsh-remote-bridge 2.5.0 → 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": {
@@ -30231,6 +29956,54 @@ window.__ModuleLoader__.load({
30231
29956
  errorNotFound: "请求的文件或资源不存在。",
30232
29957
  errorConflict: "目标已存在或当前状态发生冲突。",
30233
29958
  errorRateLimited: "请求过于频繁,请稍后重试。",
29959
+ errorTunnelModeAdminDisabled: "云隧道正在运行,管理面已停用。请先在本机面板里停止隧道,或直接在本机 WebUI 操作。",
29960
+ errorAdminProxyForwarded: "管理面不接受经代理转发的请求。请在本机用 127.0.0.1 或 localhost 直接打开。",
29961
+ errorAdminHostNotLoopback: "管理面只在本机回环地址下可用。请用 127.0.0.1 或 localhost 打开本页。",
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: "下一张",
30234
30007
  author: "作者 Github@Hakunm",
30235
30008
  version: "当前版本"
30236
30009
  },
@@ -30317,7 +30090,7 @@ window.__ModuleLoader__.load({
30317
30090
  removeRoot: "Remove root",
30318
30091
  builtinRootBadge: "Default",
30319
30092
  builtinRootHint: "Default directory · cannot be removed",
30320
- 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.",
30321
30094
  errorRootBuiltin: "This is the current DSH working directory, so it cannot be removed. You can still add other roots above.",
30322
30095
  scopeChatRead: "Read chat",
30323
30096
  scopeChatWrite: "Send chat",
@@ -30403,6 +30176,54 @@ window.__ModuleLoader__.load({
30403
30176
  errorNotFound: "The requested file or resource was not found.",
30404
30177
  errorConflict: "The target already exists or its state has changed.",
30405
30178
  errorRateLimited: "Too many requests. Try again shortly.",
30179
+ errorTunnelModeAdminDisabled: "The Cloudflare tunnel is running, so management endpoints are disabled. Stop the tunnel in the local panel, or use the local WebUI.",
30180
+ errorAdminProxyForwarded: "Management endpoints do not accept proxied requests. Open them locally via 127.0.0.1 or localhost.",
30181
+ errorAdminHostNotLoopback: "Management endpoints are available only under a loopback address. Open this page via 127.0.0.1 or localhost.",
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",
30406
30227
  author: "Author Github@Hakunm",
30407
30228
  version: "Current version"
30408
30229
  }
@@ -30412,76 +30233,553 @@ window.__ModuleLoader__.load({
30412
30233
  function getWorkspaceLocale() {
30413
30234
  return currentLocale;
30414
30235
  }
30415
- function setWorkspaceLocale(locale) {
30416
- if (locale === currentLocale) return;
30417
- 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;
30418
30678
  try {
30419
- globalThis.localStorage?.setItem(STORAGE_KEY, locale);
30420
- } catch {}
30421
- for (const listener of listeners) listener();
30422
- }
30423
- function subscribeWorkspaceLocale(listener) {
30424
- listeners.add(listener);
30425
- 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;
30426
30703
  }
30427
30704
  /**
30428
- * 取一条文案并填占位符。
30429
- *
30430
- * **缺键不抛异常**。原来这里是 `dictionaries[locale][key].replace(...)`:任何一个不存在的键
30431
- * 都会抛 TypeError,而它在 React 渲染路径上 —— 结果不是那一行显示不出原文,而是**整个设置
30432
- * 面板白屏**。缺一个描述行就把七张卡里的全部设置都搭进去,代价和收益完全不成比例。
30705
+ * Turn 尾部的图集:本轮每条交接消息各自那张图。
30433
30706
  *
30434
- * 降级成「回退到另一种语言 → 再回退成键名本身」:最坏情况是某一行露出 `rootsTabDesc`
30435
- * 这样的字面量,一眼能看出该补哪条,而不是面对一个空白面板。
30707
+ * 没有会话 id 就什么都不显示 —— 路由需要它来定位目录,猜一个只会 404。
30436
30708
  */
30437
- function translate(locale, key, values = {}) {
30438
- const fill = (template) => template?.replace(/\{(\w+)\}/g, (_, name) => String(values[name] ?? `{${name}}`));
30439
- const primary = fill(dictionaries[locale][key]);
30440
- if (primary !== void 0) return primary;
30441
- const text = fill(dictionaries["zh-CN"][key]) ?? key;
30442
- console.warn(`[dsh-remote-bridge] 缺少文案键 ${String(key)}(locale=${locale}),已降级显示键名`);
30443
- 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] });
30444
30737
  }
30445
30738
  /**
30446
- * 权限码 → 人话。
30739
+ * 一条「交接消息 → 一个图集节点」的 Definition。
30447
30740
  *
30448
- * 用户要求「中文状态下显示中文,英文显示英文」。文案刻意**用人话而不是协议码**:
30449
- * `读文件` / `写文件` / `删文件` 自己就看得懂,而 `files.read` 要对照文档才知道指什么 ——
30450
- * 出问题时用户把屏幕上的字念出来,比报一串码有用得多。
30741
+ * 只匹配 `user/message`:交接说明属于读者自己发的那条消息。助手正文、上下文注入、
30742
+ * 工具输出里到处都是引用 `.dsh-images/…` 的文本,读它们会把别人的话切成碎图。
30451
30743
  *
30452
- * 键名 `scope${码}`(`chat.read` → `scopeChatRead`):与既有 `rootsTab` 一致的拼法。
30453
- * 用**显式查表**而不是模板拼 —— 拼错会查不到键,而键查不到时 `translate` 会静默降级成
30454
- * 露出键名(见上面),界面上会突然冒出一行 `scopeChatread`。白屏那次就是这么来的。
30744
+ * 节点自身**不注册渲染器**(Turn 尾负责显示),所以过程分组里不会多出一行、
30745
+ * verbose 模式下也不会重复一次。
30455
30746
  */
30456
- const SCOPE_LABEL_KEYS = {
30457
- "chat.read": "scopeChatRead",
30458
- "chat.write": "scopeChatWrite",
30459
- "files.read": "scopeFilesRead",
30460
- "files.write": "scopeFilesWrite",
30461
- "files.delete": "scopeFilesDelete",
30462
- "settings.read": "scopeSettingsRead",
30463
- "settings.write": "scopeSettingsWrite"
30464
- };
30465
- function scopeLabel(locale, scope) {
30466
- const key = SCOPE_LABEL_KEYS[scope];
30467
- if (key === void 0) return scope;
30468
- return translate(locale, key);
30469
- }
30470
- function useWorkspaceI18n() {
30471
- const locale = (0, react.useSyncExternalStore)(subscribeWorkspaceLocale, getWorkspaceLocale, () => "zh-CN");
30472
- return {
30473
- locale,
30474
- t: (0, react.useCallback)((key, values) => translate(locale, key, values), [locale]),
30475
- toggle: (0, react.useCallback)(() => setWorkspaceLocale(locale === "zh-CN" ? "en" : "zh-CN"), [locale])
30476
- };
30477
- }
30478
- function readStoredLocale() {
30479
- try {
30480
- return globalThis.localStorage?.getItem(STORAGE_KEY) === "en" ? "en" : "zh-CN";
30481
- } catch {
30482
- 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
+ };
30483
30781
  }
30484
- }
30782
+ };
30485
30783
  //#endregion
30486
30784
  //#region src/client/styles.ts
30487
30785
  const STYLE_ID = "dsh-workspace-style";
@@ -30497,16 +30795,33 @@ window.__ModuleLoader__.load({
30497
30795
  * 类型检查、单测、构建全都发现不了,只有肉眼看渲染或量计算样式才抓得到。
30498
30796
  */
30499
30797
  const WORKSPACE_CSS = `
30500
- .daw-version{font:600 11px ui-monospace,SFMono-Regular,Consolas,monospace;color:var(--daw-muted);white-space:nowrap}
30501
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}
30502
- .daw-root *{box-sizing:border-box;letter-spacing:0}
30503
- .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}
30504
30806
  .daw-icon:hover{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}
30505
30807
  .daw-icon.danger{color:var(--daw-danger);border-color:transparent;background:transparent}
30506
30808
  .daw-icon:disabled{opacity:.45;cursor:not-allowed}
30507
- .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}
30508
- .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}
30509
- .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}
30510
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}
30511
30826
  .daw-card:hover{border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
30512
30827
  .daw-card-open{background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
@@ -30521,7 +30836,17 @@ window.__ModuleLoader__.load({
30521
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}
30522
30837
  .daw-list-title .daw-pill{margin-left:8px;vertical-align:middle}
30523
30838
  .daw-builtin-hint{text-align:right;align-self:center}
30524
- .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}}
30525
30850
  `;
30526
30851
  function installWorkspaceStyles() {
30527
30852
  if (document.getElementById(STYLE_ID) !== null) return;
@@ -30558,7 +30883,6 @@ window.__ModuleLoader__.load({
30558
30883
  }];
30559
30884
  //#endregion
30560
30885
  //#region src/client/workspace.tsx
30561
- var import_lucide_react = require_lucide_react();
30562
30886
  /** What the settings-page launcher opens: authorized roots, the first section. */
30563
30887
  const DEFAULT_PANEL_SECTION = "roots";
30564
30888
  /**
@@ -30571,9 +30895,37 @@ window.__ModuleLoader__.load({
30571
30895
  function tabLabelKey(tab) {
30572
30896
  return tab === "app-download" ? "appDownloadTab" : `${tab}Tab`;
30573
30897
  }
30574
- /** 云隧道进度条宽度:有确定百分比则用之,否则给 30% 作「流动中」占位。 */
30575
- function pct(a) {
30576
- 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
+ });
30577
30929
  }
30578
30930
  /**
30579
30931
  * 卡片头部的描述行。与 `tabLabelKey` 同理:唯一含连字符的分区显式映射。
@@ -30713,9 +31065,52 @@ window.__ModuleLoader__.load({
30713
31065
  const [openCards, setOpenCards] = (0, react.useState)(() => /* @__PURE__ */ new Set([props.initialOpen]));
30714
31066
  const [imageSaved, setImageSaved] = (0, react.useState)(false);
30715
31067
  const [error, setError] = (0, react.useState)();
31068
+ const [notice, setNotice] = (0, react.useState)();
30716
31069
  const [tunnel, setTunnel] = (0, react.useState)();
30717
31070
  const [tunnelBusy, setTunnelBusy] = (0, react.useState)(false);
30718
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
+ }, []);
30719
31114
  const isOpen = (0, react.useCallback)((card) => openCards.has(card), [openCards]);
30720
31115
  const toggleCard = (0, react.useCallback)((card) => {
30721
31116
  setOpenCards((previous) => {
@@ -30725,6 +31120,9 @@ window.__ModuleLoader__.load({
30725
31120
  });
30726
31121
  }, []);
30727
31122
  const refresh = (0, react.useCallback)(async () => {
31123
+ try {
31124
+ setTunnel(await props.api.tunnelStatus());
31125
+ } catch {}
30728
31126
  try {
30729
31127
  const next = await props.api.status();
30730
31128
  setStatus(next);
@@ -30736,49 +31134,157 @@ window.__ModuleLoader__.load({
30736
31134
  setImageDraft(settings.subdir);
30737
31135
  setImageSaved(false);
30738
31136
  }
30739
- if (openCards.has("remote")) setTunnel(await props.api.tunnelStatus());
30740
- setError(void 0);
30741
31137
  } catch (cause) {
30742
- setError(cause);
31138
+ if (!(cause instanceof WorkspaceApiError && cause.status === 403 && (cause.code === "TUNNEL_MODE_ADMIN_DISABLED" || cause.code === "ADMIN_PROXY_FORWARDED"))) setError(cause);
30743
31139
  }
30744
31140
  }, [props.api, openCards]);
30745
- 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 = {}) => {
30746
31194
  if (!window.confirm(t("tunnelStartConfirm"))) return;
31195
+ const wantConfig = opts.thenGenConfig === true;
31196
+ let alive = true;
30747
31197
  setTunnelBusy(true);
30748
31198
  setError(void 0);
30749
- setActivity(void 0);
31199
+ setConfigText(void 0);
30750
31200
  setActivity({
30751
31201
  stage: "locating",
30752
- label: "正在准备 cloudflared…",
31202
+ labelKey: "tunnelStageLocating",
30753
31203
  percent: null
30754
31204
  });
30755
- const poll = window.setInterval(() => {
30756
- props.api.tunnelActivity().then((a) => {
30757
- if (a.stage !== "idle") setActivity(a);
30758
- }).catch(() => {});
30759
- }, 350);
30760
- props.api.tunnelCreate().then(async (next) => {
30761
- 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);
30762
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);
30763
31219
  await refresh();
30764
- }).catch(setError).finally(() => {
30765
- 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;
30766
31230
  setActivity(void 0);
30767
31231
  setTunnelBusy(false);
30768
- });
31232
+ }
30769
31233
  }, [
30770
31234
  props.api,
30771
31235
  refresh,
30772
- t
30773
- ]);
30774
- 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;
30775
31246
  setTunnelBusy(true);
30776
31247
  setError(void 0);
30777
- props.api.tunnelStop().then(async (next) => {
31248
+ try {
31249
+ const next = await props.api.tunnelStop();
30778
31250
  setTunnel(next);
30779
31251
  await refresh();
30780
- }).catch(setError).finally(() => setTunnelBusy(false));
30781
- }, [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
+ ]);
30782
31288
  (0, react.useEffect)(() => {
30783
31289
  refresh();
30784
31290
  }, [refresh]);
@@ -30788,25 +31294,7 @@ window.__ModuleLoader__.load({
30788
31294
  setListenerPort(String(status.configuredPort));
30789
31295
  }, [status?.configuredHost, status?.configuredPort]);
30790
31296
  const roots = status?.roots ?? [];
30791
- const genTunnelConfig = (0, react.useCallback)((baseUrl) => {
30792
- setConfigTextBusy(true);
30793
- setError(void 0);
30794
- props.api.createConfigText({
30795
- ...roots.length === 0 ? {} : { rootIds: roots.map((root) => root.id) },
30796
- endpoints: [{
30797
- label: t("tunnelEndpointLabel"),
30798
- baseUrl
30799
- }]
30800
- }).then((value) => {
30801
- setConfigText(value);
30802
- return refresh();
30803
- }).catch(setError).finally(() => setConfigTextBusy(false));
30804
- }, [
30805
- props.api,
30806
- refresh,
30807
- roots,
30808
- t
30809
- ]);
31297
+ const managementLocked = tunnel?.running === true;
30810
31298
  const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEVICE_SCOPES]);
30811
31299
  const toggleScope = (0, react.useCallback)((scope) => {
30812
31300
  setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
@@ -30835,10 +31323,25 @@ window.__ModuleLoader__.load({
30835
31323
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
30836
31324
  className: "daw-admin",
30837
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
+ }),
30838
31330
  error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30839
31331
  className: "daw-warning",
31332
+ role: "alert",
30840
31333
  children: messageOf(error, t)
30841
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
+ }),
30842
31345
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
30843
31346
  tab: "roots",
30844
31347
  nameKey: tabLabelKey("roots"),
@@ -30863,10 +31366,14 @@ window.__ModuleLoader__.load({
30863
31366
  }),
30864
31367
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30865
31368
  className: "daw-command primary",
31369
+ disabled: managementLocked,
31370
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
30866
31371
  onClick: () => {
31372
+ setError(void 0);
30867
31373
  props.api.addRoot(path, label || void 0).then(() => {
30868
31374
  setPath("");
30869
31375
  setLabel("");
31376
+ notify("rootAdded");
30870
31377
  return refresh();
30871
31378
  }).catch(setError);
30872
31379
  },
@@ -30893,8 +31400,15 @@ window.__ModuleLoader__.load({
30893
31400
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30894
31401
  className: "daw-icon danger",
30895
31402
  title: t("removeRoot"),
31403
+ "aria-label": t("removeRoot"),
31404
+ disabled: managementLocked,
30896
31405
  onClick: () => {
30897
- 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);
30898
31412
  },
30899
31413
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
30900
31414
  })]
@@ -30917,7 +31431,7 @@ window.__ModuleLoader__.load({
30917
31431
  className: "daw-warning",
30918
31432
  children: t("httpWarning")
30919
31433
  }),
30920
- roots.length === 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31434
+ roots.length === 0 && !managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
30921
31435
  className: "daw-warning",
30922
31436
  children: t("addRootBeforeRemote")
30923
31437
  }),
@@ -30929,7 +31443,7 @@ window.__ModuleLoader__.load({
30929
31443
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindIp") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
30930
31444
  className: "daw-input",
30931
31445
  value: listenerHost,
30932
- disabled: status?.remoteEnabled,
31446
+ disabled: status?.remoteEnabled || managementLocked,
30933
31447
  spellCheck: false,
30934
31448
  placeholder: "0.0.0.0",
30935
31449
  onChange: (event) => setListenerHost(event.target.value)
@@ -30944,15 +31458,19 @@ window.__ModuleLoader__.load({
30944
31458
  max: 65535,
30945
31459
  inputMode: "numeric",
30946
31460
  value: listenerPort,
30947
- disabled: status?.remoteEnabled,
31461
+ disabled: status?.remoteEnabled || managementLocked,
30948
31462
  onChange: (event) => setListenerPort(event.target.value)
30949
31463
  })]
30950
31464
  }),
30951
31465
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
30952
31466
  className: "daw-command",
30953
- disabled: status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
31467
+ disabled: managementLocked || status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
30954
31468
  onClick: () => {
30955
- 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);
30956
31474
  },
30957
31475
  children: t("saveListener")
30958
31476
  })
@@ -31000,19 +31518,33 @@ window.__ModuleLoader__.load({
31000
31518
  className: "daw-warning",
31001
31519
  children: t("tunnelSecurityWarning")
31002
31520
  }),
31003
- 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", {
31004
31527
  className: "daw-warning",
31528
+ role: "alert",
31005
31529
  children: tunnel.error
31006
31530
  }),
31007
- tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31008
- className: "daw-tunnel-url",
31009
- style: { marginTop: 8 },
31010
- 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
+ })]
31011
31543
  }),
31012
31544
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31013
31545
  className: "daw-command primary",
31014
31546
  style: { marginTop: 10 },
31015
- disabled: tunnelBusy || roots.length === 0,
31547
+ disabled: tunnelBusy || tunnel?.running !== true && roots.length === 0,
31016
31548
  onClick: () => {
31017
31549
  if (tunnel?.running === true) stopTunnel();
31018
31550
  else createTunnel();
@@ -31025,31 +31557,39 @@ window.__ModuleLoader__.load({
31025
31557
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31026
31558
  className: "daw-progress-track",
31027
31559
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31028
- className: "daw-progress-fill",
31029
- style: { width: pct(activity) }
31560
+ className: `daw-progress-fill${activity.percent === null ? " indeterminate" : ""}`,
31561
+ style: activity.percent === null ? void 0 : { width: `${activity.percent}%` }
31030
31562
  })
31031
31563
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31032
31564
  className: "daw-list-meta",
31033
31565
  style: { marginTop: 4 },
31034
- children: activity.label
31566
+ children: activity.labelKey === "" ? "" : t(activity.labelKey, activity.labelParams ?? {})
31035
31567
  })]
31036
31568
  })
31037
31569
  ]
31038
31570
  }),
31039
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31040
- className: "daw-scope-grid",
31041
- children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31042
- className: "daw-check",
31043
- children: [
31044
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31045
- type: "checkbox",
31046
- checked: grantedScopes.includes(scope),
31047
- onChange: () => toggleScope(scope)
31048
- }),
31049
- " ",
31050
- scopeLabel(locale, scope)
31051
- ]
31052
- }, 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
+ })
31053
31593
  }),
31054
31594
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31055
31595
  className: "daw-list-meta",
@@ -31057,30 +31597,44 @@ window.__ModuleLoader__.load({
31057
31597
  }),
31058
31598
  status?.remoteEnabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31059
31599
  className: "daw-command danger",
31600
+ disabled: managementLocked,
31601
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31060
31602
  onClick: () => {
31061
- 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);
31062
31609
  },
31063
31610
  children: t("disableRemote")
31064
31611
  }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31065
31612
  className: "daw-command primary",
31066
- disabled: roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31613
+ disabled: managementLocked || roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31067
31614
  onClick: () => {
31615
+ setError(void 0);
31068
31616
  props.api.enableRemote(roots.map((root) => root.id), grantedScopes).then((result) => poll(result.id)).catch(setError);
31069
31617
  },
31070
31618
  children: t("enablePairing")
31071
31619
  }),
31072
- 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: [
31073
31621
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31074
31622
  className: "daw-command daw-inline-command",
31075
- disabled: grantedScopes.length === 0,
31623
+ disabled: managementLocked || grantedScopes.length === 0,
31624
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31076
31625
  onClick: () => {
31077
- 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);
31078
31631
  },
31079
31632
  children: t("newPairingCode")
31080
31633
  }),
31081
31634
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31082
31635
  className: "daw-command daw-inline-command",
31083
- disabled: configTextBusy,
31636
+ disabled: managementLocked || configTextBusy,
31637
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31084
31638
  onClick: () => {
31085
31639
  const manual = configEndpoint.trim();
31086
31640
  setConfigTextBusy(true);
@@ -31093,7 +31647,7 @@ window.__ModuleLoader__.load({
31093
31647
  }] }
31094
31648
  }).then((value) => {
31095
31649
  setConfigText(value);
31096
- return refresh();
31650
+ notify("configCreated");
31097
31651
  }).catch(setError).finally(() => setConfigTextBusy(false));
31098
31652
  },
31099
31653
  children: t("newConfigText")
@@ -31102,21 +31656,34 @@ window.__ModuleLoader__.load({
31102
31656
  className: "daw-command daw-inline-command",
31103
31657
  disabled: configTextBusy || tunnelBusy,
31104
31658
  onClick: () => {
31105
- if (tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0) genTunnelConfig(tunnel.url);
31106
- else createTunnel();
31659
+ tunnelConfigClick();
31107
31660
  },
31108
- children: tunnel.running === true && tunnel.url !== null && tunnel.url !== void 0 ? t("tunnelGenConfigReady") : t("tunnelGenConfigOff")
31661
+ children: tunnel.running !== true ? t("tunnelStartAndGenConfig") : t("tunnelRestartAndGenConfig")
31109
31662
  }),
31110
31663
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31111
31664
  className: "daw-list-meta daw-listener-hint",
31112
31665
  children: t("configTextScopeNote")
31113
31666
  })
31114
31667
  ] }),
31115
- pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31116
- className: "daw-code",
31117
- children: pairing.code
31118
- }),
31119
- 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", {
31120
31687
  style: {
31121
31688
  marginTop: 12,
31122
31689
  maxWidth: 560
@@ -31126,6 +31693,7 @@ window.__ModuleLoader__.load({
31126
31693
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("configTextManual") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31127
31694
  className: "daw-input",
31128
31695
  value: configEndpoint,
31696
+ disabled: managementLocked,
31129
31697
  spellCheck: false,
31130
31698
  placeholder: t("configTextManualPlaceholder"),
31131
31699
  onChange: (event) => setConfigEndpoint(event.target.value)
@@ -31152,7 +31720,8 @@ window.__ModuleLoader__.load({
31152
31720
  device,
31153
31721
  roots,
31154
31722
  onChanged: refresh,
31155
- onError: setError
31723
+ onError: setError,
31724
+ onNotice: notify
31156
31725
  }, device.id))
31157
31726
  })
31158
31727
  }, "devices"),
@@ -31256,7 +31825,10 @@ window.__ModuleLoader__.load({
31256
31825
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31257
31826
  className: "daw-list-meta",
31258
31827
  children: t("pluginTrashHint")
31259
- }), /* @__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", {
31260
31832
  className: "daw-list",
31261
31833
  children: trash.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31262
31834
  className: "daw-list-row",
@@ -31275,14 +31847,22 @@ window.__ModuleLoader__.load({
31275
31847
  })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31276
31848
  className: "daw-command",
31277
31849
  onClick: () => {
31278
- 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);
31279
31855
  },
31280
31856
  children: t("restore")
31281
31857
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31282
31858
  className: "daw-icon danger",
31283
31859
  title: t("deletePermanently"),
31860
+ "aria-label": t("deletePermanently"),
31284
31861
  onClick: () => {
31285
- 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);
31286
31866
  },
31287
31867
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31288
31868
  })] })]
@@ -31295,9 +31875,12 @@ window.__ModuleLoader__.load({
31295
31875
  descKey: tabDescKey("audit"),
31296
31876
  open: isOpen("audit"),
31297
31877
  onToggle: () => toggleCard("audit"),
31298
- 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", {
31299
31882
  className: "daw-list",
31300
- 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", {
31301
31884
  className: "daw-list-row",
31302
31885
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31303
31886
  className: "daw-list-title",
@@ -31315,7 +31898,18 @@ window.__ModuleLoader__.load({
31315
31898
  ]
31316
31899
  })] })
31317
31900
  }, String(item.id ?? index)))
31318
- })
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
+ })] })
31319
31913
  }, "audit"),
31320
31914
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AppDownloadSection, {
31321
31915
  open: isOpen("app-download"),
@@ -31375,13 +31969,6 @@ window.__ModuleLoader__.load({
31375
31969
  });
31376
31970
  }
31377
31971
  /**
31378
- * Standalone settings overlay kept for the `/dsh-workspace` page (mobile WebView / direct link).
31379
- *
31380
- * The desktop WebUI does **not** use this: its settings live inline inside DSH's own settings page
31381
- * (`PhoneSettingsBody`), so a user never opens a dialog from inside a dialog. This one keeps its own
31382
- * shell because the standalone page has no settings container to render into.
31383
- */
31384
- /**
31385
31972
  * Shows a freshly minted `DSH1:` config text.
31386
31973
  *
31387
31974
  * The text is a credential carrier, so this panel keeps the "it is a key" reminder visible and lists
@@ -31512,6 +32099,8 @@ window.__ModuleLoader__.load({
31512
32099
  const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
31513
32100
  const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
31514
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) });
31515
32104
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31516
32105
  className: "daw-list-row",
31517
32106
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
@@ -31529,7 +32118,12 @@ window.__ModuleLoader__.load({
31529
32118
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31530
32119
  className: "daw-list-meta",
31531
32120
  children: [
31532
- props.device.id,
32121
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32122
+ title: props.device.id,
32123
+ children: shortId
32124
+ }),
32125
+ " · ",
32126
+ lastSeen,
31533
32127
  " · ",
31534
32128
  props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
31535
32129
  ]
@@ -31559,7 +32153,10 @@ window.__ModuleLoader__.load({
31559
32153
  editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31560
32154
  className: "daw-command primary",
31561
32155
  onClick: () => {
31562
- 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);
31563
32160
  },
31564
32161
  children: t("save")
31565
32162
  }),
@@ -31571,8 +32168,10 @@ window.__ModuleLoader__.load({
31571
32168
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31572
32169
  className: "daw-icon danger",
31573
32170
  title: t("revokeDevice"),
32171
+ "aria-label": t("revokeDevice"),
31574
32172
  onClick: () => {
31575
- 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);
31576
32175
  },
31577
32176
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31578
32177
  })
@@ -31584,6 +32183,7 @@ window.__ModuleLoader__.load({
31584
32183
  if (tab === "devices") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Smartphone, { size: 15 });
31585
32184
  if (tab === "image") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Image, { size: 15 });
31586
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 });
31587
32187
  if (tab === "remote") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Upload, { size: 15 });
31588
32188
  if (tab === "app-download") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Download, { size: 15 });
31589
32189
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.File, { size: 15 });
@@ -31614,6 +32214,10 @@ window.__ModuleLoader__.load({
31614
32214
  if (error.code === "LISTENER_PORT_INVALID") return "listenerPortInvalid";
31615
32215
  if (error.code === "REMOTE_ENABLED") return "disableToEditListener";
31616
32216
  if (error.code === "NO_ENDPOINT") return "configTextNoEndpoints";
32217
+ if (error.code === "TUNNEL_MODE_ADMIN_DISABLED") return "errorTunnelModeAdminDisabled";
32218
+ if (error.code === "ADMIN_PROXY_FORWARDED") return "errorAdminProxyForwarded";
32219
+ if (error.code === "ADMIN_HOST_NOT_LOOPBACK") return "errorAdminHostNotLoopback";
32220
+ if (error.code === "ADMIN_LOOPBACK_REQUIRED") return "errorAdminLoopbackRequired";
31617
32221
  if (error.code.includes("PATH") || error.code.includes("SYMLINK") || error.code.includes("REPARSE")) return "errorPathInvalid";
31618
32222
  if (error.status === 403) return "errorForbidden";
31619
32223
  if (error.status === 404) return "errorNotFound";