@mrrisega/dsh-remote 0.7.2-beta.1 → 0.7.2-beta.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.
@@ -20,7 +20,7 @@
20
20
  // - 0.1.2+ ?token 浏览器鉴权会话代持(0.4.1 起)
21
21
  //
22
22
  // 不依赖任何第三方包:只使用 node 内置模块与 cordis 注入的 webServer 服务。
23
- import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, constants as fsConstants } from "node:fs";
23
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync, accessSync, chmodSync, openSync, closeSync, readSync, fstatSync, rmSync, renameSync, statSync, copyFileSync, cpSync, readdirSync, constants as fsConstants } from "node:fs";
24
24
  import { join, dirname, sep, delimiter, isAbsolute, relative } from "node:path";
25
25
  import { execSync, spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
@@ -738,6 +738,309 @@ function applySaaSMode(cfg) {
738
738
  return cfg;
739
739
  }
740
740
 
741
+ // ---------- 安装/配对自检 + 配对信息备份(2026-10-17 事故) ----------
742
+ //
743
+ // 事故:用户本机 `~/.dsh-remote`(bridge 运行时 + **账号配对信息/设备密钥**)被误删 →
744
+ // bridge 无法启动、设备离线;手机端镜像页干等 47 秒;面板状态机只显示模糊的「未运行/准备中」,
745
+ // 用户完全不知道该**重新安装**还是该**重新登录**。
746
+ //
747
+ // 这一段的职责(全部只读 + 明确的下一步动作,绝不偷偷替用户做决定):
748
+ // ① setupStateOf:把「运行时在不在(clients/)」「配对信息在不在(.dsh-config.json)」分开如实报告,
749
+ // 并给出面向非技术用户的一句话结论(缺什么、下一步做什么);
750
+ // ② 运行时缺失 → 复用既有 ensureRuntime 从插件自带 runtime/ 自愈补装(配对缺失**必须用户登录**,
751
+ // 绝不自动填入任何凭据);
752
+ // ③ 配对备份(**双份**,2026-10-17 二次加固):
753
+ // · 配置目录:<relayDir>/backup/dsh-config-<ISO>.json;
754
+ // · **插件 profile 下的独立数据目录**:<profile>/dsh-remote-web-data/config-backup/。
755
+ // 真机事故里用户删的是**整个 ~/.dsh-remote 目录** —— 只备份在 relayDir 里等于没备份,
756
+ // 所以必须再存一份在不随 relayDir 删除、也不随插件包升级(`<id>-plugin/` 会被整体替换)
757
+ // 消失的位置。落点与插件 profile 同源(profile 目录推导规则见 pluginProfileDir,与
758
+ // registerRoutes 用的是同一份),目录 700 / 文件 600 / 每处保留最近 3 份。
759
+ // 启动时发现主配置缺失但备份存在 → **不自动恢复**(防误用旧账号/别人的账号),
760
+ // 只在状态里提示「检测到历史配对备份(时间),可一键恢复」并给恢复入口。
761
+ //
762
+ // 🔒 安全边界(备份文件里是明文密码/设备私钥):
763
+ // · 日志只写**文件名与时间**,绝不写内容;状态对象里也不带任何凭据字段;
764
+ // · 备份失败一律静默(不影响安装/登录;两份里成功一份就算成功);
765
+ // · 恢复前先把"当前状态"再备份一份(可回滚);恢复必须由用户在面板里显式点击;
766
+ // · 彻底卸载时 profile 数据目录里的这份也一起删(否则"已卸载"还留着账号密钥,
767
+ // 而且重装后会提示恢复一个用户已经不要了的旧账号)。
768
+
769
+ /** 配对备份目录(相对 relayDir;第二份在 profile 数据目录,见 PROFILE_* 常量)。 */
770
+ const CONFIG_BACKUP_DIR = "backup";
771
+ const CONFIG_BACKUP_KEEP = 3;
772
+ const CONFIG_BACKUP_PREFIX = "dsh-config-";
773
+ /** profile 下本插件自己的**独立数据目录**:不随 `<id>-plugin/`(升级时整体替换)消失。 */
774
+ const PROFILE_DATA_DIR = "dsh-remote-web-data";
775
+ const PROFILE_BACKUP_SUBDIR = "config-backup";
776
+ /**
777
+ * 备份来源与优先级:`profile` = 插件 profile 数据目录(抗整目录删除),`relay` = 配置目录。
778
+ * 数组顺序 = 默认优先级(恢复默认选"最新的一份",时间相同时 **profile 胜** ——
779
+ * 它能在 relayDir 整个被删时活下来,是同一次写入里更耐久的那份副本)。
780
+ */
781
+ const CONFIG_BACKUP_SOURCES = ["profile", "relay"];
782
+ /** 来源 → 用户可见的中文标签(只说明位置,不含凭据信息)。 */
783
+ const CONFIG_BACKUP_SOURCE_TEXT = { profile: "插件持久目录", relay: "配置目录" };
784
+
785
+ /**
786
+ * 本插件所在的 profile 目录(与 registerRoutes 同一份推导规则:
787
+ * 由插件自身文件位置推导,覆盖市场 git 安装 / 本地 include 两种形态;取不到用默认 web profile)。
788
+ *
789
+ * ⚠️ 为什么不直接用 plugin 包目录:升级/重装会整目录替换 `<profile>/<id>-plugin/`,
790
+ * 放在它里面的备份会跟着没 —— 所以数据目录放在 profile 下的**同级独立目录**。
791
+ */
792
+ function pluginProfileDir() {
793
+ let dir = join(homedir(), ".dsh", "profiles", "web");
794
+ try {
795
+ const here = fileURLToPath(import.meta.url);
796
+ // 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
797
+ let m = here.split("/dsh-remote-web-plugin/")[0];
798
+ if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
799
+ if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
800
+ if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
801
+ if (m !== here) dir = m;
802
+ } catch { /* 保持默认 */ }
803
+ return dir;
804
+ }
805
+
806
+ /** 某个来源的备份目录绝对路径。 */
807
+ function configBackupDirOf(relayDir, source = "relay") {
808
+ if (source === "relay") return join(relayDir, CONFIG_BACKUP_DIR);
809
+ return join(pluginProfileDir(), PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR);
810
+ }
811
+
812
+ /** 人类可读时间(只用于状态/日志的"时间"提示,不含任何凭据)。 */
813
+ function fmtLocalTime(ms) {
814
+ const n = Number(ms);
815
+ if (!Number.isFinite(n) || n <= 0) return "";
816
+ try {
817
+ const d = new Date(n);
818
+ const p = (v) => String(v).padStart(2, "0");
819
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
820
+ } catch { return ""; }
821
+ }
822
+
823
+ /** 备份文件名的唯一合法形态(时间戳 + 可选序号;拒绝任何路径成分)。 */
824
+ const CONFIG_BACKUP_FILE_RE = /^dsh-config-[\w.-]+\.json$/;
825
+
826
+ /**
827
+ * 列出**某一个来源**的备份(新 → 旧)。只读目录;**绝不读取/返回备份文件内容**。
828
+ * @returns {Array<{source:string, file:string, name:string, at:number, size:number}>}
829
+ */
830
+ function listConfigBackupsIn(relayDir, source) {
831
+ try {
832
+ const dir = configBackupDirOf(relayDir, source);
833
+ const files = readdirSync(dir).filter((n) => CONFIG_BACKUP_FILE_RE.test(String(n)));
834
+ return files.map((file) => {
835
+ let at = 0;
836
+ let size = 0;
837
+ try {
838
+ const st = statSync(join(dir, file));
839
+ at = Number(st.mtimeMs) || 0;
840
+ size = Number(st.size) || 0;
841
+ } catch { /* 文件刚被删/无权限:忽略该项时间 */ }
842
+ // name 里体现来源:profile:<file> / relay:<file>(状态/恢复接口都用它,绝不把绝对路径发散出去)
843
+ return { source, file, name: `${source}:${file}`, at, size };
844
+ });
845
+ } catch { return []; }
846
+ }
847
+
848
+ /**
849
+ * 列出**两个来源合并**后的备份(新 → 旧)。
850
+ * 时间相同时 **profile 排前面**(默认优先恢复它,见 CONFIG_BACKUP_SOURCES 注释)。
851
+ * @returns {Array<{source:string, file:string, name:string, at:number, size:number}>}
852
+ */
853
+ function listConfigBackups(relayDir) {
854
+ const order = new Map(CONFIG_BACKUP_SOURCES.map((s, i) => [s, i]));
855
+ const all = [];
856
+ for (const source of CONFIG_BACKUP_SOURCES) all.push(...listConfigBackupsIn(relayDir, source));
857
+ return all.sort((a, b) => (b.at - a.at) || ((order.get(a.source) ?? 9) - (order.get(b.source) ?? 9))
858
+ || String(b.file).localeCompare(String(a.file)));
859
+ }
860
+
861
+ /**
862
+ * 解析用户/状态给出的备份引用 → 列表里的真实条目(**只认列表内文件**,防路径穿越/任意文件读)。
863
+ * · 省略 / 空串 → 默认:最新一份(时间相同优先 profile)
864
+ * · `profile:<file>` / `relay:<file>` → 指定来源(状态里下发的就是这种 name)
865
+ * · 裸 `<file>` → 兼容旧状态(2026-10-17 之前只写 relay 一份):两个来源里找,命中多个时优先 profile
866
+ * @returns {{source:string,file:string,name:string,at:number,size:number}|null}
867
+ */
868
+ function findConfigBackup(relayDir, ref = "") {
869
+ const raw = String(ref || "").trim();
870
+ const all = listConfigBackups(relayDir);
871
+ if (!all.length) return null;
872
+ if (!raw) return all[0]; // listConfigBackups 已按"新→旧、时间相同 profile 先"排好
873
+ if (/[\\/]/.test(raw) || raw.includes("..")) return null; // 明显是路径/穿越:直接拒绝
874
+ const m = /^(profile|relay):(.+)$/.exec(raw);
875
+ if (m) return all.find((b) => b.source === m[1] && b.file === m[2]) || null;
876
+ // 裸文件名(兼容旧状态):同名的可能两份都在 → 优先 profile
877
+ const hits = all.filter((b) => b.file === raw);
878
+ return hits.find((b) => b.source === "profile") || hits[0] || null;
879
+ }
880
+
881
+ /** 只保留**每个来源**最近 CONFIG_BACKUP_KEEP 份(更老的删掉;失败静默)。 */
882
+ function pruneConfigBackups(relayDir, keep = CONFIG_BACKUP_KEEP) {
883
+ for (const source of CONFIG_BACKUP_SOURCES) {
884
+ try {
885
+ const all = listConfigBackupsIn(relayDir, source);
886
+ for (const b of all.slice(Math.max(0, keep))) {
887
+ try { rmSync(join(configBackupDirOf(relayDir, source), b.file), { force: true }); } catch { /* 忽略 */ }
888
+ }
889
+ } catch { /* 单个来源失败不影响另一个 */ }
890
+ }
891
+ }
892
+
893
+ /**
894
+ * 把当前 `.dsh-config.json` **同时**复制到两处(relayDir/backup 与 profile 数据目录),
895
+ * 每处权限 600(目录 700)、各保留最近 3 份。
896
+ *
897
+ * 调用点:① 面板登录/配对成功(POST /dsh-remote/config);② 运行环境安装完成(ensureRuntime 成功);
898
+ * ③ 恢复前先备份当前状态。
899
+ * 🔒 日志只写文件名+时间,**绝不写内容**;任何失败静默返回,绝不影响安装/登录主流程。
900
+ * 单个落点失败(例如 relayDir 只读)不影响另一份成功 —— 至少有一份就返回 ok。
901
+ * @returns {{ok:boolean, at?:number, name?:string, sources?:{profile?:string, relay?:string}, reason?:string}}
902
+ * name = 默认恢复会选中的那一份(profile 优先);sources = 两个落点各自的文件名
903
+ */
904
+ function backupConfigFile(relayDir) {
905
+ try {
906
+ const src = configPathOf(relayDir);
907
+ if (!existsSync(src)) return { ok: false, reason: "no-config" }; // 还没配对:无可备份
908
+ const at = Date.now();
909
+ const stamp = new Date(at).toISOString().replace(/[:.]/g, "-");
910
+ const sources = {};
911
+ for (const source of CONFIG_BACKUP_SOURCES) {
912
+ try {
913
+ const dir = configBackupDirOf(relayDir, source);
914
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
915
+ // 文件名只含时间戳(不含账号/手机号等任何内容);同一毫秒重复备份时加序号,绝不覆盖已有备份
916
+ let file = `${CONFIG_BACKUP_PREFIX}${stamp}.json`;
917
+ for (let i = 2; existsSync(join(dir, file)) && i < 100; i += 1) {
918
+ file = `${CONFIG_BACKUP_PREFIX}${stamp}-${i}.json`;
919
+ }
920
+ copyFileSync(src, join(dir, file));
921
+ // 备份里是明文密码/设备私钥:POSIX 上 0600;Windows 显式收紧 ACL(mode 是空操作)
922
+ try { chmodSync(join(dir, file), 0o600); } catch { /* 非关键 */ }
923
+ try { hardenFile(join(dir, file)); } catch { /* 非关键 */ }
924
+ sources[source] = file;
925
+ } catch { /* 单个落点失败:另一份仍然有效(relayDir 只读/磁盘满等情况) */ }
926
+ }
927
+ if (!Object.keys(sources).length) return { ok: false, reason: "failed" }; // 两份都失败:静默返回
928
+ pruneConfigBackups(relayDir);
929
+ // 日志只写文件名与时间(两份各列出,便于排查到底哪一份没写成)
930
+ const names = CONFIG_BACKUP_SOURCES.filter((s) => sources[s]).map((s) => `${s}:${sources[s]}`).join("、");
931
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
932
+ `[dsh-remote-web] 已备份配对配置 ${names}(${fmtLocalTime(at)},权限 600;内容不写入日志)`);
933
+ const preferred = sources.profile ? `profile:${sources.profile}` : `relay:${sources.relay}`;
934
+ return { ok: true, at, name: preferred, sources };
935
+ } catch {
936
+ return { ok: false, reason: "failed" }; // 备份失败静默:绝不影响安装/登录
937
+ }
938
+ }
939
+
940
+ /**
941
+ * 从备份恢复配对配置。**只能由用户在面板里显式触发**(绝不自动调用 —— 旧账号备份可能是
942
+ * 别人的/已失效的,误用会把设备绑到错误账号)。
943
+ * 恢复前先把当前配置再备份一份(同时写两份),失败静默。
944
+ * @param {string} relayDir
945
+ * @param {string} [name] `profile:<file>` / `relay:<file>` / 裸文件名(兼容旧状态);省略 = 最新一份
946
+ * @returns {{ok:boolean, restored?:string, source?:string, at?:number, reason?:string}}
947
+ */
948
+ function restoreConfigBackup(relayDir, name = "") {
949
+ try {
950
+ const list = listConfigBackups(relayDir);
951
+ if (!list.length) return { ok: false, reason: "no-backup" };
952
+ // ⚠️ 只接受**列表里真实存在**的文件名(防路径穿越/任意文件读)
953
+ const target = findConfigBackup(relayDir, name);
954
+ if (!target) return { ok: false, reason: "no-such-backup" };
955
+ const src = join(configBackupDirOf(relayDir, target.source), target.file);
956
+ if (existsSync(configPathOf(relayDir))) backupConfigFile(relayDir); // 恢复前先备份当前状态(可回滚)
957
+ mkdirSync(relayDir, { recursive: true });
958
+ copyFileSync(src, configPathOf(relayDir));
959
+ try { chmodSync(configPathOf(relayDir), 0o600); } catch { /* 非关键 */ }
960
+ try { hardenFile(configPathOf(relayDir)); } catch { /* 非关键 */ }
961
+ appendLogLine(relayDir, AUTO_INSTALL_LOG,
962
+ `[dsh-remote-web] 用户一键恢复配对配置:${target.name}`
963
+ + `(来自${CONFIG_BACKUP_SOURCE_TEXT[target.source] || target.source},备份时间 ${fmtLocalTime(target.at)};内容不写入日志)`);
964
+ return { ok: true, restored: target.name, source: target.source, at: target.at };
965
+ } catch {
966
+ return { ok: false, reason: "failed" };
967
+ }
968
+ }
969
+
970
+ /** 运行时本体是否在:`<relayDir>/clients`(插件自带 runtime/ 自愈补装的就是它)。 */
971
+ function runtimeDirPresent(relayDir) {
972
+ try { return existsSync(join(relayDir, "clients")); } catch { return false; }
973
+ }
974
+
975
+ /** 配对信息是否在:`<relayDir>/.dsh-config.json`(账号密码 + 设备密钥)。 */
976
+ function configPresent(relayDir) {
977
+ try { return existsSync(configPathOf(relayDir)); } catch { return false; }
978
+ }
979
+
980
+ /**
981
+ * 安装/配对自检(对外状态的核心):明确区分「运行时缺失」与「配对缺失」。
982
+ *
983
+ * kind:ok | runtime_missing | pairing_missing | all_missing | uninstalled
984
+ * text:面向非技术用户的一句话结论(面板直接展示;旧客户端也可从 connect.detail 看到)
985
+ * @returns {{kind:string, runtimePresent:boolean, configPresent:boolean, text:string, detail:string,
986
+ * relayDir:string, backup:null|{available:boolean,name:string,at:number,text:string,action:object}}}
987
+ */
988
+ function setupStateOf(relayDir) {
989
+ const rt = runtimeDirPresent(relayDir);
990
+ const cfg = configPresent(relayDir);
991
+ // 两份来源合并(新→旧;时间相同 profile 先)。只要任一份存在就能恢复 ——
992
+ // 这正是「整个 ~/.dsh-remote 被删、但 profile 持久目录那份还在」时的救命路径。
993
+ const backups = listConfigBackups(relayDir);
994
+ const latest = backups[0] || null;
995
+ const backup = (!cfg && latest)
996
+ ? {
997
+ available: true,
998
+ name: latest.name, // profile:<file> / relay:<file>(来源可区分)
999
+ source: latest.source, // profile | relay
1000
+ at: latest.at,
1001
+ // 两份都在时把清单也带上(面板/诊断可以显示"还有哪一份");仍不含任何凭据内容
1002
+ sources: backups.map((b) => ({ name: b.name, at: b.at })),
1003
+ text: `检测到历史配对备份(${fmtLocalTime(latest.at) || "时间未知"},`
1004
+ + `${CONFIG_BACKUP_SOURCE_TEXT[latest.source] || latest.source}),可一键恢复`,
1005
+ action: {
1006
+ kind: "restore_config",
1007
+ method: "POST",
1008
+ path: "/dsh-remote/config/restore",
1009
+ body: { backup: latest.name },
1010
+ },
1011
+ }
1012
+ : null;
1013
+ const base = { runtimePresent: rt, configPresent: cfg, relayDir, backup };
1014
+
1015
+ // 已彻底卸载:不再提示"重新安装"(那是用户自己的选择;重启宿主后自然回到未安装状态)
1016
+ if (UNINSTALLED_DIRS.has(relayDir)) {
1017
+ return { ...base, kind: "uninstalled", text: "", detail: "本插件已彻底卸载(配置与运行时已清理)" };
1018
+ }
1019
+
1020
+ const RUNTIME_TEXT = "运行环境缺失:需要重新安装(应用内插件页重装或 npx @mrrisega/dsh-remote@latest)";
1021
+ const PAIRING_TEXT = "未配对:需要在面板里登录一次(账号密码),登录后会自动重建运行环境并启动 bridge";
1022
+ const runtimeDetail = () => {
1023
+ const inst = installStateOf(relayDir);
1024
+ if (inst.installing) return "插件正在后台用自带的运行环境自动补装(无需操作);若 1~2 分钟后仍缺失,请按上面方式重新安装。";
1025
+ return "插件会在下一轮状态查询时自动尝试用自带的运行环境补装;若补装失败,请按上面方式重新安装。";
1026
+ };
1027
+ const pairingDetail = () => (backup
1028
+ ? `登录会重新生成配对信息;也可以先点「恢复历史配对备份」(${fmtLocalTime(latest.at) || "时间未知"})再用旧配对。`
1029
+ : "登录是唯一会写入配对信息的入口:插件绝不自动填入任何凭据。");
1030
+
1031
+ if (!rt && !cfg) {
1032
+ return { ...base, kind: "all_missing", text: RUNTIME_TEXT + "\n" + PAIRING_TEXT,
1033
+ detail: runtimeDetail() + " " + pairingDetail() };
1034
+ }
1035
+ if (!rt) {
1036
+ return { ...base, kind: "runtime_missing", text: RUNTIME_TEXT, detail: runtimeDetail() };
1037
+ }
1038
+ if (!cfg) {
1039
+ return { ...base, kind: "pairing_missing", text: PAIRING_TEXT, detail: pairingDetail() };
1040
+ }
1041
+ return { ...base, kind: "ok", text: "", detail: "" };
1042
+ }
1043
+
741
1044
  // ---------- Harness 浏览器会话代持(0.1.2-rc.1+ 的 ?token 鉴权) ----------
