dsh-remote-bridge 2.9.0 → 2.9.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
@@ -8277,7 +8277,7 @@ window.__ModuleLoader__.load({
8277
8277
  key: "1quai1"
8278
8278
  }]
8279
8279
  ]);
8280
- createLucideIcon("Cloud", [["path", {
8280
+ const Cloud = createLucideIcon("Cloud", [["path", {
8281
8281
  d: "M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z",
8282
8282
  key: "p7xjir"
8283
8283
  }]]);
@@ -23958,7 +23958,7 @@ window.__ModuleLoader__.load({
23958
23958
  d: "M11 16.586V19a1 1 0 0 1-1 1H2L18.37 3.63a1 1 0 1 1 3 3l-9.663 9.663a1 1 0 0 1-1.414 0L8 14",
23959
23959
  key: "1sllp5"
23960
23960
  }]]);
23961
- createLucideIcon("SlidersHorizontal", [
23961
+ const SlidersHorizontal = createLucideIcon("SlidersHorizontal", [
23962
23962
  ["line", {
23963
23963
  x1: "21",
23964
23964
  x2: "14",
@@ -29825,6 +29825,7 @@ window.__ModuleLoader__.load({
29825
29825
  }]
29826
29826
  ]);
29827
29827
  exports.Check = Check;
29828
+ exports.Cloud = Cloud;
29828
29829
  exports.Copy = Copy;
29829
29830
  exports.Download = Download;
29830
29831
  exports.File = File;
@@ -29833,6 +29834,7 @@ window.__ModuleLoader__.load({
29833
29834
  exports.Languages = Languages;
29834
29835
  exports.Package = Package;
29835
29836
  exports.RefreshCw = RefreshCw;
29837
+ exports.SlidersHorizontal = SlidersHorizontal;
29836
29838
  exports.Smartphone = Smartphone;
29837
29839
  exports.Trash2 = Trash2;
29838
29840
  exports.Upload = Upload;
@@ -29894,17 +29896,21 @@ window.__ModuleLoader__.load({
29894
29896
  close: "关闭",
29895
29897
  rootsTab: "根目录",
29896
29898
  remoteTab: "远程访问",
29899
+ tunnelTab: "外网隧道",
29897
29900
  devicesTab: "设备",
29898
29901
  trashTab: "回收站",
29899
29902
  auditTab: "审计",
29900
29903
  imageTab: "识图",
29901
29904
  rootsTabDesc: "授权手机访问这台机器上的哪些目录。新增、命名、移除都在这里。",
29902
29905
  remoteTabDesc: "绑定 IP 与端口、开关远程访问、生成配对码与配置文本。",
29906
+ tunnelTabDesc: "人在外网时用:一键创建临时公网地址,把本机端口经 Cloudflare 边缘暴露出去。",
29903
29907
  devicesTabDesc: "查看已配对的手机,修改它们的权限范围与可访问目录,或吊销设备。",
29904
29908
  imageTabDesc: "设置图片交给识图工具的落盘目录名。",
29905
29909
  trashTabDesc: "经手机端删除的文件留在这里,可恢复或彻底清理。",
29906
29910
  auditTabDesc: "最近的操作记录:谁、在什么时间、动了哪个路径。",
29907
29911
  appDownloadTabDesc: "手机客户端的下载二维码与地址。",
29912
+ advancedGroupTitle: "高级设置",
29913
+ advancedGroupDesc: "平时用不到的几项,收在这里,功能与主列表一致。",
29908
29914
  imageTitle: "识图路径",
29909
29915
  imageHint: "纯文本模型收到图片时,图片会落在这个目录里,消息里只留路径给识图工具读。这里改的就是那个目录。",
29910
29916
  imageSubdir: "识图路径(目录名)",
@@ -29960,6 +29966,7 @@ window.__ModuleLoader__.load({
29960
29966
  tunnelStop: "停止云隧道",
29961
29967
  tunnelEndpointLabel: "云隧道",
29962
29968
  tunnelGenConfigReady: "生成隧道配置文件",
29969
+ tunnelGenConfigByUrl: "用隧道地址生成配置",
29963
29970
  tunnelGenConfigOff: "隧道未开启",
29964
29971
  tunnelSecurityWarning: "开启后将本机远程端口经 Cloudflare 边缘暴露到公网,任何人拿到下面的临时地址即可尝试连接。请仅在需要外网连回来时开启,用完及时停止隧道。",
29965
29972
  tunnelStartConfirm: "开启云隧道会把你设置页里的远程端口经 Cloudflare 暴露到公网,强密码(token)才能挡住乱连。确认开启吗?",
@@ -30153,17 +30160,21 @@ window.__ModuleLoader__.load({
30153
30160
  close: "Close",
30154
30161
  rootsTab: "Roots",
30155
30162
  remoteTab: "Remote",
30163
+ tunnelTab: "Public tunnel",
30156
30164
  devicesTab: "Devices",
30157
30165
  trashTab: "Trash",
30158
30166
  auditTab: "Audit",
30159
30167
  imageTab: "Image reading",
30160
30168
  rootsTabDesc: "Which directories on this machine phones may reach. Add, rename and remove them here.",
30161
30169
  remoteTabDesc: "Bind address and port, switch remote access on or off, mint pairing codes and config texts.",
30170
+ tunnelTabDesc: "For when you are out on another network: create a temporary public address that exposes the local port through the Cloudflare edge.",
30162
30171
  devicesTabDesc: "Paired phones, their permission scopes and reachable roots — or revoke them outright.",
30163
30172
  imageTabDesc: "Choose the directory name where images land for the image-reading tool.",
30164
30173
  trashTabDesc: "Files deleted from a phone land here. Restore them, or purge them for good.",
30165
30174
  auditTabDesc: "Recent activity: who, when, and which path was touched.",
30166
30175
  appDownloadTabDesc: "QR codes and addresses for the phone app download.",
30176
+ advancedGroupTitle: "Advanced settings",
30177
+ advancedGroupDesc: "The few sections nobody opens daily, kept here — every one of them works exactly as it would in the main list.",
30167
30178
  imageTitle: "Image-reading path",
30168
30179
  imageHint: "When a text-only model receives a picture, the file lands in this directory and the message keeps only its path for the image-reading tool. This setting is that directory.",
30169
30180
  imageSubdir: "Image path (directory name)",
@@ -30219,6 +30230,7 @@ window.__ModuleLoader__.load({
30219
30230
  tunnelStop: "Stop tunnel",
30220
30231
  tunnelEndpointLabel: "Cloudflare tunnel",
30221
30232
  tunnelGenConfigReady: "Generate config file from tunnel",
30233
+ tunnelGenConfigByUrl: "Generate config with tunnel address",
30222
30234
  tunnelGenConfigOff: "Tunnel not started",
30223
30235
  tunnelSecurityWarning: "Starting exposes your remote port to the public internet through Cloudflare edge; anyone with the temporary address below can try to connect. Only start it when you need to reach the machine from outside, and stop the tunnel when done.",
30224
30236
  tunnelStartConfirm: "Starting the tunnel exposes your remote port through Cloudflare to the public internet; only the device token (a strong password) keeps strangers out. Start anyway?",
@@ -30911,68 +30923,15 @@ window.__ModuleLoader__.load({
30911
30923
  }
30912
30924
  };
30913
30925
  //#endregion
30914
- //#region src/shared/contracts.ts
30915
- const DEVICE_SCOPES = [
30916
- "chat.read",
30917
- "chat.write",
30918
- "files.read",
30919
- "files.write",
30920
- "files.delete",
30921
- "settings.read",
30922
- "settings.write"
30923
- ];
30924
- /**
30925
- * 授予新设备时的默认权限(**五个非破坏性权限**)。
30926
- *
30927
- * 刻意不含 `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
30928
- * 要求才授予。`settings.write` 尤其危险 —— 拿到它就能改供应商 `baseURL`,把模型
30929
- * 请求连同真实凭据引到任意地址(SEC-H11),所以它绝不能是默认值。
30930
- *
30931
- * 三条路径必须共用这一份,否则「同一个产品里三处默认权限不一样」这种缺陷会反复
30932
- * 复发:服务端生成配置文本时的缺省、Web UI 配对码面板的初始勾选、隧道一键路径。
30933
- */
30934
- const DEFAULT_DEVICE_SCOPES = [
30935
- "chat.read",
30936
- "chat.write",
30937
- "files.read",
30938
- "files.write",
30939
- "settings.read"
30940
- ];
30941
- new Set(DEVICE_SCOPES);
30942
- //#endregion
30943
- //#region src/client/app-qr.ts
30944
- const APP_QR_CODES = [{
30945
- "key": "github",
30946
- "label": "GitHub",
30947
- "url": "https://github.com/westanke/dsh-companion/releases",
30948
- "dataUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNjQiIGhlaWdodD0iMjY0IiB2aWV3Qm94PSIwIDAgNDEgNDEiIHNoYXBlLXJlbmRlcmluZz0iY3Jpc3BFZGdlcyI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTAgMGg0MXY0MUgweiIvPjxwYXRoIHN0cm9rZT0iIzAwMDAwMCIgZD0iTTQgNC41aDdtMSAwaDFtMiAwaDFtMyAwaDNtMyAwaDFtMSAwaDFtMiAwaDdNNCA1LjVoMW01IDBoMW0zIDBoM200IDBoMW0xIDBoMW0yIDBoMW0zIDBoMW01IDBoMU00IDYuNWgxbTEgMGgzbTEgMGgxbTMgMGgybTEgMGgxbTEgMGgxbTQgMGg1bTEgMGgxbTEgMGgzbTEgMGgxTTQgNy41aDFtMSAwaDNtMSAwaDFtMSAwaDJtMiAwaDFtMSAwaDJtMSAwaDJtMSAwaDFtMiAwaDFtMiAwaDFtMSAwaDNtMSAwaDFNNCA4LjVoMW0xIDBoM20xIDBoMW0xIDBoNG0xIDBoMm0zIDBoM20yIDBoMW0yIDBoMW0xIDBoM20xIDBoMU00IDkuNWgxbTUgMGgxbTEgMGgxbTEgMGgxbTMgMGgxbTEgMGg0bTEgMGgxbTQgMGgxbTUgMGgxTTQgMTAuNWg3bTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGg3TTEyIDExLjVoMW01IDBoNW0yIDBoNE00IDEyLjVoMW0zIDBoMW0xIDBoNG03IDBoMW0xIDBoM20zIDBoNW0yIDBoMU00IDEzLjVoMW02IDBoMW0zIDBoMW0xIDBoNG02IDBoMW0xIDBoMW0zIDBoM000IDE0LjVoMm00IDBoNm0zIDBoMW0xIDBoMW0yIDBoMm0xIDBoMW0yIDBoMW0yIDBoMW0xIDBoMU00IDE1LjVoMW0yIDBoMm0zIDBoMW0xIDBoM20xIDBoMW0yIDBoNW0xIDBoMW0xIDBoMW01IDBoMk01IDE2LjVoMW0xIDBoMW0yIDBoMW0xIDBoMW0yIDBoMW0xIDBoM20yIDBoN20xIDBoMW0xIDBoMm0xIDBoMk00IDE3LjVoMW02IDBoMW0xIDBoNW0yIDBoMW0yIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDE4LjVoMm0xIDBoM20xIDBoMm01IDBoNG0zIDBoMW0xIDBoM20yIDBoMk00IDE5LjVoMW0yIDBoMm00IDBoMm0zIDBoMW0yIDBoMW0xIDBoNG0yIDBoMk02IDIwLjVoMW0yIDBoMm0yIDBoMm0yIDBoM20xIDBoMW0zIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0yIDBoMU00IDIxLjVoMW0xIDBoMW0yIDBoMW0yIDBoMW0xIDBoMm0yIDBoMm0xIDBoMm00IDBoMW0xIDBoMW0zIDBoM000IDIyLjVoNW0xIDBoMW0xIDBoMm0xIDBoM20xIDBoMW0yIDBoMW0xIDBoNG0xIDBoM20xIDBoMW0xIDBoMU01IDIzLjVoMm0xIDBoMm0yIDBoMW0yIDBoMm0yIDBoMW0zIDBoM20xIDBoNW0zIDBoMU01IDI0LjVoMW0xIDBoMm0xIDBoMW0zIDBoMm0xIDBoMm0xIDBoMW0zIDBoM20zIDBoMW0xIDBoMk00IDI1LjVoM20xIDBoMW0yIDBoMm05IDBoM20xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDI2LjVoMW0xIDBoMW0xIDBoMW0xIDBoMW00IDBoM20xIDBoMW0yIDBoMW0xIDBoNG0yIDBoMW0yIDBoMU04IDI3LjVoMm0xIDBoMW0xIDBoMm0yIDBoMW0xIDBoNW0xIDBoMW0yIDBoNW0zIDBoMU00IDI4LjVoNW0xIDBoMW0yIDBoNG0xIDBoMW0yIDBoMW0xIDBoMTFtMSAwaDJNMTIgMjkuNWgybTIgMGgybTEgMGgybTEgMGgxbTEgMGgxbTIgMGgybTMgMGgxbTEgMGgyTTQgMzAuNWg3bTEgMGgxbTEgMGgxbTQgMGgxbTEgMGgxbTIgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgyTTQgMzEuNWgxbTUgMGgxbTMgMGgxbTUgMGgzbTEgMGgybTIgMGgxbTMgMGgxTTQgMzIuNWgxbTEgMGgzbTEgMGgxbTEgMGgxbTQgMGgzbTIgMGgxbTIgMGgybTEgMGg2bTEgMGgxTTQgMzMuNWgxbTEgMGgzbTEgMGgxbTIgMGgxbTEgMGgybTMgMGgybTMgMGgxbTEgMGgybTEgMGgzbTEgMGgxTTQgMzQuNWgxbTEgMGgzbTEgMGgxbTIgMGgzbTQgMGgxbTEgMGgxbTEgMGgxbTIgMGgybTEgMGg0TTQgMzUuNWgxbTUgMGgxbTQgMGgxbTUgMGgxbTIgMGgybTEgMGg0TTQgMzYuNWg3bTEgMGgxbTEgMGgzbTEgMGgybTEgMGgxbTEgMGg0bTEgMGgybTYgMGgxIi8+PC9zdmc+Cg=="
30949
- }, {
30950
- "key": "gitee",
30951
- "label": "Gitee",
30952
- "url": "https://gitee.com/westanke/dsh-companion/releases",
30953
- "dataUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNjQiIGhlaWdodD0iMjY0IiB2aWV3Qm94PSIwIDAgNDEgNDEiIHNoYXBlLXJlbmRlcmluZz0iY3Jpc3BFZGdlcyI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTAgMGg0MXY0MUgweiIvPjxwYXRoIHN0cm9rZT0iIzAwMDAwMCIgZD0iTTQgNC41aDdtMSAwaDFtMSAwaDJtMSAwaDRtNiAwaDJtMSAwaDdNNCA1LjVoMW01IDBoMW0zIDBoMm0xIDBoMm0xIDBoMW0xIDBoMm0yIDBoMW0zIDBoMW01IDBoMU00IDYuNWgxbTEgMGgzbTEgMGgxbTIgMGgybTEgMGgxbTEgMGgxbTEgMGg0bTEgMGgxbTEgMGgybTEgMGgxbTEgMGgzbTEgMGgxTTQgNy41aDFtMSAwaDNtMSAwaDFtMSAwaDVtMiAwaDNtNSAwaDFtMiAwaDFtMSAwaDNtMSAwaDFNNCA4LjVoMW0xIDBoM20xIDBoMW0xIDBoMW0xIDBoM20zIDBoMW0yIDBoMW0xIDBoMW0xIDBoMW0yIDBoMW0xIDBoM20xIDBoMU00IDkuNWgxbTUgMGgxbTEgMGgybTMgMGgzbTQgMGgzbTEgMGgxbTEgMGgxbTUgMGgxTTQgMTAuNWg3bTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGg3TTEyIDExLjVoNW00IDBoMW0xIDBoNk00IDEyLjVoMW0zIDBoMW0xIDBoNG0xIDBoMm0xIDBoNG0zIDBoOW0yIDBoMU00IDEzLjVoMW0xIDBoM201IDBoMW0xIDBoMW0xIDBoNW00IDBoMW0xIDBoMW0zIDBoM000IDE0LjVoMW0xIDBoMW0xIDBoMW0xIDBoNG0zIDBoNG0xIDBoMW0xIDBoN20yIDBoMW0xIDBoMU01IDE1LjVoMW0zIDBoMW0xIDBoMm0yIDBoMW0xIDBoMW0xIDBoMW00IDBoMm0xIDBoNG00IDBoMk00IDE2LjVoMm0yIDBoMW0xIDBoMW0yIDBoMm0yIDBoMW0yIDBoMW0yIDBoMW0xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMk04IDE3LjVoMm0xIDBoMW0yIDBoM20xIDBoMW0zIDBoM20xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMU00IDE4LjVoMW0xIDBoMW0xIDBoM20xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMm0xIDBoMW0yIDBoMW0xIDBoNW0xIDBoMW0yIDBoMU00IDE5LjVoMW00IDBoMW0yIDBoMW0yIDBoM20xIDBoNG0yIDBoMW0xIDBoNU00IDIwLjVoNW0xIDBoNW0yIDBoMW0zIDBoMW0xIDBoNG0xIDBoMW0xIDBoMW0xIDBoMm0xIDBoMU01IDIxLjVoMW01IDBoMm01IDBoM20xIDBoMW00IDBoMW0xIDBoMW0zIDBoM001IDIyLjVoMm0yIDBoM20yIDBoMW0xIDBoMW0xIDBoMm0xIDBoMW0yIDBoMW0xIDBoMm0xIDBoM20xIDBoMW0xIDBoMU00IDIzLjVoMW0yIDBoMW0zIDBoNW01IDBoNW0xIDBoMm0xIDBoMm0zIDBoMk00IDI0LjVoNG0xIDBoMm0xIDBoMm0xIDBoMW0yIDBoMm0yIDBoNW0xIDBoM20xIDBoMm0xIDBoMk00IDI1LjVoNG00IDBoM20xIDBoMW0zIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0yIDBoMk04IDI2LjVoNW0yIDBoNG0xIDBoMW0xIDBoMm0zIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDI3LjVoMW0yIDBoMW00IDBoM200IDBoMW0xIDBoM20zIDBoMm00IDBoMU00IDI4LjVoM20zIDBoM200IDBoMW0xIDBoMW0xIDBoMW0zIDBoMm0xIDBoNW0yIDBoMk0xMiAyOS41aDFtMiAwaDFtMyAwaDFtMSAwaDJtNCAwaDJtMyAwaDFtMSAwaDJNNCAzMC41aDdtMSAwaDFtMyAwaDRtMiAwaDFtMSAwaDJtMiAwaDFtMSAwaDFtMSAwaDJtMSAwaDFNNCAzMS41aDFtNSAwaDFtMiAwaDFtMiAwaDFtMiAwaDFtMyAwaDNtMiAwaDFtMyAwaDFtMiAwaDFNNCAzMi41aDFtMSAwaDNtMSAwaDFtMSAwaDFtMSAwaDFtMSAwaDJtMiAwaDFtMyAwaDNtMSAwaDZNNCAzMy41aDFtMSAwaDNtMSAwaDFtMiAwaDFtMiAwaDFtNCAwaDhtMSAwaDNtMSAwaDFNNCAzNC41aDFtMSAwaDNtMSAwaDFtMiAwaDFtMSAwaDJtMSAwaDJtNCAwaDJtMSAwaDJtMSAwaDNNNCAzNS41aDFtNSAwaDFtNCAwaDNtMSAwaDVtMSAwaDFtMiAwaDNNNCAzNi41aDdtMSAwaDFtMiAwaDJtMSAwaDFtMiAwaDFtMSAwaDNtMSAwaDJtNyAwaDEiLz48L3N2Zz4K"
30954
- }];
30955
- //#endregion
30956
- //#region src/client/workspace.tsx
30957
- /** What the settings-page launcher opens: authorized roots, the first section. */
30958
- const DEFAULT_PANEL_SECTION = "roots";
30926
+ //#region src/client/cards/shared.tsx
30959
30927
  /**
30960
- * i18n key for a rail entry.
30928
+ * 跨卡复用的小部件与纯函数。
30961
30929
  *
30962
- * Most sections map to `${name}Tab`, but `app-download` contains a hyphen, so a bare template would
30963
- * ask for `app-downloadTab` — a key nobody should have to remember the spelling of. Map it
30964
- * explicitly instead of encoding a naming rule into a template.
30930
+ * 判据同 `context.ts`:**至少两张卡在用**才放这里。只服务一张卡的留在那张卡的文件里。
30965
30931
  */
30966
- function tabLabelKey(tab) {
30967
- return tab === "app-download" ? "appDownloadTab" : `${tab}Tab`;
30968
- }
30969
- /** 配对码剩余分钟:向上取整 —— 显示「约 9 分钟」比「约 8 分钟」在最后一刻更诚实。 */
30970
- function pairingMinutesLeft(expiresAt, now) {
30971
- return Math.max(0, Math.ceil((expiresAt - now) / 6e4));
30972
- }
30973
30932
  /**
30974
- * 通用复制小按钮:成功后短暂换成对勾 + 「已复制」,2s 自动复位;
30975
- * 非安全上下文(HTTP/LAN)拿不到 Clipboard API 时退化为选中文本提示,见 copyText 的调用方。
30933
+ * 通用复制小按钮:成功后短暂换成对勾 +「已复制」,2s 自动复位;
30934
+ * 非安全上下文(HTTP/LAN)拿不到 Clipboard API 时退化为选中文本提示,见 `copyText` 的调用方。
30976
30935
  */
30977
30936
  function CopyButton(props) {
30978
30937
  const [state, setState] = (0, react.useState)("idle");
@@ -30998,1013 +30957,1117 @@ window.__ModuleLoader__.load({
30998
30957
  })]
30999
30958
  });
31000
30959
  }
31001
- /**
31002
- * 卡片头部的描述行。与 `tabLabelKey` 同理:唯一含连字符的分区显式映射。
31003
- *
31004
- * 键名是 `${tab}TabDesc`(**带 Tab**),不是 `${tab}Desc`。这个中间段不是冗余:它让
31005
- * 描述键与标题键 `rootsTab` / `rootsTabDesc` 成对,读的人一眼能看出这两个属于同一张卡。
31006
- *
31007
- * 踩过的坑:早先这里写成 `${tab}Desc`,`rootsDesc` 之类根本不在字典里 —— `translate` 直接
31008
- * `dictionaries[locale][key].replace(...)` 抛 TypeError,整个设置面板白屏。`as MessageKey`
31009
- * 把类型检查骗过去了,所以单测全绿也照样白屏。**键名靠模板拼出来时,类型系统是最后一道
31010
- * 防线,这里偏偏被断言关掉了。** 现在改成显式查表,见下。
31011
- */
31012
- const TAB_DESC_KEYS = {
31013
- roots: "rootsTabDesc",
31014
- remote: "remoteTabDesc",
31015
- devices: "devicesTabDesc",
31016
- image: "imageTabDesc",
31017
- trash: "trashTabDesc",
31018
- plugins: "pluginsTabDesc",
31019
- "app-download": "appDownloadTabDesc"
31020
- };
31021
- function tabDescKey(tab) {
31022
- return TAB_DESC_KEYS[tab];
30960
+ /** 配对码剩余分钟:向上取整 —— 显示「约 9 分钟」比「约 8 分钟」在最后一刻更诚实。 */
30961
+ function pairingMinutesLeft(expiresAt, now) {
30962
+ return Math.max(0, Math.ceil((expiresAt - now) / 6e4));
31023
30963
  }
31024
30964
  /**
31025
- * The single desktop dialog: a vertical section rail on the left, one content pane on the right.
31026
- *
31027
- * Settings only — the five admin sections. The file tree used to live here as a sixth row; it does
31028
- * not any more, so this is the old `AdminOverlay` body with a rail, not a workspace host.
31029
- */
31030
- /**
31031
- * 导航栏 + 内容区,**不含任何对话框外壳**。
31032
- *
31033
- * 抽出来是为了让 DSH 设置页能把它**内联**渲染。
30965
+ * `host:port` 的统一显示。
31034
30966
  *
31035
- * 早先的版本做不到这一点,于是设置页里只放了一句说明加一个「打开手机设置面板」按钮,
31036
- * 真正的设置藏在按钮后面的模态框里。用户的反应很直接:「其他的可以设置的东西那去了」——
31037
- * 这就是把弹窗嵌进设置页的代价:入口看起来是空的,因为内容在下一层。
31038
- * 设置页本身就是一层容器,没有任何理由再套一层。
30967
+ * IPv6 要加方括号(`[::1]:3090`),否则地址里的冒号会让人把它读成「主机 `::1` + 端口」以外的
30968
+ * 东西,复制出去也非法。端口非有限值时不显示数字而不是显示 `NaN`。
31039
30969
  */
30970
+ function formatEndpoint(host, port) {
30971
+ return `${host.includes(":") ? `[${host}]` : host}:${Number.isFinite(port) ? port : ""}`;
30972
+ }
30973
+ /** 字节数转人话。回收站里常有 KB/MB 级文件,只显示原始字节数没人读得动。 */
30974
+ function formatBytes(value) {
30975
+ if (value < 1024) return `${value} B`;
30976
+ if (value < 1048576) return `${(value / 1024).toFixed(1)} KB`;
30977
+ return `${(value / 1024 / 1024).toFixed(1)} MB`;
30978
+ }
31040
30979
  /**
31041
- * 设置面板主体:**七张卡片平铺,各自独立开合,没有导航栏**。
31042
- *
31043
- * 上一版留了个分区导航栏、一次只显示一张卡。对照 Vision Router 的实际长相才发现差得远:
31044
- * 它是全部卡片一起列出来、想开哪张开哪张,标题栏自己就是入口。那句提示词写得很直白 ——
31045
- * 「多张卡可以同时保持展开」。所以导航栏这一层是多余的,用户还得先点导航、再点卡片头,
31046
- * 两次点击才走一步。
31047
- *
31048
- * 抽出来是为了让 DSH 设置页能把它**内联**渲染。
30980
+ * 监听器操作失败时的人话。
31049
30981
  *
31050
- * 早先的版本做不到这一点,于是设置页里只放了一句说明加一个「打开手机设置面板」按钮,
31051
- * 真正的设置藏在按钮后面的模态框里。用户的反应很直接:「其他的可以设置的东西那去了」——
31052
- * 这就是把弹窗嵌进设置页的代价:入口看起来是空的,因为内容在下一层。
31053
- * 设置页本身就是一层容器,没有任何理由再套一层。
31054
- *
31055
- * `section` / `onSection` 保留是为了兼容旧调用方,但现在**不再决定显示哪一张卡**,
31056
- * 只用来决定默认展开哪一张。
30982
+ * `code` 与 `message` 两个来源都要看:服务端给了已知码就翻成人话,否则退回原始 message;
30983
+ * 两个都没有才用 fallback。只认其中一个会在另一种情况下显示空白。
31057
30984
  */
31058
- function PhoneSettingsBody(props) {
31059
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminPanel, {
31060
- api: props.api,
31061
- initialOpen: props.section,
31062
- onOpenPluginList: props.onOpenPluginList
30985
+ function listenerOperationError(code, fallback, t) {
30986
+ if (code === "LISTENER_ADDRESS_IN_USE") return t("listenerAddressInUse");
30987
+ if (code === "LISTENER_ADDRESS_UNAVAILABLE") return t("listenerAddressUnavailable");
30988
+ if (code === "LISTENER_PERMISSION_DENIED") return t("listenerPermissionDenied");
30989
+ return fallback ?? t("listenerFailed");
30990
+ }
30991
+ /** 等一会儿。轮询与「等隧道出地址」都用它。 */
30992
+ async function delay(ms) {
30993
+ await new Promise((resolve) => {
30994
+ setTimeout(resolve, ms);
31063
30995
  });
31064
30996
  }
31065
30997
  /**
31066
- * 一个设置分区 = 一张 Vision Router 式折叠卡片。
30998
+ * 错误码 → 人话。
31067
30999
  *
31068
- * 交互模型照搬 dsh-vision-router 的 `vr-card`:外壳是圆角 12px 卡片,展开时加
31069
- * `daw-card-open`;头部是一个 `aria-expanded` 的按钮,装着图标 + 标题 + 描述 + 旋转箭头;
31070
- * 展开体是 `daw-card-body`(顶部一条分隔线)。
31071
- *
31072
- * 七张卡同时挂在页面上,各张独立开合 —— 导航栏已经删掉了,卡片头自己就是入口。
31000
+ * 这张表是「用户看到什么」的唯一来源:`messageOf` 与各卡片都靠它把裸码翻成人话。
31001
+ * 压成一句「操作失败」会让用户反复点同一个按钮 —— 每一个码都指向不同的下一步,
31002
+ * 例如 `TUNNEL_MODE_ADMIN_DISABLED` 要人去停隧道、`ADMIN_HOST_NOT_LOOPBACK` 要人换地址打开。
31073
31003
  */
31074
- function SettingsCard(props) {
31075
- const { t } = useWorkspaceI18n();
31076
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
31077
- className: `daw-card${props.open ? " daw-card-open" : ""}`,
31078
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
31079
- type: "button",
31080
- className: "daw-card-header",
31081
- "aria-expanded": props.open,
31082
- onClick: props.onToggle,
31083
- children: [
31084
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31085
- className: "daw-card-icon",
31086
- "aria-hidden": "true",
31087
- children: tabIcon(props.tab)
31088
- }),
31089
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
31090
- className: "daw-card-headtext",
31091
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31092
- className: "daw-card-name",
31093
- children: t(props.nameKey)
31094
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31095
- className: "daw-card-desc",
31096
- children: t(props.descKey)
31097
- })]
31098
- }),
31099
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31100
- className: `daw-card-chevron${props.open ? " daw-card-chevron-open" : ""}`,
31101
- "aria-hidden": "true",
31102
- children: "▾"
31103
- })
31104
- ]
31105
- }), props.open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31106
- className: "daw-card-body",
31107
- children: props.children
31108
- })]
31109
- });
31004
+ function apiErrorKey(error) {
31005
+ if (error.status === 401) return "errorUnauthorized";
31006
+ if (error.status === 429) return "errorRateLimited";
31007
+ if (error.code === "ROOT_NOT_GRANTED" || error.code === "ROOT_FORBIDDEN") return "errorRootDenied";
31008
+ if (error.code === "ROOT_BUILTIN") return "errorRootBuiltin";
31009
+ if (error.code === "ETAG_MISMATCH") return "errorEtag";
31010
+ if (error.code === "LISTENER_HOST_INVALID") return "listenerHostInvalid";
31011
+ if (error.code === "LISTENER_PORT_INVALID") return "listenerPortInvalid";
31012
+ if (error.code === "REMOTE_ENABLED") return "disableToEditListener";
31013
+ if (error.code === "NO_ENDPOINT") return "configTextNoEndpoints";
31014
+ if (error.code === "TUNNEL_MODE_ADMIN_DISABLED") return "errorTunnelModeAdminDisabled";
31015
+ if (error.code === "ADMIN_PROXY_FORWARDED") return "errorAdminProxyForwarded";
31016
+ if (error.code === "ADMIN_HOST_NOT_LOOPBACK") return "errorAdminHostNotLoopback";
31017
+ if (error.code === "ADMIN_LOOPBACK_REQUIRED") return "errorAdminLoopbackRequired";
31018
+ if (error.code === "PLUGIN_OFFICIAL_IMMUTABLE") return "pluginsErrorOfficial";
31019
+ if (error.code === "PLUGIN_NAME_INVALID") return "pluginsErrorNameInvalid";
31020
+ if (error.code === "PROFILE_UNKNOWN") return "pluginsErrorProfileUnknown";
31021
+ if (error.code === "MANIFEST_UNREADABLE") return "pluginsErrorManifestUnreadable";
31022
+ if (error.code === "PLUGIN_NOT_INSTALLED") return "pluginsErrorNotInstalled";
31023
+ if (error.code.includes("PATH") || error.code.includes("SYMLINK") || error.code.includes("REPARSE")) return "errorPathInvalid";
31024
+ if (error.status === 403) return "errorForbidden";
31025
+ if (error.status === 404) return "errorNotFound";
31026
+ if (error.status === 409) return "errorConflict";
31027
+ }
31028
+ function messageOf(cause, t) {
31029
+ if (cause instanceof WorkspaceApiError) {
31030
+ const translated = apiErrorKey(cause);
31031
+ if (translated !== void 0) return t(translated);
31032
+ }
31033
+ return cause instanceof Error ? cause.message : String(cause);
31034
+ }
31035
+ function pluginStateKey(state) {
31036
+ if (state === "loaded") return "pluginsStateLoaded";
31037
+ if (state === "runtime-provided") return "pluginsStateRuntimeProvided";
31038
+ if (state === "installed-not-loaded") return "pluginsStateInstalledNotLoaded";
31039
+ return "pluginsStateDeclaredMissing";
31110
31040
  }
31111
31041
  /**
31112
- * The settings content of one admin section, extracted from `AdminOverlay` so the unified panel and
31113
- * the standalone `/dsh-workspace` page render exactly the same body.
31114
- */
31115
- /**
31116
- * 设置面板:七张卡片平铺,各自独立开合。
31042
+ * Shows a freshly minted `DSH1:` config text.
31117
31043
  *
31118
- * `initialOpen` 决定进页面时先展开哪一张(默认第一张),之后完全由用户点卡片头控制。
31119
- * 展开状态存 Set —— 这样「同时展开两张」是自然结果,不需要额外的互斥逻辑。
31044
+ * The text is a credential carrier, so this panel keeps the "it is a key" reminder visible and lists
31045
+ * the addresses the host actually detected, letting the user confirm them before sending the text away.
31046
+ * Copying degrades to "the text is selected, copy it yourself" when the Clipboard API is unavailable —
31047
+ * the normal case when this WebUI is opened over plain HTTP on a LAN address.
31120
31048
  */
31121
- function AdminPanel(props) {
31122
- const { locale, t } = useWorkspaceI18n();
31123
- const [status, setStatus] = (0, react.useState)();
31124
- const [trash, setTrash] = (0, react.useState)([]);
31125
- const [path, setPath] = (0, react.useState)("");
31126
- const [label, setLabel] = (0, react.useState)("");
31127
- const [listenerHost, setListenerHost] = (0, react.useState)("0.0.0.0");
31128
- const [listenerPort, setListenerPort] = (0, react.useState)("3090");
31129
- const [pairing, setPairing] = (0, react.useState)();
31130
- const [configText, setConfigText] = (0, react.useState)();
31131
- const [configEndpoint, setConfigEndpoint] = (0, react.useState)("");
31132
- const [configTextBusy, setConfigTextBusy] = (0, react.useState)(false);
31133
- const [image, setImage] = (0, react.useState)();
31134
- const [imageDraft, setImageDraft] = (0, react.useState)("");
31135
- const [imageBusy, setImageBusy] = (0, react.useState)(false);
31136
- const [openCards, setOpenCards] = (0, react.useState)(() => /* @__PURE__ */ new Set([props.initialOpen]));
31137
- const [imageSaved, setImageSaved] = (0, react.useState)(false);
31138
- const [error, setError] = (0, react.useState)();
31139
- const [notice, setNotice] = (0, react.useState)();
31140
- const [tunnel, setTunnel] = (0, react.useState)();
31141
- const [tunnelBusy, setTunnelBusy] = (0, react.useState)(false);
31142
- const [activity, setActivity] = (0, react.useState)();
31143
- const [pruneDays, setPruneDays] = (0, react.useState)(30);
31144
- const [pruneBusy, setPruneBusy] = (0, react.useState)(false);
31145
- const [now, setNow] = (0, react.useState)(() => Date.now());
31146
- const notify = (0, react.useCallback)((key, params) => {
31147
- setNotice(params === void 0 ? {
31148
- id: Date.now() + Math.random(),
31149
- key
31150
- } : {
31151
- id: Date.now() + Math.random(),
31152
- key,
31153
- params
31154
- });
31155
- }, []);
31156
- (0, react.useEffect)(() => {
31157
- if (notice === void 0) return;
31158
- const timer = window.setTimeout(() => setNotice(void 0), 3500);
31159
- return () => window.clearTimeout(timer);
31160
- }, [notice]);
31161
- (0, react.useEffect)(() => {
31162
- const timer = window.setInterval(() => setNow(Date.now()), 3e4);
31163
- return () => window.clearInterval(timer);
31164
- }, []);
31165
- const copyText = (0, react.useCallback)(async (text) => {
31049
+ function ConfigTextPanel(props) {
31050
+ const { t } = useWorkspaceI18n();
31051
+ const [copyState, setCopyState] = (0, react.useState)();
31052
+ const textRef = (0, react.useRef)(null);
31053
+ const copy = (0, react.useCallback)(async () => {
31166
31054
  const clipboard = globalThis.navigator?.clipboard;
31167
31055
  if (clipboard !== void 0) try {
31168
- await clipboard.writeText(text);
31169
- return "done";
31056
+ await clipboard.writeText(props.result.text);
31057
+ setCopyState("done");
31058
+ return;
31170
31059
  } catch {}
31171
- try {
31172
- const node = document.createElement("textarea");
31173
- node.value = text;
31174
- node.style.position = "fixed";
31175
- node.style.top = "-9999px";
31176
- node.setAttribute("readonly", "");
31177
- document.body.append(node);
31060
+ const node = textRef.current;
31061
+ if (node !== null) {
31062
+ node.focus();
31178
31063
  node.select();
31179
- const ok = document.execCommand("copy");
31180
- node.remove();
31181
- if (ok) return "done";
31182
- } catch {}
31183
- return "manual";
31184
- }, []);
31185
- const isOpen = (0, react.useCallback)((card) => openCards.has(card), [openCards]);
31186
- const toggleCard = (0, react.useCallback)((card) => {
31187
- setOpenCards((previous) => {
31188
- const next = new Set(previous);
31189
- if (!next.delete(card)) next.add(card);
31190
- return next;
31191
- });
31192
- }, []);
31193
- const refresh = (0, react.useCallback)(async () => {
31194
- try {
31195
- setTunnel(await props.api.tunnelStatus());
31196
- } catch {}
31197
- try {
31198
- const next = await props.api.status();
31199
- setStatus(next);
31200
- if (openCards.has("trash")) setTrash((await props.api.trashItems()).items);
31201
- if (openCards.has("image")) {
31202
- const settings = await props.api.imageSettings();
31203
- setImage(settings);
31204
- setImageDraft(settings.subdir);
31205
- setImageSaved(false);
31206
- }
31207
- } catch (cause) {
31208
- if (!(cause instanceof WorkspaceApiError && cause.status === 403 && (cause.code === "TUNNEL_MODE_ADMIN_DISABLED" || cause.code === "ADMIN_PROXY_FORWARDED"))) setError(cause);
31209
- }
31210
- }, [props.api, openCards]);
31211
- const tunnelErrorText = (0, react.useCallback)((s) => {
31212
- if (s.errorKey !== void 0) return t(s.errorKey, s.errorParams ?? {});
31213
- return s.error ?? t("listenerFailed");
31214
- }, [t]);
31215
- const pollActivityOnce = (0, react.useCallback)(async (active) => {
31216
- while (active()) {
31217
- try {
31218
- const a = await props.api.tunnelActivity();
31219
- if (!active()) return;
31220
- setActivity(a.stage === "idle" ? void 0 : a);
31221
- } catch {}
31222
- await delay(400);
31223
- }
31224
- }, [props.api]);
31225
- const waitForTunnelUrl = (0, react.useCallback)(async (active) => {
31226
- for (let attempt = 0; attempt < 40 && active(); attempt += 1) {
31227
- await delay(500);
31228
- const s = await props.api.tunnelStatus();
31229
- if (!active()) return null;
31230
- setTunnel(s);
31231
- if (s.errorKey !== void 0 || s.error !== void 0) throw new Error(tunnelErrorText(s));
31232
- if (s.running && s.url !== null) return s.url;
31233
- }
31234
- return null;
31235
- }, [props.api, tunnelErrorText]);
31236
- const generateConfigForUrl = (0, react.useCallback)(async (baseUrl, noticeKey = "configCreated") => {
31237
- setConfigTextBusy(true);
31238
- setError(void 0);
31239
- try {
31240
- const currentRoots = status?.roots ?? [];
31241
- const value = await props.api.createConfigText({
31242
- ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) },
31243
- endpoints: [{
31244
- label: t("tunnelEndpointLabel"),
31245
- baseUrl
31246
- }]
31247
- });
31248
- setConfigText(value);
31249
- notify(noticeKey);
31250
- return true;
31251
- } catch (cause) {
31252
- setError(cause);
31253
- return false;
31254
- } finally {
31255
- setConfigTextBusy(false);
31256
- }
31257
- }, [
31258
- props.api,
31259
- status,
31260
- t,
31261
- notify
31262
- ]);
31263
- const createTunnel = (0, react.useCallback)(async (opts = {}) => {
31264
- if (!window.confirm(t("tunnelStartConfirm"))) return;
31265
- const wantConfig = opts.thenGenConfig === true;
31266
- let alive = true;
31267
- setTunnelBusy(true);
31268
- setError(void 0);
31269
- setConfigText(void 0);
31270
- setActivity({
31271
- stage: "locating",
31272
- labelKey: "tunnelStageLocating",
31273
- percent: null
31274
- });
31275
- pollActivityOnce(() => alive);
31276
- try {
31277
- const currentRoots = status?.roots ?? [];
31278
- const created = await props.api.tunnelCreate(wantConfig, { ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) } });
31279
- setTunnel(created);
31280
- setActivity(void 0);
31281
- if (created.errorKey !== void 0 || created.error !== void 0) throw new Error(tunnelErrorText(created));
31282
- if (wantConfig && created.configText !== void 0) {
31283
- setConfigText(created.configText);
31284
- await refresh();
31285
- notify("tunnelConfigAuto");
31286
- return;
31287
- }
31288
- const url = created.url ?? await waitForTunnelUrl(() => alive);
31289
- await refresh();
31290
- if (url === null) {
31291
- notify("tunnelUrlPending");
31292
- return;
31293
- }
31294
- if (wantConfig) await generateConfigForUrl(url, "tunnelConfigAuto");
31295
- else notify("tunnelStarted");
31296
- } catch (cause) {
31297
- setError(cause);
31298
- } finally {
31299
- alive = false;
31300
- setActivity(void 0);
31301
- setTunnelBusy(false);
31302
- }
31303
- }, [
31304
- props.api,
31305
- refresh,
31306
- status,
31307
- t,
31308
- notify,
31309
- pollActivityOnce,
31310
- waitForTunnelUrl,
31311
- tunnelErrorText,
31312
- generateConfigForUrl
31313
- ]);
31314
- const stopTunnel = (0, react.useCallback)(async () => {
31315
- if (!window.confirm(t("tunnelStopConfirm"))) return;
31316
- setTunnelBusy(true);
31317
- setError(void 0);
31318
- try {
31319
- const next = await props.api.tunnelStop();
31320
- setTunnel(next);
31321
- await refresh();
31322
- notify("tunnelStopped");
31323
- } catch (cause) {
31324
- setError(cause);
31325
- } finally {
31326
- setTunnelBusy(false);
31327
- }
31328
- }, [
31329
- props.api,
31330
- refresh,
31331
- t,
31332
- notify
31333
- ]);
31334
- const tunnelConfigClick = (0, react.useCallback)(async () => {
31335
- if (tunnel?.running === true) {
31336
- if (!window.confirm(t("tunnelStopConfirm"))) return;
31337
- setTunnelBusy(true);
31338
- setError(void 0);
31339
- try {
31340
- const stopped = await props.api.tunnelStop();
31341
- setTunnel(stopped);
31342
- await refresh();
31343
- } catch (cause) {
31344
- setError(cause);
31345
- } finally {
31346
- setTunnelBusy(false);
31347
- }
31348
- await createTunnel({ thenGenConfig: true });
31349
- } else await createTunnel({ thenGenConfig: true });
31350
- }, [
31351
- props.api,
31352
- tunnel,
31353
- t,
31354
- createTunnel,
31355
- refresh,
31356
- setError
31357
- ]);
31358
- (0, react.useEffect)(() => {
31359
- refresh();
31360
- }, [refresh]);
31361
- (0, react.useEffect)(() => {
31362
- if (status === void 0) return;
31363
- setListenerHost(status.configuredHost);
31364
- setListenerPort(String(status.configuredPort));
31365
- }, [status?.configuredHost, status?.configuredPort]);
31366
- const roots = status?.roots ?? [];
31367
- const managementLocked = tunnel?.running === true;
31368
- const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEFAULT_DEVICE_SCOPES]);
31369
- const toggleScope = (0, react.useCallback)((scope) => {
31370
- setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
31371
- }, []);
31372
- const grantNotice = grantedScopes.length === 0 ? t("scopeGrantNoticeEmpty") : (status?.devices?.length ?? 0) === 0 ? t("scopeGrantNotice") : t("scopeGrantNoticeExisting");
31373
- const parsedListenerPort = Number(listenerPort);
31374
- const listenerPortValid = Number.isInteger(parsedListenerPort) && parsedListenerPort >= 1 && parsedListenerPort <= 65535;
31375
- const listenerDirty = status !== void 0 && (listenerHost.trim() !== status.configuredHost || parsedListenerPort !== status.configuredPort);
31376
- const poll = async (id) => {
31377
- for (let attempt = 0; attempt < 30; attempt += 1) {
31378
- await delay(300);
31379
- const next = await props.api.operation(id);
31380
- setStatus(next);
31381
- const operation = next.operation;
31382
- if (operation?.state === "succeeded") {
31383
- if (operation.pairingCode !== void 0) setPairing({
31384
- code: operation.pairingCode,
31385
- ...operation.pairingExpiresAt === void 0 ? {} : { expiresAt: operation.pairingExpiresAt }
31386
- });
31387
- return;
31388
- }
31389
- if (operation?.state === "failed") throw new Error(listenerOperationError(operation.code, operation.message, t));
31390
31064
  }
31391
- throw new Error(t("listenerTimedOut"));
31392
- };
31393
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
31394
- className: "daw-admin",
31065
+ setCopyState("manual");
31066
+ }, [props.result.text]);
31067
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31068
+ style: { marginTop: 14 },
31395
31069
  children: [
31396
31070
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31397
- className: "daw-admin-topbar",
31398
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LanguageToggle, {})
31399
- }),
31400
- error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31401
31071
  className: "daw-warning",
