dsh-plugin-update-checker 0.0.0-stage → 0.1.4
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/LICENSE +21 -0
- package/README.md +118 -2
- package/README.zh-CN.md +113 -0
- package/cordis.patch.yml +14 -0
- package/lib/client.js +429 -0
- package/lib/host/dsh.js +158 -0
- package/lib/host/profile.js +261 -0
- package/lib/host/registry.js +195 -0
- package/lib/host/runtime.js +143 -0
- package/lib/host/scan.js +123 -0
- package/lib/host/update-checker.js +90 -0
- package/lib/index.js +23 -0
- package/package.json +62 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-update-checker — Browser half.
|
|
3
|
+
*
|
|
4
|
+
* Registers a "Check updates" tab into the Built-in plugins settings section
|
|
5
|
+
* (`settings.plugins.tab`), sitting beside the read-only inventory. The tab
|
|
6
|
+
* shows the running DSH runtime version, a prominent "Check updates" button,
|
|
7
|
+
* and — after the check — one row per installed plugin bundle reporting both:
|
|
8
|
+
*
|
|
9
|
+
* - compatibility with the current DSH runtime (peer-dependency verdict), and
|
|
10
|
+
* - update availability (published registry version vs the installed one).
|
|
11
|
+
*
|
|
12
|
+
* It calls only the host route this package ships (`/api/plugin-update-checker/
|
|
13
|
+
* scan`); it renders no local transport. A failed per-bundle lookup is shown as
|
|
14
|
+
* a row-level warning rather than blanking the report.
|
|
15
|
+
*
|
|
16
|
+
* Bundle id MUST equal package.json `name` (the dsh-client-modules Loader row
|
|
17
|
+
* resolver matches the registered module id to the package name).
|
|
18
|
+
*/
|
|
19
|
+
window.__ModuleLoader__.load({
|
|
20
|
+
id: "dsh-plugin-update-checker",
|
|
21
|
+
factory: (require) => {
|
|
22
|
+
var module = { exports: {} };
|
|
23
|
+
var exports = module.exports;
|
|
24
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
25
|
+
let react = require("react");
|
|
26
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
27
|
+
|
|
28
|
+
//#region dictionaries
|
|
29
|
+
const NS = "pluginUpdateChecker";
|
|
30
|
+
const zh = {
|
|
31
|
+
"tab": "检查更新",
|
|
32
|
+
"title": "插件更新与兼容性检查",
|
|
33
|
+
"intro": "一键检查所有已安装插件的最新版本,以及它们与当前 DSH 运行时的兼容性。",
|
|
34
|
+
"dshVersion": "当前 DSH 版本",
|
|
35
|
+
"check": "检查更新",
|
|
36
|
+
"checking": "检查中…",
|
|
37
|
+
"lastChecked": "上次检查",
|
|
38
|
+
"never": "尚未检查",
|
|
39
|
+
"error": "检查失败",
|
|
40
|
+
"retry": "重试",
|
|
41
|
+
"noPlugins": "没有检测到任何已安装的插件包。",
|
|
42
|
+
"col.plugin": "插件",
|
|
43
|
+
"col.installed": "已安装版本",
|
|
44
|
+
"col.latest": "最新版本",
|
|
45
|
+
"col.compat": "兼容性",
|
|
46
|
+
"col.update": "更新",
|
|
47
|
+
"compat.compatible": "兼容",
|
|
48
|
+
"compat.incompatible": "不兼容",
|
|
49
|
+
"compat.unknown": "未知",
|
|
50
|
+
"update.upToDate": "已是最新",
|
|
51
|
+
"update.updateAvailable": "有更新",
|
|
52
|
+
"update.unknown": "未知",
|
|
53
|
+
"update.unsupported": "不受支持",
|
|
54
|
+
"update.partOfRuntime": "运行时的一部分",
|
|
55
|
+
"update.error": "无法检查",
|
|
56
|
+
"compat.peers": "不兼容的 peer 依赖",
|
|
57
|
+
"exempted": "已豁免",
|
|
58
|
+
"updateBtn": "更新",
|
|
59
|
+
"updating": "更新中…",
|
|
60
|
+
"updated": "已更新",
|
|
61
|
+
"updateFailed": "更新失败",
|
|
62
|
+
"linked": "源码链接(请从源码更新)",
|
|
63
|
+
"linkedHint": "该插件以源码软链方式安装,无法一键更新;请到源码目录拉取最新代码后重启 DSH。",
|
|
64
|
+
"sourceLink": "前往源码仓库",
|
|
65
|
+
"runtimeName": "DSH 运行时",
|
|
66
|
+
"runtimeUpdateAvailable": "有可用更新(通过 DSH 自带更新器升级)",
|
|
67
|
+
"runtimeUpdateHint": "DSH 运行时由应用自带的更新器管理,无法在此一键更新。请在菜单中检查更新,或重新启动应用以应用更新。",
|
|
68
|
+
"sourcePath": "源码路径",
|
|
69
|
+
"restartRequired": "已更新,需重启 DSH 生效",
|
|
70
|
+
"manualRestart": "请手动重启:先停止 3080 端口上的 DSH 进程,再运行 `dsh restart` 或重新启动 DSH(也可在终端执行 `dsh stop && dsh restart`)。重启后更新才会生效。",
|
|
71
|
+
"summary": "共 {total} 个插件:{incompatible} 个不兼容,{available} 个有可用更新。"
|
|
72
|
+
};
|
|
73
|
+
const en = {
|
|
74
|
+
"tab": "Check updates",
|
|
75
|
+
"title": "Plugin update & compatibility check",
|
|
76
|
+
"intro": "Check every installed plugin for the latest published version and its compatibility with the running DSH runtime in one click.",
|
|
77
|
+
"dshVersion": "Running DSH version",
|
|
78
|
+
"check": "Check updates",
|
|
79
|
+
"checking": "Checking…",
|
|
80
|
+
"lastChecked": "Last checked",
|
|
81
|
+
"never": "Not checked yet",
|
|
82
|
+
"error": "Check failed",
|
|
83
|
+
"retry": "Retry",
|
|
84
|
+
"noPlugins": "No installed plugin bundles detected.",
|
|
85
|
+
"col.plugin": "Plugin",
|
|
86
|
+
"col.installed": "Installed",
|
|
87
|
+
"col.latest": "Latest",
|
|
88
|
+
"col.compat": "Compatibility",
|
|
89
|
+
"col.update": "Update",
|
|
90
|
+
"compat.compatible": "Compatible",
|
|
91
|
+
"compat.incompatible": "Incompatible",
|
|
92
|
+
"compat.unknown": "Unknown",
|
|
93
|
+
"update.upToDate": "Up to date",
|
|
94
|
+
"update.updateAvailable": "Update available",
|
|
95
|
+
"update.unknown": "Unknown",
|
|
96
|
+
"update.unsupported": "Unsupported",
|
|
97
|
+
"update.partOfRuntime": "Part of the runtime",
|
|
98
|
+
"update.error": "Cannot check",
|
|
99
|
+
"compat.peers": "Incompatible peer dependencies",
|
|
100
|
+
"exempted": "Exempted",
|
|
101
|
+
"updateBtn": "Update",
|
|
102
|
+
"updating": "Updating…",
|
|
103
|
+
"updated": "Updated",
|
|
104
|
+
"updateFailed": "Update failed",
|
|
105
|
+
"linked": "Source-linked (update from source)",
|
|
106
|
+
"linkedHint": "This plugin is installed as a source symlink and cannot be updated in one click. Pull the latest from its source repo, then restart DSH.",
|
|
107
|
+
"sourceLink": "Open source repository",
|
|
108
|
+
"runtimeName": "DSH runtime",
|
|
109
|
+
"runtimeUpdateAvailable": "Update available (upgrade via the app's own updater)",
|
|
110
|
+
"runtimeUpdateHint": "The DSH runtime is managed by the app's built-in updater and cannot be updated here. Check for updates from the menu, or restart the app to apply an update.",
|
|
111
|
+
"sourcePath": "Source path",
|
|
112
|
+
"restartRequired": "Updated — restart DSH to apply",
|
|
113
|
+
"manualRestart": "Restart DSH manually: stop the DSH process on port 3080, then run `dsh restart` (or `dsh stop && dsh restart`) from a terminal. The update takes effect only after the restart.",
|
|
114
|
+
"summary": "{total} plugins: {incompatible} incompatible, {available} with an update available."
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
|
|
118
|
+
const COLORS = {
|
|
119
|
+
primary: "var(--dsw-alias-state-business-primary)",
|
|
120
|
+
error: "var(--dsw-alias-state-error-primary)",
|
|
121
|
+
warning: "var(--dsw-alias-state-warning-primary, #c9851f)",
|
|
122
|
+
ok: "var(--dsw-alias-state-success-primary, #1f9d55)",
|
|
123
|
+
label: "var(--dsw-alias-label-primary)",
|
|
124
|
+
tertiary: "var(--dsw-alias-label-tertiary)",
|
|
125
|
+
border: "var(--dsw-alias-border-l4)",
|
|
126
|
+
bg: "var(--dsw-alias-bg-layer-1)",
|
|
127
|
+
// Referenced by the source-repository link. It used to be missing, so
|
|
128
|
+
// the literal fallback fired on every render and the link ignored the
|
|
129
|
+
// theme; declare the token so the link follows the palette.
|
|
130
|
+
link: "var(--dsw-alias-label-primary-bluish, #3b82f6)"
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
function badge(text, color) {
|
|
134
|
+
return react_jsx_runtime.jsx("span", {
|
|
135
|
+
style: {
|
|
136
|
+
display: "inline-flex",
|
|
137
|
+
alignItems: "center",
|
|
138
|
+
height: 18,
|
|
139
|
+
padding: "0 7px",
|
|
140
|
+
fontSize: 10,
|
|
141
|
+
fontWeight: 600,
|
|
142
|
+
lineHeight: 1,
|
|
143
|
+
borderRadius: "var(--dsw-radius-sm)",
|
|
144
|
+
color: color,
|
|
145
|
+
background: "color-mix(in srgb, " + color + " 14%, transparent)",
|
|
146
|
+
whiteSpace: "nowrap"
|
|
147
|
+
},
|
|
148
|
+
children: text
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function compatBadge(compat, t) {
|
|
153
|
+
if (compat.status === "incompatible") {
|
|
154
|
+
return badge(compat.exempted ? t("compat.incompatible") + " · " + t("exempted") : t("compat.incompatible"), COLORS.error);
|
|
155
|
+
}
|
|
156
|
+
if (compat.status === "unknown") return badge(t("compat.unknown"), COLORS.tertiary);
|
|
157
|
+
return badge(t("compat.compatible"), COLORS.ok);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function updateBadge(update, t) {
|
|
161
|
+
switch (update.status) {
|
|
162
|
+
case "update-available":
|
|
163
|
+
return badge(t("update.updateAvailable"), COLORS.warning);
|
|
164
|
+
case "up-to-date":
|
|
165
|
+
return badge(t("update.upToDate"), COLORS.ok);
|
|
166
|
+
case "part-of-runtime":
|
|
167
|
+
return badge(t("update.partOfRuntime"), COLORS.tertiary);
|
|
168
|
+
case "unsupported":
|
|
169
|
+
return badge(t("update.unsupported"), COLORS.tertiary);
|
|
170
|
+
case "unknown":
|
|
171
|
+
default:
|
|
172
|
+
return update.error ? badge(t("update.error"), COLORS.tertiary) : badge(t("update.unknown"), COLORS.tertiary);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function UpdateCheckerTab(props) {
|
|
177
|
+
const { t, scan } = props;
|
|
178
|
+
const [loading, setLoading] = react.useState(false);
|
|
179
|
+
const [error, setError] = react.useState(null);
|
|
180
|
+
const [result, setResult] = react.useState(null);
|
|
181
|
+
// Per-plugin update state keyed by bundle name.
|
|
182
|
+
const [updating, setUpdating] = react.useState({});
|
|
183
|
+
const [outcome, setOutcome] = react.useState({});
|
|
184
|
+
|
|
185
|
+
const run = react.useCallback(async () => {
|
|
186
|
+
setLoading(true);
|
|
187
|
+
setError(null);
|
|
188
|
+
try {
|
|
189
|
+
// Use the `scan` handed in by the tab registration instead of a
|
|
190
|
+
// second fetch here: one transport, one place to change it.
|
|
191
|
+
const data = await scan();
|
|
192
|
+
if (!data || data.ok !== true) throw new Error(data?.error || "scan failed");
|
|
193
|
+
setResult(data);
|
|
194
|
+
setOutcome({});
|
|
195
|
+
} catch (err) {
|
|
196
|
+
setError(String(err?.message ?? err));
|
|
197
|
+
} finally {
|
|
198
|
+
setLoading(false);
|
|
199
|
+
}
|
|
200
|
+
}, [scan]);
|
|
201
|
+
|
|
202
|
+
const doUpdate = react.useCallback(async (name, latest, registry) => {
|
|
203
|
+
// The runtime ('dsh-runtime') is not installed via pnpm and must not
|
|
204
|
+
// be updated with installBundle; its upgrade is delivered by the app's
|
|
205
|
+
// own updater. The compute below already hides the button for any
|
|
206
|
+
// non-"update-available" row, but guard anyway so a future change
|
|
207
|
+
// cannot route a runtime upgrade into installBundle.
|
|
208
|
+
if (name === "dsh-runtime") return;
|
|
209
|
+
setUpdating((s) => ({ ...s, [name]: true }));
|
|
210
|
+
setOutcome((s) => { const n = { ...s }; delete n[name]; return n; });
|
|
211
|
+
try {
|
|
212
|
+
const pm = props.remote?.pluginManager;
|
|
213
|
+
if (!pm?.installBundle) throw new Error("plugin manager service unavailable in this client");
|
|
214
|
+
// installBundle defaults to registry.npmjs.org, which is often
|
|
215
|
+
// unreachable here; probe the reachable registry first (mirrors
|
|
216
|
+
// the GUI's own fast-registry selection) and pass it explicitly.
|
|
217
|
+
// fastest() answers a Remote envelope { ok, value } — unwrap it;
|
|
218
|
+
// passing the envelope itself fails wire validation (registry
|
|
219
|
+
// must be null | string).
|
|
220
|
+
let reg = typeof registry === "string" && registry !== "" ? registry : "";
|
|
221
|
+
if (!reg) {
|
|
222
|
+
try {
|
|
223
|
+
const probe = await props.remote?.pluginRegistryProbe?.fastest();
|
|
224
|
+
if (probe?.ok && typeof probe.value === "string" && probe.value !== "") reg = probe.value;
|
|
225
|
+
} catch { /* keep empty */ }
|
|
226
|
+
}
|
|
227
|
+
// Pin to the exact latest version. A bare `name` spec leaves a
|
|
228
|
+
// profile that already pins the exact version (e.g. "0.6.5")
|
|
229
|
+
// unchanged, so installBundle reports a false "restart-required"
|
|
230
|
+
// without actually upgrading — leaving the old version after
|
|
231
|
+
// restart. `name@latest` forces pnpm to swap the version, exactly
|
|
232
|
+
// like the GUI's "Add plugin" dialog (which passes the resolved
|
|
233
|
+
// latest version as the spec).
|
|
234
|
+
const spec = typeof latest === "string" && latest !== "" ? `${name}@${latest}` : name;
|
|
235
|
+
const result = await pm.installBundle(spec, {
|
|
236
|
+
enabled: true,
|
|
237
|
+
requestId: `update-checker:${name}:${Date.now()}`,
|
|
238
|
+
...(reg ? { registry: reg } : {}),
|
|
239
|
+
});
|
|
240
|
+
if (!result?.ok) throw new Error(result?.error?.message || result?.error?.code || "install failed");
|
|
241
|
+
const value = result.value ?? {};
|
|
242
|
+
const application = value.application ?? "applied";
|
|
243
|
+
// `changed` is set by the host when the profile was actually
|
|
244
|
+
// modified (pnpm really swapped the version). installBundle may
|
|
245
|
+
// still report ok without changing anything (e.g. a bare spec
|
|
246
|
+
// against an already-pinned version), so surface that.
|
|
247
|
+
const changed = value.changed === true;
|
|
248
|
+
if (!changed) throw new Error("registry returned no newer version; profile unchanged");
|
|
249
|
+
setOutcome((s) => ({ ...s, [name]: { ok: true, restartRequired: application === "restart-required", application } }));
|
|
250
|
+
// When the change is live immediately, re-scan so the row flips
|
|
251
|
+
// to "up to date" instead of still showing "update available".
|
|
252
|
+
if (application !== "restart-required") { try { await run(); } catch { /* ignore */ } }
|
|
253
|
+
} catch (err) {
|
|
254
|
+
setOutcome((s) => ({ ...s, [name]: { ok: false, error: String(err?.message ?? err) } }));
|
|
255
|
+
} finally {
|
|
256
|
+
setUpdating((s) => ({ ...s, [name]: false }));
|
|
257
|
+
}
|
|
258
|
+
}, [run]);
|
|
259
|
+
|
|
260
|
+
const plugins = result?.plugins ?? [];
|
|
261
|
+
const incompatible = plugins.filter((p) => p.compatibility?.status === "incompatible").length;
|
|
262
|
+
const available = plugins.filter((p) => p.update?.status === "update-available").length;
|
|
263
|
+
|
|
264
|
+
return react_jsx_runtime.jsxs("div", {
|
|
265
|
+
style: { width: "100%", maxWidth: 760, display: "flex", flexDirection: "column", gap: 14, color: COLORS.label },
|
|
266
|
+
children: [
|
|
267
|
+
react_jsx_runtime.jsxs("div", {
|
|
268
|
+
style: { display: "flex", alignItems: "baseline", gap: 10, flexWrap: "wrap" },
|
|
269
|
+
children: [
|
|
270
|
+
react_jsx_runtime.jsx("h3", { style: { margin: 0, fontSize: 13, fontWeight: 600 }, children: t("dshVersion") }),
|
|
271
|
+
react_jsx_runtime.jsx("code", { style: { fontSize: 13, color: COLORS.tertiary }, children: result?.dshVersion ?? (loading ? "…" : "—") })
|
|
272
|
+
]
|
|
273
|
+
}),
|
|
274
|
+
react_jsx_runtime.jsxs("div", {
|
|
275
|
+
style: { display: "flex", alignItems: "center", gap: 12 },
|
|
276
|
+
children: [
|
|
277
|
+
react_jsx_runtime.jsx("button", {
|
|
278
|
+
type: "button",
|
|
279
|
+
disabled: loading,
|
|
280
|
+
onClick: () => run(),
|
|
281
|
+
style: {
|
|
282
|
+
appearance: "none",
|
|
283
|
+
cursor: loading ? "default" : "pointer",
|
|
284
|
+
border: "none",
|
|
285
|
+
borderRadius: "var(--dsw-radius-md)",
|
|
286
|
+
background: COLORS.primary,
|
|
287
|
+
color: "var(--dsw-alias-on-state-business, #fff)",
|
|
288
|
+
font: "inherit",
|
|
289
|
+
fontWeight: 600,
|
|
290
|
+
height: 36,
|
|
291
|
+
padding: "0 18px"
|
|
292
|
+
},
|
|
293
|
+
children: loading ? t("checking") : t("check")
|
|
294
|
+
}),
|
|
295
|
+
result ? react_jsx_runtime.jsx("span", { style: { fontSize: 13, color: COLORS.tertiary }, children: t("lastChecked") + ": " + new Date(result.checkedAt).toLocaleString() }) : null
|
|
296
|
+
]
|
|
297
|
+
}),
|
|
298
|
+
error ? react_jsx_runtime.jsxs("div", {
|
|
299
|
+
style: { display: "flex", alignItems: "center", gap: 10, color: COLORS.error, fontSize: 13 },
|
|
300
|
+
children: [
|
|
301
|
+
react_jsx_runtime.jsx("span", { children: t("error") + ": " + error }),
|
|
302
|
+
react_jsx_runtime.jsx("button", {
|
|
303
|
+
type: "button",
|
|
304
|
+
onClick: () => run(),
|
|
305
|
+
style: { border: "0.5px solid " + COLORS.border, borderRadius: "var(--dsw-radius-sm)", background: "transparent", color: COLORS.label, font: "inherit", cursor: "pointer", padding: "4px 10px" },
|
|
306
|
+
children: t("retry")
|
|
307
|
+
})
|
|
308
|
+
]
|
|
309
|
+
}) : null,
|
|
310
|
+
result && plugins.length > 0 ? react_jsx_runtime.jsx("p", {
|
|
311
|
+
style: { margin: 0, fontSize: 13, color: COLORS.tertiary },
|
|
312
|
+
children: t("summary", { total: plugins.length, incompatible, available })
|
|
313
|
+
}) : null,
|
|
314
|
+
!loading && result && plugins.length === 0 ? react_jsx_runtime.jsx("p", { style: { margin: 0, fontSize: 13, color: COLORS.tertiary }, children: t("noPlugins") }) : null,
|
|
315
|
+
Object.values(outcome).some((o) => o?.restartRequired === true) ? react_jsx_runtime.jsxs("div", {
|
|
316
|
+
style: { display: "flex", flexDirection: "column", gap: 6, border: "0.5px solid " + COLORS.warning, borderRadius: "var(--dsw-radius-md)", background: "color-mix(in srgb, " + COLORS.warning + " 10%, transparent)", padding: "10px 12px", fontSize: 13, color: COLORS.warning },
|
|
317
|
+
children: [
|
|
318
|
+
react_jsx_runtime.jsx("span", { style: { fontWeight: 600 }, children: t("restartRequired") }),
|
|
319
|
+
react_jsx_runtime.jsx("span", { style: { color: COLORS.label, fontSize: 12 }, children: t("manualRestart") })
|
|
320
|
+
]
|
|
321
|
+
}) : null,
|
|
322
|
+
result && plugins.length > 0 ? react_jsx_runtime.jsx("div", {
|
|
323
|
+
style: { display: "flex", flexDirection: "column", gap: 10 },
|
|
324
|
+
children: plugins.map((p) => react_jsx_runtime.jsxs("div", {
|
|
325
|
+
key: p.name,
|
|
326
|
+
style: { border: "0.5px solid " + COLORS.border, borderRadius: "var(--dsw-radius-md)", background: COLORS.bg, padding: "12px 14px", display: "flex", flexDirection: "column", gap: 8 },
|
|
327
|
+
children: [
|
|
328
|
+
react_jsx_runtime.jsxs("div", {
|
|
329
|
+
style: { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, flexWrap: "wrap" },
|
|
330
|
+
children: [
|
|
331
|
+
react_jsx_runtime.jsx("span", { style: { fontWeight: 600, fontSize: 13 }, children: p.name === "dsh-runtime" ? t("runtimeName") : p.name }),
|
|
332
|
+
react_jsx_runtime.jsxs("div", { style: { display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }, children: [compatBadge(p.compatibility, t), updateBadge(p.update, t), (() => {
|
|
333
|
+
if (p.linked) return badge(t("linked"), COLORS.tertiary);
|
|
334
|
+
// The runtime is updated via the app's own updater, never
|
|
335
|
+
// installBundle; show a "check the menu" badge, not a button.
|
|
336
|
+
if (p.name === "dsh-runtime" && p.update?.status === "update-available") {
|
|
337
|
+
return react_jsx_runtime.jsx("span", { style: { fontSize: 12, color: COLORS.warning }, children: t("runtimeUpdateAvailable") });
|
|
338
|
+
}
|
|
339
|
+
if (p.update?.status !== "update-available") return null;
|
|
340
|
+
const busy = updating[p.name] === true;
|
|
341
|
+
const o = outcome[p.name];
|
|
342
|
+
if (o?.ok) return badge(o.restartRequired ? t("restartRequired") : t("updated"), COLORS.ok);
|
|
343
|
+
return react_jsx_runtime.jsx("button", {
|
|
344
|
+
type: "button",
|
|
345
|
+
disabled: busy,
|
|
346
|
+
onClick: () => doUpdate(p.name, p.update?.latest, p.update?.registry ?? p.registry),
|
|
347
|
+
style: { appearance: "none", cursor: busy ? "default" : "pointer", border: "none", borderRadius: "var(--dsw-radius-sm)", background: COLORS.warning, color: "#fff", font: "inherit", fontWeight: 600, fontSize: 12, height: 24, padding: "0 12px" },
|
|
348
|
+
children: busy ? t("updating") : t("updateBtn")
|
|
349
|
+
});
|
|
350
|
+
})()] })
|
|
351
|
+
]
|
|
352
|
+
}),
|
|
353
|
+
react_jsx_runtime.jsxs("div", {
|
|
354
|
+
style: { display: "flex", gap: 16, fontSize: 12, color: COLORS.tertiary, flexWrap: "wrap" },
|
|
355
|
+
children: [
|
|
356
|
+
react_jsx_runtime.jsxs("span", { children: [t("col.installed") + ": ", react_jsx_runtime.jsx("code", { children: p.installedVersion ?? "—" })] }),
|
|
357
|
+
react_jsx_runtime.jsxs("span", { children: [t("col.latest") + ": ", react_jsx_runtime.jsx("code", { children: p.linked ? t("linked") : (p.update?.latest ?? "—") })] })
|
|
358
|
+
]
|
|
359
|
+
}),
|
|
360
|
+
p.compatibility?.status === "incompatible" && p.compatibility.peers ? react_jsx_runtime.jsxs("div", {
|
|
361
|
+
style: { fontSize: 12, color: COLORS.error },
|
|
362
|
+
children: [
|
|
363
|
+
react_jsx_runtime.jsx("span", { children: t("compat.peers") + ": " }),
|
|
364
|
+
react_jsx_runtime.jsxs("code", { children: Object.entries(p.compatibility.peers).map((e, _i) => react_jsx_runtime.jsx("div", { children: e[0] + " " + e[1] }, e[0])) })
|
|
365
|
+
]
|
|
366
|
+
}) : null,
|
|
367
|
+
p.update?.status === "unknown" && p.update.error ? react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: COLORS.tertiary }, children: p.update.error }) : null,
|
|
368
|
+
p.update?.status === "unsupported" && p.update.error ? react_jsx_runtime.jsx("div", { style: { fontSize: 12, color: COLORS.tertiary }, children: p.update.error }) : null,
|
|
369
|
+
p.linked ? react_jsx_runtime.jsxs("div", {
|
|
370
|
+
style: { display: "flex", flexDirection: "column", gap: 4, fontSize: 12, color: COLORS.tertiary },
|
|
371
|
+
children: [
|
|
372
|
+
react_jsx_runtime.jsx("span", { children: t("linkedHint") }),
|
|
373
|
+
react_jsx_runtime.jsx("span", { children: [t("sourcePath") + ": ", react_jsx_runtime.jsx("code", { children: p.source?.dir ?? "—" })] }),
|
|
374
|
+
p.source?.link ? react_jsx_runtime.jsx("a", {
|
|
375
|
+
href: p.source.link,
|
|
376
|
+
target: "_blank",
|
|
377
|
+
rel: "noreferrer",
|
|
378
|
+
style: { color: COLORS.link },
|
|
379
|
+
children: t("sourceLink")
|
|
380
|
+
}) : null
|
|
381
|
+
]
|
|
382
|
+
}) : null,
|
|
383
|
+
outcome[p.name]?.ok === false ? react_jsx_runtime.jsxs("div", { style: { fontSize: 12, color: COLORS.error }, children: [react_jsx_runtime.jsx("span", { children: t("updateFailed") + ": " }), react_jsx_runtime.jsx("span", { children: outcome[p.name].error || "unknown error" })] }) : null,
|
|
384
|
+
p.name === "dsh-runtime" && p.update?.status === "update-available" ? react_jsx_runtime.jsx("div", {
|
|
385
|
+
style: { fontSize: 12, color: COLORS.warning },
|
|
386
|
+
children: t("runtimeUpdateHint")
|
|
387
|
+
}) : null
|
|
388
|
+
]
|
|
389
|
+
}, p.name))
|
|
390
|
+
}) : null
|
|
391
|
+
]
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
//#region plugin body
|
|
396
|
+
/** Services required by the Settings registration + the update action. */
|
|
397
|
+
const inject = [
|
|
398
|
+
"slots",
|
|
399
|
+
"locale",
|
|
400
|
+
"modules",
|
|
401
|
+
"remote",
|
|
402
|
+
"remote.pluginManager",
|
|
403
|
+
"remote.pluginRegistryProbe"
|
|
404
|
+
];
|
|
405
|
+
|
|
406
|
+
/** Register the check-updates tab into the Built-in plugins settings section. */
|
|
407
|
+
function apply(ctx) {
|
|
408
|
+
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "plugin-update-checker: dictionaries");
|
|
409
|
+
const t = ctx.locale.bind(NS);
|
|
410
|
+
const scan = async () => {
|
|
411
|
+
const res = await fetch("/api/plugin-update-checker/scan", { method: "POST" });
|
|
412
|
+
return res.json();
|
|
413
|
+
};
|
|
414
|
+
ctx.slots.inject("settings.plugins.tab", () => ctx.slots.register({
|
|
415
|
+
name: "settings.plugins.tab",
|
|
416
|
+
id: "update-checker",
|
|
417
|
+
order: 20,
|
|
418
|
+
label: () => t("tab"),
|
|
419
|
+
locale: NS,
|
|
420
|
+
inject: () => ({ t, scan, remote: ctx.remote })
|
|
421
|
+
}, UpdateCheckerTab), "plugin-update-checker: tab");
|
|
422
|
+
}
|
|
423
|
+
//#endregion
|
|
424
|
+
|
|
425
|
+
exports.apply = apply;
|
|
426
|
+
exports.inject = inject;
|
|
427
|
+
return module.exports;
|
|
428
|
+
}
|
|
429
|
+
});
|
package/lib/host/dsh.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-update-checker — host/dsh.js
|
|
3
|
+
*
|
|
4
|
+
* 由 lib/host/update-checker.js 按下述职责拆出(函数体逐字保留):
|
|
5
|
+
* require, isRuntimePackage, getDshRuntimeVersion, evaluatePluginCompatibility, readProfileVersionExemptions
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs'
|
|
8
|
+
import { dirname, join } from 'node:path'
|
|
9
|
+
import { fileURLToPath } from 'node:url'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { default as semver } from 'semver'
|
|
12
|
+
import { activeProfileDir } from './profile.js'
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether a bundle name belongs to the DSH *runtime* itself (as opposed to a
|
|
18
|
+
* user-installed plugin).
|
|
19
|
+
*
|
|
20
|
+
* Every @deepseek-ai/dsh* package is part of the runtime the launcher boots
|
|
21
|
+
* (dsh-base, dsh-web-app, dsh-app-boot, ...). The launcher symlinks them (it
|
|
22
|
+
* does not copy them) into the shared profiles tree from the runtime install,
|
|
23
|
+
* so the bundle scanner must NOT treat them as user source-links or as
|
|
24
|
+
* independently updatable plugins. The runtime carries a single version, which
|
|
25
|
+
* is checked as one unit (see checkDshRuntime), not bundle by bundle.
|
|
26
|
+
*/
|
|
27
|
+
export function isRuntimePackage(name) {
|
|
28
|
+
return name === '@deepseek-ai/dsh' || (typeof name === 'string' && name.startsWith('@deepseek-ai/dsh-'))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Running DSH runtime version.
|
|
33
|
+
*
|
|
34
|
+
* The launcher ships "@deepseek-ai/dsh-app-boot" whose own "version" equals the
|
|
35
|
+
* running runtime. We read that JSON (never execute the module, because a
|
|
36
|
+
* link-installed plugin cannot resolve "@deepseek-ai/*" from its own tree).
|
|
37
|
+
*
|
|
38
|
+
* Resolution strategy, most to least reliable:
|
|
39
|
+
* 1. require.resolve("@deepseek-ai/dsh-app-boot/package.json") - works when the
|
|
40
|
+
* plugin is hoisted into the profile tree (the normal link case).
|
|
41
|
+
* 2. Walk UP from this file's real path's directory ancestors for
|
|
42
|
+
* node_modules/@deepseek-ai/dsh-app-boot/package.json. Note we must convert
|
|
43
|
+
* import.meta.url (a file:// URL) with fileURLToPath first, otherwise the
|
|
44
|
+
* ancestor walk starts from a "file://..." string and never matches.
|
|
45
|
+
* 3. Walk UP from the active profile's node_modules/@deepseek-ai/dsh-app-boot/
|
|
46
|
+
* package.json. The profile's shared tree is a DSH-maintained symlink that
|
|
47
|
+
* always points at the current runtime, so this is a reliable fallback for
|
|
48
|
+
* link:-installed plugins whose real path lives outside the profile tree.
|
|
49
|
+
* 4. DSH_VERSION env (some launchers set it).
|
|
50
|
+
*/
|
|
51
|
+
export function getDshRuntimeVersion() {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(readFileSync(require.resolve('@deepseek-ai/dsh-app-boot/package.json'), 'utf8')).version
|
|
54
|
+
} catch { /* fall through */ }
|
|
55
|
+
|
|
56
|
+
const anchors = [
|
|
57
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
58
|
+
activeProfileDir(),
|
|
59
|
+
]
|
|
60
|
+
for (const start of anchors) {
|
|
61
|
+
let dir = start
|
|
62
|
+
for (;;) {
|
|
63
|
+
const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh-app-boot', 'package.json')
|
|
64
|
+
if (existsSync(candidate)) {
|
|
65
|
+
try { return JSON.parse(readFileSync(candidate, 'utf8')).version } catch { /* keep walking */ }
|
|
66
|
+
}
|
|
67
|
+
const parent = dirname(dir)
|
|
68
|
+
if (parent === dir) break
|
|
69
|
+
dir = parent
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return process.env.DSH_VERSION || '0.0.0'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reimplementation of @deepseek-ai/dsh-app-boot's evaluatePluginCompatibility
|
|
77
|
+
* (read-only, no shared-state side effects). Returns undefined when compatible,
|
|
78
|
+
* otherwise the { name, version, runtimeVersion, peers, exempted } shape the
|
|
79
|
+
* browser half renders.
|
|
80
|
+
*/
|
|
81
|
+
export function evaluatePluginCompatibility(manifest, exemptions, runtimeVersion) {
|
|
82
|
+
if (!manifest) return undefined
|
|
83
|
+
|
|
84
|
+
// engines.dsh decides ALONE: when it is present the launcher ignores
|
|
85
|
+
// peerDependencies entirely (dsh-app-boot's unmetRequirements returns the
|
|
86
|
+
// engines verdict and never reaches the peer loop). Mirroring only the peer
|
|
87
|
+
// branch made this checker disagree with the launcher for any plugin that
|
|
88
|
+
// moved its requirement onto engines.dsh - it reported "compatible" for a
|
|
89
|
+
// bundle the launcher would skip.
|
|
90
|
+
const engines = manifest.engines
|
|
91
|
+
if (engines !== null && typeof engines === 'object'
|
|
92
|
+
&& Object.prototype.hasOwnProperty.call(engines, 'dsh')) {
|
|
93
|
+
const range = engines.dsh
|
|
94
|
+
if (typeof range !== 'string') throw new Error('engines.dsh must be a string')
|
|
95
|
+
const met = range.trim() !== ''
|
|
96
|
+
&& semver.satisfies(runtimeVersion, range, { includePrerelease: true })
|
|
97
|
+
if (met) return undefined
|
|
98
|
+
return {
|
|
99
|
+
name: manifest.name,
|
|
100
|
+
version: manifest.version,
|
|
101
|
+
runtimeVersion,
|
|
102
|
+
peers: { 'engines.dsh': range },
|
|
103
|
+
exempted: false,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!Object.prototype.hasOwnProperty.call(manifest, 'peerDependencies')) return undefined
|
|
108
|
+
const dependencies = manifest.peerDependencies
|
|
109
|
+
const peers = {}
|
|
110
|
+
for (const [name, range] of Object.entries(dependencies)) {
|
|
111
|
+
if (typeof range !== 'string') throw new Error(`peerDependencies[${JSON.stringify(name)}] must be a string`)
|
|
112
|
+
if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue
|
|
113
|
+
const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range) ? runtimeVersion : range
|
|
114
|
+
if (requirement.trim() === '' || !semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) {
|
|
115
|
+
peers[name] = range
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Object.keys(peers).length === 0) return undefined
|
|
119
|
+
const name = manifest.name
|
|
120
|
+
const version = manifest.version
|
|
121
|
+
const key = `${name}@${version}`
|
|
122
|
+
return {
|
|
123
|
+
name,
|
|
124
|
+
version,
|
|
125
|
+
runtimeVersion,
|
|
126
|
+
peers,
|
|
127
|
+
exempted: (Object.prototype.hasOwnProperty.call(exemptions, key) ? exemptions[key] : undefined)?.includes(runtimeVersion) === true,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read the profile's version-exemption map from compatibility.json. */
|
|
132
|
+
export function readProfileVersionExemptions(profileDir) {
|
|
133
|
+
try {
|
|
134
|
+
const raw = JSON.parse(readFileSync(join(profileDir, 'compatibility.json'), 'utf8'))
|
|
135
|
+
return raw?.exemptions ?? {}
|
|
136
|
+
} catch {
|
|
137
|
+
return {}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The profile directory the launcher passed on the command line, when we can
|
|
143
|
+
* recognize it.
|
|
144
|
+
*
|
|
145
|
+
* The Electron desktop host does NOT export DSH_PROFILE into the host process
|
|
146
|
+
* (only DSH_CLIENT_VERSION), so the old `process.env.DSH_PROFILE || 'web'`
|
|
147
|
+
* fell back to 'web' and the scan silently reported the *web* profile's
|
|
148
|
+
* plugins while the app was actually running *desktop*. The launcher does pass
|
|
149
|
+
* the profile as an absolute path in argv (e.g.
|
|
150
|
+
* DeepSeek Harness --expose-internals <host.js> <app.asar/dsh>
|
|
151
|
+
* /Users/me/.dsh/profiles/desktop <runtime> ...),
|
|
152
|
+
* so argv is the authoritative source: it is the same value the launcher used
|
|
153
|
+
* to boot the runtime, not a guess.
|
|
154
|
+
*
|
|
155
|
+
* We only accept a candidate that (a) is absolute, (b) is not a flag, and
|
|
156
|
+
* (c) sits directly under a `profiles` directory — so runtime, app.asar and
|
|
157
|
+
* host.js paths never match. Returns undefined when nothing matches.
|
|
158
|
+
*/
|