@noob-stupid/dsh-plugin-console 0.5.1 → 0.5.3

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/README.md CHANGED
@@ -154,6 +154,29 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
154
154
 
155
155
  ---
156
156
 
157
+ ## Screenshots · 界面一览
158
+
159
+ > 入口挂在**官方插件管理页自己的插槽**(`plugins.bundle.config`,key = 包名):装完之后它出现在
160
+ > 「设置 → 插件 → 已安装」里我们这一条下面,用的是官方容器与官方风格 —— 不是 DOM 注入,官方更新也不怕。
161
+
162
+ **1 · 已安装列表里的入口**(官方页,点开我们这条即见)
163
+
164
+ ![Entry inside the official plugins page](docs/images/official-plugins-page.png)
165
+
166
+ **2 · 「升级安全」面板**(就地展开:运行模式 / 框架版本 / 回滚点 / 环境指纹 / 补声明 / 契约预检 / 各功能入口)
167
+
168
+ ![Upgrade safety panel expanded in place](docs/images/upgrade-safety-panel.png)
169
+
170
+ **3 · 框架升级 / 回滚**(可选目标版本、常驻的升级进度、一键回滚上一版)
171
+
172
+ ![Framework upgrade and rollback panel](docs/images/framework-upgrade-rollback.png)
173
+
174
+ **4 · 门控明细**(谁被隔离、为什么:升级预扫 or 启动失败隔离,附来源与时间)
175
+
176
+ ![Compatibility gate detail list](docs/images/compat-gate-detail.png)
177
+
178
+ ---
179
+
157
180
  ## Features
158
181
 
159
182
  ### Installed plugins (one-click toggle + details)