31402
- role: "alert",
31403
- children: messageOf(error, t)
31072
+ children: t("configTextSecurity")
31404
31073
  }),
31405
- notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31406
- className: "daw-success",
31407
- role: "status",
31408
- children: t(notice.key, notice.params)
31074
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31075
+ className: "daw-list-meta",
31076
+ children: t("configTextHint")
31409
31077
  }),
31410
- managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31411
- className: "daw-warning",
31412
- role: "status",
31413
- children: t("managementLockedWhileTunnel")
31078
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
31079
+ ref: textRef,
31080
+ className: "daw-input",
31081
+ style: {
31082
+ width: "100%",
31083
+ height: "auto",
31084
+ minHeight: 76,
31085
+ marginTop: 8,
31086
+ padding: "8px 9px",
31087
+ fontFamily: "ui-monospace,SFMono-Regular,Consolas,monospace",
31088
+ fontSize: 12,
31089
+ lineHeight: 1.5,
31090
+ resize: "vertical"
31091
+ },
31092
+ readOnly: true,
31093
+ rows: 4,
31094
+ spellCheck: false,
31095
+ value: props.result.text,
31096
+ onFocus: (event) => event.currentTarget.select()
31414
31097
  }),
31415
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31416
- tab: "roots",
31417
- nameKey: tabLabelKey("roots"),
31418
- descKey: tabDescKey("roots"),
31419
- open: isOpen("roots"),
31420
- onToggle: () => toggleCard("roots"),
31098
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31099
+ className: "daw-form-row",
31100
+ style: {
31101
+ marginTop: 8,
31102
+ marginBottom: 10,
31103
+ gridTemplateColumns: "auto auto minmax(0,1fr)",
31104
+ alignItems: "center"
31105
+ },
31421
31106
  children: [
31422
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31423
- className: "daw-form-row",
31424
- children: [
31425
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31426
- className: "daw-input",
31427
- placeholder: t("absolutePath"),
31428
- value: path,
31429
- onChange: (event) => setPath(event.target.value)
31430
- }),
31431
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31432
- className: "daw-input",
31433
- placeholder: t("displayLabel"),
31434
- value: label,
31435
- onChange: (event) => setLabel(event.target.value)
31436
- }),
31437
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31438
- className: "daw-command primary",
31439
- disabled: managementLocked,
31440
- title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31441
- onClick: () => {
31442
- setError(void 0);
31443
- props.api.addRoot(path, label || void 0).then(() => {
31444
- setPath("");
31445
- setLabel("");
31446
- notify("rootAdded");
31447
- return refresh();
31448
- }).catch(setError);
31449
- },
31450
- children: t("addRoot")
31451
- })
31452
- ]
31107
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31108
+ className: "daw-command primary",
31109
+ onClick: () => {
31110
+ copy();
31111
+ },
31112
+ children: t("copy")
31453
31113
  }),