742
1045
 
743
1046
  /**
@@ -1764,6 +2067,7 @@ function ensureRuntime(relayDir) {
1764
2067
  if (local.ok) {
1765
2068
  appendLogLine(relayDir, AUTO_INSTALL_LOG,
1766
2069
  `[dsh-remote-web] 已用插件自带的运行环境就地补齐(v${PLUGIN_VERSION},本地拷贝,未使用 npx)`);
2070
+ backupConfigFile(relayDir); // 安装完成后备份配对信息(文件不存在则静默跳过;内容绝不写日志)
1767
2071
  telemetryRuntimeReady(relayDir);
1768
2072
  return true;
1769
2073
  }
@@ -1845,6 +2149,7 @@ function ensureRuntime(relayDir) {
1845
2149
  appendLogLine(relayDir, AUTO_INSTALL_LOG, `[auto-install] npx 退出 code=${code ?? "?"}`);
1846
2150
  if (code === 0 && runtimeReady(relayDir)) {
1847
2151
  noteProvisionSuccess(relayDir);
2152
+ backupConfigFile(relayDir); // 安装完成后备份配对信息(无配置/失败都静默)
1848
2153
  telemetryRuntimeReady(relayDir); // 退出码 0 ≠ 装好了
1849
2154
  } else {
1850
2155
  // 退出码非 0、卡住超时、或退出码 0 但安装脚本没落盘(装到一半/装错包)→ 都算失败并进入退避
@@ -3386,6 +3691,10 @@ async function composeStatus(relayDir) {
3386
3691
  // 是否需要重启 DeepSeek harness(首次安装 / 在线更新后必须重启才加载插件本体):
3387
3692
  // {pending, kind: first-install|update, reason, at},供面板顶部醒目提示 + 重启按钮。
3388
3693
  restart: readRestartState(relayDir),
3694
+ // 安装/配对自检(2026-10-17 事故):运行时(clients/)与配对(.dsh-config.json)是否齐全、
3695
+ // 缺什么、下一步该做什么;配对主配置缺失但备份存在时给出可一键恢复的 backup.action。
3696
+ // 字段契约见 setupStateOf;connect.setup 是同一份对象(bridge-status 短轮询也能拿到)。
3697
+ setup: setupStateOf(relayDir),
3389
3698
  // 连接阶段(登录后自动闭环:环境 → bridge 进程 → 设备已在中继注册):
3390
3699
  // 面板用它显示「正在准备运行环境…/正在启动 Bridge…/正在连接中继…/已连接 ✅」,
3391
3700
  // 并用 /dsh-remote/bridge-status 以 2~3s 短轮询自动推进(无需用户点按钮或刷新页面)。
@@ -4447,6 +4756,10 @@ function uninstallSelf(relayDir, profileDir, patchFile, pkgFile) {
4447
4756
  rmSync(join(profileDir, `${id}-plugin`), { recursive: true, force: true });
4448
4757
  rmSync(join(profileDir, "node_modules", id), { recursive: true, force: true });
4449
4758
  }
4759
+ // 第二份配对备份住在 profile 的独立数据目录(2026-10-17 双份加固):里面是账号密码/设备私钥,
4760
+ // 「彻底卸载」必须一起清掉 —— 否则卸载后仍留着凭据副本,且重装后还会提示恢复一个
4761
+ // 用户已经明确不要了的旧账号配对。
4762
+ rmSync(join(profileDir, PROFILE_DATA_DIR), { recursive: true, force: true });
4450
4763
  out.removedDir = true;
4451
4764
  } catch { /* ignore */ }
