@dshtrading/client-ui-updater 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +75 -0
- package/lib/client/contract.d.ts +15 -0
- package/lib/client/locales.d.ts +6 -0
- package/lib/client/locales.js +61 -0
- package/lib/client.js +465 -0
- package/lib/client.js.map +1 -0
- package/lib/environment.d.ts +30 -0
- package/lib/environment.js +83 -0
- package/lib/github.d.ts +13 -0
- package/lib/github.js +85 -0
- package/lib/index.d.ts +18 -0
- package/lib/index.js +101 -0
- package/lib/semver.js +36 -0
- package/lib/updater-service.d.ts +127 -0
- package/lib/updater-service.js +497 -0
- package/package.json +63 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { fetchLatestRelease, manifestAssetName, parseUpdateManifest, payloadAssetName } from "./github.js";
|
|
2
|
+
import { isNewerVersion } from "./semver.js";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { unzipSync } from "fflate";
|
|
7
|
+
//#region src/updater-service.ts
|
|
8
|
+
/**
|
|
9
|
+
* Updater state machine: check → apply → done, plus the wire snapshot the
|
|
10
|
+
* /dshtrading/api/updater routes and the settings page both consume.
|
|
11
|
+
*
|
|
12
|
+
* 增量更新设计(Agent Note: 2026-09-04-auto-update-plugin):
|
|
13
|
+
* - 检测:GitHub /releases/latest(自动排除 draft/prerelease),tag vX.Y.Z 与
|
|
14
|
+
* @dshtrading/* 家族版本(changesets fixed 组,本包自身版本即家族版本)比较。
|
|
15
|
+
* - 增量通道:发布资产两件——trust anchor 清单 updates-manifest-v<tag>.json
|
|
16
|
+
* (记录 payload zip 的 sha256/bytes)+ payload zip(packages/@dshtrading/<pkg>/
|
|
17
|
+
* 展开目录,非 tarball——宿主侧零 tar 解析,只走 fflate unzip)。
|
|
18
|
+
* - 应用:下载 zip → sha256 校验 → 解压进 profile 内 staging → 逐包版本比对 →
|
|
19
|
+
* backup rename → swap → 版本回读验证;Windows 文件占用按 EPERM/EBUSY 重试;
|
|
20
|
+
* 任一包失败整体回滚(备份移回)。新代码在宿主重启后生效(桌面壳重启宿主)。
|
|
21
|
+
* - 边界:只覆盖 @dshtrading/* 插件家族(profile 内 node_modules);Electron/Node/
|
|
22
|
+
* dsh 宿主闭包属于完整安装包通道,reseed 语义天然覆盖(新版本安装包 stamp 变更
|
|
23
|
+
* → 下次启动 reseed 回捆绑 payload)。
|
|
24
|
+
*/
|
|
25
|
+
/** Business error with a stable code (routes map it onto HTTP payloads). */
|
|
26
|
+
var UpdaterError = class extends Error {
|
|
27
|
+
code;
|
|
28
|
+
constructor(code, message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.code = code;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
const AUTO_CHECK_MS_DEFAULT = 216e5;
|
|
34
|
+
const INITIAL_DELAY_MS_DEFAULT = 15e3;
|
|
35
|
+
const CHECK_TTL_MS_DEFAULT = 18e5;
|
|
36
|
+
/** Windows rename retries for files/dirs briefly held open (AV scans etc.). */
|
|
37
|
+
const SWAP_RETRY_ATTEMPTS = 4;
|
|
38
|
+
const SWAP_RETRY_DELAY_MS = 300;
|
|
39
|
+
const applyIdle = () => ({
|
|
40
|
+
phase: "idle",
|
|
41
|
+
step: void 0,
|
|
42
|
+
startedAt: void 0,
|
|
43
|
+
finishedAt: void 0,
|
|
44
|
+
targetVersion: void 0,
|
|
45
|
+
updated: [],
|
|
46
|
+
skippedCount: 0,
|
|
47
|
+
error: void 0
|
|
48
|
+
});
|
|
49
|
+
function sha256Hex(data) {
|
|
50
|
+
return createHash("sha256").update(data).digest("hex");
|
|
51
|
+
}
|
|
52
|
+
function sleep(ms) {
|
|
53
|
+
return new Promise((resolvePromise) => {
|
|
54
|
+
setTimeout(resolvePromise, ms);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** rename with bounded retries (Windows briefly holds loaded plugin files). */
|
|
58
|
+
async function renameWithRetry(from, to, log) {
|
|
59
|
+
let lastError;
|
|
60
|
+
for (let attempt = 1; attempt <= SWAP_RETRY_ATTEMPTS; attempt += 1) try {
|
|
61
|
+
fs.renameSync(from, to);
|
|
62
|
+
return;
|
|
63
|
+
} catch (error) {
|
|
64
|
+
lastError = error;
|
|
65
|
+
const code = error.code;
|
|
66
|
+
if (!(code === "EPERM" || code === "EBUSY" || code === "ENOTEMPTY" || code === "EACCES") || attempt === SWAP_RETRY_ATTEMPTS) break;
|
|
67
|
+
log(`rename ${path.basename(from)} -> ${path.basename(to)} retry ${attempt}/3 (${code})`);
|
|
68
|
+
await sleep(SWAP_RETRY_DELAY_MS * attempt);
|
|
69
|
+
}
|
|
70
|
+
throw lastError;
|
|
71
|
+
}
|
|
72
|
+
function rmRf(target) {
|
|
73
|
+
fs.rmSync(target, {
|
|
74
|
+
recursive: true,
|
|
75
|
+
force: true
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Zip entry path safety: no absolute paths, no .. segments (zip-slip guard). */
|
|
79
|
+
function safeZipEntryName(name) {
|
|
80
|
+
const normalized = name.replaceAll("\\", "/");
|
|
81
|
+
if (normalized.startsWith("/") || normalized.includes("../") || normalized === ".." || normalized.includes("/..")) return;
|
|
82
|
+
return normalized;
|
|
83
|
+
}
|
|
84
|
+
var UpdaterService = class {
|
|
85
|
+
options;
|
|
86
|
+
env;
|
|
87
|
+
now;
|
|
88
|
+
autoCheckMs;
|
|
89
|
+
initialDelayMs;
|
|
90
|
+
checkTtlMs;
|
|
91
|
+
log;
|
|
92
|
+
checkState;
|
|
93
|
+
applyState = applyIdle();
|
|
94
|
+
progress = void 0;
|
|
95
|
+
timers = [];
|
|
96
|
+
checkInFlight = false;
|
|
97
|
+
applyInFlight = false;
|
|
98
|
+
disposed = false;
|
|
99
|
+
constructor(options) {
|
|
100
|
+
this.options = options;
|
|
101
|
+
this.env = options.env;
|
|
102
|
+
this.now = options.now ?? (() => Date.now());
|
|
103
|
+
this.autoCheckMs = options.autoCheckMs ?? AUTO_CHECK_MS_DEFAULT;
|
|
104
|
+
this.initialDelayMs = options.initialDelayMs ?? INITIAL_DELAY_MS_DEFAULT;
|
|
105
|
+
this.checkTtlMs = options.checkTtlMs ?? CHECK_TTL_MS_DEFAULT;
|
|
106
|
+
this.log = options.logger ?? ((line) => {
|
|
107
|
+
console.log("[dshtrading-updater]", line);
|
|
108
|
+
});
|
|
109
|
+
this.checkState = this.loadPersistedCheck();
|
|
110
|
+
}
|
|
111
|
+
/** Load persisted check state + schedule auto checks. Idempotent. */
|
|
112
|
+
start() {
|
|
113
|
+
if (this.disposed || this.timers.length > 0) return;
|
|
114
|
+
if (this.env.supported) {
|
|
115
|
+
const initialTimer = setTimeout(() => {
|
|
116
|
+
this.check(false).catch(() => void 0);
|
|
117
|
+
}, this.initialDelayMs);
|
|
118
|
+
const intervalTimer = setInterval(() => {
|
|
119
|
+
this.check(false).catch(() => void 0);
|
|
120
|
+
}, this.autoCheckMs);
|
|
121
|
+
initialTimer.unref?.();
|
|
122
|
+
intervalTimer.unref?.();
|
|
123
|
+
this.timers.push(initialTimer, intervalTimer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
dispose() {
|
|
127
|
+
this.disposed = true;
|
|
128
|
+
for (const timer of this.timers.splice(0)) {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
clearInterval(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Check GitHub for a newer stable release. Non-forced calls honor the TTL
|
|
135
|
+
* and double as the periodic tick; force=true bypasses the cache (UI button).
|
|
136
|
+
*/
|
|
137
|
+
async check(force) {
|
|
138
|
+
if (!force && this.checkFresh()) return this.snapshot();
|
|
139
|
+
if (this.checkInFlight) return this.snapshot();
|
|
140
|
+
this.checkInFlight = true;
|
|
141
|
+
try {
|
|
142
|
+
const release = await fetchLatestRelease({
|
|
143
|
+
repo: this.options.repo,
|
|
144
|
+
...this.options.github?.apiBase === void 0 ? {} : { apiBase: this.options.github.apiBase },
|
|
145
|
+
...this.options.github?.fetchImpl === void 0 ? {} : { fetchImpl: this.options.github.fetchImpl },
|
|
146
|
+
...this.options.github?.timeoutMs === void 0 ? {} : { timeoutMs: this.options.github.timeoutMs }
|
|
147
|
+
});
|
|
148
|
+
const latest = release === void 0 ? void 0 : this.latestOf(release);
|
|
149
|
+
const available = latest?.version !== void 0 && isNewerVersion(latest.version, this.env.familyVersion);
|
|
150
|
+
this.checkState = {
|
|
151
|
+
at: new Date(this.now()).toISOString(),
|
|
152
|
+
status: "ok",
|
|
153
|
+
error: void 0,
|
|
154
|
+
available,
|
|
155
|
+
latest
|
|
156
|
+
};
|
|
157
|
+
this.persistCheck();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
this.checkState = {
|
|
160
|
+
at: new Date(this.now()).toISOString(),
|
|
161
|
+
status: "error",
|
|
162
|
+
error: error instanceof Error ? error.message : String(error),
|
|
163
|
+
available: this.checkState.available,
|
|
164
|
+
latest: this.checkState.latest
|
|
165
|
+
};
|
|
166
|
+
this.persistCheck();
|
|
167
|
+
this.log("check failed: " + (error instanceof Error ? error.message : String(error)));
|
|
168
|
+
} finally {
|
|
169
|
+
this.checkInFlight = false;
|
|
170
|
+
}
|
|
171
|
+
return this.snapshot();
|
|
172
|
+
}
|
|
173
|
+
checkFresh() {
|
|
174
|
+
if (this.checkState.status !== "ok" || this.checkState.at === void 0) return false;
|
|
175
|
+
const at = Date.parse(this.checkState.at);
|
|
176
|
+
if (!Number.isFinite(at)) return false;
|
|
177
|
+
return this.now() - at < this.checkTtlMs;
|
|
178
|
+
}
|
|
179
|
+
latestOf(release) {
|
|
180
|
+
const hasManifest = release.assets.some((asset) => asset.name === manifestAssetName(release.tagName));
|
|
181
|
+
const hasPayload = release.assets.some((asset) => asset.name === payloadAssetName(release.tagName));
|
|
182
|
+
return {
|
|
183
|
+
version: release.version ?? "",
|
|
184
|
+
tagName: release.tagName,
|
|
185
|
+
name: release.name,
|
|
186
|
+
notes: release.notes,
|
|
187
|
+
url: release.url,
|
|
188
|
+
publishedAt: release.publishedAt,
|
|
189
|
+
payloadAvailable: hasManifest && hasPayload
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Kick off the incremental apply pipeline. Returns immediately with the
|
|
194
|
+
* running snapshot; the UI polls state. Single-flight: a second call while
|
|
195
|
+
* running throws UPDATER_BUSY.
|
|
196
|
+
*/
|
|
197
|
+
async apply() {
|
|
198
|
+
if (this.applyInFlight || this.applyState.phase === "running") throw new UpdaterError("UPDATER_BUSY", "an update is already in progress");
|
|
199
|
+
if (!this.env.supported || this.env.profileRoot === void 0 || this.env.packagesRoot === void 0) throw new UpdaterError("UPDATER_UNSUPPORTED", "incremental update requires a desktop-seeded profile");
|
|
200
|
+
if (this.checkState.status !== "ok" || this.checkState.available !== true || this.checkState.latest === void 0) throw new UpdaterError("UPDATER_NO_UPDATE", "no newer release is available");
|
|
201
|
+
if (this.checkState.latest.payloadAvailable !== true) throw new UpdaterError("UPDATER_PAYLOAD_UNAVAILABLE", "this release does not carry the incremental payload");
|
|
202
|
+
this.applyInFlight = true;
|
|
203
|
+
const target = this.checkState.latest;
|
|
204
|
+
this.applyState = {
|
|
205
|
+
phase: "running",
|
|
206
|
+
step: "prepare",
|
|
207
|
+
startedAt: new Date(this.now()).toISOString(),
|
|
208
|
+
finishedAt: void 0,
|
|
209
|
+
targetVersion: target.version,
|
|
210
|
+
updated: [],
|
|
211
|
+
skippedCount: 0,
|
|
212
|
+
error: void 0
|
|
213
|
+
};
|
|
214
|
+
this.progress = void 0;
|
|
215
|
+
this.runApply(target).catch((error) => {
|
|
216
|
+
const message = error instanceof UpdaterError ? `${error.code}: ${error.message}` : error instanceof Error ? error.message : String(error);
|
|
217
|
+
this.log("apply failed: " + message);
|
|
218
|
+
this.applyState = {
|
|
219
|
+
...this.applyState,
|
|
220
|
+
phase: "error",
|
|
221
|
+
step: void 0,
|
|
222
|
+
finishedAt: new Date(this.now()).toISOString(),
|
|
223
|
+
error: message
|
|
224
|
+
};
|
|
225
|
+
}).finally(() => {
|
|
226
|
+
this.applyInFlight = false;
|
|
227
|
+
this.progress = void 0;
|
|
228
|
+
});
|
|
229
|
+
return this.snapshot();
|
|
230
|
+
}
|
|
231
|
+
/** The full pipeline; only called via apply() (single-flight owner). */
|
|
232
|
+
async runApply(target) {
|
|
233
|
+
const profileRoot = this.env.profileRoot;
|
|
234
|
+
const packagesRoot = this.env.packagesRoot;
|
|
235
|
+
if (profileRoot === void 0 || packagesRoot === void 0) throw new UpdaterError("UPDATER_UNSUPPORTED", "incremental update requires a desktop-seeded profile");
|
|
236
|
+
this.applyState = {
|
|
237
|
+
...this.applyState,
|
|
238
|
+
step: "prepare"
|
|
239
|
+
};
|
|
240
|
+
const release = await fetchLatestRelease({
|
|
241
|
+
repo: this.options.repo,
|
|
242
|
+
...this.options.github?.apiBase === void 0 ? {} : { apiBase: this.options.github.apiBase },
|
|
243
|
+
...this.options.github?.fetchImpl === void 0 ? {} : { fetchImpl: this.options.github.fetchImpl },
|
|
244
|
+
...this.options.github?.timeoutMs === void 0 ? {} : { timeoutMs: this.options.github.timeoutMs }
|
|
245
|
+
});
|
|
246
|
+
if (release === void 0 || release.tagName !== target.tagName) throw new UpdaterError("UPDATER_STALE_TARGET", "the target release changed; check again");
|
|
247
|
+
const manifestAsset = release.assets.find((asset) => asset.name === manifestAssetName(release.tagName));
|
|
248
|
+
if (manifestAsset === void 0) throw new UpdaterError("UPDATER_PAYLOAD_UNAVAILABLE", "update manifest asset missing");
|
|
249
|
+
const fetchImpl = this.options.github?.fetchImpl ?? fetch;
|
|
250
|
+
const manifest = parseUpdateManifest(await this.downloadText(fetchImpl, manifestAsset.url));
|
|
251
|
+
if (manifest.version !== target.version || manifest.tag !== target.tagName) throw new UpdaterError("UPDATER_MANIFEST_MISMATCH", "manifest does not match the target release");
|
|
252
|
+
const payloadAsset = release.assets.find((asset) => asset.name === manifest.payload.file);
|
|
253
|
+
if (payloadAsset === void 0) throw new UpdaterError("UPDATER_PAYLOAD_UNAVAILABLE", "payload zip asset missing");
|
|
254
|
+
this.applyState = {
|
|
255
|
+
...this.applyState,
|
|
256
|
+
step: "download"
|
|
257
|
+
};
|
|
258
|
+
const zipBytes = await this.downloadPayload(fetchImpl, payloadAsset, manifest);
|
|
259
|
+
this.log(`payload downloaded: ${zipBytes.length} bytes, sha256 ok`);
|
|
260
|
+
this.applyState = {
|
|
261
|
+
...this.applyState,
|
|
262
|
+
step: "verify"
|
|
263
|
+
};
|
|
264
|
+
this.progress = {
|
|
265
|
+
step: "verify",
|
|
266
|
+
bytes: void 0,
|
|
267
|
+
total: void 0,
|
|
268
|
+
percent: void 0
|
|
269
|
+
};
|
|
270
|
+
const staged = this.stagePackages(zipBytes, manifest, profileRoot);
|
|
271
|
+
this.applyState = {
|
|
272
|
+
...this.applyState,
|
|
273
|
+
step: "install"
|
|
274
|
+
};
|
|
275
|
+
const updated = [];
|
|
276
|
+
let skippedCount = 0;
|
|
277
|
+
const swapped = [];
|
|
278
|
+
try {
|
|
279
|
+
for (const pkg of staged) {
|
|
280
|
+
const bareName = pkg.name.startsWith("@dshtrading/") ? pkg.name.slice(12) : pkg.name;
|
|
281
|
+
const targetDir = path.join(packagesRoot, bareName);
|
|
282
|
+
const installedVersion = this.installedVersionOf(targetDir);
|
|
283
|
+
if (installedVersion === pkg.version) {
|
|
284
|
+
skippedCount += 1;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
const backupDir = targetDir + ".updater-bak";
|
|
288
|
+
rmRf(backupDir);
|
|
289
|
+
if (fs.existsSync(targetDir)) await renameWithRetry(targetDir, backupDir, this.log);
|
|
290
|
+
try {
|
|
291
|
+
await renameWithRetry(pkg.dir, targetDir, this.log);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (fs.existsSync(backupDir) && !fs.existsSync(targetDir)) await renameWithRetry(backupDir, targetDir, this.log).catch(() => void 0);
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
swapped.push({
|
|
297
|
+
backup: backupDir,
|
|
298
|
+
target: targetDir
|
|
299
|
+
});
|
|
300
|
+
if (this.installedVersionOf(targetDir) !== pkg.version) throw new UpdaterError("UPDATER_VERIFY_FAILED", `post-swap version mismatch for ${pkg.name}`);
|
|
301
|
+
updated.push(pkg.name);
|
|
302
|
+
this.log(`updated ${pkg.name}: ${installedVersion} -> ${pkg.version}`);
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
for (const swap of swapped.reverse()) {
|
|
306
|
+
rmRf(swap.target);
|
|
307
|
+
await renameWithRetry(swap.backup, swap.target, this.log).catch(() => void 0);
|
|
308
|
+
}
|
|
309
|
+
if (error instanceof UpdaterError) throw error;
|
|
310
|
+
throw new UpdaterError("UPDATER_INSTALL_FAILED", error instanceof Error ? error.message : String(error));
|
|
311
|
+
} finally {
|
|
312
|
+
rmRf(path.join(profileRoot, ".dshtrading-updater"));
|
|
313
|
+
}
|
|
314
|
+
for (const swap of swapped) rmRf(swap.backup);
|
|
315
|
+
this.applyState = {
|
|
316
|
+
phase: "done",
|
|
317
|
+
step: void 0,
|
|
318
|
+
startedAt: this.applyState.startedAt,
|
|
319
|
+
finishedAt: new Date(this.now()).toISOString(),
|
|
320
|
+
targetVersion: target.version,
|
|
321
|
+
updated,
|
|
322
|
+
skippedCount,
|
|
323
|
+
error: void 0
|
|
324
|
+
};
|
|
325
|
+
this.log(`update applied: ${updated.length} package(s) -> ${target.version} (restart to activate)`);
|
|
326
|
+
}
|
|
327
|
+
installedVersionOf(packageDir) {
|
|
328
|
+
try {
|
|
329
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
|
|
330
|
+
return typeof manifest.version === "string" ? manifest.version : "unknown";
|
|
331
|
+
} catch {
|
|
332
|
+
return "missing";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
/** Unzip into a staging dir under the profile and cross-check the manifest. */
|
|
336
|
+
stagePackages(zipBytes, manifest, profileRoot) {
|
|
337
|
+
let entries;
|
|
338
|
+
try {
|
|
339
|
+
entries = unzipSync(zipBytes);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", "payload zip unreadable: " + (error instanceof Error ? error.message : String(error)));
|
|
342
|
+
}
|
|
343
|
+
const byPackage = /* @__PURE__ */ new Map();
|
|
344
|
+
for (const [rawName, data] of Object.entries(entries)) {
|
|
345
|
+
if (rawName.endsWith("/")) continue;
|
|
346
|
+
const name = safeZipEntryName(rawName);
|
|
347
|
+
if (name === void 0) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `unsafe zip entry: ${rawName}`);
|
|
348
|
+
const segments = name.split("/");
|
|
349
|
+
if (segments[0] !== "packages" || segments[1] !== "@dshtrading" || segments.length < 4) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `unexpected zip entry: ${name}`);
|
|
350
|
+
const pkgName = `@dshtrading/${segments[2]}`;
|
|
351
|
+
const relativePath = segments.slice(3).join("/");
|
|
352
|
+
let files = byPackage.get(pkgName);
|
|
353
|
+
if (files === void 0) {
|
|
354
|
+
files = /* @__PURE__ */ new Map();
|
|
355
|
+
byPackage.set(pkgName, files);
|
|
356
|
+
}
|
|
357
|
+
files.set(relativePath, data);
|
|
358
|
+
}
|
|
359
|
+
for (const entry of manifest.packages) if (!byPackage.has(entry.name)) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `manifest package missing from payload: ${entry.name}`);
|
|
360
|
+
const stagingRoot = path.join(profileRoot, ".dshtrading-updater", `staging-${process.pid}-${this.now()}`);
|
|
361
|
+
const staged = [];
|
|
362
|
+
for (const [pkgName, files] of byPackage) {
|
|
363
|
+
const manifestEntry = manifest.packages.find((item) => item.name === pkgName);
|
|
364
|
+
if (manifestEntry === void 0) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `payload package missing from manifest: ${pkgName}`);
|
|
365
|
+
const packageJson = files.get("package.json");
|
|
366
|
+
if (packageJson === void 0) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `payload package has no package.json: ${pkgName}`);
|
|
367
|
+
let declaredVersion;
|
|
368
|
+
try {
|
|
369
|
+
declaredVersion = JSON.parse(new TextDecoder().decode(packageJson)).version;
|
|
370
|
+
} catch {
|
|
371
|
+
throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `payload package.json unreadable: ${pkgName}`);
|
|
372
|
+
}
|
|
373
|
+
if (declaredVersion !== manifestEntry.version) throw new UpdaterError("UPDATER_PAYLOAD_CORRUPT", `payload version mismatch for ${pkgName}`);
|
|
374
|
+
const dir = path.join(stagingRoot, ...pkgName.split("/"));
|
|
375
|
+
for (const [relativePath, data] of files) {
|
|
376
|
+
const file = path.join(dir, ...relativePath.split("/"));
|
|
377
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
378
|
+
fs.writeFileSync(file, data);
|
|
379
|
+
}
|
|
380
|
+
staged.push({
|
|
381
|
+
name: pkgName,
|
|
382
|
+
version: manifestEntry.version,
|
|
383
|
+
dir
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
staged.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
387
|
+
return staged;
|
|
388
|
+
}
|
|
389
|
+
/** Stream the payload zip with progress; verify sha256 + byte length. */
|
|
390
|
+
async downloadPayload(fetchImpl, asset, manifest) {
|
|
391
|
+
this.progress = {
|
|
392
|
+
step: "download",
|
|
393
|
+
bytes: 0,
|
|
394
|
+
total: asset.size > 0 ? asset.size : void 0,
|
|
395
|
+
percent: 0
|
|
396
|
+
};
|
|
397
|
+
const chunks = await this.downloadChunks(fetchImpl, asset.url, (bytes, total) => {
|
|
398
|
+
const knownTotal = total ?? (asset.size > 0 ? asset.size : void 0);
|
|
399
|
+
this.progress = {
|
|
400
|
+
step: "download",
|
|
401
|
+
bytes,
|
|
402
|
+
total: knownTotal,
|
|
403
|
+
percent: knownTotal !== void 0 && knownTotal > 0 ? Math.min(99, Math.round(bytes / knownTotal * 100)) : void 0
|
|
404
|
+
};
|
|
405
|
+
});
|
|
406
|
+
const payload = Buffer.concat(chunks);
|
|
407
|
+
if (manifest.payload.bytes > 0 && payload.length !== manifest.payload.bytes) throw new UpdaterError("UPDATER_SIZE_MISMATCH", `payload size ${payload.length} != manifest ${manifest.payload.bytes}`);
|
|
408
|
+
if (sha256Hex(payload) !== manifest.payload.sha256.toLowerCase()) throw new UpdaterError("UPDATER_HASH_MISMATCH", "payload sha256 mismatch");
|
|
409
|
+
return payload;
|
|
410
|
+
}
|
|
411
|
+
async downloadText(fetchImpl, url) {
|
|
412
|
+
const response = await fetchImpl(url, { headers: {
|
|
413
|
+
accept: "application/octet-stream",
|
|
414
|
+
"user-agent": "dsh-trading-updater"
|
|
415
|
+
} });
|
|
416
|
+
if (!response.ok) throw new UpdaterError("UPDATER_DOWNLOAD_FAILED", `${url} -> ${response.status}`);
|
|
417
|
+
return response.text();
|
|
418
|
+
}
|
|
419
|
+
async downloadChunks(fetchImpl, url, onProgress) {
|
|
420
|
+
const response = await fetchImpl(url, { headers: {
|
|
421
|
+
accept: "application/octet-stream",
|
|
422
|
+
"user-agent": "dsh-trading-updater"
|
|
423
|
+
} });
|
|
424
|
+
if (!response.ok || response.body === null) throw new UpdaterError("UPDATER_DOWNLOAD_FAILED", `${url} -> ${response.status}`);
|
|
425
|
+
const headerTotal = Number(response.headers.get("content-length") ?? "");
|
|
426
|
+
const total = Number.isFinite(headerTotal) && headerTotal > 0 ? headerTotal : void 0;
|
|
427
|
+
const reader = response.body.getReader();
|
|
428
|
+
const chunks = [];
|
|
429
|
+
let received = 0;
|
|
430
|
+
for (;;) {
|
|
431
|
+
const { done, value } = await reader.read();
|
|
432
|
+
if (done) break;
|
|
433
|
+
if (value !== void 0) {
|
|
434
|
+
chunks.push(Buffer.from(value));
|
|
435
|
+
received += value.byteLength;
|
|
436
|
+
onProgress(received, total);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return chunks;
|
|
440
|
+
}
|
|
441
|
+
snapshot() {
|
|
442
|
+
return {
|
|
443
|
+
environment: {
|
|
444
|
+
familyVersion: this.env.familyVersion,
|
|
445
|
+
appVersion: this.env.desktopMarker?.appVersion,
|
|
446
|
+
profileRoot: this.env.profileRoot,
|
|
447
|
+
supported: this.env.supported
|
|
448
|
+
},
|
|
449
|
+
check: this.checkState,
|
|
450
|
+
apply: this.applyState,
|
|
451
|
+
progress: this.applyState.phase === "running" ? this.progress : void 0
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
stateFile() {
|
|
455
|
+
const file = this.options.statePath;
|
|
456
|
+
return {
|
|
457
|
+
dir: path.dirname(file),
|
|
458
|
+
file
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
loadPersistedCheck() {
|
|
462
|
+
try {
|
|
463
|
+
const lastCheck = JSON.parse(fs.readFileSync(this.stateFile().file, "utf8")).lastCheck;
|
|
464
|
+
if (lastCheck !== void 0 && typeof lastCheck === "object" && "status" in lastCheck) return {
|
|
465
|
+
at: lastCheck.at,
|
|
466
|
+
status: lastCheck.status,
|
|
467
|
+
error: lastCheck.error,
|
|
468
|
+
available: lastCheck.available === true,
|
|
469
|
+
latest: lastCheck.latest
|
|
470
|
+
};
|
|
471
|
+
} catch {}
|
|
472
|
+
return {
|
|
473
|
+
at: void 0,
|
|
474
|
+
status: "idle",
|
|
475
|
+
error: void 0,
|
|
476
|
+
available: false,
|
|
477
|
+
latest: void 0
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
persistCheck() {
|
|
481
|
+
const { dir, file } = this.stateFile();
|
|
482
|
+
try {
|
|
483
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
484
|
+
const state = {
|
|
485
|
+
version: 1,
|
|
486
|
+
lastCheck: this.checkState
|
|
487
|
+
};
|
|
488
|
+
const tmp = file + ".tmp";
|
|
489
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n");
|
|
490
|
+
fs.renameSync(tmp, file);
|
|
491
|
+
} catch (error) {
|
|
492
|
+
this.log("state persist failed: " + (error instanceof Error ? error.message : String(error)));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
//#endregion
|
|
497
|
+
export { UpdaterError, UpdaterService };
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dshtrading/client-ui-updater",
|
|
3
|
+
"description": "dsh-trading auto-update plugin: GitHub Releases version check with release notes, incremental update of the @dshtrading/* plugin family inside a desktop-seeded profile (vendor payload zip, per-package swap), and the '软件更新' first-level settings section. Browser half registers the settings.section; node half registers the /dshtrading/api/updater routes and the update engine.",
|
|
4
|
+
"version": "0.1.2",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./client": {
|
|
14
|
+
"types": "./lib/types/client/index.d.ts",
|
|
15
|
+
"default": "./lib/client.js"
|
|
16
|
+
},
|
|
17
|
+
"./locales": {
|
|
18
|
+
"types": "./lib/client/locales.d.ts",
|
|
19
|
+
"default": "./lib/client/locales.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib"
|
|
25
|
+
],
|
|
26
|
+
"license": "PolyForm-Noncommercial-1.0.0",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"fflate": "^0.8.2"
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@deepseek-ai/cordis": ">=4.0.0",
|
|
32
|
+
"@deepseek-ai/dsh-client-locale": ">=0.1.2-rc.1",
|
|
33
|
+
"@deepseek-ai/dsh-client-ui-slots": ">=0.1.2-rc.1",
|
|
34
|
+
"react": "^18.2.0",
|
|
35
|
+
"@types/react": "~18.3.1"
|
|
36
|
+
},
|
|
37
|
+
"dsh": {
|
|
38
|
+
"client": {
|
|
39
|
+
"inject": [
|
|
40
|
+
"@deepseek-ai/dsh-client-locale",
|
|
41
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
42
|
+
],
|
|
43
|
+
"platform": "web"
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
48
|
+
"@deepseek-ai/dsh-client-locale": "0.1.2-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-client-ui-slots": "0.1.2-rc.1",
|
|
50
|
+
"@types/node": "^24.0.0",
|
|
51
|
+
"@types/react": "~18.3.1",
|
|
52
|
+
"lightningcss": "^1.0.0",
|
|
53
|
+
"react": "^18.2.0",
|
|
54
|
+
"tsdown": "^0.22.0",
|
|
55
|
+
"typescript": "^5.9.0",
|
|
56
|
+
"vitest": "^3.0.0"
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"bundle": "tsdown && tsdown --config tsdown.client.config.mjs",
|
|
60
|
+
"build": "tsdown && tsdown --config tsdown.client.config.mjs",
|
|
61
|
+
"test": "vitest run"
|
|
62
|
+
}
|
|
63
|
+
}
|