31454
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31455
- className: "daw-list",
31456
- children: roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31457
- className: "daw-list-row",
31458
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31459
- className: "daw-list-title",
31460
- children: [root.label, root.builtin === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31461
- className: "daw-pill on",
31462
- children: t("builtinRootBadge")
31463
- })]
31464
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31465
- className: "daw-list-meta",
31466
- children: root.realPath
31467
- })] }), root.builtin === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31468
- className: "daw-list-meta daw-builtin-hint",
31469
- children: t("builtinRootHint")
31470
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31471
- className: "daw-icon danger",
31472
- title: t("removeRoot"),
31473
- "aria-label": t("removeRoot"),
31474
- disabled: managementLocked,
31475
- onClick: () => {
31476
- if (!window.confirm(t("removeRootConfirm", { label: root.label }))) return;
31477
- setError(void 0);
31478
- props.api.removeRoot(root.id).then(() => {
31479
- notify("rootRemoved");
31480
- return refresh();
31481
- }).catch(setError);
31482
- },
31483
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31484
- })]
31485
- }, root.id))
31114
+ copyState === "done" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31115
+ className: "daw-pill on",
31116
+ children: t("copied")
31486
31117
  }),
31487
- roots.some((root) => root.builtin === true) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31488
- className: "daw-list-meta daw-builtin-notice",
31489
- children: t("builtinRootNotice")
31118
+ copyState === "manual" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31119
+ className: "daw-list-meta",
31120
+ children: t("copyManual")
31490
31121
  })
31491
31122
  ]
31492
- }, "roots"),
31493
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31494
- tab: "remote",
31495
- nameKey: tabLabelKey("remote"),
31496
- descKey: tabDescKey("remote"),
31497
- open: isOpen("remote"),
31498
- onToggle: () => toggleCard("remote"),
31123
+ }),
31124
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31125
+ className: "daw-list-meta",
31126
+ style: { marginBottom: 2 },
31499
31127
  children: [
31500
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31501
- className: "daw-warning",
31502
- children: t("httpWarning")
31503
- }),
31504
- roots.length === 0 && !managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31505
- className: "daw-warning",
31506
- children: t("addRootBeforeRemote")
31507
- }),
31508
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31509
- className: "daw-listener-form",
31128
+ t("configTextDevice"),
31129
+ ": ",
31130
+ props.result.deviceName,
31131
+ " · ",
31132
+ t("configTextDeviceId"),
31133
+ ": ",
31134
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31135
+ style: { fontFamily: "ui-monospace,SFMono-Regular,Consolas,monospace" },
31136
+ children: props.result.deviceId
31137
+ })
31138
+ ]
31139
+ }),
31140
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31141
+ className: "daw-list-meta",
31142
+ style: { marginBottom: 10 },
31143
+ children: t("configTextScopes")
31144
+ }),
31145
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("configTextDetected") }),
31146
+ props.result.endpoints.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31147
+ className: "daw-warning",
31148
+ style: { marginTop: 8 },
31149
+ children: t("configTextNoEndpoints")
31150
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31151
+ className: "daw-list",
31152
+ children: props.result.endpoints.map((endpoint) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31153
+ className: "daw-list-row",
31154
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31155
+ className: "daw-list-title",
31156
+ children: endpoint.label
31157
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31158
+ className: "daw-list-meta",
31159
+ children: [endpoint.baseUrl, endpoint.kind === void 0 ? "" : ` · ${endpoint.kind}`]
31160
+ })] })
31161
+ }, `${endpoint.label}:${endpoint.baseUrl}`))
31162
+ })
31163
+ ]
31164
+ });
31165
+ }
31166
+ //#endregion
31167
+ //#region src/client/plugins.tsx
31168
+ /**
31169
+ * 插件清单的右侧栏形态:一个真正的 `SidebarRightTabDefinition`。
31170
+ *
31171
+ * ## 为什么不是「再注册一个 slot」
31172
+ *
31173
+ * 报告里第 2 项讲的是 DSH 0.2.1-rc.3 的三处 pin 机制(`view-pins` / `panel-pins` /
31174
+ * `guide-pins`)。pin 只作用于**注册了 panel / view / guide 的插件**——桥原来那两个座位
31175
+ * (`conversation.chat.turnTail` 与 `settings.section`)都不在这三者的管辖内,所以哪怕把
31176
+ * 三个新 slot 全注册一遍,桥自己的东西也一个都 pin 不到,只能消费别人的条目。
31177
+ *
31178
+ * 要真拿到 pin,唯一的路是**成为 tab 类型提供方**:往 `sidebarRightTabs` 注册一个
31179
+ * definition,并在 `sidebar.right.pane.tab` / `.title` 两个 keyed seat 上按 `definition.id`
31180
+ * 挂上 body 与标题。这样 guide 页会列出这张卡片,用户能在 rc.3 的 More 菜单里把它
31181
+ * 固定 / 取消固定(`guide-pins.d.ts` 的 `FIXED_GUIDE_KIND = "files"` 只豁免文件卡片,
31182
+ * 桥的 kind 不在豁免名单里)。
31183
+ *
31184
+ * ## 为什么单独一个文件
31185
+ *
31186
+ * `workspace.tsx` 已经 1200 多行,而 `docs/project/TECH-DEBT.md` 的行数预算写的是
31187
+ * 600 行/文件、超预算要先登记。把新 UI 面拆出来既不加剧那笔债,也让「设置页里的折叠卡」
31188
+ * 与「右侧栏的常驻 tab」这两种形态各自独立演进。
31189
+ *
31190
+ * ## rc.2 上的降级
31191
+ *
31192
+ * `sidebarRightTabs` 在 rc.2 与 rc.3 都提供,definition 契约两版一致,所以 tab 本身能用;
31193
+ * 但 rc.2 的 `GuideBody` 没有 `entryPins` / `entryActions` / More 菜单,卡片会正常列出、
31194
+ * 点开也正常,只是**不能 pin**。这是可接受的降级:能力随宿主版本生长,桥不做版本判断。
31195
+ */
31196
+ /** definition 的 id:tab 系统的实现身份,同时是 body/title 两个 keyed seat 的 key。 */
31197
+ const PLUGINS_TAB_ID = "dsh-remote-bridge-plugins";
31198
+ /**
31199
+ * tab 类型名。
31200
+ *
31201
+ * 必须与 DSH 内置类型不冲突 —— 内置的有 `guide` / `files` / `terminal` / `browser` /
31202
+ * `plan` / `schedule` / `deliverables` 等。冲突时 `sidebarRightTabs.register` 会抛
31203
+ * `sidebarRight: tab kind "…" is already registered`,整个插件起不来,所以带上插件前缀。
31204
+ */
31205
+ const PLUGINS_TAB_KIND = "remote-bridge-plugins";
31206
+ /**
31207
+ * 构造 tab 类型定义。
31208
+ *
31209
+ * 文案用 thunk(`() => string`)而不是现取的字符串:契约里写明 thunk 每次使用都会重读,
31210
+ * 所以切换语言不需要重新注册。`title` 是 tab chip 的初始文字,开 tab 的那一刻被捕获进
31211
+ * layout record。
31212
+ *
31213
+ * `patterns` 故意省略 —— 省略即「page type」,只能按 kind 打开(guide 卡片点一下就是这条路),
31214
+ * 不会被文件地址意外匹配到。`priority` 用默认的 `extension`。
31215
+ */
31216
+ function pluginsTabDefinition() {
31217
+ const label = () => translate(getWorkspaceLocale(), "pluginsTab");
31218
+ const description = () => translate(getWorkspaceLocale(), "pluginsTabDesc");
31219
+ return {
31220
+ id: PLUGINS_TAB_ID,
31221
+ kind: PLUGINS_TAB_KIND,
31222
+ title: label,
31223
+ guide: [{
31224
+ id: "plugins",
31225
+ order: 40,
31226
+ title: label,
31227
+ description,
31228
+ icon: import_lucide_react.Package
31229
+ }]
31230
+ };
31231
+ }
31232
+ /**
31233
+ * tab chip 里的文字。
31234
+ *
31235
+ * 不读 `useTabInfo` 是刻意的:标题在开 tab 时就被捕获了,跟着当前语言走没有意义;
31236
+ * 而且 rc.2 / rc.3 的 hook 注入形状不同,少依赖一处就少一处版本耦合。
31237
+ */
31238
+ function PluginsTabTitle() {
31239
+ const { t } = useWorkspaceI18n();
31240
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: t("pluginsTab") });
31241
+ }
31242
+ /**
31243
+ * 右侧栏里的插件清单。
31244
+ *
31245
+ * 与设置页那张折叠卡读同一对端点(`GET/PATCH /settings/plugins`),但形态不同:
31246
+ * 这里是**常驻面板**,没有卡片外壳,清单直接铺开;刷新按钮常驻而不是「展开才拉」——
31247
+ * 用户既然把这张卡 pin 到了 guide 页,就是打算经常看。
31248
+ *
31249
+ * 三条与设置页一致的产品约束(详见 `workspace.tsx` 的 `PluginsSection` 注释):
31250
+ * 官方包不可停用、`runtime-provided` 不是故障、改完要重启才生效。
31251
+ */
31252
+ function makePluginsTabBody(api) {
31253
+ return function PluginsTabBody() {
31254
+ const { t } = useWorkspaceI18n();
31255
+ const [inventory, setInventory] = (0, react.useState)(void 0);
31256
+ const [busy, setBusy] = (0, react.useState)(false);
31257
+ const [checking, setChecking] = (0, react.useState)(false);
31258
+ const [toggling, setToggling] = (0, react.useState)(void 0);
31259
+ const [error, setError] = (0, react.useState)(void 0);
31260
+ const [restartRequired, setRestartRequired] = (0, react.useState)(false);
31261
+ const load = (0, react.useCallback)(async (checkUpdates) => {
31262
+ if (checkUpdates) setChecking(true);
31263
+ else setBusy(true);
31264
+ setError(void 0);
31265
+ try {
31266
+ setInventory(await api.pluginInventory(checkUpdates));
31267
+ } catch (cause) {
31268
+ setError(messageOf(cause, t));
31269
+ } finally {
31270
+ if (checkUpdates) setChecking(false);
31271
+ else setBusy(false);
31272
+ }
31273
+ }, [t]);
31274
+ (0, react.useEffect)(() => {
31275
+ load(false);
31276
+ }, [load]);
31277
+ const toggle = (0, react.useCallback)(async (entry) => {
31278
+ setToggling(entry.name);
31279
+ setError(void 0);
31280
+ try {
31281
+ await api.togglePlugin(entry.name, !entry.loaded);
31282
+ setInventory(await api.pluginInventory(false));
31283
+ setRestartRequired(true);
31284
+ } catch (cause) {
31285
+ setError(messageOf(cause, t));
31286
+ } finally {
31287
+ setToggling(void 0);
31288
+ }
31289
+ }, [t]);
31290
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31291
+ className: "daw-plugins-tab",
31292
+ children: [
31293
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31294
+ className: "daw-list-meta",
31295
+ children: t("pluginsHint")
31296
+ }),
31297
+ error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31298
+ className: "daw-warning",
31299
+ role: "alert",
31300
+ children: error
31301
+ }),
31302
+ restartRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31303
+ className: "daw-success",
31304
+ role: "status",
31305
+ children: t("pluginsRestartRequired")
31306
+ }),
31307
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31308
+ className: "daw-form-row",
31309
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
31310
+ type: "button",
31311
+ className: "daw-command",
31312
+ disabled: busy,
31313
+ onClick: () => {
31314
+ load(false);
31315
+ },
31510
31316
  children: [
31511
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31512
- className: "daw-field",
31513
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindIp") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31514
- className: "daw-input",
31515
- value: listenerHost,
31516
- disabled: status?.remoteEnabled || managementLocked,
31517
- spellCheck: false,
31518
- placeholder: "0.0.0.0",
31519
- onChange: (event) => setListenerHost(event.target.value)
31520
- })]
31521
- }),
31522
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31523
- className: "daw-field",
31524
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindPort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31525
- className: "daw-input",
31526
- type: "number",
31527
- min: 1,
31528
- max: 65535,
31529
- inputMode: "numeric",
31530
- value: listenerPort,
31531
- disabled: status?.remoteEnabled || managementLocked,
31532
- onChange: (event) => setListenerPort(event.target.value)
31533
- })]
31534
- }),
31535
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31536
- className: "daw-command",
31537
- disabled: managementLocked || status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
31538
- onClick: () => {
31539
- setError(void 0);
31540
- props.api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(() => {
31541
- notify("listenerSaved");
31542
- return refresh();
31543
- }).catch(setError);
31544
- },
31545
- children: t("saveListener")
31546
- })
31547
- ]
31548
- }),
31549
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31550
- className: "daw-listener-status",
31551
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
31552
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [t("configuredListener"), ":"] }),
31553
- " ",
31554
- formatEndpoint(status?.configuredHost ?? listenerHost, status?.configuredPort ?? parsedListenerPort)
31555
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
31556
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [t("actualListener"), ":"] }),
31317
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.RefreshCw, { size: 14 }),
31557
31318
  " ",
31558
- formatEndpoint(status?.host ?? "127.0.0.1", status?.port ?? parsedListenerPort)
31559
- ] })]
31560
- }),
31561
- status?.remoteEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31562
- className: "daw-list-meta daw-listener-hint",
31563
- children: t("disableToEditListener")
31564
- }),
31565
- !status?.remoteEnabled && listenerDirty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31566
- className: "daw-list-meta daw-listener-hint",
31567
- children: t("saveBeforeEnable")
31568
- }),
31569
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31570
- className: `daw-pill${status?.remoteEnabled ? " on" : ""}`,
31571
- children: status?.remoteEnabled ? t("remoteEnabled") : t("loopbackOnly")
31572
- }) }),
31573
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31574
- className: "daw-tunnel",
31575
- children: [
31576
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31577
- className: "daw-tunnel-head",
31578
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("tunnelTitle") }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31579
- className: `daw-pill${tunnel?.running ? " on" : ""}`,
31580
- children: tunnel?.running ? t("tunnelRunning") : t("tunnelStopped")
31581
- })]
31582
- }),
31583
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31584
- className: "daw-list-meta",
31585
- children: t("tunnelHint")
31586
- }),
31587
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31588
- className: "daw-warning",
31589
- children: t("tunnelSecurityWarning")
31590
- }),
31591
- tunnel?.errorKey !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31592
- className: "daw-warning",
31593
- role: "alert",
31594
- children: t(tunnel.errorKey, tunnel.errorParams ?? {})
31595
- }),
31596
- tunnel?.errorKey === void 0 && tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31597
- className: "daw-warning",
31598
- role: "alert",
31599
- children: tunnel.error
31600
- }),
31601
- tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31602
- className: "daw-url-row",
31603
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31604
- className: "daw-tunnel-url",
31605
- children: tunnel.url
31606
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31607
- text: tunnel.url,
31608
- title: t("copyUrl"),
31609
- copiedText: t("copied"),
31610
- manualText: t("copyManual"),
31611
- copyText
31612
- })]
31613
- }),
31614
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31615
- className: "daw-command primary",
31616
- style: { marginTop: 10 },
31617
- disabled: tunnelBusy || tunnel?.running !== true && roots.length === 0,
31618
- onClick: () => {
31619
- if (tunnel?.running === true) stopTunnel();
31620
- else createTunnel();
31621
- },
31622
- children: tunnel?.running === true ? t("tunnelStop") : t("tunnelCreate")
31623
- }),
31624
- activity !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31625
- className: "daw-tunnel-progress",
31626
- style: { marginTop: 10 },
31627
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31628
- className: "daw-progress-track",
31629
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31630
- className: `daw-progress-fill${activity.percent === null ? " indeterminate" : ""}`,
31631
- style: activity.percent === null ? void 0 : { width: `${activity.percent}%` }
31632
- })
31633
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31634
- className: "daw-list-meta",
31635
- style: { marginTop: 4 },
31636
- children: activity.labelKey === "" ? "" : t(activity.labelKey, activity.labelParams ?? {})
31637
- })]
31638
- })
31319
+ busy ? t("loading") : t("refresh")
31639
31320
  ]
31640
- }),
31641
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("fieldset", {
31642
- disabled: managementLocked,
31643
- style: {
31644
- border: "none",
31645
- margin: 0,
31646
- padding: 0
31321
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31322
+ type: "button",
31323
+ className: "daw-command",
31324
+ disabled: checking,
31325
+ onClick: () => {
31326
+ load(true);
31647
31327
  },
31648
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31649
- className: "daw-scope-grid",
31650
- children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31651
- className: "daw-check",
31328
+ children: checking ? t("pluginsChecking") : t("pluginsCheckUpdates")
31329
+ })]
31330
+ }),
31331
+ inventory === void 0 ? busy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31332
+ className: "daw-list-meta",
31333
+ children: t("loading")
31334
+ }) : null : !inventory.available ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31335
+ className: "daw-list-meta",
31336
+ children: t("pluginsUnavailable", { reason: inventory.reason ?? "" })
31337
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31338
+ className: "daw-list-meta",
31339
+ children: [
31340
+ inventory.profile !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [inventory.profile, " · "] }),
31341
+ inventory.loadedCount,
31342
+ " / ",
31343
+ inventory.items.length,
31344
+ inventory.problemCount > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" · ", t("pluginsProblems", { count: inventory.problemCount })] })
31345
+ ]
31346
+ }), inventory.items.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31347
+ className: "daw-list-meta daw-empty",
31348
+ children: t("pluginsEmpty")
31349
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31350
+ className: "daw-list",
31351
+ children: inventory.items.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31352
+ className: "daw-list-row",
31353
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
31354
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31355
+ className: "daw-list-title",
31356
+ children: [entry.name, entry.installed !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
31357
+ className: "daw-list-meta",
31358
+ children: [" · ", entry.installed]
31359
+ })]
31360
+ }),
31361
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31362
+ className: "daw-list-meta",
31363
+ children: entry.description ?? t("pluginsNoDescription")
31364
+ }),
31365
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31366
+ className: "daw-list-meta",
31652
31367
  children: [
31653
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31654
- type: "checkbox",
31655
- checked: grantedScopes.includes(scope),
31656
- onChange: () => toggleScope(scope)
31368
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31369
+ className: `daw-pill${entry.loaded ? " on" : ""}`,
31370
+ children: t(pluginStateKey(entry.state))
31657
31371
  }),
31658
- " ",
31659
- scopeLabel(locale, scope)
31372
+ entry.updateAvailable === true && entry.latestVersion !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31373
+ className: "daw-pill",
31374
+ children: t("pluginsUpdateAvailable", { version: entry.latestVersion })
31375
+ })] }),
31376
+ entry.updateAvailable === false && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" · ", t("pluginsLatest")] })
31660
31377
  ]
31661
- }, scope))
31662
- })
31663
- }),
31664
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31665
- className: "daw-list-meta",
31666
- children: grantNotice
31667
- }),
31668
- status?.remoteEnabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31669
- className: "daw-command danger",
31670
- disabled: managementLocked,
31671
- title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31672
- onClick: () => {
31673
- if (!window.confirm(t("disableRemoteConfirm"))) return;
31674
- setError(void 0);
31675
- props.api.disableRemote().then((result) => poll(result.id)).then(() => {
31676
- notify("remoteDisabled");
31677
- return refresh();
31678
- }).catch(setError);
31679
- },
31680
- children: t("disableRemote")
31681
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31682
- className: "daw-command primary",
31683
- disabled: managementLocked || roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
31684
- onClick: () => {
31685
- setError(void 0);
31686
- props.api.enableRemote(roots.map((root) => root.id), grantedScopes).then((result) => poll(result.id)).catch(setError);
31687
- },
31688
- children: t("enablePairing")
31689
- }),
31690
- (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31691
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31692
- className: "daw-command daw-inline-command",
31693
- disabled: managementLocked || grantedScopes.length === 0,
31694
- title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31695
- onClick: () => {
31696
- setError(void 0);
31697
- props.api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => {
31698
- setPairing(value);
31699
- notify("pairingCreated");
31700
- }).catch(setError);
31701
- },
31702
- children: t("newPairingCode")
31703
- }),
31704
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31705
- className: "daw-command daw-inline-command",
31706
- disabled: managementLocked || configTextBusy,
31707
- title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
31708
- onClick: () => {
31709
- const manual = configEndpoint.trim();
31710
- setConfigTextBusy(true);
31711
- setError(void 0);
31712
- props.api.createConfigText({
31713
- ...roots.length === 0 ? {} : { rootIds: roots.map((root) => root.id) },
31714
- ...manual === "" ? {} : { endpoints: [{
31715
- label: t("configTextManualLabel"),
31716
- baseUrl: manual
31717
- }] }
31718
- }).then((value) => {
31719
- setConfigText(value);
31720
- notify("configCreated");
31721
- }).catch(setError).finally(() => setConfigTextBusy(false));
31722
- },
31723
- children: t("newConfigText")
31724
- }),
31725
- tunnel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31726
- className: "daw-command daw-inline-command",
31727
- disabled: configTextBusy || tunnelBusy,
31378
+ })
31379
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: entry.toggleable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31380
+ type: "button",
31381
+ className: "daw-command",
31382
+ disabled: toggling === entry.name,
31728
31383
  onClick: () => {
31729
- tunnelConfigClick();
31384
+ toggle(entry);
31730
31385
  },
