@mrrisega/dsh-remote 0.6.0 → 0.6.1

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.
@@ -612,15 +612,135 @@ async function relayFetch(relayDir, pathname, init) {
612
612
  }
613
613
  }
614
614
 
615
- // ---------- v2 账号/配额/邀请代理(我的信息 免费额度提示) ----------
615
+ // ---------- bridge_secret 自愈(device-login 共享密钥) ----------
616
616
 
617
- /** 获取短期 relay token:SaaS → device-login;本地模式 → /_login(从隧道地址推导同源)。 */
618
- async function relayToken(relayDir) {
619
- const cfg = loadConfig(relayDir);
617
+ /**
618
+ * 企业端 POST /api/device-login 强制校验 `x-dsh-bridge-secret`(缺失/失效 → 401 "需要有效设备密钥"),
619
+ * 密钥由服务端公开配置 /api/public-config 下发、与 dsh-setup.mjs 的 `bridge_secret` 同源。
620
+ *
621
+ * 一键安装器(npx @mrrisega/dsh-remote → dsh-setup.mjs)会在安装时取一次并写入 .dsh-config.json;
622
+ * 但**只装插件**的路径(dsh plugin add / 插件市场安装)没有这一步,于是:
623
+ * 首次安装 → 打开设置面板立即登录 → 面板请求 /dsh-remote/access-key & mobile-sessions
624
+ * → relayToken 拿不到 token(401 需要设备密钥)→ 面板显示「尚未登录」(其实是密钥缺失)
625
+ * 直到后台自愈(scheduleRuntime → ensureRuntime 跑一次 npx 安装器)把 bridge_secret 写回配置,
626
+ * 或用户手动刷新页面才恢复——这正是「首次安装后立即登录,二维码/设备列表报红字」的根因。
627
+ *
628
+ * 这里在插件侧补上同一份自愈:缺密钥就取一次、落盘并缓存;失败短退避后可再试,不阻塞面板。
629
+ */
630
+ let bridgeSecretCache = { dir: "", secret: "", failedAt: 0 };
631
+ const BRIDGE_SECRET_RETRY_MS = 15_000;
632
+
633
+ /** 读取配置里已保存的 bridge_secret(无则空串)。 */
634
+ function readBridgeSecret(relayDir) {
635
+ try {
636
+ return String(loadConfig(relayDir).bridge_secret || "").trim();
637
+ } catch {
638
+ return "";
639
+ }
640
+ }
641
+
642
+ /**
643
+ * 取 device-login 共享密钥:配置已有 → 直接用;否则向企业端公开配置取一次并写回配置(内存缓存兜底)。
644
+ * @param {string} relayDir 配置目录
645
+ * @param {object} [cfgOverride] 尚未落盘的配置(登录请求刚写入 phone/password 时用),仅用于判断模式
646
+ * @param {{force?: boolean, timeoutMs?: number}} [opts] force=true 跳过本地文件/缓存强制重取;
647
+ * timeoutMs 限制单次公开配置请求耗时(登录路径用更短的上限,避免拖慢登录响应)
648
+ * @returns {Promise<string>} 密钥(取不到为空串,调用方按“中继未就绪”降级,不抛错)
649
+ */
650
+ async function bridgeSecretOf(relayDir, cfgOverride, opts) {
651
+ const force = !!(opts && opts.force);
652
+ if (!force) {
653
+ const local = readBridgeSecret(relayDir);
654
+ if (local) {
655
+ bridgeSecretCache = { dir: relayDir, secret: local, failedAt: 0 };
656
+ return local;
657
+ }
658
+ if (bridgeSecretCache.dir === relayDir && bridgeSecretCache.secret) return bridgeSecretCache.secret;
659
+ if (bridgeSecretCache.dir === relayDir && Date.now() - bridgeSecretCache.failedAt < BRIDGE_SECRET_RETRY_MS) return ""; // 负缓存:别把公开配置打爆
660
+ }
661
+ const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
662
+ if (cfg.local_key) return ""; // 自建模式走 /_login,不用设备密钥
620
663
  const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
621
- if (cfg.local_key) {
622
- // 本地认证:POST {tunnel 同源}/_login
623
- const u = new URL(cfg.tunnel_url || api.replace(/^https?/, "wss"));
664
+ const timeoutMs = Number((opts && opts.timeoutMs) || 0) > 0 ? Number(opts.timeoutMs) : 6000;
665
+ let secret = "";
666
+ try {
667
+ const r = await fetch(`${api}/api/public-config`, { signal: AbortSignal.timeout(timeoutMs) });
668
+ if (r.ok) {
669
+ const d = await r.json();
670
+ secret = String((d && d.bridge_secret) || "").trim();
671
+ }
672
+ } catch { /* 网络抖动:负缓存短退避后再试 */ }
673
+ if (!secret) {
674
+ bridgeSecretCache = { dir: relayDir, secret: "", failedAt: Date.now() };
675
+ return "";
676
+ }
677
+ bridgeSecretCache = { dir: relayDir, secret, failedAt: 0 };
678
+ // 落盘(与 dsh-setup.mjs 同一份配置):即使本进程退出,下次启动也不再缺密钥/不再是旧密钥
679
+ let healed = false;
680
+ try {
681
+ const cur = loadConfig(relayDir);
682
+ if (!cur.bridge_secret || (force && cur.bridge_secret !== secret)) {
683
+ cur.bridge_secret = secret;
684
+ saveConfig(relayDir, cur);
685
+ healed = true;
686
+ }
687
+ } catch { /* 写盘失败不致命:内存缓存本次进程内仍生效 */ }
688
+ // 密钥是「从无到有」补上的:已经在跑的 bridge 是用空密钥起的,隧道认证同样会失败——
689
+ // 后台重启一次让它带上密钥(一次性事件,失败只记日志不打断面板请求)。
690
+ if (healed && !UNINSTALLED_DIRS.has(relayDir)) {
691
+ setTimeout(() => {
692
+ try {
693
+ const st = launchdStatus();
694
+ if (!st.running) return;
695
+ const r = startBridge(relayDir);
696
+ console.log(`[dsh-remote-web] 已补记 bridge_secret,后台重启 bridge 使其生效: ${r.status}`);
697
+ } catch (e) {
698
+ console.warn(`[dsh-remote-web] 补记 bridge_secret 后重启 bridge 失败: ${e.message}`);
699
+ }
700
+ }, 50).unref?.();
701
+ }
702
+ return secret;
703
+ }
704
+
705
+ // ---------- v2 账号/配额/邀请代理(我的信息 与 免费额度提示) ----------
706
+
707
+ /** token 获取失败的原因(供 UI 区分“未登录”与“中继未就绪”,不再一律谎报“尚未登录”)。 */
708
+ const AUTH_NO_CREDENTIALS = "no_credentials"; // 本机没存账号/自建密钥 → 该去登录
709
+ const AUTH_BAD_CREDENTIALS = "bad_credentials"; // 账号密码被企业端拒绝 → 该重新登录
710
+ const AUTH_RELAY_UNREACHABLE = "relay_unreachable"; // 网络/5xx/超时 → 稍后重试
711
+ const AUTH_RELAY_NOT_READY = "relay_not_ready"; // 凭证齐全但企业端未接受(缺失/轮换中的设备密钥等)→ 稍后重试
712
+
713
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
714
+
715
+ /** 单次 device-login:返回 { ok, status, token, code, detail }(不抛错,便于按原因降级/重试)。 */
716
+ async function deviceLoginOnce(api, cfg, secret) {
717
+ try {
718
+ const r = await fetch(`${api}/api/device-login`, {
719
+ method: "POST",
720
+ headers: { "content-type": "application/json", ...(secret ? { "x-dsh-bridge-secret": secret } : {}) },
721
+ body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
722
+ signal: AbortSignal.timeout(6000)
723
+ });
724
+ const text = await r.text();
725
+ let d = null;
726
+ try { d = JSON.parse(text); } catch { d = null; }
727
+ return {
728
+ ok: r.ok,
729
+ status: r.status,
730
+ token: (d && d.token) || "",
731
+ code: (d && d.error && d.error.code) || "",
732
+ detail: (d && d.error && (d.error.message || d.error.code)) || "",
733
+ };
734
+ } catch (e) {
735
+ return { ok: false, status: 0, token: "", code: "", detail: e.message };
736
+ }
737
+ }
738
+
739
+ /** 单次本地认证(自建模式):POST {隧道同源}/_login。 */
740
+ async function localLoginOnce(cfg, api) {
741
+ const fallbackApi = (api || cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
742
+ try {
743
+ const u = new URL(cfg.tunnel_url || fallbackApi.replace(/^https?/, "wss"));
624
744
  u.protocol = u.protocol === "wss:" ? "https:" : "http:";
625
745
  u.pathname = "/_login";
626
746
  const r = await fetch(u.toString(), {
@@ -629,20 +749,59 @@ async function relayToken(relayDir) {
629
749
  body: JSON.stringify({ key: cfg.local_key }),
630
750
  signal: AbortSignal.timeout(6000)
631
751
  });
632
- if (!r.ok) return "";
752
+ if (!r.ok) return { ok: false, status: r.status, token: "", code: "", detail: "" };
633
753
  const d = await r.json();
634
- return d.token || "";
754
+ return { ok: true, status: 200, token: (d && d.token) || "", code: "", detail: "" };
755
+ } catch (e) {
756
+ return { ok: false, status: 0, token: "", code: "", detail: e.message };
635
757
  }
636
- if (!cfg.phone || !cfg.password) return "";
637
- const r = await fetch(`${api}/api/device-login`, {
638
- method: "POST",
639
- headers: { "content-type": "application/json", ...(cfg.bridge_secret ? { "x-dsh-bridge-secret": cfg.bridge_secret } : {}) },
640
- body: JSON.stringify({ phone: cfg.phone, email: cfg.phone, password: cfg.password }),
641
- signal: AbortSignal.timeout(6000)
642
- });
643
- if (!r.ok) return "";
644
- const d = await r.json();
645
- return d.token || "";
758
+ }
759
+
760
+ /**
761
+ * 获取短期 relay token:SaaS device-login;本地模式 /_login(从隧道地址推导同源)
762
+ * 与旧版差异:① device-login 共享密钥时自愈补齐;② 服务端轮换密钥(401/403)自动重取重试;
763
+ * ③ 网络抖动/5xx 退避重试一次;④ 失败时给出**可判定原因**而非空串(消除“尚未登录”误报)。
764
+ * @returns {Promise<{token:string, reason:string, status:number, detail:string}>}
765
+ */
766
+ async function relayTokenWithReason(relayDir, cfgOverride) {
767
+ const cfg = cfgOverride && typeof cfgOverride === "object" ? { ...loadConfig(relayDir), ...cfgOverride } : loadConfig(relayDir);
768
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
769
+ if (cfg.local_key) {
770
+ let r = await localLoginOnce(cfg, api);
771
+ if (!r.token && (r.status === 0 || r.status >= 500)) { await sleep(350); r = await localLoginOnce(cfg, api); }
772
+ if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
773
+ return {
774
+ token: "",
775
+ reason: r.status === 401 || r.status === 403 ? AUTH_BAD_CREDENTIALS : AUTH_RELAY_UNREACHABLE,
776
+ status: r.status,
777
+ detail: r.detail,
778
+ };
779
+ }
780
+ if (!cfg.phone || !cfg.password) return { token: "", reason: AUTH_NO_CREDENTIALS, status: 0, detail: "" };
781
+
782
+ let secret = await bridgeSecretOf(relayDir, cfgOverride);
783
+ let r = await deviceLoginOnce(api, cfg, secret);
784
+ // 带了密钥仍被拒 → 可能是服务端轮换了共享密钥:丢弃缓存强制重取一次再试
785
+ if (!r.token && (r.status === 401 || r.status === 403) && secret) {
786
+ const fresh = await bridgeSecretOf(relayDir, cfgOverride, { force: true });
787
+ if (fresh && fresh !== secret) r = await deviceLoginOnce(api, cfg, fresh);
788
+ }
789
+ // 网络抖动/超时/5xx:短退避重试一次(首次安装后中继握手偶发失败很常见)
790
+ if (!r.token && (r.status === 0 || r.status >= 500)) {
791
+ await sleep(350);
792
+ r = await deviceLoginOnce(api, cfg, await bridgeSecretOf(relayDir, cfgOverride));
793
+ }
794
+ if (r.token) return { token: r.token, reason: "", status: r.status, detail: "" };
795
+ if (r.code === "bad_credentials") return { token: "", reason: AUTH_BAD_CREDENTIALS, status: r.status, detail: r.detail };
796
+ // 凭证齐全但企业端没给 token(设备密钥缺失/轮换中、限流、其它 4xx)→ 可重试,不谎报“未登录”
797
+ if (r.status === 0 || r.status >= 500) return { token: "", reason: AUTH_RELAY_UNREACHABLE, status: r.status, detail: r.detail };
798
+ return { token: "", reason: AUTH_RELAY_NOT_READY, status: r.status, detail: r.detail };
799
+ }
800
+
801
+ /** 兼容旧调用方(反馈/账号/配额等只需 token 字符串)。 */
802
+ async function relayToken(relayDir) {
803
+ const r = await relayTokenWithReason(relayDir).catch(() => ({ token: "" }));
804
+ return r.token || "";
646
805
  }
647
806
 
648
807
  /** 我的信息:SaaS 账号的生效套餐/到期日/邀请码(经 /api/me)。 */
@@ -700,6 +859,35 @@ async function relayInviteRecords(relayDir) {
700
859
  return { records: r.body.records || [], rewards: r.body.rewards || [] };
701
860
  }
702
861
 
862
+ // ---------- 交流群二维码(运营配置,公开读取) ----------
863
+
864
+ /**
865
+ * 交流群二维码/客服微信:企业端配置经公开配置 /api/public-config 的 community 字段下发
866
+ * (community.qrcode 形如 "/qr/group-qr.png")。面板与反馈页展示「加入交流群」用。
867
+ *
868
+ * 面板跑在电脑本机 dsh web(http://127.0.0.1:3080),相对路径无法直接当 <img src>,
869
+ * 故这里按企业端公开约定拼成绝对地址:<api_url><qrcode>(与手机端 PWA 的 API_BASE + qr 一致)。
870
+ * 30s 内存缓存:面板与反馈卡都会取,避免重复打公开配置。
871
+ */
872
+ const COMMUNITY_TTL_MS = 30_000;
873
+ let communityCache = { dir: "", at: 0, data: null };
874
+
875
+ async function communityInfo(relayDir) {
876
+ if (communityCache.data && communityCache.dir === relayDir && Date.now() - communityCache.at < COMMUNITY_TTL_MS) {
877
+ return communityCache.data;
878
+ }
879
+ const cfg = loadConfig(relayDir);
880
+ const api = (cfg.api_url || DEFAULT_API).replace(/\/+$/, "");
881
+ const r = await relayFetch(relayDir, "/api/public-config");
882
+ const b = r.ok && r.body && typeof r.body === "object" ? r.body : {};
883
+ const c = b.community && typeof b.community === "object" ? b.community : {};
884
+ const raw = String(c.qrcode || "").trim();
885
+ const qrcode = raw && !/^https?:/i.test(raw) ? api + (raw.startsWith("/") ? raw : `/${raw}`) : raw;
886
+ const data = { qrcode, wechat: String(c.wechat || "") };
887
+ communityCache = { dir: relayDir, at: Date.now(), data };
888
+ return data;
889
+ }
890
+
703
891
  // ---------- 一次性访问密钥 / 已授权设备代理(E1 企业端新增 auth-key / mobile-sessions) ----------
704
892
 
705
893
  /** 从 relay 响应里尽量提取人类可读错误信息(兼容 {error:{message}} / {error:".."} / {message} / 纯文本)。 */
@@ -722,6 +910,57 @@ function notLoggedInJson() {
722
910
  return { ok: false, error: "尚未登录:请先在「账号」卡片登录手机号账号(或切换到自建服务)后重试", hint: "login_required" };
723
911
  }
724
912
 
913
+ /**
914
+ * 凭证齐全但拿不到 relay token 时的统一返回:区分「密码失效需重新登录」与「中继暂未就绪可重试」。
915
+ * 关键:不再把中继未就绪/设备密钥缺失谎报成“尚未登录”(旧版 UI 因此显示误导性红字)。
916
+ */
917
+ function relayAuthFailureJson(reason, detail) {
918
+ if (reason === AUTH_NO_CREDENTIALS) return { status: 401, body: notLoggedInJson() };
919
+ if (reason === AUTH_BAD_CREDENTIALS) {
920
+ return {
921
+ status: 401,
922
+ body: {
923
+ ok: false,
924
+ error: "本机保存的账号密码已被中继拒绝(可能已在别处修改过密码):请用新密码重新登录「账号」卡片后重试",
925
+ hint: "relogin_required",
926
+ retryable: false,
927
+ detail: String(detail || ""),
928
+ },
929
+ };
930
+ }
931
+ const unreachable = reason === AUTH_RELAY_UNREACHABLE;
932
+ return {
933
+ status: 503,
934
+ body: {
935
+ ok: false,
936
+ error: unreachable
937
+ ? "中继服务暂时不可达(网络波动或中继正在重启),请稍后重试"
938
+ : "账号已登录,但中继连接尚未就绪(正在建立安全通道),请稍后重试",
939
+ hint: unreachable ? "relay_unreachable" : "relay_not_ready",
940
+ retryable: true,
941
+ detail: String(detail || ""),
942
+ },
943
+ };
944
+ }
945
+
946
+ /**
947
+ * 取 relay token 或直接回错误响应(面板代理统一入口)。
948
+ * @returns {Promise<string>} token;为空串表示已写出错误响应(调用方直接 return)
949
+ */
950
+ async function relayTokenOrReject(relayDir, res, cfgOverride) {
951
+ const a = await relayTokenWithReason(relayDir, cfgOverride).catch((e) => ({
952
+ token: "",
953
+ reason: AUTH_RELAY_UNREACHABLE,
954
+ status: 0,
955
+ detail: e && e.message,
956
+ }));
957
+ if (a.token) return a.token;
958
+ const { status, body } = relayAuthFailureJson(a.reason, a.detail);
959
+ console.warn(`[dsh-remote-web] 取 relay token 失败(${a.reason}/${a.status}): ${a.detail || "无详情"}`);
960
+ sendJson(res, status, body);
961
+ return "";
962
+ }
963
+
725
964
  /**
726
965
  * 透传企业端响应体:契约字段可能在顶层或 data 子对象里(容错)。
727
966
  * 返回扁平对象;数组字段只取首层数组。
@@ -737,8 +976,8 @@ function flattenRelayBody(r) {
737
976
  * 契约容错:url 必须可用;qr_data_url 取不到时返回 null(UI 只展示链接并说明“二维码暂不可用”,不报错)。
738
977
  */
739
978
  async function proxyCreateAccessKey(relayDir, res) {
740
- const token = await relayToken(relayDir).catch(() => "");
741
- if (!token) return sendJson(res, 401, notLoggedInJson());
979
+ const token = await relayTokenOrReject(relayDir, res);
980
+ if (!token) return;
742
981
  const r = await relayFetch(relayDir, "/api/auth-key", {
743
982
  method: "POST",
744
983
  headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
@@ -766,8 +1005,8 @@ async function proxyCreateAccessKey(relayDir, res) {
766
1005
  * GET /dsh-remote/mobile-sessions → 已授权设备列表(企业端 GET /api/mobile-sessions,Bearer)。
767
1006
  */
768
1007
  async function proxyMobileSessions(relayDir, res) {
769
- const token = await relayToken(relayDir).catch(() => "");
770
- if (!token) return sendJson(res, 401, notLoggedInJson());
1008
+ const token = await relayTokenOrReject(relayDir, res);
1009
+ if (!token) return;
771
1010
  const r = await relayFetch(relayDir, "/api/mobile-sessions", {
772
1011
  method: "GET",
773
1012
  headers: { authorization: `Bearer ${token}` },
@@ -788,8 +1027,8 @@ async function proxyRevokeMobileSession(relayDir, req, res) {
788
1027
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
789
1028
  const id = String(body.id ?? "").trim();
790
1029
  if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
791
- const token = await relayToken(relayDir).catch(() => "");
792
- if (!token) return sendJson(res, 401, notLoggedInJson());
1030
+ const token = await relayTokenOrReject(relayDir, res);
1031
+ if (!token) return;
793
1032
  const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}/revoke`, {
794
1033
  method: "POST",
795
1034
  headers: { authorization: `Bearer ${token}` },
@@ -812,8 +1051,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
812
1051
  if (body.__parseError) return sendJson(res, 400, { ok: false, error: "JSON 解析失败" });
813
1052
  const id = String(body.id ?? "").trim();
814
1053
  if (!id) return sendJson(res, 400, { ok: false, error: "缺少参数 id(会话 ID)" });
815
- const token = await relayToken(relayDir).catch(() => "");
816
- if (!token) return sendJson(res, 401, notLoggedInJson());
1054
+ const token = await relayTokenOrReject(relayDir, res);
1055
+ if (!token) return;
817
1056
  const r = await relayFetch(relayDir, `/api/mobile-sessions/${encodeURIComponent(id)}`, {
818
1057
  method: "DELETE",
819
1058
  headers: { authorization: `Bearer ${token}` },
@@ -831,8 +1070,8 @@ async function proxyDeleteMobileSession(relayDir, req, res) {
831
1070
  * (企业端 POST /api/mobile-sessions/purge,Bearer)。
832
1071
  */
833
1072
  async function proxyPurgeMobileSessions(relayDir, res) {
834
- const token = await relayToken(relayDir).catch(() => "");
835
- if (!token) return sendJson(res, 401, notLoggedInJson());
1073
+ const token = await relayTokenOrReject(relayDir, res);
1074
+ if (!token) return;
836
1075
  const r = await relayFetch(relayDir, "/api/mobile-sessions/purge", {
837
1076
  method: "POST",
838
1077
  headers: { authorization: `Bearer ${token}` },
@@ -1030,7 +1269,7 @@ const PLUGIN_ID = "dsh-remote-web";
1030
1269
  const PLUGIN_LEGACY_IDS = ["dsh-remote-ui"];
1031
1270
  const PLUGIN_ALL_IDS = [PLUGIN_ID, ...PLUGIN_LEGACY_IDS];
1032
1271
  /** 插件自身发布版本(与 dsh-remote 根包同步递增)。 */
1033
- const PLUGIN_VERSION = "0.6.0";
1272
+ const PLUGIN_VERSION = "0.6.1";
1034
1273
  const UPDATE_LOG = ".dsh-update.log";
1035
1274
  const UPDATE_MARKER = ".dsh-update-running";
1036
1275
 
@@ -1282,6 +1521,14 @@ function registerRoutes(ctx, relayDir) {
1282
1521
  });
1283
1522
  },
1284
1523
  },
1524
+ // 交流群二维码(设置面板「加入交流群」按钮 + 用户反馈页展示;未配置返回空串,UI 不展示入口)
1525
+ {
1526
+ method: "GET",
1527
+ path: "/dsh-remote/community",
1528
+ handler: async (_req, res) => {
1529
+ sendJson(res, 200, { ok: true, ...(await communityInfo(relayDir)) });
1530
+ },
1531
+ },
1285
1532
  // 一次性访问密钥(📱 远程访问卡):GET 即创建新 key,企业端 POST /api/auth-key(Bearer)
1286
1533
  {
1287
1534
  method: "GET",
@@ -1352,6 +1599,14 @@ function registerRoutes(ctx, relayDir) {
1352
1599
  }
1353
1600
  // SaaS 权威归一化:清除自建残留(local_key/假 tunnel_url),api/tunnel 一律按云端重算
1354
1601
  applySaaSMode(cfg);
1602
+ // device-login 共享密钥自愈:只装插件的路径没有安装器那一步,缺密钥会导致
1603
+ // 「首次安装后立即登录」时面板拿不到二维码/设备列表(旧版要等后台自愈或手动刷新)。
1604
+ // 这里在启动 bridge 之前补齐,bridge 首次启动即可带上密钥。
1605
+ if (!cfg.bridge_secret) {
1606
+ // 3s 上限:这次取密钥在登录响应路径上,宁可先放行(后续请求还会自愈)也不拖慢登录
1607
+ const secret = await bridgeSecretOf(relayDir, cfg, { timeoutMs: 3000 });
1608
+ if (secret) cfg.bridge_secret = secret;
1609
+ }
1355
1610
  }
1356
1611
  saveConfig(relayDir, cfg);
1357
1612
  const bridgeRestart = startBridge(relayDir);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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",
@@ -40,4 +40,4 @@
40
40
  },
41
41
  "homepage": "https://github.com/mrRisega/dsh-remote/tree/main/packages/dsh-remote-web#readme",
42
42
  "license": "PolyForm-Noncommercial-1.0.0"
43
- }
43
+ }