package/lib/client.js CHANGED
@@ -91,6 +91,14 @@ window.__ModuleLoader__.load({
91
91
  const zh = {
92
92
  tab: "插件管理",
93
93
  safetyTab: "升级安全",
94
+ safetyModeManagedShort: "托管",
95
+ safetyModeObserverShort: "观察者",
96
+ safetyBackfillTitle: "补声明(已装未声明)",
97
+ safetyBackfillShort: "{n} 项待补声明",
98
+ safetyBackfillDesc: "有 {n} 个已装插件没写进 profile 清单 —— 官方/第三方插件页看不见它们,pnpm 操作还可能把它们还原。补声明会把磁盘上的真实版本写进清单(不改包、不重装)。",
99
+ safetyBackfillNone: "已装插件都在清单里声明过了(官方面板的「已安装」与 pnpm 都能看见)",
100
+ safetyBackfillBtn: "补声明({n} 项)",
101
+ safetyBackfillDone: "补声明完成",
94
102
  safetyMode: "运行模式",
95
103
  safetyModeManaged: "托管:框架升级与回滚由本控制台负责(默认,行为与之前一致)",
96
104
  safetyModeManagedOn: "已切到托管模式:框架升级与回滚仍由本控制台负责",
@@ -366,6 +374,7 @@ window.__ModuleLoader__.load({
366
374
  consentWaiting: "等待授权确认…",
367
375
  dismissFailure: "关闭此条提示",
368
376
  sourcesBtn: "软件源",
377
+ sourcesTimeout: "读取软件源超时(DSH 可能正在重启或繁忙)——点重试即可",
369
378
  sourcesTitle: "软件源管理",
370
379
  sourcesDesc: "分工:① 软件源 = 下载 npm 包(安装用);② 索引源 = 市场首页清单 JSON;③ Git 源 = 克隆仓库代码(无 npm 包时);④ 搜索源 = 搜索时查哪个平台——搜到之后仍按 ①/③ 下载",
371
380
  sourcePrimary: "主源",
@@ -548,6 +557,14 @@ window.__ModuleLoader__.load({
548
557
  const en = {
549
558
  tab: "Plugin console",
550
559
  safetyTab: "Upgrade safety",
560
+ safetyModeManagedShort: "managed",
561
+ safetyModeObserverShort: "observer",
562
+ safetyBackfillTitle: "Missing dependency declarations",
563
+ safetyBackfillShort: "{n} to declare",
564
+ safetyBackfillDesc: "{n} installed plugins are not declared in the profile manifest — third-party plugin pages cannot see them and a pnpm operation may restore them. Declaring writes the on-disk version into the manifest (no repack, no reinstall).",
565
+ safetyBackfillNone: "Every installed plugin is declared in the manifest (visible to plugin pages and pnpm)",
566
+ safetyBackfillBtn: "Declare {n}",
567
+ safetyBackfillDone: "Declarations written",
551
568
  safetyMode: "Operating mode",
552
569
  safetyModeManaged: "Managed: this console owns framework upgrade and rollback (default, unchanged behaviour)",
553
570
  safetyModeManagedOn: "Switched to managed: framework upgrade and rollback stay with this console",
@@ -824,6 +841,7 @@ window.__ModuleLoader__.load({
824
841
  dismissFailure: "Dismiss this notice",
825
842
  sourcesBtn: "Sources",
826
843
  sourcesTitle: "Software sources",
844
+ sourcesTimeout: "Timed out reading sources (the DSH service may be restarting or busy) — hit retry",
827
845
  sourcesDesc: "Roles: ① registry = download npm packages (install); ② index sources = market list JSON; ③ git sources = clone repo code (when no npm package); ④ search sources = where to search — results are still downloaded via ①/③",
828
846
  sourcePrimary: "Primary",
829
847
  setPrimary: "Set primary",
@@ -1943,6 +1961,9 @@ window.__ModuleLoader__.load({
1943
1961
  react.useEffect(() => {
1944
1962
  call("/plugin-console/sources").then(
1945
1963
  (data) => {
1964
+ // 2026-09-24 用户实测:软件源弹窗每次打开都重新请求 → 展开要等(右侧「功能包」里的软件源因为是复用已取到的数据,所以很快)。
1965
+ // 这里把挂载时取到的整份配置缓存下来,弹窗直接渲染缓存;保存/恢复默认后再刷新缓存。
1966
+ setSourcesData((prev) => (prev === null ? data.sources : prev));
1946
1967
  setSearchSourcesList(data.sources.searchSources ?? []);
1947
1968
  setGitSourcesList(data.sources.gitSources ?? []);
1948
1969
  },
@@ -2744,8 +2765,14 @@ window.__ModuleLoader__.load({
2744
2765
  /** 软件源管理:打开时拉取当前配置。 */
2745
2766
  const openSources = () => {
2746
2767
  setSourcesOpen(true);
2747
- call("/plugin-console/sources").then(
2768
+ // 已有缓存(挂载时取过 / 上次打开取过)→ 立即渲染,不再白等一次往返
2769
+ if (sourcesData !== null && sourcesData.error === undefined) return;
2770
+ // 2026-09-24(图1 复现):请求若一直不落地,弹窗体只看 sourcesData === null 就会永远显示
2771
+ // 「正在读取插件…」——界面把失败吞掉,用户只能干等。这里给 15s 上限,超时/失败都落到可重试错误态。
2772
+ const timeout = new Promise((resolve) => setTimeout(() => resolve({ __timeout: true }), 15000));
2773
+ Promise.race([call("/plugin-console/sources"), timeout]).then(
2748
2774
  (data) => {
2775
+ if (data !== null && data.__timeout === true) { setSourcesData({ error: t("sourcesTimeout") }); return }
2749
2776
  setSourcesData(data.sources);
2750
2777
  setSearchSourcesList(data.sources.searchSources ?? []);
2751
2778
  setGiteeClientId(data.sources.gitee?.clientId ?? "");
@@ -2753,7 +2780,7 @@ window.__ModuleLoader__.load({
2753
2780
  // 未配置时为空表单引导首次填写
2754
2781
  setGiteeClientSecret("");
2755
2782
  },
2756
- (error) => { setSourcesOpen(false); setMessage(t("failed") + ":" + friendlyGithubError(error).message); },
2783
+ (error) => { setSourcesData({ error: friendlyGithubError(error).message }); },
2757
2784
  );
2758
2785
  };
2759
2786
  const saveGiteeSetup = () => {
@@ -4692,7 +4719,11 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4692
4719
  el("p", { className: styles.message }, t("sourcesDesc")),
4693
4720
  sourcesData === null
4694
4721
  ? el("p", { className: styles.status }, t("loading"))
4695
- : el("div", { className: styles.accWrap },
4722
+ : sourcesData.error !== undefined
4723
+ ? el("div", null,
4724
+ el("p", { className: styles.status }, t("failed") + ":" + sourcesData.error),
4725
+ el("button", { type: "button", className: styles.toggle, onClick: () => { setSourcesData(null); openSources(); } }, t("retry")))
4726
+ : el("div", { className: styles.accWrap },
4696
4727
  accSection("registries", t("sourcesBtn"), [
4697
4728
  ...sourcesData.registries.map((src) =>
4698
4729
  editReg !== null && editReg.id === src.id
@@ -4845,28 +4876,32 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4845
4876
  : null,
4846
4877
  );
4847
4878
  }
4848
- // 官方风格「升级安全」页(2026-09-24):**纯加法**。
4849
- // 原 tab(id=console)与悬浮面板一行不动;这里把门控常驻化的状态、模式开关、一键适配与各功能入口
4850
- // 用官方卡片风呈现,并把控制台功能**就地展开**(官方 settings.plugins.tab 插槽的 owner props 是空的,
4851
- // 没有「切到兄弟 tab」的 API,所以就地承载而不是跳页)。
4852
- // 触发源已从「我们的升级动作」换成环境指纹变化(服务端 domain/compat-state.js):
4853
- // 官方桌面端自带升级器 / 官方只发桌面端时,门控依然守得住。
4879
+ // 「升级安全」入口(2026-09-24 按用户要求改造为**长按钮 + 抽屉**):纯加法。
4880
+ // 默认只渲染一个长按钮,点开右侧抽屉 —— 里面才是模式开关、状态、一键适配、补声明与各功能入口。
4881
+ // 为什么不直接塞进「图2」那一页:那一页是第三方插件的 tab(@linxin666 的 family-plugins),
4882
+ // 它没有开放任何子插槽;官方 settings.plugins.tab 的 owner props 也是空的(没有切兄弟 tab 的 API)。
4883
+ // 所以做法是「就地抽屉」,而不是往别人的 DOM 里注入(注入会在对方更新时碎掉)。
4884
+ // 原 tab(id=console)与悬浮面板一行未动。
4854
4885
  function PluginSafetyTab({ t }) {
4886
+ const [open, setOpen] = react.useState(false);
4855
4887
  const [status, setStatus] = react.useState(null);
4856
4888
  const [busy, setBusy] = react.useState(false);
4857
4889
  const [note, setNote] = react.useState(null);
4858
4890
  const [section, setSection] = react.useState(null);
4891
+ const [backfill, setBackfill] = react.useState(null);
4859
4892
  const load = async () => {
4860
4893
  try { const data = await call("/plugin-console/compat-status"); if (data !== null) setStatus(data); } catch {}
4861
4894
  };
4862
- react.useEffect(() => { load(); }, []);
4895
+ const loadBackfill = async () => {
4896
+ try { const data = await call("/plugin-console/declare-installed", { mode: "plan" }); if (data !== null) setBackfill(data); } catch {}
4897
+ };
4898
+ react.useEffect(() => { ensureSafetyStyle(); load(); loadBackfill(); }, []);
4863
4899
  const switchMode = async (mode) => {
4864
4900
  if (busy) return;
4865
4901
  setBusy(true);
4866
4902
  try {
4867
- const data = await call("/plugin-console/compat-mode", { mode: mode });
4903
+ await call("/plugin-console/compat-mode", { mode: mode });
4868
4904
  setNote({ text: mode === "observer" ? t("safetyModeObserverOn") : t("safetyModeManagedOn") });
4869
- if (data !== null) setStatus(null);
4870
4905
  await load();
4871
4906
  } catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
4872
4907
  finally { setBusy(false); }
@@ -4882,55 +4917,99 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4882
4917
  const pre = await call("/plugin-console/framework-preflight", {});
4883
4918
  const blockers = (pre && pre.scan && pre.scan.blockers) || 0;
4884
4919
  if (!apply || blockers === 0) {
4885
- const patched = await call("/plugin-console/framework-preflight-patch", { mode: "plan" });
4886
- const planCount = (patched && patched.changes && patched.changes.length) || 0;
4887
- setNote({ text: blockers === 0 ? t("fwPreflightClean") : fill(t("safetyPreflightFound"), [["n", planCount || blockers]]) });
4920
+ const planned = await call("/plugin-console/framework-preflight-patch", { mode: "plan" });
4921
+ const n = (planned && planned.changes && planned.changes.length) || 0;
4922
+ setNote({ text: blockers === 0 ? t("fwPreflightClean") : fill(t("safetyPreflightFound"), [["n", n || blockers]]) });
4888
4923
  } else {
4889
4924
  const done = await call("/plugin-console/framework-preflight-patch", { mode: "apply" });
4890
- const changed = (done && done.changes && done.changes.length) || 0;
4891
- setNote({ text: fill(t("safetyPreflightPatched"), [["n", changed]]) });
4925
+ const n = (done && done.changes && done.changes.length) || 0;
4926
+ setNote({ text: fill(t("safetyPreflightPatched"), [["n", n]]) });
4892
4927
  }
4893
4928
  } catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
4894
4929
  finally { setBusy(false); }
4895
4930
  };
4931
+ const applyBackfill = async () => {
4932
+ if (busy) return;
4933
+ setBusy(true);
4934
+ try {
4935
+ const done = await call("/plugin-console/declare-installed", { mode: "apply" });
4936
+ setNote({ text: (done && done.note) || t("safetyBackfillDone") });
4937
+ await loadBackfill();
4938
+ } catch (error) { setNote({ text: (error && error.message) || t("safetySwitchFailed") }); }
4939
+ finally { setBusy(false); }
4940
+ };
4896
4941
  const fill = (text, pairs) => {
4897
4942
  let out = String(text);
4898
4943
  for (const pair of pairs) out = out.split("{" + pair[0] + "}").join(String(pair[1]));
4899
4944
  return out;
4900
4945
  };
4901
- const card = (icon, title, desc, control) => el("div", { style: { display: "flex", alignItems: "center", gap: "14px", padding: "14px 16px", borderRadius: "14px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-elevated, rgba(127,127,127,.06))" } },
4902
- el("div", { style: { width: "40px", height: "40px", borderRadius: "12px", display: "flex", alignItems: "center", justifyContent: "center", background: "var(--dsw-alias-bg-secondary, rgba(127,127,127,.12))", fontSize: "18px", flex: "0 0 auto" } }, icon),
4946
+ // 图标:16px 线条 SVG(用户反馈:emoji 当 logo 太差)
4947
+ const stroke = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", style: { flex: "0 0 auto", opacity: .8, marginTop: "2px" } };
4948
+ const ICONS = {
4949
+ shield: "M12 3l7 3v6c0 4-3 7-7 9-4-2-7-5-7-9V6z",
4950
+ pin: "M12 17v4M7 4h10l-1 6 3 3H5l3-3z",
4951
+ back: "M11 19l-7-7 7-7M4 12h11a5 5 0 015 5v1",
4952
+ scan: "M4 8V6a2 2 0 012-2h2M20 8V6a2 2 0 00-2-2h-2M4 16v2a2 2 0 002 2h2M20 16v2a2 2 0 01-2 2h-2M9 12h6",
4953
+ list: "M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01",
4954
+ pulse: "M3 12h4l2-5 4 10 2-5h6"
4955
+ };
4956
+ const icon = (key) => el("svg", stroke, el("path", { d: ICONS[key] || ICONS.shield }));
4957
+ const row = (key, title, desc, control) => el("div", { style: { display: "flex", alignItems: "flex-start", gap: "10px", padding: "10px 12px" } },
4958
+ icon(key),
4903
4959
  el("div", { style: { flex: "1 1 auto", minWidth: "0" } },
4904
- el("div", { style: { fontSize: "14px", fontWeight: 500 } }, title),
4905
- el("div", { style: { fontSize: "12.5px", opacity: .72, marginTop: "2px", wordBreak: "break-word" } }, desc)),
4906
- control === undefined ? null : control);
4907
- const sw = (on, onClick, title) => el("button", { type: "button", title: title, onClick: onClick, disabled: busy, style: { width: "42px", height: "24px", borderRadius: "999px", border: "none", padding: 0, cursor: busy ? "default" : "pointer", background: on ? "var(--dsw-alias-bg-accent, #4d6bfe)" : "var(--dsw-alias-bg-secondary, rgba(127,127,127,.35))", position: "relative", flex: "0 0 auto" } },
4908
- el("span", { style: { position: "absolute", top: "3px", left: on ? "21px" : "3px", width: "18px", height: "18px", borderRadius: "50%", background: "#fff", transition: "left .18s ease" } }));
4909
- const jump = (id, label) => el("button", { key: id, type: "button", className: styles.toggle, style: { marginRight: "8px", marginBottom: "8px" }, onClick: () => setSection(id) }, label);
4910
- const mode = status !== null && status.mode === "observer" ? "observer" : "managed";
4960
+ el("div", { style: { fontSize: "13px", fontWeight: 500 } }, title),
4961
+ el("div", { style: { fontSize: "12px", opacity: .7, marginTop: "2px", wordBreak: "break-word" } }, desc)),
4962
+ control === undefined || control === null ? null : control);
4963
+ const sw = (on, onClick, title) => el("button", { type: "button", title: title, onClick: onClick, disabled: busy, style: { width: "40px", height: "22px", borderRadius: "999px", border: "none", padding: 0, cursor: busy ? "default" : "pointer", background: on ? "var(--dsw-alias-bg-accent, #4d6bfe)" : "var(--dsw-alias-bg-secondary, rgba(127,127,127,.35))", position: "relative", flex: "0 0 auto", marginTop: "2px" } },
4964
+ el("span", { style: { position: "absolute", top: "3px", left: on ? "21px" : "3px", width: "16px", height: "16px", borderRadius: "50%", background: "#fff", transition: "left .18s cubic-bezier(.34,1.56,.64,1)" } }));
4965
+ const jump = (id, label) => el("button", { key: id, type: "button", className: styles.toggle, style: { marginRight: "8px", marginBottom: "8px" }, onClick: () => setSection((prev) => (prev === id ? null : id)) }, label);
4966
+ const mode = status !== null && status.mode === "observer" ? "observer" : status !== null && status.mode === "managed" ? "managed" : "observer";
4911
4967
  const fp = status !== null && status.fingerprint !== null && status.fingerprint !== undefined ? status.fingerprint : {};
4912
4968
  const rb = status !== null && status.rollback !== null && status.rollback !== undefined ? status.rollback : {};
4913
4969
  const fw = status !== null && typeof status.frameworkVersion === "string" && status.frameworkVersion !== "" ? status.frameworkVersion : t("safetyUnknown");
4914
- return el("div", { style: { display: "flex", flexDirection: "column", gap: "12px", maxWidth: "760px" } },
4915
- card("🛡️", t("safetyMode"), mode === "managed" ? t("safetyModeManaged") : t("safetyModeObserver"), sw(mode === "observer", () => switchMode(mode === "managed" ? "observer" : "managed"), t("safetyModeSwitch"))),
4916
- card("📌", t("safetyFrameworkTitle"), fill(t("safetyFrameworkDesc"), [["v", fw]])),
4917
- card("⏪", t("safetyRollbackTitle"), rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]])),
4918
- card("🔎", t("safetyFpTitle"), fp.changed === true ? fill(t("safetyFpChanged"), [["reasons", (fp.reasons || []).join(";")]]) : t("safetyFpStable"), fp.changed === true ? el("button", { type: "button", className: styles.toggle, onClick: ack, disabled: busy }, t("safetyFpAck")) : null),
4919
- card("🩺", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
4970
+ const pending = backfill !== null && typeof backfill.count === "number" ? backfill.count : 0;
4971
+ // 触发条:默认只看到这一行(点开在**原地**展开,功能包那种下拉,不走右侧固定抽屉)
4972
+ // 收起/展开:收起时**清掉就地分区**,否则再展开时内嵌控制台会重新挂载并把上次那个面板自动打开一遍
4973
+ // (用户实测:点过「框架升级/回滚」→ 关掉弹窗 → 收起 → 再展开,弹窗又自己弹出来了)
4974
+ const toggleOpen = () => {
4975
+ if (open) { setOpen(false); setSection(null); } else { setOpen(true); }
4976
+ };
4977
+ const trigger = el("button", { type: "button", "aria-expanded": open ? "true" : "false", onClick: toggleOpen, style: { width: "100%", display: "flex", alignItems: "center", gap: "10px", padding: "12px 14px", borderRadius: "12px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-layer-4, rgba(127,127,127,.10))", cursor: "pointer", textAlign: "left", color: "inherit" } },
4978
+ icon("shield"),
4979
+ el("div", { style: { flex: "1 1 auto", minWidth: "0" } },
4980
+ el("div", { style: { fontSize: "13px", fontWeight: 500 } }, t("safetyTab")),
4981
+ el("div", { style: { fontSize: "12px", opacity: .7, marginTop: "2px" } }, (mode === "observer" ? t("safetyModeObserverShort") : t("safetyModeManagedShort")) + " · " + fw + (pending > 0 ? " · " + fill(t("safetyBackfillShort"), [["n", pending]]) : ""))),
4982
+ el("span", { style: { opacity: .5, flex: "0 0 auto", transform: open ? "rotate(90deg)" : "none", transition: "transform .2s cubic-bezier(.34,1.56,.64,1)" } }, el("svg", { width: 14, height: 14, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }, el("path", { d: "M9 6l6 6-6 6" }))));
4983
+ const panel = el("div", { className: "pc-safety-panel", style: { marginTop: "8px", padding: "8px", borderRadius: "12px", border: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", background: "var(--dsw-alias-bg-layer-4, rgba(127,127,127,.10))", display: "flex", flexDirection: "column", gap: "4px" } },
4984
+ row("shield", t("safetyMode"), mode === "observer" ? t("safetyModeObserver") : t("safetyModeManaged") + (status !== null && typeof status.modeReason === "string" && status.modeReason !== "" ? "(" + status.modeReason + ")" : ""), sw(mode === "observer", () => switchMode(mode === "observer" ? "managed" : "observer"), t("safetyModeSwitch"))),
4985
+ row("pin", t("safetyFrameworkTitle"), fill(t("safetyFrameworkDesc"), [["v", fw]])),
4986
+ row("back", t("safetyRollbackTitle"), rb.applicable === true ? fill(t("safetyRollbackOk"), [["from", rb.from], ["to", rb.to]]) : fill(t("safetyRollbackNone"), [["reason", rb.reason === null || rb.reason === undefined ? "-" : rb.reason]])),
4987
+ row("scan", t("safetyFpTitle"), fp.changed === true ? fill(t("safetyFpChanged"), [["reasons", (fp.reasons || []).join(";")]]) : t("safetyFpStable"), fp.changed === true ? el("button", { type: "button", className: styles.toggle, onClick: ack, disabled: busy }, t("safetyFpAck")) : null),
4988
+ row("list", t("safetyBackfillTitle"), pending > 0 ? fill(t("safetyBackfillDesc"), [["n", pending]]) : t("safetyBackfillNone"), pending > 0 ? el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: applyBackfill }, fill(t("safetyBackfillBtn"), [["n", pending]])) : null),
4989
+ row("pulse", t("safetyAdaptTitle"), t("safetyAdaptDesc"), el("div", { style: { display: "flex", gap: "8px", flex: "0 0 auto" } },
4920
4990
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(false) }, t("fwPreflightBtn")),
4921
4991
  el("button", { type: "button", className: styles.toggle, disabled: busy, onClick: () => preflight(true) }, t("fwPreflightPatch")))),
4922
- note === null ? null : el("div", { style: { fontSize: "12.5px", opacity: .85 } }, note.text),
4923
- el("div", null,
4924
- el("div", { style: { fontSize: "12.5px", opacity: .6, margin: "4px 0 8px" } }, t("safetyJumpHint")),
4992
+ note === null ? null : el("div", { style: { fontSize: "12px", opacity: .8, padding: "0 12px 6px" } }, note.text),
4993
+ el("div", { style: { padding: "4px 12px 2px" } },
4925
4994
  jump("market", t("safetyJumpMarket")),
4926
4995
  jump("skills", t("safetyJumpSkills")),
4927
4996
  jump("framework", t("safetyJumpFramework")),
4928
4997
  jump("gate", t("safetyJumpGate")),
4929
4998
  jump("sources", t("safetyJumpSources")),
4930
4999
  jump("ai", t("safetyJumpAi"))),
4931
- section === null ? null : el("div", { style: { marginTop: "4px", borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", paddingTop: "12px" } },
4932
- el("div", { style: { fontSize: "12.5px", opacity: .6, marginBottom: "8px" } }, t("safetyEmbedded")),
5000
+ section === null ? null : el("div", { style: { margin: "4px 12px 8px", borderTop: "1px solid var(--dsw-alias-border-secondary, rgba(127,127,127,.18))", paddingTop: "10px" } },
4933
5001
  el(PluginConsoleTab, { t: t, section: section, key: section })));
5002
+ return el("div", null, trigger, open ? panel : null);
5003
+ }
5004
+ // 「功能包」那种就地展开的 Q 弹动画(一次性注入,不依赖外部 CSS)
5005
+ function ensureSafetyStyle() {
5006
+ try {
5007
+ if (typeof document === "undefined" || document.querySelector("style[data-pc-safety]") !== null) return;
5008
+ const tag = document.createElement("style");
5009
+ tag.dataset.pcSafety = "1";
5010
+ tag.textContent = "@keyframes pcSafetyPop{0%{opacity:0;transform:translateY(-6px) scaleY(.96)}60%{opacity:1;transform:translateY(0) scaleY(1.02)}100%{transform:translateY(0) scaleY(1)}}.pc-safety-panel{animation:pcSafetyPop .28s cubic-bezier(.34,1.56,.64,1);transform-origin:top center}";
5011
+ document.head.appendChild(tag);
5012
+ } catch {}
4934
5013
  }
4935
5014
  function apply(ctx) {
4936
5015
  ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-console: dictionaries");
@@ -4943,12 +5022,16 @@ setMessage(t("failed") + ":" + (data?.reason ?? "未知"));
4943
5022
  locale: NS,
4944
5023
  inject: () => ({}),
4945
5024
  }, PluginConsoleTab));
4946
- // 官方风格「升级安全」入口(纯加法):原 tab id=console 保持不变,这里只是多挂一个
4947
- ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
4948
- name: "settings.plugins.tab",
4949
- id: "console-safety",
4950
- order: 21,
4951
- label: () => t("safetyTab"),
5025
+ // 「升级安全」入口:注册进**官方插件管理页自己的子插槽**(2026-09-24 落点更正)。
5026
+ // 官方页 @deepseek-ai/dsh-client-ui-plugin-manager 占 main + sidebar.panellist,
5027
+ // 并声明了 children 子插槽;其中 `plugins.bundle.config` 按**包名**挂该 bundle 的配置卡片:
5028
+ // 渲染点 = renderSlot("plugins.bundle.config", { view: "page" }, { entryKey: pkg.name })
5029
+ // 且仅当 configured = ledger.bundles.has(pkg.name) 为真 —— 我们是 profile bundle,天然满足。
5030
+ // 所以入口长在那一页「已安装 → 我们的 bundle」详情里,用的是官方容器与官方风格,不是 DOM 注入。
5031
+ // 之前那个独立的「升级安全」tab 已按用户要求关闭(错误落点不占位);原 tab(id=console) 一行未动。
5032
+ ctx.slots.inject("plugins.bundle.config", () => ctx.slots.register({
5033
+ name: "plugins.bundle.config",
5034
+ key: "@noob-stupid/dsh-plugin-console",
4952
5035
  locale: NS,
4953
5036
  inject: () => ({}),
4954
5037
  }, PluginSafetyTab));
@@ -14,7 +14,8 @@ import { dshHome } from '../infra/paths.js'
14
14
 
15
15
  /** 运行模式。managed = 托管(我们负责框架升级与回滚,既有行为);observer = 观察者(只守门,不碰升级)。 */
16
16
  const COMPAT_MODES = ['managed', 'observer']
17
- const DEFAULT_MODE = 'managed'
17
+ // 默认**观察者**(用户 2026-09-24 定):只守门、不接管框架升级;一旦用户走了本控制台的框架升级,服务端会自动切回托管
18
+ const DEFAULT_MODE = 'observer'
18
19
 
19
20
  const stateDir = () => join(dshHome(), 'plugin-console')
20
21
  const modeFile = () => join(stateDir(), 'compat-mode.json')
@@ -41,18 +42,18 @@ function writeJson(path, value) {
41
42
  function readCompatMode() {
42
43
  const rec = readJson(modeFile())
43
44
  const mode = typeof rec?.mode === 'string' && COMPAT_MODES.includes(rec.mode) ? rec.mode : DEFAULT_MODE
44
- return { mode, at: typeof rec?.at === 'number' ? rec.at : null, source: rec === null ? 'default' : 'file' }
45
+ return { mode, at: typeof rec?.at === 'number' ? rec.at : null, reason: typeof rec?.reason === 'string' ? rec.reason : null, source: rec === null ? 'default' : 'file' }
45
46
  }
46
47
 
47
48
  /**
48
49
  * 写运行模式(只接受白名单值,避免拼错导致「模式静默失效」)。
49
50
  * @returns {{ ok: boolean, mode: string, error?: string }}
50
51
  */
51
- function writeCompatMode(mode) {
52
+ function writeCompatMode(mode, meta = {}) {
52
53
  if (typeof mode !== 'string' || !COMPAT_MODES.includes(mode)) {
53
54
  return { ok: false, mode: readCompatMode().mode, error: `未知模式 ${String(mode)}(可选:${COMPAT_MODES.join(' / ')})` }
54
55
  }
55
- writeJson(modeFile(), { mode, at: Date.now() })
56
+ writeJson(modeFile(), { mode, at: Date.now(), reason: typeof meta.reason === 'string' ? meta.reason : null })
56
57
  return { ok: true, mode }
57
58
  }
58
59
 
@@ -117,4 +117,50 @@ async function undeclareProfileDependency(profileDir, packageName) {
117
117
  })
118
118
  }
119
119
 
120
- export { DEFAULT_BUNDLES, addBundleToManifest, declareProfileDependency, installedVersionOf, removeBundleFromManifest, undeclareProfileDependency }
120
+ /**
121
+ * 扫描「已装但未声明」的插件(补声明用)。
122
+ *
123
+ * 判据刻意保守:只看 profile 的 `dsh.profile.bundles` 里**非默认**且**真装在磁盘上**的包。
124
+ * 为什么不扫整个 node_modules:那会把框架自带包、传递依赖一并卷进来(它们本来就不该出现在
125
+ * 用户清单里)。bundles 是「用户显式加进组合树的东西」—— 正是该被声明的那一批。
126
+ *
127
+ * 为什么值得做(2026-09-24 用户实测):第三方插件管理器(如 @linxin666 的插件页)与 pnpm
128
+ * 都只认清单里的依赖 —— 手铺安装的包在它们眼里不存在:看不见,还会被 pnpm 还原。
129
+ * @returns {{ candidates: {name: string, version: string}[], skipped: {name: string, reason: string}[] }}
130
+ */
131
+ async function planDependencyBackfill(profileDir) {
132
+ const manifest = await readManifest(profileDir)
133
+ const bundles = Array.isArray(manifest.dsh?.profile?.bundles) ? manifest.dsh.profile.bundles : []
134
+ const deps = manifest.dependencies ?? {}
135
+ const candidates = []
136
+ const skipped = []
137
+ for (const name of bundles) {
138
+ if (typeof name !== 'string' || name === '') continue
139
+ if (DEFAULT_BUNDLES.includes(name)) continue
140
+ if (typeof deps[name] === 'string' && deps[name] !== '') continue
141
+ const version = await installedVersionOf(profileDir, name)
142
+ if (version === null) {
143
+ skipped.push({ name, reason: '磁盘上读不到已安装版本(可能已卸载)' })
144
+ continue
145
+ }
146
+ candidates.push({ name, version })
147
+ }
148
+ return { candidates, skipped }
149
+ }
150
+
151
+ /** 执行补声明:逐条写入清单(每条独立走写队列,一条失败不影响其它)。 */
152
+ async function applyDependencyBackfill(profileDir) {
153
+ const { candidates, skipped } = await planDependencyBackfill(profileDir)
154
+ const declared = []
155
+ for (const item of candidates) {
156
+ const result = await declareProfileDependency(profileDir, item.name, item.version)
157
+ if (result.changed) declared.push({ name: item.name, version: result.version })
158
+ else skipped.push({ name: item.name, reason: result.reason ?? '无需改动' })
159
+ }
160
+ return { declared, skipped }
161
+ }
162
+
163
+ export {
164
+ DEFAULT_BUNDLES, addBundleToManifest, applyDependencyBackfill, declareProfileDependency,
165
+ installedVersionOf, planDependencyBackfill, removeBundleFromManifest, undeclareProfileDependency,
166
+ }
@@ -1,15 +1,20 @@
1
- // L2 · routes —— 门控常驻化(观察者/托管模式 · 环境指纹 · 回滚点)
2
- // GET /plugin-console/compat-status 一次拿齐:模式 + 框架版本 + 指纹变化 + 回滚点可用性
3
- // POST /plugin-console/compat-mode 切换运行模式(managed / observer)
4
- // POST /plugin-console/compat-stamp 把当前指纹记为基线(用户确认「环境变更」后调用)
1
+ // L2 · routes —— 门控常驻化(观察者/托管模式 · 环境指纹 · 回滚点)+ 官方互操作
2
+ // GET /plugin-console/compat-status 一次拿齐:模式 + 框架版本 + 指纹变化 + 回滚点可用性
3
+ // POST /plugin-console/compat-mode 切换运行模式(managed / observer)
4
+ // POST /plugin-console/compat-stamp 把当前指纹记为基线(用户确认「环境变更」后调用)
5
+ // POST /plugin-console/declare-installed 补声明:把「已装但未声明」的插件写进 profile 依赖
5
6
  //
6
7
  // 与 /framework-preflight 的分工:预检负责「扫什么」,这里负责「什么时候扫、以什么模式守门」。
7
8
  // 触发源不再是我们的升级按钮,而是环境指纹变化 —— 官方桌面端自带升级器 / 官方只发桌面端 /
8
9
  // 手动 pnpm 操作,都会在这里被发现(见 domain/compat-state.js 头注释)。
10
+ //
11
+ // declare-installed 存在的理由(2026-09-24 用户实测):第三方插件管理器与 pnpm 都只认清单里的
12
+ // 依赖,手铺安装的包在它们眼里不存在(看不见 + 会被还原)。老规矩:先 plan 再 apply。
9
13
 
10
14
  import { dirname } from 'node:path'
11
15
  import { compareFingerprint, computeFingerprint, readCompatMode, readFingerprint, readRollbackRecord, stampFingerprint, writeCompatMode } from '../domain/compat-state.js'
12
16
  import { currentFrameworkVersion } from '../domain/framework.js'
17
+ import { applyDependencyBackfill, planDependencyBackfill } from '../domain/manifest.js'
13
18
  import { sendError, sendJson } from '../infra/httpd.js'
14
19
  import { baseDirOf, profileDirOf, resolvePackageJson } from '../infra/paths.js'
15
20
  import { existsSync } from 'node:fs'
@@ -62,6 +67,7 @@ async function routeCompatStatusGet(req, res, rc) {
62
67
  mode: mode.mode,
63
68
  modeSource: mode.source,
64
69
  modeChangedAt: mode.at,
70
+ modeReason: mode.reason ?? null,
65
71
  frameworkVersion: current.frameworkVersion,
66
72
  fingerprint: { current, previous, changed, reasons, baselineAt },
67
73
  rollback: readRollbackRecord(),
@@ -80,4 +86,29 @@ async function routeCompatStamp(req, res, rc) {
80
86
  sendJson(res, 200, { ok: true, fingerprint: stamped })
81
87
  }
82
88
 
83
- export { routeCompatMode, routeCompatStamp, routeCompatStatusGet }
89
+ /** 补声明:plan 只报「有几项要补」,apply 真写清单(逐条写、逐条回报)。 */
90
+ async function routeDeclareInstalled(req, res, rc) {
91
+ const mode = rc.body?.mode === 'apply' ? 'apply' : 'plan'
92
+ const profileDir = profileDirOf(rc.ctx)
93
+ try {
94
+ if (mode === 'apply') {
95
+ const result = await applyDependencyBackfill(profileDir)
96
+ sendJson(res, 200, {
97
+ ok: true,
98
+ mode,
99
+ declared: result.declared,
100
+ skipped: result.skipped,
101
+ note: result.declared.length === 0
102
+ ? '没有需要补声明的插件'
103
+ : `已补声明 ${result.declared.length} 项 —— 它们现在会出现在官方/第三方插件页的「已安装」里,也不会再被 pnpm 还原`,
104
+ })
105
+ return
106
+ }
107
+ const plan = await planDependencyBackfill(profileDir)
108
+ sendJson(res, 200, { ok: true, mode, candidates: plan.candidates, skipped: plan.skipped, count: plan.candidates.length })
109
+ } catch (error) {
110
+ sendError(res, 500, `补声明失败:${error instanceof Error ? error.message : String(error)}`)
111
+ }
112
+ }
113
+
114
+ export { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled }
@@ -10,6 +10,7 @@ import { dirname, join } from 'node:path'
10
10
  import { tmpdir } from 'node:os'
11
11
  import { pathToFileURL } from 'node:url'
12
12
  import { createRequire } from 'node:module'
13
+ import { readCompatMode, writeCompatMode } from '../domain/compat-state.js'
13
14
  import { FRAMEWORK_BACKUP_ROOT, backupProfileSnapshot, checkpointFrameworkTree, preflightDisableIncompatible, relaunchPrelude, resolveDshBin, resolveFrameworkRootNodeModules, resolveFrameworkUpgradePlan } from '../domain/framework.js'
14
15
  import { fwIntegrityCheck } from '../infra/fw-integrity-check.js'
15
16
  import { CORE_PATCH_ROW_IDS } from '../domain/patch.js'
@@ -22,6 +23,21 @@ import { sendError, sendJson } from '../infra/httpd.js'
22
23
  import { dshHome, findPatchPath, pluginRoot } from '../infra/paths.js'
23
24
  import { setFwCheckCache } from '../state.js'
24
25
 
26
+ /**
27
+ * 用户语义(2026-09-24):「运行模式」默认开启 = **观察者**(只守门、不接管升级);
28
+ * 但只要有人走了本控制台这条框架升级路径,就**自动切回托管** —— 此时升级确实由我们负责。
29
+ * 返回提示文案(无切换时返回 null)。
30
+ */
31
+ function autoSwitchToManaged(reason) {
32
+ try {
33
+ if (readCompatMode().mode === 'managed') return null
34
+ writeCompatMode('managed', { reason })
35
+ return '检测到使用本控制台的框架升级:运行模式已自动切回「托管」'
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
25
41
  async function routeFrameworkUpgrade(req, res, rc) {
26
42
  const backupProfileSnapshot = rc.deps.backupProfileSnapshot
27
43
  const preflightDisableIncompatible = rc.deps.preflightDisableIncompatible
@@ -103,6 +119,9 @@ async function routeFrameworkUpgrade(req, res, rc) {
103
119
  // 保证新框架 boot 时不会被某行 import 失败拖垮(loader 单行失败 = 整个服务起不来)。
104
120
  let preflight = { disabled: [], skipped: [] }
105
121
  if (hasUpdate) {
122
+ // 用户语义(2026-09-24):走本控制台这条升级路径 = 升级由我们负责 → 自动从观察者切回托管
123
+ const switched = autoSwitchToManaged('使用本控制台执行框架升级')
124
+ if (switched !== null) steps.push(switched)
106
125
  try {
107
126
  preflight = await preflightDisableIncompatible({ ports: ctx, profileDir, patchPath: findPatchPath(ctx), targetVersion: target })
108
127
  } catch (error) {
@@ -14,7 +14,7 @@ import { runSuiteInstallJob } from '../domain/suite.js'
14
14
  import { readBody, sendError } from '../infra/httpd.js'
15
15
  import { routeAiConsent, routeAiEmpowerCancel, routeAiEmpowerList, routeAiEmpowerPlan, routeAiEmpowerRun, routeAiEmpowerStatus } from './ai.js'
16
16
  import { routeComponentAutostart, routeComponentStart, routeComponentStatus, routeComponentStop, routeComponents, routeRepoClone, routeRepoLandConfig, routeRepoList, routeRepoOpen, routeRepoRemove } from './components.js'
17
- import { routeCompatMode, routeCompatStamp, routeCompatStatusGet } from './compat.js'
17
+ import { routeCompatMode, routeCompatStamp, routeCompatStatusGet, routeDeclareInstalled } from './compat.js'
18
18
  import { routeFrameworkUpgrade } from './framework-upgrade.js'
19
19
  import { routeFrameworkPreflight, routeFrameworkPreflightPatch } from './framework-preflight.js'
20
20
  import { routeCheckUpdate, routeCompatGate, routeFrameworkCheck, routeFrameworkRelaunch, routeFrameworkRollback, routeFrameworkUpgradeStatusGet, routeRestart } from './framework.js'
@@ -95,6 +95,7 @@ const ROUTES = [
95
95
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-preflight-patch`, handler: routeFrameworkPreflightPatch },
96
96
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-mode`, handler: routeCompatMode },
97
97
  { methods: ['POST'], path: `${ROUTE_PREFIX}/compat-stamp`, handler: routeCompatStamp },
98
+ { methods: ['POST'], path: `${ROUTE_PREFIX}/declare-installed`, handler: routeDeclareInstalled },
98
99
  { methods: ['POST'], path: `${ROUTE_PREFIX}/framework-rollback`, handler: routeFrameworkRollback },
99
100
  { methods: ['POST'], path: `${ROUTE_PREFIX}/restart`, handler: routeRestart },
100
101
  { methods: ['POST'], path: `${ROUTE_PREFIX}/toggle`, handler: routeToggle },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@noob-stupid/dsh-plugin-console",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "description": "DSH 框架升级安全与插件升级门控:一键升级、失败自动回滚、升级后回滚上版、旧插件不适配自动禁用;内置多源插件市场为发现层,插件源全部可自定义,可指向公司内网私有源 / 私有索引 / 本地 Git 仓库,纯内网离线可用 | Framework upgrade safety & plugin version gating for DSH, with a customizable multi-source plugin market: point every source at internal mirrors or a local file:// repo for intranet-only, offline installs.",
5
5
  "repository": {
6
6
  "type": "git",