31731
- children: tunnel.running !== true ? t("tunnelStartAndGenConfig") : t("tunnelRestartAndGenConfig")
31732
- }),
31733
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31734
- className: "daw-list-meta daw-listener-hint",
31735
- children: t("configTextScopeNote")
31736
- })
31737
- ] }),
31738
- pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31739
- className: "daw-code-row",
31740
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31741
- className: "daw-code",
31742
- children: pairing.code
31743
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
31744
- className: "daw-copy-inline",
31745
- text: pairing.code,
31746
- title: t("copyPairing"),
31747
- copiedText: t("copied"),
31748
- manualText: t("copyManual"),
31749
- copyText
31750
- })]
31751
- }), pairing.expiresAt !== void 0 && pairing.expiresAt > now && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31752
- className: "daw-list-meta",
31753
- style: { marginTop: -4 },
31754
- children: t("pairingExpiresAt", { minutes: pairingMinutesLeft(pairing.expiresAt, now) })
31755
- })] }),
31756
- (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31757
- style: {
31758
- marginTop: 12,
31759
- maxWidth: 560
31760
- },
31761
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31762
- className: "daw-field",
31763
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("configTextManual") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31764
- className: "daw-input",
31765
- value: configEndpoint,
31766
- disabled: managementLocked,
31767
- spellCheck: false,
31768
- placeholder: t("configTextManualPlaceholder"),
31769
- onChange: (event) => setConfigEndpoint(event.target.value)
31770
- })]
31771
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31772
- className: "daw-list-meta",
31773
- style: { marginTop: 5 },
31774
- children: t("configTextManualHint")
31775
- })]
31386
+ children: entry.loaded ? t("pluginsDisable") : t("pluginsEnable")
31387
+ }) })]
31388
+ }, entry.name))
31389
+ })] })
31390
+ ]
31391
+ });
31392
+ };
31393
+ }
31394
+ //#endregion
31395
+ //#region src/client/styles.ts
31396
+ const STYLE_ID = "dsh-workspace-style";
31397
+ /**
31398
+ * 插件的全部样式。
31399
+ *
31400
+ * 导出出来是为了让测试能守住一条容易失守的不变量:**CSS 变量块必须同时覆盖
31401
+ * `.daw-root` 与 `.daw-settings-section`**。
31402
+ *
31403
+ * 这条防线来自一个真实发生过的隐形 bug:设置页内联渲染时,那一节没有 `.daw-root` 祖先,
31404
+ * 于是 `var(--daw-accent)` 解析失败 → `.daw-command.primary` 的 `background` 失效变透明、
31405
+ * 而 `color` 仍是 `#fff` → **白字落在白底上,按钮彻底看不见**。
31406
+ * 类型检查、单测、构建全都发现不了,只有肉眼看渲染或量计算样式才抓得到。
31407
+ */
31408
+ const WORKSPACE_CSS = `
31409
+ .daw-root,.daw-settings-section,.daw-plugins-tab{--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}
31410
+ .daw-root *,.daw-settings-section *,.daw-plugins-tab *{box-sizing:border-box;letter-spacing:0}
31411
+ /* 输入控件基线(UI-04):此前只有 :focus-visible 一条,非聚焦态全靠浏览器默认(2px inset/Arial/灰底),
31412
+ 深色令牌下实测白底黑字与面板割裂。这里给齐边框/底/字/内边距,聚焦态只换描边。 */
31413
+ .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}
31414
+ .daw-input::placeholder{color:var(--dsw-alias-label-tertiary,var(--daw-muted))}
31415
+ .daw-input:focus-visible{border-color:var(--dsw-alias-brand-primary,var(--daw-accent));outline:none}
31416
+ .daw-input:disabled{opacity:.55;cursor:not-allowed}.daw-icon{width:30px;padding:0}
31417
+ .daw-icon:hover{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}
31418
+ .daw-icon.danger{color:var(--daw-danger);border-color:transparent;background:transparent}
31419
+ .daw-icon:disabled{opacity:.45;cursor:not-allowed}
31420
+ /* 普通命令按钮基线(UI-04):旧版写死 background:#fff/border:#cbd2d8,深色令牌下成了白块黑字。
31421
+ 全部改走 DSH 令牌;.primary 沿用面板底色做次按钮、真正的主操作由调用方加 .primary 配合。 */
31422
+ .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}
31423
+ .daw-command:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
31424
+ .daw-command:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:1px}
31425
+ /* 禁用态(UI-05):此前无任何 :disabled 样式,只剩浏览器默认的 30% 文字且仍是箭头手型。 */
31426
+ .daw-command:disabled{opacity:.5;cursor:not-allowed}
31427
+ .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}
31428
+ .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))}
31429
+ .daw-command.danger{color:var(--daw-danger)}
31430
+ .daw-command.danger:hover:not(:disabled){border-color:var(--daw-danger)}
31431
+ /* 图标+文字的小复制按钮:图标和文字对齐,窄屏只露图标也不改变按钮高度。 */
31432
+ .daw-command.icon-as-command{display:inline-flex;align-items:center;gap:6px;padding:0 10px;white-space:nowrap}
31433
+ .daw-copy-label{font-size:12px}
31434
+ .daw-empty{padding:14px 2px;text-align:center}
31435
+ .daw-admin{overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}.daw-admin h3{font-size:14px;margin:0 0 12px}
31436
+ .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}
31437
+ .daw-card:hover{border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
31438
+ .daw-card-open{background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
31439
+ .daw-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
31440
+ .daw-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:-2px}
31441
+ .daw-card-icon{color:var(--dsw-alias-label-tertiary,var(--daw-muted));flex:none;display:flex;align-items:center}
31442
+ .daw-card-headtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
31443
+ .daw-card-name{color:var(--dsw-alias-label-primary,var(--daw-ink));font-size:15px;font-weight:600;line-height:1.4}
31444
+ .daw-card-desc{color:var(--dsw-alias-label-tertiary,var(--daw-muted));font-size:13px;line-height:1.5}
31445
+ .daw-card-chevron{color:var(--dsw-alias-label-tertiary,var(--daw-muted));flex:none;transition:transform .16s;font-size:12px;line-height:1}
31446
+ .daw-card-chevron-open{transform:rotate(180deg)}
31447
+ .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-plugins-tab{overflow:auto;padding:14px 16px;display:flex;flex-direction:column;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}
31448
+ .daw-list-title .daw-pill{margin-left:8px;vertical-align:middle}
31449
+ .daw-builtin-hint{text-align:right;align-self:center}
31450
+ .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}
31451
+ /* 成功反馈条(UI-07):与 .daw-warning 同形,但走成功色,操作成功不再静默。 */
31452
+ .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}
31453
+ .daw-admin-topbar{display:flex;justify-content:flex-end;margin-bottom:8px}
31454
+ .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}
31455
+ .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}
31456
+ /* 隧道/监听进行中的流动条(UI-08):percent=null 时铺一段高光在轨道里循环滑动,
31457
+ 不再把「未知进度」伪装成恒定 30%。 */
31458
+ .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}
31459
+ @keyframes daw-indeterminate{0%{transform:translateX(-120%)}100%{transform:translateX(280%)}}
31460
+ /* 「高级设置」组:外层是个普通卡片,组内的卡片缩进一格并挂一道左侧色条,
31461
+ 让人一眼看出「这些是嵌进去的,不是与主列表同级」。功能上与主列表完全一致。 */
31462
+ .daw-advanced-group .daw-card-body{display:flex;flex-direction:column;gap:10px;padding-bottom:14px}
31463
+ .daw-advanced-body .daw-card{border-left:3px solid var(--dsw-alias-state-info-primary,var(--daw-accent));background:var(--dsw-alias-bg-layer-3,var(--daw-panel))}
31464
+ .daw-advanced-body .daw-card-header{padding:12px 14px}
31465
+ .daw-advanced-body .daw-card-name{font-size:14px}
31466
+ @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}}
31467
+ `;
31468
+ function installWorkspaceStyles() {
31469
+ if (document.getElementById(STYLE_ID) !== null) return;
31470
+ const style = document.createElement("style");
31471
+ style.id = STYLE_ID;
31472
+ style.dataset.plugin = "dsh-workspace";
31473
+ style.textContent = WORKSPACE_CSS;
31474
+ document.head.appendChild(style);
31475
+ }
31476
+ //#endregion
31477
+ //#region src/client/app-qr.ts
31478
+ const APP_QR_CODES = [{
31479
+ "key": "github",
31480
+ "label": "GitHub",
31481
+ "url": "https://github.com/westanke/dsh-companion/releases",
31482
+ "dataUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNjQiIGhlaWdodD0iMjY0IiB2aWV3Qm94PSIwIDAgNDEgNDEiIHNoYXBlLXJlbmRlcmluZz0iY3Jpc3BFZGdlcyI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTAgMGg0MXY0MUgweiIvPjxwYXRoIHN0cm9rZT0iIzAwMDAwMCIgZD0iTTQgNC41aDdtMSAwaDFtMiAwaDFtMyAwaDNtMyAwaDFtMSAwaDFtMiAwaDdNNCA1LjVoMW01IDBoMW0zIDBoM200IDBoMW0xIDBoMW0yIDBoMW0zIDBoMW01IDBoMU00IDYuNWgxbTEgMGgzbTEgMGgxbTMgMGgybTEgMGgxbTEgMGgxbTQgMGg1bTEgMGgxbTEgMGgzbTEgMGgxTTQgNy41aDFtMSAwaDNtMSAwaDFtMSAwaDJtMiAwaDFtMSAwaDJtMSAwaDJtMSAwaDFtMiAwaDFtMiAwaDFtMSAwaDNtMSAwaDFNNCA4LjVoMW0xIDBoM20xIDBoMW0xIDBoNG0xIDBoMm0zIDBoM20yIDBoMW0yIDBoMW0xIDBoM20xIDBoMU00IDkuNWgxbTUgMGgxbTEgMGgxbTEgMGgxbTMgMGgxbTEgMGg0bTEgMGgxbTQgMGgxbTUgMGgxTTQgMTAuNWg3bTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGg3TTEyIDExLjVoMW01IDBoNW0yIDBoNE00IDEyLjVoMW0zIDBoMW0xIDBoNG03IDBoMW0xIDBoM20zIDBoNW0yIDBoMU00IDEzLjVoMW02IDBoMW0zIDBoMW0xIDBoNG02IDBoMW0xIDBoMW0zIDBoM000IDE0LjVoMm00IDBoNm0zIDBoMW0xIDBoMW0yIDBoMm0xIDBoMW0yIDBoMW0yIDBoMW0xIDBoMU00IDE1LjVoMW0yIDBoMm0zIDBoMW0xIDBoM20xIDBoMW0yIDBoNW0xIDBoMW0xIDBoMW01IDBoMk01IDE2LjVoMW0xIDBoMW0yIDBoMW0xIDBoMW0yIDBoMW0xIDBoM20yIDBoN20xIDBoMW0xIDBoMm0xIDBoMk00IDE3LjVoMW02IDBoMW0xIDBoNW0yIDBoMW0yIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDE4LjVoMm0xIDBoM20xIDBoMm01IDBoNG0zIDBoMW0xIDBoM20yIDBoMk00IDE5LjVoMW0yIDBoMm00IDBoMm0zIDBoMW0yIDBoMW0xIDBoNG0yIDBoMk02IDIwLjVoMW0yIDBoMm0yIDBoMm0yIDBoM20xIDBoMW0zIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0yIDBoMU00IDIxLjVoMW0xIDBoMW0yIDBoMW0yIDBoMW0xIDBoMm0yIDBoMm0xIDBoMm00IDBoMW0xIDBoMW0zIDBoM000IDIyLjVoNW0xIDBoMW0xIDBoMm0xIDBoM20xIDBoMW0yIDBoMW0xIDBoNG0xIDBoM20xIDBoMW0xIDBoMU01IDIzLjVoMm0xIDBoMm0yIDBoMW0yIDBoMm0yIDBoMW0zIDBoM20xIDBoNW0zIDBoMU01IDI0LjVoMW0xIDBoMm0xIDBoMW0zIDBoMm0xIDBoMm0xIDBoMW0zIDBoM20zIDBoMW0xIDBoMk00IDI1LjVoM20xIDBoMW0yIDBoMm05IDBoM20xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDI2LjVoMW0xIDBoMW0xIDBoMW0xIDBoMW00IDBoM20xIDBoMW0yIDBoMW0xIDBoNG0yIDBoMW0yIDBoMU04IDI3LjVoMm0xIDBoMW0xIDBoMm0yIDBoMW0xIDBoNW0xIDBoMW0yIDBoNW0zIDBoMU00IDI4LjVoNW0xIDBoMW0yIDBoNG0xIDBoMW0yIDBoMW0xIDBoMTFtMSAwaDJNMTIgMjkuNWgybTIgMGgybTEgMGgybTEgMGgxbTEgMGgxbTIgMGgybTMgMGgxbTEgMGgyTTQgMzAuNWg3bTEgMGgxbTEgMGgxbTQgMGgxbTEgMGgxbTIgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgyTTQgMzEuNWgxbTUgMGgxbTMgMGgxbTUgMGgzbTEgMGgybTIgMGgxbTMgMGgxTTQgMzIuNWgxbTEgMGgzbTEgMGgxbTEgMGgxbTQgMGgzbTIgMGgxbTIgMGgybTEgMGg2bTEgMGgxTTQgMzMuNWgxbTEgMGgzbTEgMGgxbTIgMGgxbTEgMGgybTMgMGgybTMgMGgxbTEgMGgybTEgMGgzbTEgMGgxTTQgMzQuNWgxbTEgMGgzbTEgMGgxbTIgMGgzbTQgMGgxbTEgMGgxbTEgMGgxbTIgMGgybTEgMGg0TTQgMzUuNWgxbTUgMGgxbTQgMGgxbTUgMGgxbTIgMGgybTEgMGg0TTQgMzYuNWg3bTEgMGgxbTEgMGgzbTEgMGgybTEgMGgxbTEgMGg0bTEgMGgybTYgMGgxIi8+PC9zdmc+Cg=="
31483
+ }, {
31484
+ "key": "gitee",
31485
+ "label": "Gitee",
31486
+ "url": "https://gitee.com/westanke/dsh-companion/releases",
31487
+ "dataUrl": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNjQiIGhlaWdodD0iMjY0IiB2aWV3Qm94PSIwIDAgNDEgNDEiIHNoYXBlLXJlbmRlcmluZz0iY3Jpc3BFZGdlcyI+PHBhdGggZmlsbD0iI2ZmZmZmZiIgZD0iTTAgMGg0MXY0MUgweiIvPjxwYXRoIHN0cm9rZT0iIzAwMDAwMCIgZD0iTTQgNC41aDdtMSAwaDFtMSAwaDJtMSAwaDRtNiAwaDJtMSAwaDdNNCA1LjVoMW01IDBoMW0zIDBoMm0xIDBoMm0xIDBoMW0xIDBoMm0yIDBoMW0zIDBoMW01IDBoMU00IDYuNWgxbTEgMGgzbTEgMGgxbTIgMGgybTEgMGgxbTEgMGgxbTEgMGg0bTEgMGgxbTEgMGgybTEgMGgxbTEgMGgzbTEgMGgxTTQgNy41aDFtMSAwaDNtMSAwaDFtMSAwaDVtMiAwaDNtNSAwaDFtMiAwaDFtMSAwaDNtMSAwaDFNNCA4LjVoMW0xIDBoM20xIDBoMW0xIDBoMW0xIDBoM20zIDBoMW0yIDBoMW0xIDBoMW0xIDBoMW0yIDBoMW0xIDBoM20xIDBoMU00IDkuNWgxbTUgMGgxbTEgMGgybTMgMGgzbTQgMGgzbTEgMGgxbTEgMGgxbTUgMGgxTTQgMTAuNWg3bTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGgxbTEgMGg3TTEyIDExLjVoNW00IDBoMW0xIDBoNk00IDEyLjVoMW0zIDBoMW0xIDBoNG0xIDBoMm0xIDBoNG0zIDBoOW0yIDBoMU00IDEzLjVoMW0xIDBoM201IDBoMW0xIDBoMW0xIDBoNW00IDBoMW0xIDBoMW0zIDBoM000IDE0LjVoMW0xIDBoMW0xIDBoMW0xIDBoNG0zIDBoNG0xIDBoMW0xIDBoN20yIDBoMW0xIDBoMU01IDE1LjVoMW0zIDBoMW0xIDBoMm0yIDBoMW0xIDBoMW0xIDBoMW00IDBoMm0xIDBoNG00IDBoMk00IDE2LjVoMm0yIDBoMW0xIDBoMW0yIDBoMm0yIDBoMW0yIDBoMW0yIDBoMW0xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMk04IDE3LjVoMm0xIDBoMW0yIDBoM20xIDBoMW0zIDBoM20xIDBoMm0xIDBoMW0xIDBoMW0xIDBoMU00IDE4LjVoMW0xIDBoMW0xIDBoM20xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMm0xIDBoMW0yIDBoMW0xIDBoNW0xIDBoMW0yIDBoMU00IDE5LjVoMW00IDBoMW0yIDBoMW0yIDBoM20xIDBoNG0yIDBoMW0xIDBoNU00IDIwLjVoNW0xIDBoNW0yIDBoMW0zIDBoMW0xIDBoNG0xIDBoMW0xIDBoMW0xIDBoMm0xIDBoMU01IDIxLjVoMW01IDBoMm01IDBoM20xIDBoMW00IDBoMW0xIDBoMW0zIDBoM001IDIyLjVoMm0yIDBoM20yIDBoMW0xIDBoMW0xIDBoMm0xIDBoMW0yIDBoMW0xIDBoMm0xIDBoM20xIDBoMW0xIDBoMU00IDIzLjVoMW0yIDBoMW0zIDBoNW01IDBoNW0xIDBoMm0xIDBoMm0zIDBoMk00IDI0LjVoNG0xIDBoMm0xIDBoMm0xIDBoMW0yIDBoMm0yIDBoNW0xIDBoM20xIDBoMm0xIDBoMk00IDI1LjVoNG00IDBoM20xIDBoMW0zIDBoMm0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0yIDBoMk04IDI2LjVoNW0yIDBoNG0xIDBoMW0xIDBoMm0zIDBoMW0xIDBoMW0xIDBoMW0xIDBoMW0xIDBoMU02IDI3LjVoMW0yIDBoMW00IDBoM200IDBoMW0xIDBoM20zIDBoMm00IDBoMU00IDI4LjVoM20zIDBoM200IDBoMW0xIDBoMW0xIDBoMW0zIDBoMm0xIDBoNW0yIDBoMk0xMiAyOS41aDFtMiAwaDFtMyAwaDFtMSAwaDJtNCAwaDJtMyAwaDFtMSAwaDJNNCAzMC41aDdtMSAwaDFtMyAwaDRtMiAwaDFtMSAwaDJtMiAwaDFtMSAwaDFtMSAwaDJtMSAwaDFNNCAzMS41aDFtNSAwaDFtMiAwaDFtMiAwaDFtMiAwaDFtMyAwaDNtMiAwaDFtMyAwaDFtMiAwaDFNNCAzMi41aDFtMSAwaDNtMSAwaDFtMSAwaDFtMSAwaDFtMSAwaDJtMiAwaDFtMyAwaDNtMSAwaDZNNCAzMy41aDFtMSAwaDNtMSAwaDFtMiAwaDFtMiAwaDFtNCAwaDhtMSAwaDNtMSAwaDFNNCAzNC41aDFtMSAwaDNtMSAwaDFtMiAwaDFtMSAwaDJtMSAwaDJtNCAwaDJtMSAwaDJtMSAwaDNNNCAzNS41aDFtNSAwaDFtNCAwaDNtMSAwaDVtMSAwaDFtMiAwaDNNNCAzNi41aDdtMSAwaDFtMiAwaDJtMSAwaDFtMiAwaDFtMSAwaDNtMSAwaDJtNyAwaDEiLz48L3N2Zz4K"
31488
+ }];
31489
+ //#endregion
31490
+ //#region src/client/cards/shell.tsx
31491
+ /**
31492
+ * 每张卡的图标。
31493
+ *
31494
+ * `remote` 与 `tunnel` 刻意不同款:前者是「在本机开个口子」(Upload),后者是「把机器搬到
31495
+ * 外网」(Cloud)。两者共用同一个图标会让用户以为它们是同一件事的两个开关 ——
31496
+ * 而那正是拆分前那张 123 行卡片的毛病。
31497
+ */
31498
+ function tabIcon(tab) {
31499
+ if (tab === "roots") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.HardDrive, { size: 15 });
31500
+ if (tab === "devices") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Smartphone, { size: 15 });
31501
+ if (tab === "image") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Image, { size: 15 });
31502
+ if (tab === "trash") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 });
31503
+ if (tab === "plugins") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Package, { size: 15 });
31504
+ if (tab === "remote") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Upload, { size: 15 });
31505
+ if (tab === "tunnel") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Cloud, { size: 15 });
31506
+ if (tab === "app-download") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Download, { size: 15 });
31507
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.File, { size: 15 });
31508
+ }
31509
+ function SettingsCard(props) {
31510
+ const { t } = useWorkspaceI18n();
31511
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
31512
+ className: `daw-card${props.open ? " daw-card-open" : ""}`,
31513
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
31514
+ type: "button",
31515
+ className: "daw-card-header",
31516
+ "aria-expanded": props.open,
31517
+ onClick: props.onToggle,
31518
+ children: [
31519
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31520
+ className: "daw-card-icon",
31521
+ "aria-hidden": "true",
31522
+ children: tabIcon(props.tab)
31523
+ }),
31524
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
31525
+ className: "daw-card-headtext",
31526
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31527
+ className: "daw-card-name",
31528
+ children: t(props.nameKey)
31529
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31530
+ className: "daw-card-desc",
31531
+ children: t(props.descKey)
31532
+ })]
31533
+ }),
31534
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31535
+ className: `daw-card-chevron${props.open ? " daw-card-chevron-open" : ""}`,
31536
+ "aria-hidden": "true",
31537
+ children: "▾"
31538
+ })
31539
+ ]
31540
+ }), props.open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31541
+ className: "daw-card-body",
31542
+ children: props.children
31543
+ })]
31544
+ });
31545
+ }
31546
+ /**
31547
+ * 「高级设置」组:把平时用不到的分区收进一个可折叠的容器。
31548
+ *
31549
+ * 关键点:**分组只影响显隐,不影响功能**。组里的每一张仍是完整的 `SettingsCard`,
31550
+ * 该拉的请求照拉、该写的接口照写 —— `image` 的懒加载挂在 `openCards` 上、
31551
+ * `plugins` 的懒加载挂在它自己的 `open` 上,两者都不经过这一层。
31552
+ *
31553
+ * 组本身**默认收起**:设置页一进来先看到主列表那几张常用的卡。
31554
+ */
31555
+ function AdvancedGroup(props) {
31556
+ const { t } = useWorkspaceI18n();
31557
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
31558
+ className: `daw-card${props.open ? " daw-card-open" : ""} daw-advanced-group`,
31559
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
31560
+ type: "button",
31561
+ className: "daw-card-header",
31562
+ "aria-expanded": props.open,
31563
+ onClick: props.onToggle,
31564
+ children: [
31565
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31566
+ className: "daw-card-icon",
31567
+ "aria-hidden": "true",
31568
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.SlidersHorizontal, { size: 15 })
31569
+ }),
31570
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
31571
+ className: "daw-card-headtext",
31572
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31573
+ className: "daw-card-name",
31574
+ children: t("advancedGroupTitle")
31575
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31576
+ className: "daw-card-desc",
31577
+ children: t("advancedGroupDesc")
31578
+ })]
31579
+ }),
31580
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31581
+ className: `daw-card-chevron${props.open ? " daw-card-chevron-open" : ""}`,
31582
+ "aria-hidden": "true",
31583
+ children: "▾"
31584
+ })
31585
+ ]
31586
+ }), props.open && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31587
+ className: "daw-card-body daw-advanced-body",
31588
+ children: props.children
31589
+ })]
31590
+ });
31591
+ }
31592
+ /**
31593
+ * 卡片标题的 i18n 键。
31594
+ *
31595
+ * 大多数分区是 `${tab}Tab`,但 `app-download` 带连字符,裸模板会拼出 `app-downloadTab`
31596
+ * —— 没人该去记那个拼法的键名。显式映射,而不是把命名规则编码进模板。
31597
+ */
31598
+ function tabLabelKey(tab) {
31599
+ return tab === "app-download" ? "appDownloadTab" : `${tab}Tab`;
31600
+ }
31601
+ /**
31602
+ * 卡片头部的描述行。与 `tabLabelKey` 同理:唯一含连字符的分区显式映射。
31603
+ *
31604
+ * 键名是 `${tab}TabDesc`(**带 Tab**),不是 `${tab}Desc`。这个中间段不是冗余:它让
31605
+ * 描述键与标题键 `rootsTab` / `rootsTabDesc` 成对,读的人一眼能看出这两个属于同一张卡。
31606
+ *
31607
+ * 踩过的坑:早先这里写成 `${tab}Desc`,`rootsDesc` 之类根本不在字典里 —— `translate` 直接
31608
+ * `dictionaries[locale][key].replace(...)` 抛 TypeError,整个设置面板白屏。`as MessageKey`
31609
+ * 把类型检查骗过去了,所以单测全绿也照样白屏。**键名靠模板拼出来时,类型系统是最后一道
31610
+ * 防线,这里偏偏被断言关掉了。** 现在改成显式查表,见下。
31611
+ */
31612
+ const TAB_DESC_KEYS = {
31613
+ roots: "rootsTabDesc",
31614
+ remote: "remoteTabDesc",
31615
+ tunnel: "tunnelTabDesc",
31616
+ devices: "devicesTabDesc",
31617
+ image: "imageTabDesc",
31618
+ trash: "trashTabDesc",
31619
+ plugins: "pluginsTabDesc",
31620
+ "app-download": "appDownloadTabDesc"
31621
+ };
31622
+ function tabDescKey(tab) {
31623
+ return TAB_DESC_KEYS[tab];
31624
+ }
31625
+ //#endregion
31626
+ //#region src/client/cards/app-download.tsx
31627
+ /**
31628
+ * 「App 下载」卡:手机客户端的下载二维码与地址。
31629
+ *
31630
+ * 这张卡不碰任何服务端状态 —— 二维码是构建期生成的常量(`app-qr.ts`,由
31631
+ * `scripts/generate-app-qr.mjs` 产出)。它唯一的问题是「二维码指向的地址是否还有效」,
31632
+ * 那由生成脚本负责,不在这里。
31633
+ */
31634
+ function AppDownloadCard(props) {
31635
+ const { t } = useWorkspaceI18n();
31636
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31637
+ tab: "app-download",
31638
+ nameKey: tabLabelKey("app-download"),
31639
+ descKey: tabDescKey("app-download"),
31640
+ open: props.open,
31641
+ onToggle: props.onToggle,
31642
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31643
+ className: "daw-list-meta",
31644
+ children: t("appDownloadHint")
31645
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31646
+ className: "daw-qr-grid",
31647
+ children: APP_QR_CODES.map((code) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("figure", {
31648
+ className: "daw-qr-card",
31649
+ children: [
31650
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
31651
+ className: "daw-qr-image",
31652
+ src: code.dataUrl,
31653
+ alt: t("appDownloadQrAlt", { site: code.label }),
31654
+ width: 180,
31655
+ height: 180
31656
+ }),
31657
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("figcaption", {
31658
+ className: "daw-qr-site",
31659
+ children: code.label
31660
+ }),
31661
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
31662
+ className: "daw-qr-url",
31663
+ href: code.url,
31664
+ target: "_blank",
31665
+ rel: "noreferrer",
31666
+ children: code.url
31667
+ })
31668
+ ]
31669
+ }, code.key))
31670
+ })]
31671
+ });
31672
+ }
31673
+ //#endregion
31674
+ //#region src/client/cards/context.ts
31675
+ /**
31676
+ * 卡片共享上下文:`AdminPanel` 拆成 `cards/*.tsx` 之后,各卡不再共用一个巨型函数作用域,
31677
+ * 而是从这里取「大家都要用」的东西。
31678
+ *
31679
+ * ## 为什么用 context 而不是逐层传 props
31680
+ *
31681
+ * 七张卡共享的是**同一份状态**,不是各自的副本:`error` 红条要跨卡显示、`refresh()` 一次
31682
+ * 刷新所有分区、`openCards` 决定展开态。逐层传 props 会让 `AdminPanel` 重新长成一张巨大的
31683
+ * 中转表(每个 prop 出现七八遍),跟没拆一样。context 把「谁给的」这件事收敛到一处。
31684
+ *
31685
+ * ## 放进来的判据
31686
+ *
31687
+ * **至少两张卡要用**,才进这里。只有一张卡用的状态留在那个文件里,不往上提 ——
31688
+ * 否则 context 会变成第二个巨型组件,只是换了个写法。
31689
+ *
31690
+ * ## 不放进来的东西
31691
+ *
31692
+ * 各卡自己的草稿态(根目录输入框里还没提交的路径、识图目录名草稿)刻意留在卡片内:
31693
+ * 它们不需要跨卡共享,提上来只会让「这张卡正在编辑什么」泄漏到全局。
31694
+ */
31695
+ const CardsContext = (0, react.createContext)(void 0);
31696
+ const CardsProvider = CardsContext.Provider;
31697
+ function useCards() {
31698
+ const value = (0, react.useContext)(CardsContext);
31699
+ if (value === void 0) throw new Error("useCards 必须在 CardsProvider 内使用 —— 卡片没有拿到共享上下文");
31700
+ return value;
31701
+ }
31702
+ //#endregion
31703
+ //#region src/shared/contracts.ts
31704
+ const DEVICE_SCOPES = [
31705
+ "chat.read",
31706
+ "chat.write",
31707
+ "files.read",
31708
+ "files.write",
31709
+ "files.delete",
31710
+ "settings.read",
31711
+ "settings.write"
31712
+ ];
31713
+ /**
31714
+ * 授予新设备时的默认权限(**五个非破坏性权限**)。
31715
+ *
31716
+ * 刻意不含 `files.delete` 与 `settings.write`:这两个是破坏性权限,应当是用户显式
31717
+ * 要求才授予。`settings.write` 尤其危险 —— 拿到它就能改供应商 `baseURL`,把模型
31718
+ * 请求连同真实凭据引到任意地址(SEC-H11),所以它绝不能是默认值。
31719
+ *
31720
+ * 三条路径必须共用这一份,否则「同一个产品里三处默认权限不一样」这种缺陷会反复
31721
+ * 复发:服务端生成配置文本时的缺省、Web UI 配对码面板的初始勾选、隧道一键路径。
31722
+ */
31723
+ const DEFAULT_DEVICE_SCOPES = [
31724
+ "chat.read",
31725
+ "chat.write",
31726
+ "files.read",
31727
+ "files.write",
31728
+ "settings.read"
31729
+ ];
31730
+ new Set(DEVICE_SCOPES);
31731
+ //#endregion
31732
+ //#region src/client/cards/devices.tsx
31733
+ /**
31734
+ * 「设备」卡:查看已配对的手机、改名、调权限,以及一键清理长期不动的设备。
31735
+ *
31736
+ * 从 `workspace.tsx` 的 `AdminPanel` 搬出来。搬的是**整张卡连同它的 `DeviceRow`**:
31737
+ * 设备行的编辑态(editing / scopes / rootIds / name)只服务这一张卡,留在卡外没有意义。
31738
+ *
31739
+ * ## 一个「保存」按钮同时提交改名与权限
31740
+ *
31741
+ * 理由(原样保留):界面上它们在同一块编辑区里,并排两个「保存」必然有人点错。分成两次
31742
+ * 请求(而不是一个 PATCH 带全部字段)是因为它们是两件不同的事:改名不需要权限校验通过,
31743
+ * 权限校验失败也不该把名字回滚。
31744
+ *
31745
+ * 顺序:**先改名,后权限**。名字是无害的装饰性字段;万一权限请求失败,留下一个已改的
31746
+ * 名字比「权限已变但界面报错」好。
31747
+ */
31748
+ function DevicesCard(props) {
31749
+ const { api, t, status, managementLocked, refresh, notify, setError } = useCards();
31750
+ const [pruneDays, setPruneDays] = (0, react.useState)(30);
31751
+ const [pruneBusy, setPruneBusy] = (0, react.useState)(false);
31752
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31753
+ tab: "devices",
31754
+ nameKey: tabLabelKey("devices"),
31755
+ descKey: tabDescKey("devices"),
31756
+ open: props.open,
31757
+ onToggle: props.onToggle,
31758
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31759
+ className: "daw-inline-command",
31760
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31761
+ className: "daw-field",
31762
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pruneInactiveDays") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31763
+ className: "daw-input",
31764
+ type: "number",
31765
+ min: 1,
31766
+ max: 3650,
31767
+ value: pruneDays,
31768
+ disabled: managementLocked || pruneBusy,
31769
+ onChange: (event) => setPruneDays(Math.max(1, Math.min(3650, Number(event.target.value) || 1)))
31770
+ })]
31771
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31772
+ className: "daw-command",
31773
+ disabled: managementLocked || pruneBusy,
31774
+ onClick: () => {
31775
+ if (!confirm(t("pruneInactiveConfirm", { days: String(pruneDays) }))) return;
31776
+ setPruneBusy(true);
31777
+ setError(void 0);
31778
+ api.pruneInactiveDevices(pruneDays).then((result) => {
31779
+ if (result.pruned === 0) notify("pruneInactiveNone");
31780
+ else notify("pruneInactiveDone", { count: String(result.pruned) });
31781
+ return refresh();
31782
+ }).catch(setError).finally(() => setPruneBusy(false));
31783
+ },
31784
+ children: pruneBusy ? t("pruneInactiveBusy") : t("pruneInactiveAction", { days: String(pruneDays) })
31785
+ })]
31786
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31787
+ className: "daw-list",
31788
+ children: (status?.devices ?? []).map((device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceRow, { device }, device.id))
31789
+ })]
31790
+ });
31791
+ }
31792
+ /**
31793
+ * 一台设备的行:名字 + 身份摘要 + 可展开的权限编辑区。
31794
+ *
31795
+ * 共享的东西(api / roots / refresh / notify / setError)从 `useCards()` 取,而不是
31796
+ * 一层层往下传 —— 这张行的调用点只有一处,多传六个 prop 只是把巨型 props 表挪个地方。
31797
+ */
31798
+ function DeviceRow(props) {
31799
+ const { api, roots, refresh, notify, setError } = useCards();
31800
+ const { locale, t } = useWorkspaceI18n();
31801
+ const [editing, setEditing] = (0, react.useState)(false);
31802
+ const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
31803
+ const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
31804
+ const [name, setName] = (0, react.useState)(props.device.name);
31805
+ const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
31806
+ const shortId = props.device.id.length > 14 ? `${props.device.id.slice(0, 8)}…${props.device.id.slice(-4)}` : props.device.id;
31807
+ const lastSeen = props.device.lastSeenAt === void 0 ? t("deviceNeverSeen") : t("deviceLastSeen", { time: new Date(props.device.lastSeenAt).toLocaleString(locale) });
31808
+ const autoNamed = props.device.nameSource === "default" || props.device.nameSource === "detected";
31809
+ const saveAll = () => {
31810
+ const trimmed = name.trim();
31811
+ const rename = trimmed !== "" && trimmed !== props.device.name;
31812
+ (rename ? api.renameDevice(props.device.id, trimmed) : Promise.resolve()).then(() => api.updateDevice(props.device.id, scopes, rootIds)).then(() => refresh()).then(() => {
31813
+ setEditing(false);
31814
+ notify(rename ? "deviceNameSaved" : "permissionsSaved");
31815
+ }).catch(setError);
31816
+ };
31817
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31818
+ className: "daw-list-row",
31819
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
31820
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31821
+ className: "daw-list-title",
31822
+ children: [
31823
+ props.device.name,
31824
+ props.device.revokedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31825
+ className: "daw-pill",
31826
+ children: t("revoked")
31776
31827
  }),