4452
4765
  return out;
@@ -4897,6 +5210,13 @@ async function composeConnect(relayDir, opts = {}) {
4897
5210
  const elapsed = book.lastAttemptAt ? now - book.lastAttemptAt : wait;
4898
5211
  const autoRetrying = phase !== "online" && phase !== "no_account" && retryable !== false;
4899
5212
  const nextRetryInMs = autoRetrying ? Math.max(0, wait - elapsed) : 0;
5213
+ // 【2026-10-17 事故】把「安装/配对自检」结论作为独立字段下发:`~/.dsh-remote` 被误删后,
5214
+ // 面板以前只能显示模糊的「未运行/准备中」,用户不知道该重装还是该重新登录。
5215
+ // 只在**需要用户动手**的状态把结论前置到 detail(运行时缺失但插件正在自动补装时仍由既有
5216
+ // no_runtime/installing 文案说明进度,不制造"需要重装"的误导)。
5217
+ const setup = setupStateOf(relayDir);
5218
+ const setupNeedsUser = setup.kind === "all_missing" || setup.kind === "pairing_missing";
5219
+ if (setupNeedsUser) detail = detail ? setup.text + "\n" + detail : setup.text;
4900
5220
  const conn = {
4901
5221
  phase,
4902
5222
  online: phase === "online",
@@ -4941,6 +5261,10 @@ async function composeConnect(relayDir, opts = {}) {
4941
5261
  nextRetryInMs,
4942
5262
  error,
4943
5263
  bindError: bindFresh && bindError ? { code: bindError.code || "", message: bindError.message || "" } : null,
5264
+ // 安装/配对自检(2026-10-17):kind/text/backup.action 见 setupStateOf 的长注释。
5265
+ // 面板据此显示「运行环境缺失:需要重新安装…」「未配对:需要在面板里登录一次…」
5266
+ // 以及「检测到历史配对备份(时间),可一键恢复」。
5267
+ setup,
4944
5268
  logPath: join(relayDir, BRIDGE_LOG_FILE),
4945
5269
  installLogPath: join(relayDir, AUTO_INSTALL_LOG),
4946
5270
  };
@@ -5179,7 +5503,21 @@ function ensureConnection(relayDir, opts = {}) {
5179
5503
  const now = Date.now();
5180
5504
  if (!opts.force && book.lastAttemptAt && now - book.lastAttemptAt < connectBackoffMs(book.attempts)) return null;
5181
5505
  const cfg = loadConfig(relayDir);
5182
- if (!hasAccountCreds(cfg)) return null; // 未登录:不补装也不拉服务,先让用户登录
5506
+ if (!hasAccountCreds(cfg)) {
5507
+ // 【2026-10-17 事故】旧规则「未登录 → 不补装也不拉服务」在**运行时**上必须放宽:
5508
+ // 运行时与配对信息都住在 ~/.dsh-remote 里,用户整目录被删后,登录前 ensureRuntime 一次都不会跑,
5509
+ // 于是「登录后自动完成剩余步骤」的第一步就永远缺失。现在:运行时缺失就先复用既有 ensureRuntime
5510
+ // 从插件自带 runtime/ 本地补齐(零网络);**配对信息仍然只能由用户登录重建**,
5511
+ // 绝不自动填入任何账号/密码(安全边界),也绝不因为有备份就自动恢复旧配对。
5512
+ if (!runtimeReady(relayDir)) {
5513
+ if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
5514
+ book.lastAttemptAt = now;
5515
+ book.attempts += 1;
5516
+ ensureRuntime(relayDir);
5517
+ return { action: "provision" };
5518
+ }
5519
+ return null; // 运行时已就绪但未登录:不拉服务,先让用户登录
5520
+ }
5183
5521
  if (!runtimeReady(relayDir)) {
5184
5522
  if (installStateOf(relayDir).stale) clearStaleInstallMarker(relayDir);
5185
5523
  book.lastAttemptAt = now;
@@ -6412,17 +6750,9 @@ export function localRequestBlocked(req) {
6412
6750
  }
6413
6751
 
6414
6752
  function registerRoutes(ctx, relayDir) {
6415
- // 本插件所在 profile(由插件自身文件位置推导,覆盖市场 git 安装与本地 include 两种形态)
6416
- let profileDir = join(homedir(), ".dsh", "profiles", "web");
6417
- try {
6418
- const here = fileURLToPath(import.meta.url);
6419
- // 依次尝试两种安装布局(当前名优先,历史名兜底);split()[0] 未命中时返回原串,需显式判断后再试下一种
6420
- let m = here.split("/dsh-remote-web-plugin/")[0];
6421
- if (m === here) m = here.split("/node_modules/dsh-remote-web/")[0];
6422
- if (m === here) m = here.split("/dsh-remote-ui-plugin/")[0];
6423
- if (m === here) m = here.split("/node_modules/dsh-remote-ui/")[0];
6424
- if (m !== here) profileDir = m;
6425
- } catch { /* 保持默认 */ }
6753
+ // 本插件所在 profile:与配对备份的持久数据目录共用同一份推导(pluginProfileDir),
6754
+ // 避免"卸载删的目录"和"备份写的目录"各算各的(2026-10-17 双份备份加固时抽出)。
6755
+ const profileDir = pluginProfileDir();
6426
6756
  const routes = [
6427
6757
  // 自管理:版本信息 / 检查更新 / 一键更新 / 更新日志 / 彻底卸载
6428
6758
  {
@@ -6900,6 +7230,9 @@ function registerRoutes(ctx, relayDir) {
6900
7230
  // 账号/模式变化 → 先停掉旧 bridge 并作废它的注册证据,否则新账号永远等不到这台设备
6901
7231
  const accountFpBefore = accountFingerprintOf(loadConfig(relayDir));
6902
7232
  saveConfig(relayDir, cfg);
7233
+ // 配对成功即备份(2026-10-17 事故):.dsh-config.json 里是账号配对+设备密钥,
7234
+ // 一旦被误删/写坏,用户可以一键恢复最近一份(不自动恢复,见 setupStateOf)。
7235
+ backupConfigFile(relayDir);
6903
7236
  invalidateRelayToken(relayDir); // 账号可能变了:丢弃旧 token 缓存,立即用新账号认证
6904
7237
  if (accountFingerprintOf(cfg) !== accountFpBefore) resetBridgeForAccountChange(relayDir, "账号/模式已变更");
6905
7238
  const bridgeRestart = startBridge(relayDir);
@@ -6908,6 +7241,43 @@ function registerRoutes(ctx, relayDir) {
6908
7241
  sendJson(res, 200, { ok: true, bridgeRestart, ...(await composeStatus(relayDir)) });
6909
7242
  },
6910
7243
  },
7244
+ {
7245
+ /**
7246
+ * 一键恢复历史配对备份(2026-10-17 事故)。
7247
+ *
7248
+ * 只在用户**显式点击**时调用:绝不自动恢复 —— 旧备份可能是别人的账号/已失效的凭据,
7249
+ * 自动套用会把这台设备绑到错误账号(比"未配对"更糟)。恢复前 restoreConfigBackup 会先把
7250
+ * 当前配置再备份一份(若存在),所以这一步随时可回滚。
7251
+ * body = {} → 恢复最新一份;body = { backup: "<文件名>" } → 恢复指定一份。
7252
+ */
7253
+ method: "POST",
7254
+ path: "/dsh-remote/config/restore",
7255
+ handler: async (req, res) => {
7256
+ const body = await readJsonBody(req);
7257
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
7258
+ const backupName = typeof body.backup === "string" ? body.backup : "";
7259
+ const restored = restoreConfigBackup(relayDir, backupName);
7260
+ if (!restored.ok) {
7261
+ const msg = restored.reason === "no-backup" || restored.reason === "no-such-backup"
7262
+ ? "没有可恢复的配对备份(备份目录为空或文件名不存在)"
7263
+ : "恢复配对备份失败(磁盘不可写/文件损坏),请重新登录一次";
7264
+ return sendJson(res, restored.reason === "failed" ? 500 : 404, { ok: false, code: restored.reason, error: msg });
7265
+ }
7266
+ // 恢复的可能是旧账号的配对:与"登录"同一套收尾 —— 旧 token 作废、旧 bridge 停掉重拉。
7267
+ invalidateRelayToken(relayDir);
7268
+ resetBridgeForAccountChange(relayDir, "从历史备份恢复配对信息");
7269
+ const bridgeRestart = startBridge(relayDir);
7270
+ // 恢复成功本身就是一次配对状态写入 → 让最新备份也指向它(保留时间线)
7271
+ backupConfigFile(relayDir);
7272
+ sendJson(res, 200, {
7273
+ ok: true,
7274
+ restored: restored.restored,
7275
+ restoredAt: restored.at,
7276
+ bridgeRestart,
7277
+ ...(await composeStatus(relayDir)),
7278
+ });
7279
+ },
7280
+ },
6911
7281
  {
6912
7282
  method: "POST",
6913
7283
  path: "/dsh-remote/logout",
@@ -7531,4 +7901,16 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
7531
7901
  linuxSystemdUnitState as __linuxSystemdUnitState,
7532
7902
  selfhealRestartGate as __selfhealRestartGate, noteSelfhealRestart as __noteSelfhealRestart,
7533
7903
  noteSelfhealBlocked as __noteSelfhealBlocked, resetSelfhealGuard as __resetSelfhealGuard,
7534
- selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS };
7904
+ selfhealGuardOf as __selfhealGuardOf, SELFHEAL_MAX_RESTARTS as __SELFHEAL_MAX_RESTARTS,
7905
+ // 2026-10-17 事故:安装/配对自检 + 配对备份/恢复。见 test/repair-backup.test.mjs
7906
+ // (用例必须把 relayDir 指向 mkdtemp 临时目录、HOME 指向假目录,绝不能在真实
7907
+ // ~/.dsh-remote 或真实 ~/.dsh 上做写入测试)。
7908
+ setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
7909
+ backupConfigFile as __backupConfigFile, listConfigBackups as __listConfigBackups,
7910
+ restoreConfigBackup as __restoreConfigBackup, runtimeDirPresent as __runtimeDirPresent,
7911
+ CONFIG_BACKUP_KEEP as __CONFIG_BACKUP_KEEP, CONFIG_BACKUP_DIR as __CONFIG_BACKUP_DIR,
7912
+ pruneConfigBackups as __pruneConfigBackups,
7913
+ // 双份备份(relayDir + profile 独立数据目录)的落点/推导/卸载清理都要能被行为级单测钉住
7914
+ configBackupDirOf as __configBackupDirOf, pluginProfileDir as __pluginProfileDir,
7915
+ listConfigBackupsIn as __listConfigBackupsIn, uninstallSelf as __uninstallSelf,
7916
+ PROFILE_DATA_DIR as __PROFILE_DATA_DIR, PROFILE_BACKUP_SUBDIR as __PROFILE_BACKUP_SUBDIR };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.2-beta.1",
3
+ "version": "0.7.2-beta.2",
4
4
  "description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",