31777
- configText !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigTextPanel, { result: configText })
31828
+ autoNamed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31829
+ className: "daw-pill",
31830
+ title: t("deviceRenameHint"),
31831
+ children: t("deviceDetectedName")
31832
+ })
31778
31833
  ]
31779
- }, "remote"),
31780
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31781
- tab: "devices",
31782
- nameKey: tabLabelKey("devices"),
31783
- descKey: tabDescKey("devices"),
31784
- open: isOpen("devices"),
31785
- onToggle: () => toggleCard("devices"),
31786
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31787
- className: "daw-inline-command",
31788
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31789
- className: "daw-field",
31790
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("pruneInactiveDays") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31791
- className: "daw-input",
31792
- type: "number",
31793
- min: 1,
31794
- max: 3650,
31795
- value: pruneDays,
31796
- disabled: managementLocked || pruneBusy,
31797
- onChange: (event) => setPruneDays(Math.max(1, Math.min(3650, Number(event.target.value) || 1)))
31798
- })]
31799
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31800
- className: "daw-command",
31801
- disabled: managementLocked || pruneBusy,
31802
- onClick: () => {
31803
- if (!confirm(t("pruneInactiveConfirm", { days: String(pruneDays) }))) return;
31804
- setPruneBusy(true);
31805
- setError(void 0);
31806
- props.api.pruneInactiveDevices(pruneDays).then((result) => {
31807
- if (result.pruned === 0) notify("pruneInactiveNone");
31808
- else notify("pruneInactiveDone", { count: String(result.pruned) });
31809
- return refresh();
31810
- }).catch(setError).finally(() => setPruneBusy(false));
31811
- },
31812
- children: pruneBusy ? t("pruneInactiveBusy") : t("pruneInactiveAction", { days: String(pruneDays) })
31813
- })]
31814
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31815
- className: "daw-list",
31816
- children: (status?.devices ?? []).map((device) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DeviceRow, {
31817
- api: props.api,
31818
- device,
31819
- roots,
31820
- onChanged: refresh,
31821
- onError: setError,
31822
- onNotice: notify
31823
- }, device.id))
31824
- })]
31825
- }, "devices"),
31826
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31827
- tab: "image",
31828
- nameKey: tabLabelKey("image"),
31829
- descKey: tabDescKey("image"),
31830
- open: isOpen("image"),
31831
- onToggle: () => toggleCard("image"),
31834
+ }),
31835
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31836
+ className: "daw-list-meta",
31832
31837
  children: [
31833
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31834
- className: "daw-list-meta",
31835
- children: t("imageHint")
31836
- }),
31837
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31838
- className: "daw-field",
31839
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageSubdir") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31840
- className: "daw-input",
31841
- value: imageDraft,
31842
- spellCheck: false,
31843
- disabled: imageBusy || image === void 0,
31844
- onChange: (event) => {
31845
- setImageDraft(event.target.value);
31846
- setImageSaved(false);
31847
- }
31848
- })]
31849
- }),
31850
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31851
- className: "daw-list-meta",
31852
- style: { marginTop: 5 },
31853
- children: t("imageSubdirHint")
31854
- }),
31855
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31856
- className: "daw-field",
31857
- style: { marginTop: 14 },
31858
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageDefault") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31859
- className: "daw-input",
31860
- value: image?.defaultSubdir ?? "",
31861
- readOnly: true,
31862
- disabled: true
31863
- })]
31864
- }),
31865
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31866
- className: "daw-list-meta",
31867
- style: { marginTop: 5 },
31868
- children: t("imageDefaultHint")
31869
- }),
31870
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31871
- className: "daw-field",
31872
- style: { marginTop: 14 },
31873
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageFallback") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31874
- className: "daw-input",
31875
- value: image?.fallbackDir ?? "",
31876
- readOnly: true,
31877
- disabled: true
31878
- })]
31879
- }),
31880
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31881
- className: "daw-list-meta",
31882
- style: { marginTop: 5 },
31883
- children: t("imageFallbackHint")
31884
- }),
31885
- image !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31886
- className: "daw-list-meta",
31887
- style: { marginTop: 12 },
31888
- children: t("imageExample", { subdir: image.subdir })
31838
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31839
+ title: props.device.id,
31840
+ children: shortId
31889
31841
  }),
31890
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31891
- className: "daw-form-row",
31892
- style: {
31893
- marginTop: 12,
31894
- gridTemplateColumns: "auto auto minmax(0,1fr)",
31895
- alignItems: "center"
31896
- },
31897
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31898
- className: "daw-command primary",
31899
- disabled: imageBusy || image === void 0 || imageDraft.trim() === "" || imageDraft.trim() === image.subdir,
31900
- onClick: () => {
31901
- setImageBusy(true);
31902
- setError(void 0);
31903
- props.api.saveImageSubdir(imageDraft.trim()).then((next) => {
31904
- setImage(next);
31905
- setImageDraft(next.subdir);
31906
- setImageSaved(true);
31907
- }).catch(setError).finally(() => setImageBusy(false));
31908
- },
31909
- children: t("save")
31910
- }), imageSaved && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
31911
- className: "daw-pill on",
31912
- children: t("imageSaved")
31913
- })]
31914
- })
31842
+ props.device.platform !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31843
+ " · ",
31844
+ props.device.platform,
31845
+ props.device.model === void 0 ? "" : ` · ${props.device.model}`
31846
+ ] }),
31847
+ " · ",
31848
+ lastSeen,
31849
+ " · ",
31850
+ props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
31915
31851
  ]
31916
- }, "image"),
31917
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31918
- tab: "trash",
31919
- nameKey: tabLabelKey("trash"),
31920
- descKey: tabDescKey("trash"),
31921
- open: isOpen("trash"),
31922
- onToggle: () => toggleCard("trash"),
31923
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31924
- className: "daw-list-meta",
31925
- children: t("pluginTrashHint")
31926
- }), trash.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31927
- className: "daw-list-meta daw-empty",
31928
- children: t("trashEmpty")
31929
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31930
- className: "daw-list",
31931
- children: trash.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31932
- className: "daw-list-row",
31933
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31934
- className: "daw-list-title",
31935
- children: item.path
31936
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
31937
- className: "daw-list-meta",
31938
- children: [
31939
- item.kind,
31940
- " · ",
31941
- formatBytes(item.size),
31942
- " · ",
31943
- new Date(item.createdAt).toLocaleString(locale)
31944
- ]
31945
- })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31946
- className: "daw-command",
31947
- onClick: () => {
31948
- setError(void 0);
31949
- props.api.restoreTrash(item.id).then(() => {
31950
- notify("trashRestored");
31951
- return refresh();
31952
- }).catch(setError);
31953
- },
31954
- children: t("restore")
31955
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31956
- className: "daw-icon danger",
31957
- title: t("deletePermanently"),
31958
- "aria-label": t("deletePermanently"),
31959
- onClick: () => {
31960
- if (confirm(t("purgeConfirm"))) props.api.purgeTrash(item.id).then(() => {
31961
- notify("trashPurged");
31962
- return refresh();
31963
- }).catch(setError);
31964
- },
31965
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31966
- })] })]
31967
- }, item.id))
31968
- })]
31969
- }, "trash"),
31970
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PluginsSection, {
31971
- api: props.api,
31972
- open: isOpen("plugins"),
31973
- onToggle: () => toggleCard("plugins"),
31974
- onOpenPluginList: props.onOpenPluginList
31975
31852
  }),
31976
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AppDownloadSection, {
31977
- open: isOpen("app-download"),
31978
- onToggle: () => toggleCard("app-download")
31853
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
31854
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31855
+ className: "daw-field",
31856
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("deviceRename") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31857
+ className: "daw-input",
31858
+ value: name,
31859
+ placeholder: t("deviceRenamePlaceholder"),
31860
+ title: t("deviceRenameHint"),
31861
+ onChange: (event) => setName(event.target.value),
31862
+ onKeyDown: (event) => {
31863
+ if (event.key === "Enter") saveAll();
31864
+ }
31865
+ })]
31866
+ }),
31867
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31868
+ className: "daw-scope-grid",
31869
+ children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31870
+ className: "daw-check",
31871
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31872
+ type: "checkbox",
31873
+ checked: scopes.includes(scope),
31874
+ onChange: () => setScopes((current) => toggle(current, scope))
31875
+ }), scopeLabel(locale, scope)]
31876
+ }, scope))
31877
+ }),
31878
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31879
+ className: "daw-scope-grid",
31880
+ children: roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31881
+ className: "daw-check",
31882
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31883
+ type: "checkbox",
31884
+ checked: rootIds.includes(root.id),
31885
+ onChange: () => setRootIds((current) => toggle(current, root.id))
31886
+ }), root.label]
31887
+ }, root.id))
31888
+ })
31889
+ ] })
31890
+ ] }), props.device.revokedAt === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
31891
+ editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31892
+ className: "daw-command primary",
31893
+ onClick: saveAll,
31894
+ children: t("save")
31895
+ }),
31896
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31897
+ className: "daw-command",
31898
+ onClick: () => {
31899
+ setName(props.device.name);
31900
+ setEditing((value) => !value);
31901
+ },
31902
+ children: editing ? t("cancel") : t("permissions")
31903
+ }),
31904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
31905
+ className: "daw-icon danger",
31906
+ title: t("revokeDevice"),
31907
+ "aria-label": t("revokeDevice"),
31908
+ onClick: () => {
31909
+ if (!window.confirm(t("revokeConfirm", { name: props.device.name }))) return;
31910
+ api.revokeDevice(props.device.id).then(() => refresh()).then(() => notify("deviceRevoked")).catch(setError);
31911
+ },
31912
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
31979
31913
  })
31980
- ]
31914
+ ] })]
31981
31915
  });
31982
31916
  }
31917
+ //#endregion
31918
+ //#region src/client/cards/image.tsx
31983
31919
  /**
31984
- * 插件卡片:这台机器 profile 里装了哪些插件、能不能停用、有没有更新。
31920
+ * 「识图」卡:设置图片交给识图工具的落盘目录名。
31921
+ *
31922
+ * 上半截可改(当前值,存数据库、改完立即生效,不必重启);下半截「当前默认」**只读**
31923
+ * —— 那是出厂字面量 `.dsh-images`,历史交接消息和下游工具都按它写过路径,它不是一个
31924
+ * 跟着用户改的偏好项。兜底目录同样只读:它是解析不出会话工作目录时的落点。
31985
31925
  *
31986
- * ## 为什么加这张卡
31926
+ * ## 数据是懒加载的,判据是 `open` 而不是「组开没开」
31987
31927
  *
31988
- * 用户的反馈是「功能单调点,无法查看装的插件情况」—— 想知道装了哪些插件、有没有出问题的,
31989
- * 必须回到电脑前的 WebUI。宿主的 `GET /settings/plugins` 与 `PATCH /settings/plugins` 早就有,
31990
- * 只是浏览器半边从来没有消费它们;这张卡补的就是那半截。
31928
+ * 原实现把 `imageSettings()` 放在 `AdminPanel.refresh()` 里,条件是 `openCards.has('image')`。
31929
+ * 拆出来之后这条判据变成这张卡自己的 `open` prop —— **语义完全等价**:`open` 就是
31930
+ * `isOpen('image')` 的返回值,同一个 Set、同一个值。请求仍然只在真正展开这一节时才发,
31931
+ * 不会因为设置页一进来就打一个没人看的往返。
31932
+ *
31933
+ * 反过来,如果把这层懒加载丢掉、改成挂载即拉,那就是行为变更:整整多一个请求。
31934
+ */
31935
+ function ImageCard(props) {
31936
+ const { api, t, setError } = useCards();
31937
+ const [image, setImage] = (0, react.useState)();
31938
+ const [draft, setDraft] = (0, react.useState)("");
31939
+ const [busy, setBusy] = (0, react.useState)(false);
31940
+ const [saved, setSaved] = (0, react.useState)(false);
31941
+ (0, react.useEffect)(() => {
31942
+ if (!props.open) return;
31943
+ let cancelled = false;
31944
+ api.imageSettings().then((settings) => {
31945
+ if (cancelled) return;
31946
+ setImage(settings);
31947
+ setDraft(settings.subdir);
31948
+ setSaved(false);
31949
+ }).catch((cause) => {
31950
+ if (!cancelled) setError(cause);
31951
+ });
31952
+ return () => {
31953
+ cancelled = true;
31954
+ };
31955
+ }, [
31956
+ props.open,
31957
+ api,
31958
+ setError
31959
+ ]);
31960
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
31961
+ tab: "image",
31962
+ nameKey: tabLabelKey("image"),
31963
+ descKey: tabDescKey("image"),
31964
+ open: props.open,
31965
+ onToggle: props.onToggle,
31966
+ children: [
31967
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
31968
+ className: "daw-list-meta",
31969
+ children: t("imageHint")
31970
+ }),
31971
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31972
+ className: "daw-field",
31973
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageSubdir") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31974
+ className: "daw-input",
31975
+ value: draft,
31976
+ spellCheck: false,
31977
+ disabled: busy || image === void 0,
31978
+ onChange: (event) => {
31979
+ setDraft(event.target.value);
31980
+ setSaved(false);
31981
+ }
31982
+ })]
31983
+ }),
31984
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
31985
+ className: "daw-list-meta",
31986
+ style: { marginTop: 5 },
31987
+ children: t("imageSubdirHint")
31988
+ }),
31989
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
31990
+ className: "daw-field",
31991
+ style: { marginTop: 14 },
31992
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageDefault") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
31993
+ className: "daw-input",
31994
+ value: image?.defaultSubdir ?? "",
31995
+ readOnly: true,
31996
+ disabled: true
31997
+ })]
31998
+ }),
31999
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32000
+ className: "daw-list-meta",
32001
+ style: { marginTop: 5 },
32002
+ children: t("imageDefaultHint")
32003
+ }),
32004
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32005
+ className: "daw-field",
32006
+ style: { marginTop: 14 },
32007
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("imageFallback") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32008
+ className: "daw-input",
32009
+ value: image?.fallbackDir ?? "",
32010
+ readOnly: true,
32011
+ disabled: true
32012
+ })]
32013
+ }),
32014
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32015
+ className: "daw-list-meta",
32016
+ style: { marginTop: 5 },
32017
+ children: t("imageFallbackHint")
32018
+ }),
32019
+ image !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32020
+ className: "daw-list-meta",
32021
+ style: { marginTop: 12 },
32022
+ children: t("imageExample", { subdir: image.subdir })
32023
+ }),
32024
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32025
+ className: "daw-form-row",
32026
+ style: {
32027
+ marginTop: 12,
32028
+ gridTemplateColumns: "auto auto minmax(0,1fr)",
32029
+ alignItems: "center"
32030
+ },
32031
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32032
+ className: "daw-command primary",
32033
+ disabled: busy || image === void 0 || draft.trim() === "" || draft.trim() === image.subdir,
32034
+ onClick: () => {
32035
+ setBusy(true);
32036
+ setError(void 0);
32037
+ api.saveImageSubdir(draft.trim()).then((next) => {
32038
+ setImage(next);
32039
+ setDraft(next.subdir);
32040
+ setSaved(true);
32041
+ }).catch(setError).finally(() => setBusy(false));
32042
+ },
32043
+ children: t("save")
32044
+ }), saved && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32045
+ className: "daw-pill on",
32046
+ children: t("imageSaved")
32047
+ })]
32048
+ })
32049
+ ]
32050
+ });
32051
+ }
32052
+ //#endregion
32053
+ //#region src/client/cards/plugins.tsx
32054
+ /**
32055
+ * 「插件」卡:这台机器 profile 里装了哪些插件、能不能停用、有没有更新。
31991
32056
  *
31992
- * ## 三个必须说清楚的地方
32057
+ * ## 为什么它收在「高级设置」组里
31993
32058
  *
31994
- * 1. **官方包不能停**(`toggleable === false`)。它们随 DSH 运行时安装、不由 profile 管理,
31995
- * 改 bundles 也加载不出来。服务端会拒绝并回 `PLUGIN_OFFICIAL_IMMUTABLE`,这里干脆不给按钮。
31996
- * 2. **`runtime-provided` 不是故障**。官方包出现在 `dsh.profile.bundles` 里但不在 profile 的
31997
- * node_modules 下,早期实现把它误判成「声明加载但缺失」,真机上一次冒出 5 条假警报。
31998
- * 3. **改完要重启**。bundles 只在启动时读一次,所以成功提示不是「已停用」而是
31999
- * 「已写进 package.json,重启后生效」—— 否则用户会以为点了没反应,再点几次。
32059
+ * DSH 内核自己有插件页(侧栏「插件」),这张卡与它是**同一条车道**:看清单、启停、检查更新。
32060
+ * 桥不跑内核、也不该重做内核的 UI,所以把它从主列表收进高级组 —— 功能照留(手机端与
32061
+ * 桌面端都能从这里改),但不再占日常视线。
32000
32062
  *
32001
- * ## 更新检查为什么是按钮而不是自动
32063
+ * ## 为什么还要留这一张
32002
32064
  *
32003
- * 它要打 npm registry,十次里九次用户只想看「装了啥」。默认不查,`updateAvailable` 因此是
32004
- * 三态(`null` = 没查),而不是压成 `false` 去谎称「已是最新」。
32065
+ * 手机 App 那边没有内核的插件页可点,`pluginInventory` 是它唯一能看到「这台机器装了什么」的
32066
+ * 通道。删掉这张卡等于把这个能力从移动端拿走,所以是隐藏而不是删除。
32005
32067
  */
32006
- function PluginsSection(props) {
32068
+ function PluginsCard(props) {
32007
32069
  const { t } = useWorkspaceI18n();
32070
+ const { api } = useCards();
32008
32071
  const [inventory, setInventory] = (0, react.useState)(void 0);
32009
32072
  const [busy, setBusy] = (0, react.useState)(false);
32010
32073
  const [checking, setChecking] = (0, react.useState)(false);
@@ -32017,14 +32080,14 @@ window.__ModuleLoader__.load({
32017
32080
  else setBusy(true);
32018
32081
  setError(void 0);
32019
32082
  try {
32020
- setInventory(await props.api.pluginInventory(checkUpdates));
32083
+ setInventory(await api.pluginInventory(checkUpdates));
32021
32084
  } catch (cause) {
32022
32085
  setError(messageOf(cause, t));
32023
32086
  } finally {
32024
32087
  if (checkUpdates) setChecking(false);
32025
32088
  else setBusy(false);
32026
32089
  }
32027
- }, [props.api, t]);
32090
+ }, [api, t]);
32028
32091
  (0, react.useEffect)(() => {
32029
32092
  if (!props.open || inventory !== void 0) return;
32030
32093
  load(false);
@@ -32037,15 +32100,15 @@ window.__ModuleLoader__.load({
32037
32100
  setToggling(entry.name);
32038
32101
  setError(void 0);
32039
32102
  try {
32040
- await props.api.togglePlugin(entry.name, !entry.loaded);
32041
- setInventory(await props.api.pluginInventory(false));
32103
+ await api.togglePlugin(entry.name, !entry.loaded);
32104
+ setInventory(await api.pluginInventory(false));
32042
32105
  setRestartRequired(true);
32043
32106
  } catch (cause) {
32044
32107
  setError(messageOf(cause, t));
32045
32108
  } finally {
32046
32109
  setToggling(void 0);
32047
32110
  }
32048
- }, [props.api, t]);
32111
+ }, [api, t]);
32049
32112
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32050
32113
  tab: "plugins",
32051
32114
  nameKey: tabLabelKey("plugins"),
@@ -32157,667 +32220,981 @@ window.__ModuleLoader__.load({
32157
32220
  ]
32158
32221
  });
32159
32222
  }
32160
- /** 四种 `PluginState` 各自的中文/英文说法 —— 状态本身是产品语义,不该在界面上露出裸码。 */
32161
- function pluginStateKey(state) {
32162
- if (state === "loaded") return "pluginsStateLoaded";
32163
- if (state === "runtime-provided") return "pluginsStateRuntimeProvided";
32164
- if (state === "installed-not-loaded") return "pluginsStateInstalledNotLoaded";
32165
- return "pluginsStateDeclaredMissing";
32166
- }
32167
- /**
32168
- * The App download page: one QR card per release host.
32169
- *
32170
- * The codes come from `APP_QR_CODES` as inlined SVG data URLs, so this page makes **no network
32171
- * request** and adds no runtime dependency — the bytes are in the bundle. Both point at a Releases
32172
- * *listing* page rather than a fixed file, so publishing a new APK needs no change here.
32173
- *
32174
- * The address is rendered as a real link next to the image on purpose: a phone cannot scan its own
32175
- * screen, so this page is mostly used by the desktop admin to send a code to someone else. Without
32176
- * the text there would be no way to type the address in by hand when a camera is unavailable.
32177
- */
32178
- function AppDownloadSection(props) {
32179
- const { t } = useWorkspaceI18n();
32180
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32181
- tab: "app-download",
32182
- nameKey: tabLabelKey("app-download"),
32183
- descKey: tabDescKey("app-download"),
32184
- open: props.open,
32185
- onToggle: props.onToggle,
32186
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
32187
- className: "daw-list-meta",
32188
- children: t("appDownloadHint")
32189
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32190
- className: "daw-qr-grid",
32191
- children: APP_QR_CODES.map((code) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("figure", {
32192
- className: "daw-qr-card",
32193
- children: [
32194
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
32195
- className: "daw-qr-image",
32196
- src: code.dataUrl,
32197
- alt: t("appDownloadQrAlt", { site: code.label }),
32198
- width: 180,
32199
- height: 180
32200
- }),
32201
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("figcaption", {
32202
- className: "daw-qr-site",
32203
- children: code.label
32204
- }),
32205
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("a", {
32206
- className: "daw-qr-url",
32207
- href: code.url,
32208
- target: "_blank",
32209
- rel: "noreferrer",
32210
- children: code.url
32211
- })
32212
- ]
32213
- }, code.key))
32214
- })]
32215
- });
32216
- }
32223
+ //#endregion
32224
+ //#region src/client/cards/remote.tsx
32217
32225
  /**
32218
- * Shows a freshly minted `DSH1:` config text.
32226
+ * 「远程访问」卡:在本机开一个口子 —— 绑定 IP/端口、开关远程访问、生成配对码与令牌配置。
32219
32227
  *
32220
- * The text is a credential carrier, so this panel keeps the "it is a key" reminder visible and lists
32221
- * the addresses the host actually detected, letting the user confirm them before sending the text away.
32222
- * Copying degrades to "the text is selected, copy it yourself" when the Clipboard API is unavailable —
32223
- * the normal case when this WebUI is opened over plain HTTP on a LAN address.
32228
+ * ## 这张卡为什么还是最长的一张
32229
+ *
32230
+ * 它把三件相邻但不同的事收在一处:**监听器**(绑哪个地址)、**配对码**(把手机接进来)、
32231
+ * **权限勾选**(接进来之后能干什么)。三者是同一条流程的三步(勾权限 → 生成访问码 →
32232
+ * App 输入码 → 权限随设备落库),拆开会让人在两张卡之间来回跳,所以留在一起。
32233
+ *
32234
+ * 「外网隧道」已经从这张卡里独立出去了(见 `tunnel.tsx`)—— 那不是同一条流程,
32235
+ * 是另一条通道。当年挤在一张卡里,用户会以为隧道是远程访问的一个开关。
32236
+ *
32237
+ * ## 哪些东西不在这里
32238
+ *
32239
+ * `refresh()` 与隧道状态的拉取**不搬**:隧道状态必须**先于、独立于** `/manage/status`
32240
+ * 拉取(隧道运行期 status 会 403 中断整个 try),那属于 `AdminPanel`。
32241
+ * 本卡只消费它的结果(`tunnel` / `managementLocked` 等经 `context.ts` 下发)。
32242
+ *
32243
+ * ## 权限勾选的默认值为什么是「五个非破坏性权限」
32244
+ *
32245
+ * 见下方 `grantedScopes` 的初始化处 —— 这是 SEC-H11 的修复点,默认值偏松不是小事。
32224
32246
  */
32225
- function ConfigTextPanel(props) {
32226
- const { t } = useWorkspaceI18n();
32227
- const [copyState, setCopyState] = (0, react.useState)();
32228
- const textRef = (0, react.useRef)(null);
32229
- const copy = (0, react.useCallback)(async () => {
32230
- const clipboard = globalThis.navigator?.clipboard;
32231
- if (clipboard !== void 0) try {
32232
- await clipboard.writeText(props.result.text);
32233
- setCopyState("done");
32234
- return;
32235
- } catch {}
32236
- const node = textRef.current;
32237
- if (node !== null) {
32238
- node.focus();
32239
- node.select();
32247
+ function RemoteCard(props) {
32248
+ const { t, locale } = useWorkspaceI18n();
32249
+ const { api, status, roots, managementLocked, refresh, notify, copyText, setError, setStatus, tunnel, tunnelBusy, configText, configTextBusy, setConfigText, setConfigTextBusy, configEndpoint, setConfigEndpoint, tunnelConfigClick } = useCards();
32250
+ const [listenerHost, setListenerHost] = (0, react.useState)("0.0.0.0");
32251
+ const [listenerPort, setListenerPort] = (0, react.useState)("3090");
32252
+ const [pairing, setPairing] = (0, react.useState)();
32253
+ const [now, setNow] = (0, react.useState)(() => Date.now());
32254
+ (0, react.useEffect)(() => {
32255
+ const timer = window.setInterval(() => setNow(Date.now()), 3e4);
32256
+ return () => window.clearInterval(timer);
32257
+ }, []);
32258
+ (0, react.useEffect)(() => {
32259
+ if (status === void 0) return;
32260
+ setListenerHost(status.configuredHost);
32261
+ setListenerPort(String(status.configuredPort));
32262
+ }, [status?.configuredHost, status?.configuredPort]);
32263
+ const [grantedScopes, setGrantedScopes] = (0, react.useState)([...DEFAULT_DEVICE_SCOPES]);
32264
+ const toggleScope = (0, react.useCallback)((scope) => {
32265
+ setGrantedScopes((current) => current.includes(scope) ? current.filter((item) => item !== scope) : [...current, scope]);
32266
+ }, []);
32267
+ const grantNotice = grantedScopes.length === 0 ? t("scopeGrantNoticeEmpty") : (status?.devices?.length ?? 0) === 0 ? t("scopeGrantNotice") : t("scopeGrantNoticeExisting");
32268
+ const parsedListenerPort = Number(listenerPort);
32269
+ const listenerPortValid = Number.isInteger(parsedListenerPort) && parsedListenerPort >= 1 && parsedListenerPort <= 65535;
32270
+ const listenerDirty = status !== void 0 && (listenerHost.trim() !== status.configuredHost || parsedListenerPort !== status.configuredPort);
32271
+ const poll = async (id) => {
32272
+ for (let attempt = 0; attempt < 30; attempt += 1) {
32273
+ await delay(300);
32274
+ const next = await api.operation(id);
32275
+ setStatus(next);
32276
+ const operation = next.operation;
32277
+ if (operation?.state === "succeeded") {
32278
+ if (operation.pairingCode !== void 0) setPairing({
32279
+ code: operation.pairingCode,
32280
+ ...operation.pairingExpiresAt === void 0 ? {} : { expiresAt: operation.pairingExpiresAt }
32281
+ });
32282
+ return;
32283
+ }
32284
+ if (operation?.state === "failed") throw new Error(listenerOperationError(operation.code, operation.message, t));
32240
32285
  }
32241
- setCopyState("manual");
32242
- }, [props.result.text]);
32243
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32244
- style: { marginTop: 14 },
32286
+ throw new Error(t("listenerTimedOut"));
32287
+ };
32288
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32289
+ tab: "remote",
32290
+ nameKey: tabLabelKey("remote"),
32291
+ descKey: tabDescKey("remote"),
32292
+ open: props.open,
32293
+ onToggle: props.onToggle,
32245
32294
  children: [
32246
32295
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32247
32296
  className: "daw-warning",
32248
- children: t("configTextSecurity")
32249
- }),
32250
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32251
- className: "daw-list-meta",
32252
- children: t("configTextHint")
32297
+ children: t("httpWarning")
32253
32298
  }),
32254
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
32255
- ref: textRef,
32256
- className: "daw-input",
32257
- style: {
32258
- width: "100%",
32259
- height: "auto",
32260
- minHeight: 76,
32261
- marginTop: 8,
32262
- padding: "8px 9px",
32263
- fontFamily: "ui-monospace,SFMono-Regular,Consolas,monospace",
32264
- fontSize: 12,
32265
- lineHeight: 1.5,
32266
- resize: "vertical"
32267
- },
32268
- readOnly: true,
32269
- rows: 4,
32270
- spellCheck: false,
32271
- value: props.result.text,
32272
- onFocus: (event) => event.currentTarget.select()
32299
+ roots.length === 0 && !managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32300
+ className: "daw-warning",
32301
+ children: t("addRootBeforeRemote")
32273
32302
  }),
32274
32303
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32275
- className: "daw-form-row",
32276
- style: {
32277
- marginTop: 8,
32278
- marginBottom: 10,
32279
- gridTemplateColumns: "auto auto minmax(0,1fr)",
32280
- alignItems: "center"
32281
- },
32304
+ className: "daw-listener-form",
32282
32305
  children: [
32306
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32307
+ className: "daw-field",
32308
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindIp") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32309
+ className: "daw-input",
32310
+ value: listenerHost,
32311
+ disabled: status?.remoteEnabled || managementLocked,
32312
+ spellCheck: false,
32313
+ placeholder: "0.0.0.0",
32314
+ onChange: (event) => setListenerHost(event.target.value)
32315
+ })]
32316
+ }),
32317
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32318
+ className: "daw-field",
32319
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("bindPort") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32320
+ className: "daw-input",
32321
+ type: "number",
32322
+ min: 1,
32323
+ max: 65535,
32324
+ inputMode: "numeric",
32325
+ value: listenerPort,
32326
+ disabled: status?.remoteEnabled || managementLocked,
32327
+ onChange: (event) => setListenerPort(event.target.value)
32328
+ })]
32329
+ }),
32283
32330
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32284
- className: "daw-command primary",
32331
+ className: "daw-command",
32332
+ disabled: managementLocked || status?.remoteEnabled || listenerHost.trim() === "" || !listenerPortValid || !listenerDirty,
32285
32333
  onClick: () => {
32286
- copy();
32334
+ setError(void 0);
32335
+ api.configureListener(listenerHost.trim(), parsedListenerPort).then((result) => poll(result.id)).then(() => {
32336
+ notify("listenerSaved");
32337
+ return refresh();
32338
+ }).catch(setError);
32287
32339
  },
32288
- children: t("copy")
32289
- }),
32290
- copyState === "done" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32291
- className: "daw-pill on",
32292
- children: t("copied")
32293
- }),
32294
- copyState === "manual" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32295
- className: "daw-list-meta",
32296
- children: t("copyManual")
32340
+ children: t("saveListener")
32297
32341
  })
32298
32342
  ]
32299
32343
  }),
32300
32344
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32301
- className: "daw-list-meta",
32302
- style: { marginBottom: 2 },
32303
- children: [
32304
- t("configTextDevice"),
32305
- ": ",
32306
- props.result.deviceName,
32307
- " · ",
32308
- t("configTextDeviceId"),
32309
- ": ",
32310
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32311
- style: { fontFamily: "ui-monospace,SFMono-Regular,Consolas,monospace" },
32312
- children: props.result.deviceId
32313
- })
32314
- ]
32315
- }),
32316
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32317
- className: "daw-list-meta",
32318
- style: { marginBottom: 10 },
32319
- children: t("configTextScopes")
32345
+ className: "daw-listener-status",
32346
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
32347
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [t("configuredListener"), ":"] }),
32348
+ " ",
32349
+ formatEndpoint(status?.configuredHost ?? listenerHost, status?.configuredPort ?? parsedListenerPort)
32350
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", { children: [
32351
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("strong", { children: [t("actualListener"), ":"] }),
32352
+ " ",
32353
+ formatEndpoint(status?.host ?? "127.0.0.1", status?.port ?? parsedListenerPort)
32354
+ ] })]
32320
32355
  }),
32321
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("configTextDetected") }),
32322
- props.result.endpoints.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32323
- className: "daw-warning",
32324
- style: { marginTop: 8 },
32325
- children: t("configTextNoEndpoints")
32326
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32327
- className: "daw-list",
32328
- children: props.result.endpoints.map((endpoint) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32329
- className: "daw-list-row",
32330
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32331
- className: "daw-list-title",
32332
- children: endpoint.label
32333
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32334
- className: "daw-list-meta",
32335
- children: [endpoint.baseUrl, endpoint.kind === void 0 ? "" : ` · ${endpoint.kind}`]
32336
- })] })
32337
- }, `${endpoint.label}:${endpoint.baseUrl}`))
32338
- })
32339
- ]
32340
- });
32341
- }
32342
- function DeviceRow(props) {
32343
- const { locale, t } = useWorkspaceI18n();
32344
- const [editing, setEditing] = (0, react.useState)(false);
32345
- const [scopes, setScopes] = (0, react.useState)(props.device.scopes);
32346
- const [rootIds, setRootIds] = (0, react.useState)(props.device.rootIds);
32347
- const [name, setName] = (0, react.useState)(props.device.name);
32348
- const toggle = (list, value) => list.includes(value) ? list.filter((item) => item !== value) : [...list, value];
32349
- const shortId = props.device.id.length > 14 ? `${props.device.id.slice(0, 8)}…${props.device.id.slice(-4)}` : props.device.id;
32350
- const lastSeen = props.device.lastSeenAt === void 0 ? t("deviceNeverSeen") : t("deviceLastSeen", { time: new Date(props.device.lastSeenAt).toLocaleString(locale) });
32351
- const autoNamed = props.device.nameSource === "default" || props.device.nameSource === "detected";
32352
- const saveAll = () => {
32353
- const trimmed = name.trim();
32354
- const rename = trimmed !== "" && trimmed !== props.device.name;
32355
- (rename ? props.api.renameDevice(props.device.id, trimmed) : Promise.resolve()).then(() => props.api.updateDevice(props.device.id, scopes, rootIds)).then(props.onChanged).then(() => {
32356
- setEditing(false);
32357
- props.onNotice(rename ? "deviceNameSaved" : "permissionsSaved");
32358
- }).catch(props.onError);
32359
- };
32360
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32361
- className: "daw-list-row",
32362
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
32363
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32364
- className: "daw-list-title",
32365
- children: [
32366
- props.device.name,
32367
- props.device.revokedAt !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32368
- className: "daw-pill",
32369
- children: t("revoked")
32370
- }),
32371
- autoNamed && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32372
- className: "daw-pill",
32373
- title: t("deviceRenameHint"),
32374
- children: t("deviceDetectedName")
32375
- })
32376
- ]
32356
+ status?.remoteEnabled && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32357
+ className: "daw-list-meta daw-listener-hint",
32358
+ children: t("disableToEditListener")
32377
32359
  }),
32378
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32379
- className: "daw-list-meta",
32380
- children: [
32381
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32382
- title: props.device.id,
32383
- children: shortId
32384
- }),
32385
- props.device.platform !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
32386
- " · ",
32387
- props.device.platform,
32388
- props.device.model === void 0 ? "" : ` · ${props.device.model}`
32389
- ] }),
32390
- " · ",
32391
- lastSeen,
32392
- " · ",
32393
- props.device.scopes.length === 0 ? t("noScopes") : props.device.scopes.map((scope) => scopeLabel(locale, scope)).join(" · ")
32394
- ]
32360
+ !status?.remoteEnabled && listenerDirty && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32361
+ className: "daw-list-meta daw-listener-hint",
32362
+ children: t("saveBeforeEnable")
32395
32363
  }),
32396
- editing && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
32397
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32398
- className: "daw-field",
32399
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("deviceRename") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32400
- className: "daw-input",
32401
- value: name,
32402
- placeholder: t("deviceRenamePlaceholder"),
32403
- title: t("deviceRenameHint"),
32404
- onChange: (event) => setName(event.target.value),
32405
- onKeyDown: (event) => {
32406
- if (event.key === "Enter") saveAll();
32407
- }
32408
- })]
32409
- }),
32410
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32364
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32365
+ className: `daw-pill${status?.remoteEnabled ? " on" : ""}`,
32366
+ children: status?.remoteEnabled ? t("remoteEnabled") : t("loopbackOnly")
32367
+ }) }),
32368
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("fieldset", {
32369
+ disabled: managementLocked,
32370
+ style: {
32371
+ border: "none",
32372
+ margin: 0,
32373
+ padding: 0
32374
+ },
32375
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32411
32376
  className: "daw-scope-grid",
32412
32377
  children: DEVICE_SCOPES.map((scope) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32413
32378
  className: "daw-check",
32414
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32415
- type: "checkbox",
32416
- checked: scopes.includes(scope),
32417
- onChange: () => setScopes((current) => toggle(current, scope))
32418
- }), scopeLabel(locale, scope)]
32419
- }, scope))
32420
- }),
32421
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32422
- className: "daw-scope-grid",
32423
- children: props.roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32424
- className: "daw-check",
32425
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32426
- type: "checkbox",
32427
- checked: rootIds.includes(root.id),
32428
- onChange: () => setRootIds((current) => toggle(current, root.id))
32429
- }), root.label]
32430
- }, root.id))
32379
+ children: [
32380
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32381
+ type: "checkbox",
32382
+ checked: grantedScopes.includes(scope),
32383
+ onChange: () => toggleScope(scope)
32384
+ }),
32385
+ " ",
32386
+ scopeLabel(locale, scope)
32387
+ ]
32388
+ }, scope))
32431
32389
  })
32432
- ] })
32433
- ] }), props.device.revokedAt === void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
32434
- editing && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32435
- className: "daw-command primary",
32436
- onClick: saveAll,
32437
- children: t("save")
32438
32390
  }),
32439
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32440
- className: "daw-command",
32391
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32392
+ className: "daw-list-meta",
32393
+ children: grantNotice
32394
+ }),
32395
+ status?.remoteEnabled ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32396
+ className: "daw-command danger",
32397
+ disabled: managementLocked,
32398
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
32441
32399
  onClick: () => {
32442
- setName(props.device.name);
32443
- setEditing((value) => !value);
32400
+ if (!window.confirm(t("disableRemoteConfirm"))) return;
32401
+ setError(void 0);
32402
+ api.disableRemote().then((result) => poll(result.id)).then(() => {
32403
+ notify("remoteDisabled");
32404
+ return refresh();
32405
+ }).catch(setError);
32444
32406
  },
32445
- children: editing ? t("cancel") : t("permissions")
32446
- }),
32447
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32448
- className: "daw-icon danger",
32449
- title: t("revokeDevice"),
32450
- "aria-label": t("revokeDevice"),
32407
+ children: t("disableRemote")
32408
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32409
+ className: "daw-command primary",
32410
+ disabled: managementLocked || roots.length === 0 || listenerDirty || !listenerPortValid || listenerHost.trim() === "" || grantedScopes.length === 0,
32451
32411
  onClick: () => {
32452
- if (!window.confirm(t("revokeConfirm", { name: props.device.name }))) return;
32453
- props.api.revokeDevice(props.device.id).then(props.onChanged).then(() => props.onNotice("deviceRevoked")).catch(props.onError);
32412
+ setError(void 0);
32413
+ api.enableRemote(roots.map((root) => root.id), grantedScopes).then((result) => poll(result.id)).catch(setError);
32454
32414
  },
32455
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
32456
- })
32457
- ] })]
32415
+ children: t("enablePairing")
32416
+ }),
32417
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
32418
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32419
+ className: "daw-command daw-inline-command",
32420
+ disabled: managementLocked || grantedScopes.length === 0,
32421
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
32422
+ onClick: () => {
32423
+ setError(void 0);
32424
+ api.createPairing(roots.map((root) => root.id), grantedScopes).then((value) => {
32425
+ setPairing(value);
32426
+ notify("pairingCreated");
32427
+ }).catch(setError);
32428
+ },
32429
+ children: t("newPairingCode")
32430
+ }),
32431
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32432
+ className: "daw-command daw-inline-command",
32433
+ disabled: managementLocked || configTextBusy,
32434
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
32435
+ onClick: () => {
32436
+ const manual = configEndpoint.trim();
32437
+ setConfigTextBusy(true);
32438
+ setError(void 0);
32439
+ api.createConfigText({
32440
+ ...roots.length === 0 ? {} : { rootIds: roots.map((root) => root.id) },
32441
+ ...manual === "" ? {} : { endpoints: [{
32442
+ label: t("configTextManualLabel"),
32443
+ baseUrl: manual
32444
+ }] }
32445
+ }).then((value) => {
32446
+ setConfigText(value);
32447
+ notify("configCreated");
32448
+ }).catch(setError).finally(() => setConfigTextBusy(false));
32449
+ },
32450
+ children: t("newConfigText")
32451
+ }),
32452
+ tunnel !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32453
+ className: "daw-command daw-inline-command",
32454
+ disabled: configTextBusy || tunnelBusy,
32455
+ onClick: () => {
32456
+ tunnelConfigClick();
32457
+ },
32458
+ children: tunnel.running !== true ? t("tunnelStartAndGenConfig") : t("tunnelRestartAndGenConfig")
32459
+ }),
32460
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32461
+ className: "daw-list-meta daw-listener-hint",
32462
+ children: t("configTextScopeNote")
32463
+ })
32464
+ ] }),
32465
+ pairing !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32466
+ className: "daw-code-row",
32467
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32468
+ className: "daw-code",
32469
+ children: pairing.code
32470
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
32471
+ className: "daw-copy-inline",
32472
+ text: pairing.code,
32473
+ title: t("copyPairing"),
32474
+ copiedText: t("copied"),
32475
+ manualText: t("copyManual"),
32476
+ copyText
32477
+ })]
32478
+ }), pairing.expiresAt !== void 0 && pairing.expiresAt > now && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32479
+ className: "daw-list-meta",
32480
+ style: { marginTop: -4 },
32481
+ children: t("pairingExpiresAt", { minutes: pairingMinutesLeft(pairing.expiresAt, now) })
32482
+ })] }),
32483
+ (status?.remoteEnabled === true || managementLocked) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32484
+ style: {
32485
+ marginTop: 12,
32486
+ maxWidth: 560
32487
+ },
32488
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("label", {
32489
+ className: "daw-field",
32490
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("configTextManual") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32491
+ className: "daw-input",
32492
+ value: configEndpoint,
32493
+ disabled: managementLocked,
32494
+ spellCheck: false,
32495
+ placeholder: t("configTextManualPlaceholder"),
32496
+ onChange: (event) => setConfigEndpoint(event.target.value)
32497
+ })]
32498
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32499
+ className: "daw-list-meta",
32500
+ style: { marginTop: 5 },
32501
+ children: t("configTextManualHint")
32502
+ })]
32503
+ }),
32504
+ configText !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigTextPanel, { result: configText })
32505
+ ]
32458
32506
  });
32459
32507
  }
32460
- function tabIcon(tab) {
32461
- if (tab === "roots") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.HardDrive, { size: 15 });
32462
- if (tab === "devices") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Smartphone, { size: 15 });
32463
- if (tab === "image") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Image, { size: 15 });
32464
- if (tab === "trash") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 });
32465
- if (tab === "plugins") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Package, { size: 15 });
32466
- if (tab === "remote") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Upload, { size: 15 });
32467
- if (tab === "app-download") return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Download, { size: 15 });
32468
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.File, { size: 15 });
32469
- }
32470
- function messageOf(cause, t) {
32471
- if (cause instanceof WorkspaceApiError) {
32472
- const translated = apiErrorKey(cause);
32473
- if (translated !== void 0) return t(translated);
32474
- }
32475
- return cause instanceof Error ? cause.message : String(cause);
32476
- }
32477
- function listenerOperationError(code, fallback, t) {
32478
- if (code === "LISTENER_ADDRESS_IN_USE") return t("listenerAddressInUse");
32479
- if (code === "LISTENER_ADDRESS_UNAVAILABLE") return t("listenerAddressUnavailable");
32480
- if (code === "LISTENER_PERMISSION_DENIED") return t("listenerPermissionDenied");
32481
- return fallback ?? t("listenerFailed");
32482
- }
32483
- function formatEndpoint(host, port) {
32484
- return `${host.includes(":") ? `[${host}]` : host}:${Number.isFinite(port) ? port : ""}`;
32485
- }
32486
- function apiErrorKey(error) {
32487
- if (error.status === 401) return "errorUnauthorized";
32488
- if (error.status === 429) return "errorRateLimited";
32489
- if (error.code === "ROOT_NOT_GRANTED" || error.code === "ROOT_FORBIDDEN") return "errorRootDenied";
32490
- if (error.code === "ROOT_BUILTIN") return "errorRootBuiltin";
32491
- if (error.code === "ETAG_MISMATCH") return "errorEtag";
32492
- if (error.code === "LISTENER_HOST_INVALID") return "listenerHostInvalid";
32493
- if (error.code === "LISTENER_PORT_INVALID") return "listenerPortInvalid";
32494
- if (error.code === "REMOTE_ENABLED") return "disableToEditListener";
32495
- if (error.code === "NO_ENDPOINT") return "configTextNoEndpoints";
32496
- if (error.code === "TUNNEL_MODE_ADMIN_DISABLED") return "errorTunnelModeAdminDisabled";
32497
- if (error.code === "ADMIN_PROXY_FORWARDED") return "errorAdminProxyForwarded";
32498
- if (error.code === "ADMIN_HOST_NOT_LOOPBACK") return "errorAdminHostNotLoopback";
32499
- if (error.code === "ADMIN_LOOPBACK_REQUIRED") return "errorAdminLoopbackRequired";
32500
- if (error.code === "PLUGIN_OFFICIAL_IMMUTABLE") return "pluginsErrorOfficial";
32501
- if (error.code === "PLUGIN_NAME_INVALID") return "pluginsErrorNameInvalid";
32502
- if (error.code === "PROFILE_UNKNOWN") return "pluginsErrorProfileUnknown";
32503
- if (error.code === "MANIFEST_UNREADABLE") return "pluginsErrorManifestUnreadable";
32504
- if (error.code === "PLUGIN_NOT_INSTALLED") return "pluginsErrorNotInstalled";
32505
- if (error.code.includes("PATH") || error.code.includes("SYMLINK") || error.code.includes("REPARSE")) return "errorPathInvalid";
32506
- if (error.status === 403) return "errorForbidden";
32507
- if (error.status === 404) return "errorNotFound";
32508
- if (error.status === 409) return "errorConflict";
32509
- }
32510
- function formatBytes(value) {
32511
- if (value < 1024) return `${value} B`;
32512
- if (value < 1048576) return `${(value / 1024).toFixed(1)} KB`;
32513
- return `${(value / 1024 / 1024).toFixed(1)} MB`;
32508
+ //#endregion
32509
+ //#region src/client/cards/roots.tsx
32510
+ /**
32511
+ * 「根目录」卡:授权手机访问这台机器上的哪些目录。
32512
+ *
32513
+ * 为什么它收在「高级设置」组里而不是主列表:它一次配置完就基本不动,而主列表要留给
32514
+ * 日常会看的东西。但它在组内**排第一位** —— 没有授权根目录,启用远程访问与创建隧道
32515
+ * 都会被 `roots.length === 0` 锁死,它是所有远程能力的前置。
32516
+ */
32517
+ function RootsCard(props) {
32518
+ const { api, t, roots, managementLocked, refresh, notify, setError } = useCards();
32519
+ const [path, setPath] = (0, react.useState)("");
32520
+ const [label, setLabel] = (0, react.useState)("");
32521
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32522
+ tab: "roots",
32523
+ nameKey: tabLabelKey("roots"),
32524
+ descKey: tabDescKey("roots"),
32525
+ open: props.open,
32526
+ onToggle: props.onToggle,
32527
+ children: [
32528
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32529
+ className: "daw-form-row",
32530
+ children: [
32531
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32532
+ className: "daw-input",
32533
+ placeholder: t("absolutePath"),
32534
+ value: path,
32535
+ onChange: (event) => setPath(event.target.value)
32536
+ }),
32537
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
32538
+ className: "daw-input",
32539
+ placeholder: t("displayLabel"),
32540
+ value: label,
32541
+ onChange: (event) => setLabel(event.target.value)
32542
+ }),
32543
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32544
+ className: "daw-command primary",
32545
+ disabled: managementLocked,
32546
+ title: managementLocked ? t("managementLockedWhileTunnel") : void 0,
32547
+ onClick: () => {
32548
+ setError(void 0);
32549
+ api.addRoot(path, label || void 0).then(() => {
32550
+ setPath("");
32551
+ setLabel("");
32552
+ notify("rootAdded");
32553
+ return refresh();
32554
+ }).catch(setError);
32555
+ },
32556
+ children: t("addRoot")
32557
+ })
32558
+ ]
32559
+ }),
32560
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32561
+ className: "daw-list",
32562
+ children: roots.map((root) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32563
+ className: "daw-list-row",
32564
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32565
+ className: "daw-list-title",
32566
+ children: [root.label, root.builtin === true && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32567
+ className: "daw-pill on",
32568
+ children: t("builtinRootBadge")
32569
+ })]
32570
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32571
+ className: "daw-list-meta",
32572
+ children: root.realPath
32573
+ })] }), root.builtin === true ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32574
+ className: "daw-list-meta daw-builtin-hint",
32575
+ children: t("builtinRootHint")
32576
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32577
+ className: "daw-icon danger",
32578
+ title: t("removeRoot"),
32579
+ "aria-label": t("removeRoot"),
32580
+ disabled: managementLocked,
32581
+ onClick: () => {
32582
+ if (!window.confirm(t("removeRootConfirm", { label: root.label }))) return;
32583
+ setError(void 0);
32584
+ api.removeRoot(root.id).then(() => {
32585
+ notify("rootRemoved");
32586
+ return refresh();
32587
+ }).catch(setError);
32588
+ },
32589
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
32590
+ })]
32591
+ }, root.id))
32592
+ }),
32593
+ roots.some((root) => root.builtin === true) && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32594
+ className: "daw-list-meta daw-builtin-notice",
32595
+ children: t("builtinRootNotice")
32596
+ })
32597
+ ]
32598
+ });
32514
32599
  }
32515
- function delay(ms) {
32516
- return new Promise((resolve) => setTimeout(resolve, ms));
32600
+ //#endregion
32601
+ //#region src/client/cards/trash.tsx
32602
+ /**
32603
+ * 「回收站」卡:手机端删掉的文件留在这里,可以还原或彻底删除。
32604
+ *
32605
+ * 从 `workspace.tsx` 的 `AdminPanel` 搬出来。与 `image.tsx` 同样处理懒加载:
32606
+ *
32607
+ * ## 列表数据是懒加载的,判据是 `open`
32608
+ *
32609
+ * 原实现把 `trashItems()` 放在 `AdminPanel.refresh()` 里,条件是 `openCards.has('trash')`。
32610
+ * 拆出来后这条判据变成这张卡自己的 `open` prop —— **语义完全等价**:`open` 就是
32611
+ * `isOpen('trash')` 的返回值,同一个 Set、同一个值。
32612
+ *
32613
+ * 但有一个细节必须一起搬:原实现里还原/彻底删除之后走的是 `return refresh()`,而
32614
+ * `refresh()` 在「卡展开着」时会把回收站列表重新拉一遍 —— 所以删除动作的结果能立刻
32615
+ * 反映到列表上。列表状态现在住在这张卡里,`refresh()` 已经不知道它的存在,于是
32616
+ * 这里必须自己再拉一次,否则用户点了「还原」、文件从磁盘回来了、列表里那一行却还在。
32617
+ */
32618
+ function TrashCard(props) {
32619
+ const { api, t, locale, refresh, notify, setError } = useCards();
32620
+ const [trash, setTrash] = (0, react.useState)([]);
32621
+ (0, react.useEffect)(() => {
32622
+ if (!props.open) return;
32623
+ let cancelled = false;
32624
+ api.trashItems().then((next) => {
32625
+ if (cancelled) return;
32626
+ setTrash(next.items);
32627
+ }).catch((cause) => {
32628
+ if (!cancelled) setError(cause);
32629
+ });
32630
+ return () => {
32631
+ cancelled = true;
32632
+ };
32633
+ }, [
32634
+ props.open,
32635
+ api,
32636
+ setError
32637
+ ]);
32638
+ /**
32639
+ * 还原/删除之后的重新拉取。
32640
+ *
32641
+ * 两件事都要做:`refresh()` 让设置页其余分区跟着更新(原来的 `return refresh()` 就是这么
32642
+ * 干的),再单独把本卡的列表换掉 —— 后者正是拆卡之后必须自己补上的那一步。
32643
+ */
32644
+ const reloadAfterAction = () => Promise.all([refresh(), api.trashItems().then((next) => setTrash(next.items))]).then(() => void 0);
32645
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32646
+ tab: "trash",
32647
+ nameKey: tabLabelKey("trash"),
32648
+ descKey: tabDescKey("trash"),
32649
+ open: props.open,
32650
+ onToggle: props.onToggle,
32651
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
32652
+ className: "daw-list-meta",
32653
+ children: t("pluginTrashHint")
32654
+ }), trash.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32655
+ className: "daw-list-meta daw-empty",
32656
+ children: t("trashEmpty")
32657
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32658
+ className: "daw-list",
32659
+ children: trash.map((item) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32660
+ className: "daw-list-row",
32661
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32662
+ className: "daw-list-title",
32663
+ children: item.path
32664
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32665
+ className: "daw-list-meta",
32666
+ children: [
32667
+ item.kind,
32668
+ " · ",
32669
+ formatBytes(item.size),
32670
+ " · ",
32671
+ new Date(item.createdAt).toLocaleString(locale)
32672
+ ]
32673
+ })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32674
+ className: "daw-command",
32675
+ onClick: () => {
32676
+ setError(void 0);
32677
+ api.restoreTrash(item.id).then(() => {
32678
+ notify("trashRestored");
32679
+ return reloadAfterAction();
32680
+ }).catch(setError);
32681
+ },
32682
+ children: t("restore")
32683
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32684
+ className: "daw-icon danger",
32685
+ title: t("deletePermanently"),
32686
+ "aria-label": t("deletePermanently"),
32687
+ onClick: () => {
32688
+ if (confirm(t("purgeConfirm"))) api.purgeTrash(item.id).then(() => {
32689
+ notify("trashPurged");
32690
+ return reloadAfterAction();
32691
+ }).catch(setError);
32692
+ },
32693
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.Trash2, { size: 15 })
32694
+ })] })]
32695
+ }, item.id))
32696
+ })]
32697
+ });
32517
32698
  }
32518
32699
  //#endregion
32519
- //#region src/client/plugins.tsx
32700
+ //#region src/client/cards/tunnel.tsx
32520
32701
  /**
32521
- * 插件清单的右侧栏形态:一个真正的 `SidebarRightTabDefinition`。
32702
+ * 「外网隧道」卡:Cloudflare Quick Tunnel —— 公网连入的兜底通道。
32522
32703
  *
32523
- * ## 为什么不是「再注册一个 slot」
32704
+ * ## 为什么从 `remote` 里拆出来
32524
32705
  *
32525
- * 报告里第 2 项讲的是 DSH 0.2.1-rc.3 的三处 pin 机制(`view-pins` / `panel-pins` /
32526
- * `guide-pins`)。pin 只作用于**注册了 panel / view / guide 的插件**——桥原来那两个座位
32527
- * (`conversation.chat.turnTail` 与 `settings.section`)都不在这三者的管辖内,所以哪怕把
32528
- * 三个新 slot 全注册一遍,桥自己的东西也一个都 pin 不到,只能消费别人的条目。
32706
+ * 拆之前这张卡挤在「远程访问」卡的末尾,一共 123 行里它占 37 行,语义却是两件事:
32707
+ * 前者是「在本机开个口子」(绑 IP/端口 + 配对码),后者是「把机器搬到外网」。
32708
+ * 用户看到同一个卡里既绑端口又开隧道,会以为隧道是远程访问的一个开关 ——
32709
+ * 它不是,它是**独立的一条通道**,与「远程访问已启用」和「选择权限」并列。
32529
32710
  *
32530
- * 要真拿到 pin,唯一的路是**成为 tab 类型提供方**:往 `sidebarRightTabs` 注册一个
32531
- * definition,并在 `sidebar.right.pane.tab` / `.title` 两个 keyed seat 上按 `definition.id`
32532
- * 挂上 body 与标题。这样 guide 页会列出这张卡片,用户能在 rc.3 的 More 菜单里把它
32533
- * 固定 / 取消固定(`guide-pins.d.ts` 的 `FIXED_GUIDE_KIND = "files"` 只豁免文件卡片,
32534
- * 桥的 kind 不在豁免名单里)。
32711
+ * ## 状态为什么住在 `AdminPanel` 而不是这里
32535
32712
  *
32536
- * ## 为什么单独一个文件
32713
+ * 这张卡用到的 `tunnel` / `tunnelBusy` / `activity` / `createTunnel` / `stopTunnel`
32714
+ * 有一半要供主壳算 `managementLocked`(隧道运行 ⇒ 除隧道自身外所有管理写操作 403),
32715
+ * 另一半还要被 `remote` 卡的「一键开隧道并出令牌」按钮消费。状态搬进这个文件,
32716
+ * 主壳就没有依据去禁用别的卡片了。所以它们经 `context.ts` 下发,见那里的长注释。
32537
32717
  *
32538
- * `workspace.tsx` 已经 1200 多行,而 `docs/project/TECH-DEBT.md` 的行数预算写的是
32539
- * 600 行/文件、超预算要先登记。把新 UI 面拆出来既不加剧那笔债,也让「设置页里的折叠卡」
32540
- * 与「右侧栏的常驻 tab」这两种形态各自独立演进。
32718
+ * ## 必须保住的一处判据
32719
+ *
32720
+ * 「停止」按钮**绝不能**被 `roots.length === 0` 锁死。隧道运行期间管理面关门,
32721
+ * `status` / `roots` 可能刷新失败为空;若停止也要求有根目录,用户就再也关不掉隧道了。
32722
+ * 只有「创建」需要至少一个授权根(配置文本要有落点)。
32723
+ */
32724
+ function TunnelCard(props) {
32725
+ const { t } = useWorkspaceI18n();
32726
+ const { tunnel, tunnelBusy, activity, roots, copyText, createTunnel, stopTunnel, configText, generateConfigForUrl, configTextBusy, managementLocked, setError } = useCards();
32727
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(SettingsCard, {
32728
+ tab: "tunnel",
32729
+ nameKey: tabLabelKey("tunnel"),
32730
+ descKey: tabDescKey("tunnel"),
32731
+ open: props.open,
32732
+ onToggle: props.onToggle,
32733
+ children: [
32734
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32735
+ className: "daw-tunnel",
32736
+ children: [
32737
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32738
+ className: "daw-tunnel-head",
32739
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("tunnelTitle") }) }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32740
+ className: `daw-pill${tunnel?.running ? " on" : ""}`,
32741
+ children: tunnel?.running ? t("tunnelRunning") : t("tunnelStopped")
32742
+ })]
32743
+ }),
32744
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
32745
+ className: "daw-list-meta",
32746
+ children: t("tunnelHint")
32747
+ }),
32748
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32749
+ className: "daw-warning",
32750
+ children: t("tunnelSecurityWarning")
32751
+ }),
32752
+ tunnel?.errorKey !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32753
+ className: "daw-warning",
32754
+ role: "alert",
32755
+ children: t(tunnel.errorKey, tunnel.errorParams ?? {})
32756
+ }),
32757
+ tunnel?.errorKey === void 0 && tunnel?.error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32758
+ className: "daw-warning",
32759
+ role: "alert",
32760
+ children: tunnel.error
32761
+ }),
32762
+ tunnel?.url !== void 0 && tunnel.url !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32763
+ className: "daw-url-row",
32764
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32765
+ className: "daw-tunnel-url",
32766
+ children: tunnel.url
32767
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyButton, {
32768
+ text: tunnel.url,
32769
+ title: t("copyUrl"),
32770
+ copiedText: t("copied"),
32771
+ manualText: t("copyManual"),
32772
+ copyText
32773
+ })]
32774
+ }),
32775
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32776
+ className: "daw-command primary",
32777
+ style: { marginTop: 10 },
32778
+ disabled: tunnelBusy || tunnel?.running !== true && roots.length === 0,
32779
+ onClick: () => {
32780
+ if (tunnel?.running === true) stopTunnel();
32781
+ else createTunnel({ thenGenConfig: true });
32782
+ },
32783
+ children: tunnel?.running === true ? t("tunnelStop") : t("tunnelCreate")
32784
+ }),
32785
+ activity !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32786
+ className: "daw-tunnel-progress",
32787
+ style: { marginTop: 10 },
32788
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32789
+ className: "daw-progress-track",
32790
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32791
+ className: `daw-progress-fill${activity.percent === null ? " indeterminate" : ""}`,
32792
+ style: activity.percent === null ? void 0 : { width: `${activity.percent}%` }
32793
+ })
32794
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32795
+ className: "daw-list-meta",
32796
+ style: { marginTop: 4 },
32797
+ children: activity.labelKey === "" ? "" : t(activity.labelKey, activity.labelParams ?? {})
32798
+ })]
32799
+ })
32800
+ ]
32801
+ }),
32802
+ tunnel?.running === true && tunnel.url != null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32803
+ className: "daw-command",
32804
+ style: { marginTop: 10 },
32805
+ disabled: configTextBusy,
32806
+ onClick: () => {
32807
+ generateConfigForUrl(tunnel.url, "tunnelConfigAuto").catch(setError);
32808
+ },
32809
+ children: t("tunnelGenConfigByUrl")
32810
+ }),
32811
+ configText !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ConfigTextPanel, { result: configText })
32812
+ ]
32813
+ });
32814
+ }
32815
+ //#endregion
32816
+ //#region src/client/workspace.tsx
32817
+ /**
32818
+ * 收进「高级设置」的分区:主列表不显示,但**每一个都照旧可用**。
32541
32819
  *
32542
- * ## rc.2 上的降级
32820
+ * 顺序即展开后的显示顺序。要加一张卡回主列表,把它从 `ADVANCED_TABS` 移回
32821
+ * `ADMIN_TABS` 即可 —— `TAB_DESC_KEYS` 是 `Record<AdminTab, …>` 全覆盖的,
32822
+ * 两处都能编译通过,不需要再补别的。
32543
32823
  *
32544
- * `sidebarRightTabs` 在 rc.2 与 rc.3 都提供,definition 契约两版一致,所以 tab 本身能用;
32545
- * 但 rc.2 的 `GuideBody` 没有 `entryPins` / `entryActions` / More 菜单,卡片会正常列出、
32546
- * 点开也正常,只是**不能 pin**。这是可接受的降级:能力随宿主版本生长,桥不做版本判断。
32824
+ * `roots` 排第一位:它是所有远程能力的**前置**(没有授权根目录,启用远程访问与创建隧道
32825
+ * 都会被 `roots.length === 0` 锁死)。放在组首,用户一展开就先看到它。
32547
32826
  */
32548
- /** definition 的 id:tab 系统的实现身份,同时是 body/title 两个 keyed seat 的 key。 */
32549
- const PLUGINS_TAB_ID = "dsh-remote-bridge-plugins";
32827
+ const ADVANCED_TABS = [
32828
+ "roots",
32829
+ "image",
32830
+ "plugins"
32831
+ ];
32550
32832
  /**
32551
- * tab 类型名。
32833
+ * 进设置页时先展开哪一张。
32552
32834
  *
32553
- * 必须与 DSH 内置类型不冲突 —— 内置的有 `guide` / `files` / `terminal` / `browser` /
32554
- * `plan` / `schedule` / `deliverables` 等。冲突时 `sidebarRightTabs.register` 会抛
32555
- * `sidebarRight: tab kind "…" is already registered`,整个插件起不来,所以带上插件前缀。
32835
+ * 不能指向 `ADVANCED_TABS` 里的卡:那样进来看到的是一张收起的卡(甚至整组是收起的),
32836
+ * 用户不会知道该先授权根目录。见 `AdminPanel` 里 `advancedOpen` 的初始化 ——
32837
+ * 万一以后有人把它改成组内卡,那组会自动展开,不会出现「默认卡在关着的组里」。
32556
32838
  */
32557
- const PLUGINS_TAB_KIND = "remote-bridge-plugins";
32839
+ const DEFAULT_PANEL_SECTION = "remote";
32558
32840
  /**
32559
- * 构造 tab 类型定义。
32841
+ * The single desktop dialog: a vertical section rail on the left, one content pane on the right.
32560
32842
  *
32561
- * 文案用 thunk(`() => string`)而不是现取的字符串:契约里写明 thunk 每次使用都会重读,
32562
- * 所以切换语言不需要重新注册。`title` 是 tab chip 的初始文字,开 tab 的那一刻被捕获进
32563
- * layout record。
32843
+ * Settings only — the five admin sections. The file tree used to live here as a sixth row; it does
32844
+ * not any more, so this is the old `AdminOverlay` body with a rail, not a workspace host.
32845
+ */
32846
+ /**
32847
+ * 导航栏 + 内容区,**不含任何对话框外壳**。
32564
32848
  *
32565
- * `patterns` 故意省略 —— 省略即「page type」,只能按 kind 打开(guide 卡片点一下就是这条路),
32566
- * 不会被文件地址意外匹配到。`priority` 用默认的 `extension`。
32849
+ * 抽出来是为了让 DSH 设置页能把它**内联**渲染。
32850
+ *
32851
+ * 早先的版本做不到这一点,于是设置页里只放了一句说明加一个「打开手机设置面板」按钮,
32852
+ * 真正的设置藏在按钮后面的模态框里。用户的反应很直接:「其他的可以设置的东西那去了」——
32853
+ * 这就是把弹窗嵌进设置页的代价:入口看起来是空的,因为内容在下一层。
32854
+ * 设置页本身就是一层容器,没有任何理由再套一层。
32567
32855
  */
32568
- function pluginsTabDefinition() {
32569
- const label = () => translate(getWorkspaceLocale(), "pluginsTab");
32570
- const description = () => translate(getWorkspaceLocale(), "pluginsTabDesc");
32571
- return {
32572
- id: PLUGINS_TAB_ID,
32573
- kind: PLUGINS_TAB_KIND,
32574
- title: label,
32575
- guide: [{
32576
- id: "plugins",
32577
- order: 40,
32578
- title: label,
32579
- description,
32580
- icon: import_lucide_react.Package
32581
- }]
32582
- };
32583
- }
32584
32856
  /**
32585
- * tab chip 里的文字。
32857
+ * 设置面板主体:**七张卡片平铺,各自独立开合,没有导航栏**。
32586
32858
  *
32587
- * 不读 `useTabInfo` 是刻意的:标题在开 tab 时就被捕获了,跟着当前语言走没有意义;
32588
- * 而且 rc.2 / rc.3 的 hook 注入形状不同,少依赖一处就少一处版本耦合。
32859
+ * 上一版留了个分区导航栏、一次只显示一张卡。对照 Vision Router 的实际长相才发现差得远:
32860
+ * 它是全部卡片一起列出来、想开哪张开哪张,标题栏自己就是入口。那句提示词写得很直白 ——
32861
+ * 「多张卡可以同时保持展开」。所以导航栏这一层是多余的,用户还得先点导航、再点卡片头,
32862
+ * 两次点击才走一步。
32863
+ *
32864
+ * 抽出来是为了让 DSH 设置页能把它**内联**渲染。
32865
+ *
32866
+ * 早先的版本做不到这一点,于是设置页里只放了一句说明加一个「打开手机设置面板」按钮,
32867
+ * 真正的设置藏在按钮后面的模态框里。用户的反应很直接:「其他的可以设置的东西那去了」——
32868
+ * 这就是把弹窗嵌进设置页的代价:入口看起来是空的,因为内容在下一层。
32869
+ * 设置页本身就是一层容器,没有任何理由再套一层。
32870
+ *
32871
+ * `section` / `onSection` 保留是为了兼容旧调用方,但现在**不再决定显示哪一张卡**,
32872
+ * 只用来决定默认展开哪一张。
32589
32873
  */
32590
- function PluginsTabTitle() {
32591
- const { t } = useWorkspaceI18n();
32592
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: t("pluginsTab") });
32874
+ function PhoneSettingsBody(props) {
32875
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AdminPanel, {
32876
+ api: props.api,
32877
+ initialOpen: props.section,
32878
+ onOpenPluginList: props.onOpenPluginList
32879
+ });
32593
32880
  }
32594
32881
  /**
32595
- * 右侧栏里的插件清单。
32596
- *
32597
- * 与设置页那张折叠卡读同一对端点(`GET/PATCH /settings/plugins`),但形态不同:
32598
- * 这里是**常驻面板**,没有卡片外壳,清单直接铺开;刷新按钮常驻而不是「展开才拉」——
32599
- * 用户既然把这张卡 pin 到了 guide 页,就是打算经常看。
32882
+ * The settings content of one admin section, extracted from `AdminOverlay` so the unified panel and
32883
+ * the standalone `/dsh-workspace` page render exactly the same body.
32884
+ */
32885
+ /**
32886
+ * 设置面板:卡片平铺,各自独立开合。
32600
32887
  *
32601
- * 三条与设置页一致的产品约束(详见 `workspace.tsx` 的 `PluginsSection` 注释):
32602
- * 官方包不可停用、`runtime-provided` 不是故障、改完要重启才生效。
32888
+ * `initialOpen` 决定进页面时先展开哪一张(默认第一张),之后完全由用户点卡片头控制。
32889
+ * 展开状态存 Set —— 这样「同时展开两张」是自然结果,不需要额外的互斥逻辑。
32603
32890
  */
32604
- function makePluginsTabBody(api) {
32605
- return function PluginsTabBody() {
32606
- const { t } = useWorkspaceI18n();
32607
- const [inventory, setInventory] = (0, react.useState)(void 0);
32608
- const [busy, setBusy] = (0, react.useState)(false);
32609
- const [checking, setChecking] = (0, react.useState)(false);
32610
- const [toggling, setToggling] = (0, react.useState)(void 0);
32611
- const [error, setError] = (0, react.useState)(void 0);
32612
- const [restartRequired, setRestartRequired] = (0, react.useState)(false);
32613
- const load = (0, react.useCallback)(async (checkUpdates) => {
32614
- if (checkUpdates) setChecking(true);
32615
- else setBusy(true);
32616
- setError(void 0);
32891
+ function AdminPanel(props) {
32892
+ const { locale, t } = useWorkspaceI18n();
32893
+ const [status, setStatus] = (0, react.useState)();
32894
+ const [configText, setConfigText] = (0, react.useState)();
32895
+ const [configEndpoint, setConfigEndpoint] = (0, react.useState)("");
32896
+ const [configTextBusy, setConfigTextBusy] = (0, react.useState)(false);
32897
+ const [openCards, setOpenCards] = (0, react.useState)(() => /* @__PURE__ */ new Set([props.initialOpen]));
32898
+ const [advancedOpen, setAdvancedOpen] = (0, react.useState)(() => ADVANCED_TABS.includes(props.initialOpen));
32899
+ const [error, setError] = (0, react.useState)();
32900
+ const [notice, setNotice] = (0, react.useState)();
32901
+ const [tunnel, setTunnel] = (0, react.useState)();
32902
+ const [tunnelBusy, setTunnelBusy] = (0, react.useState)(false);
32903
+ const [activity, setActivity] = (0, react.useState)();
32904
+ const notify = (0, react.useCallback)((key, params) => {
32905
+ setNotice(params === void 0 ? {
32906
+ id: Date.now() + Math.random(),
32907
+ key
32908
+ } : {
32909
+ id: Date.now() + Math.random(),
32910
+ key,
32911
+ params
32912
+ });
32913
+ }, []);
32914
+ const copyText = (0, react.useCallback)(async (text) => {
32915
+ const clipboard = globalThis.navigator?.clipboard;
32916
+ if (clipboard !== void 0) try {
32917
+ await clipboard.writeText(text);
32918
+ return "done";
32919
+ } catch {}
32920
+ try {
32921
+ const node = document.createElement("textarea");
32922
+ node.value = text;
32923
+ node.style.position = "fixed";
32924
+ node.style.top = "-9999px";
32925
+ node.setAttribute("readonly", "");
32926
+ document.body.append(node);
32927
+ node.select();
32928
+ const ok = document.execCommand("copy");
32929
+ node.remove();
32930
+ if (ok) return "done";
32931
+ } catch {}
32932
+ return "manual";
32933
+ }, []);
32934
+ const isOpen = (0, react.useCallback)((card) => openCards.has(card), [openCards]);
32935
+ const toggleCard = (0, react.useCallback)((card) => {
32936
+ setOpenCards((previous) => {
32937
+ const next = new Set(previous);
32938
+ if (!next.delete(card)) next.add(card);
32939
+ return next;
32940
+ });
32941
+ }, []);
32942
+ const refresh = (0, react.useCallback)(async () => {
32943
+ try {
32944
+ setTunnel(await props.api.tunnelStatus());
32945
+ } catch {}
32946
+ try {
32947
+ const next = await props.api.status();
32948
+ setStatus(next);
32949
+ } catch (cause) {
32950
+ if (!(cause instanceof WorkspaceApiError && cause.status === 403 && (cause.code === "TUNNEL_MODE_ADMIN_DISABLED" || cause.code === "ADMIN_PROXY_FORWARDED"))) setError(cause);
32951
+ }
32952
+ }, [props.api, openCards]);
32953
+ const tunnelErrorText = (0, react.useCallback)((s) => {
32954
+ if (s.errorKey !== void 0) return t(s.errorKey, s.errorParams ?? {});
32955
+ return s.error ?? t("listenerFailed");
32956
+ }, [t]);
32957
+ const pollActivityOnce = (0, react.useCallback)(async (active) => {
32958
+ while (active()) {
32617
32959
  try {
32618
- setInventory(await api.pluginInventory(checkUpdates));
32619
- } catch (cause) {
32620
- setError(messageOf(cause, t));
32621
- } finally {
32622
- if (checkUpdates) setChecking(false);
32623
- else setBusy(false);
32960
+ const a = await props.api.tunnelActivity();
32961
+ if (!active()) return;
32962
+ setActivity(a.stage === "idle" ? void 0 : a);
32963
+ } catch {}
32964
+ await delay(400);
32965
+ }
32966
+ }, [props.api]);
32967
+ const waitForTunnelUrl = (0, react.useCallback)(async (active) => {
32968
+ for (let attempt = 0; attempt < 40 && active(); attempt += 1) {
32969
+ await delay(500);
32970
+ const s = await props.api.tunnelStatus();
32971
+ if (!active()) return null;
32972
+ setTunnel(s);
32973
+ if (s.errorKey !== void 0 || s.error !== void 0) throw new Error(tunnelErrorText(s));
32974
+ if (s.running && s.url !== null) return s.url;
32975
+ }
32976
+ return null;
32977
+ }, [props.api, tunnelErrorText]);
32978
+ const generateConfigForUrl = (0, react.useCallback)(async (baseUrl, noticeKey = "configCreated") => {
32979
+ setConfigTextBusy(true);
32980
+ setError(void 0);
32981
+ try {
32982
+ const currentRoots = status?.roots ?? [];
32983
+ const value = await props.api.createConfigText({
32984
+ ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) },
32985
+ endpoints: [{
32986
+ label: t("tunnelEndpointLabel"),
32987
+ baseUrl
32988
+ }]
32989
+ });
32990
+ setConfigText(value);
32991
+ notify(noticeKey);
32992
+ return true;
32993
+ } catch (cause) {
32994
+ setError(cause);
32995
+ return false;
32996
+ } finally {
32997
+ setConfigTextBusy(false);
32998
+ }
32999
+ }, [
33000
+ props.api,
33001
+ status,
33002
+ t,
33003
+ notify
33004
+ ]);
33005
+ const createTunnel = (0, react.useCallback)(async (opts = {}) => {
33006
+ if (!window.confirm(t("tunnelStartConfirm"))) return;
33007
+ const wantConfig = opts.thenGenConfig === true;
33008
+ let alive = true;
33009
+ setTunnelBusy(true);
33010
+ setError(void 0);
33011
+ setConfigText(void 0);
33012
+ setActivity({
33013
+ stage: "locating",
33014
+ labelKey: "tunnelStageLocating",
33015
+ percent: null
33016
+ });
33017
+ pollActivityOnce(() => alive);
33018
+ try {
33019
+ const currentRoots = status?.roots ?? [];
33020
+ const created = await props.api.tunnelCreate(wantConfig, { ...currentRoots.length === 0 ? {} : { rootIds: currentRoots.map((root) => root.id) } });
33021
+ setTunnel(created);
33022
+ setActivity(void 0);
33023
+ if (created.errorKey !== void 0 || created.error !== void 0) throw new Error(tunnelErrorText(created));
33024
+ if (wantConfig && created.configText !== void 0) {
33025
+ setConfigText(created.configText);
33026
+ await refresh();
33027
+ notify("tunnelConfigAuto");
33028
+ return;
32624
33029
  }
32625
- }, [t]);
32626
- (0, react.useEffect)(() => {
32627
- load(false);
32628
- }, [load]);
32629
- const toggle = (0, react.useCallback)(async (entry) => {
32630
- setToggling(entry.name);
33030
+ const url = created.url ?? await waitForTunnelUrl(() => alive);
33031
+ await refresh();
33032
+ if (url === null) {
33033
+ notify("tunnelUrlPending");
33034
+ return;
33035
+ }
33036
+ if (wantConfig) await generateConfigForUrl(url, "tunnelConfigAuto");
33037
+ else notify("tunnelStarted");
33038
+ } catch (cause) {
33039
+ setError(cause);
33040
+ } finally {
33041
+ alive = false;
33042
+ setActivity(void 0);
33043
+ setTunnelBusy(false);
33044
+ }
33045
+ }, [
33046
+ props.api,
33047
+ refresh,
33048
+ status,
33049
+ t,
33050
+ notify,
33051
+ pollActivityOnce,
33052
+ waitForTunnelUrl,
33053
+ tunnelErrorText,
33054
+ generateConfigForUrl
33055
+ ]);
33056
+ const stopTunnel = (0, react.useCallback)(async () => {
33057
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
33058
+ setTunnelBusy(true);
33059
+ setError(void 0);
33060
+ try {
33061
+ const next = await props.api.tunnelStop();
33062
+ setTunnel(next);
33063
+ await refresh();
33064
+ notify("tunnelStopped");
33065
+ } catch (cause) {
33066
+ setError(cause);
33067
+ } finally {
33068
+ setTunnelBusy(false);
33069
+ }
33070
+ }, [
33071
+ props.api,
33072
+ refresh,
33073
+ t,
33074
+ notify
33075
+ ]);
33076
+ const tunnelConfigClick = (0, react.useCallback)(async () => {
33077
+ if (tunnel?.running === true) {
33078
+ if (!window.confirm(t("tunnelStopConfirm"))) return;
33079
+ setTunnelBusy(true);
32631
33080
  setError(void 0);
32632
33081
  try {
32633
- await api.togglePlugin(entry.name, !entry.loaded);
32634
- setInventory(await api.pluginInventory(false));
32635
- setRestartRequired(true);
33082
+ const stopped = await props.api.tunnelStop();
33083
+ setTunnel(stopped);
33084
+ await refresh();
32636
33085
  } catch (cause) {
32637
- setError(messageOf(cause, t));
33086
+ setError(cause);
32638
33087
  } finally {
32639
- setToggling(void 0);
33088
+ setTunnelBusy(false);
32640
33089
  }
32641
- }, [t]);
32642
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32643
- className: "daw-plugins-tab",
33090
+ await createTunnel({ thenGenConfig: true });
33091
+ } else await createTunnel({ thenGenConfig: true });
33092
+ }, [
33093
+ props.api,
33094
+ tunnel,
33095
+ t,
33096
+ createTunnel,
33097
+ refresh,
33098
+ setError
33099
+ ]);
33100
+ (0, react.useEffect)(() => {
33101
+ refresh();
33102
+ }, [refresh]);
33103
+ const roots = status?.roots ?? [];
33104
+ const managementLocked = tunnel?.running === true;
33105
+ const cards = {
33106
+ api: props.api,
33107
+ t,
33108
+ locale,
33109
+ status,
33110
+ roots,
33111
+ managementLocked,
33112
+ refresh,
33113
+ notify,
33114
+ copyText,
33115
+ error,
33116
+ setError,
33117
+ setStatus,
33118
+ tunnel,
33119
+ tunnelBusy,
33120
+ activity,
33121
+ configText,
33122
+ configTextBusy,
33123
+ setConfigText,
33124
+ setConfigTextBusy,
33125
+ configEndpoint,
33126
+ setConfigEndpoint,
33127
+ createTunnel,
33128
+ stopTunnel,
33129
+ tunnelConfigClick,
33130
+ generateConfigForUrl
33131
+ };
33132
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardsProvider, {
33133
+ value: cards,
33134
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("main", {
33135
+ className: "daw-admin",
32644
33136
  children: [
32645
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
32646
- className: "daw-list-meta",
32647
- children: t("pluginsHint")
33137
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33138
+ className: "daw-admin-topbar",
33139
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(LanguageToggle, {})
32648
33140
  }),
32649
33141
  error !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32650
33142
  className: "daw-warning",
32651
33143
  role: "alert",
32652
- children: error
33144
+ children: messageOf(error, t)
32653
33145
  }),
32654
- restartRequired && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33146
+ notice !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32655
33147
  className: "daw-success",
32656
33148
  role: "status",
32657
- children: t("pluginsRestartRequired")
33149
+ children: t(notice.key, notice.params)
32658
33150
  }),
32659
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32660
- className: "daw-form-row",
32661
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
32662
- type: "button",
32663
- className: "daw-command",
32664
- disabled: busy,
32665
- onClick: () => {
32666
- load(false);
32667
- },
32668
- children: [
32669
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(import_lucide_react.RefreshCw, { size: 14 }),
32670
- " ",
32671
- busy ? t("loading") : t("refresh")
32672
- ]
32673
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32674
- type: "button",
32675
- className: "daw-command",
32676
- disabled: checking,
32677
- onClick: () => {
32678
- load(true);
32679
- },
32680
- children: checking ? t("pluginsChecking") : t("pluginsCheckUpdates")
32681
- })]
33151
+ managementLocked && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
33152
+ className: "daw-warning",
33153
+ role: "status",
33154
+ children: t("managementLockedWhileTunnel")
32682
33155
  }),
32683
- inventory === void 0 ? busy ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32684
- className: "daw-list-meta",
32685
- children: t("loading")
32686
- }) : null : !inventory.available ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32687
- className: "daw-list-meta",
32688
- children: t("pluginsUnavailable", { reason: inventory.reason ?? "" })
32689
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32690
- className: "daw-list-meta",
33156
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RemoteCard, {
33157
+ open: isOpen("remote"),
33158
+ onToggle: () => toggleCard("remote")
33159
+ }),
33160
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TunnelCard, {
33161
+ open: isOpen("tunnel"),
33162
+ onToggle: () => toggleCard("tunnel")
33163
+ }),
33164
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(DevicesCard, {
33165
+ open: isOpen("devices"),
33166
+ onToggle: () => toggleCard("devices")
33167
+ }),
33168
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(TrashCard, {
33169
+ open: isOpen("trash"),
33170
+ onToggle: () => toggleCard("trash")
33171
+ }),
33172
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AppDownloadCard, {
33173
+ open: isOpen("app-download"),
33174
+ onToggle: () => toggleCard("app-download")
33175
+ }),
33176
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(AdvancedGroup, {
33177
+ open: advancedOpen,
33178
+ onToggle: () => setAdvancedOpen((v) => !v),
32691
33179
  children: [
32692
- inventory.profile !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [inventory.profile, " · "] }),
32693
- inventory.loadedCount,
32694
- " / ",
32695
- inventory.items.length,
32696
- inventory.problemCount > 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" · ", t("pluginsProblems", { count: inventory.problemCount })] })
33180
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RootsCard, {
33181
+ open: isOpen("roots"),
33182
+ onToggle: () => toggleCard("roots")
33183
+ }),
33184
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ImageCard, {
33185
+ open: isOpen("image"),
33186
+ onToggle: () => toggleCard("image")
33187
+ }),
33188
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PluginsCard, {
33189
+ open: isOpen("plugins"),
33190
+ onToggle: () => toggleCard("plugins"),
33191
+ onOpenPluginList: props.onOpenPluginList
33192
+ })
32697
33193
  ]
32698
- }), inventory.items.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32699
- className: "daw-list-meta daw-empty",
32700
- children: t("pluginsEmpty")
32701
- }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32702
- className: "daw-list",
32703
- children: inventory.items.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32704
- className: "daw-list-row",
32705
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [
32706
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32707
- className: "daw-list-title",
32708
- children: [entry.name, entry.installed !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
32709
- className: "daw-list-meta",
32710
- children: [" · ", entry.installed]
32711
- })]
32712
- }),
32713
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
32714
- className: "daw-list-meta",
32715
- children: entry.description ?? t("pluginsNoDescription")
32716
- }),
32717
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
32718
- className: "daw-list-meta",
32719
- children: [
32720
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32721
- className: `daw-pill${entry.loaded ? " on" : ""}`,
32722
- children: t(pluginStateKey(entry.state))
32723
- }),
32724
- entry.updateAvailable === true && entry.latestVersion !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" ", /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
32725
- className: "daw-pill",
32726
- children: t("pluginsUpdateAvailable", { version: entry.latestVersion })
32727
- })] }),
32728
- entry.updateAvailable === false && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" · ", t("pluginsLatest")] })
32729
- ]
32730
- })
32731
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: entry.toggleable && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
32732
- type: "button",
32733
- className: "daw-command",
32734
- disabled: toggling === entry.name,
32735
- onClick: () => {
32736
- toggle(entry);
32737
- },
32738
- children: entry.loaded ? t("pluginsDisable") : t("pluginsEnable")
32739
- }) })]
32740
- }, entry.name))
32741
- })] })
33194
+ })
32742
33195
  ]
32743
- });
32744
- };
32745
- }
32746
- //#endregion
32747
- //#region src/client/styles.ts
32748
- const STYLE_ID = "dsh-workspace-style";
32749
- /**
32750
- * 插件的全部样式。
32751
- *
32752
- * 导出出来是为了让测试能守住一条容易失守的不变量:**CSS 变量块必须同时覆盖
32753
- * `.daw-root` 与 `.daw-settings-section`**。
32754
- *
32755
- * 这条防线来自一个真实发生过的隐形 bug:设置页内联渲染时,那一节没有 `.daw-root` 祖先,
32756
- * 于是 `var(--daw-accent)` 解析失败 → `.daw-command.primary` 的 `background` 失效变透明、
32757
- * 而 `color` 仍是 `#fff` → **白字落在白底上,按钮彻底看不见**。
32758
- * 类型检查、单测、构建全都发现不了,只有肉眼看渲染或量计算样式才抓得到。
32759
- */
32760
- const WORKSPACE_CSS = `
32761
- .daw-root,.daw-settings-section,.daw-plugins-tab{--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}
32762
- .daw-root *,.daw-settings-section *,.daw-plugins-tab *{box-sizing:border-box;letter-spacing:0}
32763
- /* 输入控件基线(UI-04):此前只有 :focus-visible 一条,非聚焦态全靠浏览器默认(2px inset/Arial/灰底),
32764
- 深色令牌下实测白底黑字与面板割裂。这里给齐边框/底/字/内边距,聚焦态只换描边。 */
32765
- .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}
32766
- .daw-input::placeholder{color:var(--dsw-alias-label-tertiary,var(--daw-muted))}
32767
- .daw-input:focus-visible{border-color:var(--dsw-alias-brand-primary,var(--daw-accent));outline:none}
32768
- .daw-input:disabled{opacity:.55;cursor:not-allowed}.daw-icon{width:30px;padding:0}
32769
- .daw-icon:hover{background:var(--dsw-alias-bg-module-platform,var(--daw-accent-soft))}
32770
- .daw-icon.danger{color:var(--daw-danger);border-color:transparent;background:transparent}
32771
- .daw-icon:disabled{opacity:.45;cursor:not-allowed}
32772
- /* 普通命令按钮基线(UI-04):旧版写死 background:#fff/border:#cbd2d8,深色令牌下成了白块黑字。
32773
- 全部改走 DSH 令牌;.primary 沿用面板底色做次按钮、真正的主操作由调用方加 .primary 配合。 */
32774
- .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}
32775
- .daw-command:hover:not(:disabled){border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
32776
- .daw-command:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:1px}
32777
- /* 禁用态(UI-05):此前无任何 :disabled 样式,只剩浏览器默认的 30% 文字且仍是箭头手型。 */
32778
- .daw-command:disabled{opacity:.5;cursor:not-allowed}
32779
- .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}
32780
- .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))}
32781
- .daw-command.danger{color:var(--daw-danger)}
32782
- .daw-command.danger:hover:not(:disabled){border-color:var(--daw-danger)}
32783
- /* 图标+文字的小复制按钮:图标和文字对齐,窄屏只露图标也不改变按钮高度。 */
32784
- .daw-command.icon-as-command{display:inline-flex;align-items:center;gap:6px;padding:0 10px;white-space:nowrap}
32785
- .daw-copy-label{font-size:12px}
32786
- .daw-empty{padding:14px 2px;text-align:center}
32787
- .daw-admin{overflow:auto;padding:18px;display:flex;flex-direction:column;gap:10px}.daw-admin h3{font-size:14px;margin:0 0 12px}
32788
- .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}
32789
- .daw-card:hover{border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
32790
- .daw-card-open{background:var(--dsw-alias-bg-layer-2,var(--daw-panel));border-color:var(--dsw-alias-label-dimmed,var(--daw-line))}
32791
- .daw-card-header{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
32792
- .daw-card-header:focus-visible{outline:2px solid var(--dsw-alias-brand-primary,var(--daw-accent));outline-offset:-2px}
32793
- .daw-card-icon{color:var(--dsw-alias-label-tertiary,var(--daw-muted));flex:none;display:flex;align-items:center}
32794
- .daw-card-headtext{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
32795
- .daw-card-name{color:var(--dsw-alias-label-primary,var(--daw-ink));font-size:15px;font-weight:600;line-height:1.4}
32796
- .daw-card-desc{color:var(--dsw-alias-label-tertiary,var(--daw-muted));font-size:13px;line-height:1.5}
32797
- .daw-card-chevron{color:var(--dsw-alias-label-tertiary,var(--daw-muted));flex:none;transition:transform .16s;font-size:12px;line-height:1}
32798
- .daw-card-chevron-open{transform:rotate(180deg)}
32799
- .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-plugins-tab{overflow:auto;padding:14px 16px;display:flex;flex-direction:column;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}
32800
- .daw-list-title .daw-pill{margin-left:8px;vertical-align:middle}
32801
- .daw-builtin-hint{text-align:right;align-self:center}
32802
- .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}
32803
- /* 成功反馈条(UI-07):与 .daw-warning 同形,但走成功色,操作成功不再静默。 */
32804
- .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}
32805
- .daw-admin-topbar{display:flex;justify-content:flex-end;margin-bottom:8px}
32806
- .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}
32807
- .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}
32808
- /* 隧道/监听进行中的流动条(UI-08):percent=null 时铺一段高光在轨道里循环滑动,
32809
- 不再把「未知进度」伪装成恒定 30%。 */
32810
- .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}
32811
- @keyframes daw-indeterminate{0%{transform:translateX(-120%)}100%{transform:translateX(280%)}}
32812
- @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}}
32813
- `;
32814
- function installWorkspaceStyles() {
32815
- if (document.getElementById(STYLE_ID) !== null) return;
32816
- const style = document.createElement("style");
32817
- style.id = STYLE_ID;
32818
- style.dataset.plugin = "dsh-workspace";
32819
- style.textContent = WORKSPACE_CSS;
32820
- document.head.appendChild(style);
33196
+ })
33197
+ });
32821
33198
  }
32822
33199
  //#endregion
32823
33200
  //#region src/client/index.tsx