electron-updater-for-render 1.0.0

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.
@@ -0,0 +1,337 @@
1
+ // src/main/index.ts
2
+ import path from "path";
3
+ import fs from "original-fs";
4
+ import crypto from "crypto";
5
+ import { app, dialog } from "electron";
6
+ import { pipeline } from "stream/promises";
7
+ import { Readable, Transform } from "stream";
8
+ var RenderUpdater = class {
9
+ versionsDir;
10
+ currentVersionFile;
11
+ baseUrl;
12
+ activeVersion;
13
+ publicKey;
14
+ isDownloading = false;
15
+ autoDownload;
16
+ autoPrompt;
17
+ maxVersionsToKeep;
18
+ onUpdateAvailable;
19
+ onDownloadProgress;
20
+ onDownloadComplete;
21
+ onError;
22
+ onBeforeRestart;
23
+ constructor(options) {
24
+ this.versionsDir = options.versionsDir;
25
+ this.currentVersionFile = path.join(this.versionsDir, "current.json");
26
+ this.baseUrl = options.updateUrl;
27
+ this.publicKey = options.publicKey;
28
+ this.autoDownload = options.autoDownload ?? false;
29
+ this.autoPrompt = options.autoPrompt ?? true;
30
+ this.maxVersionsToKeep = options.maxVersionsToKeep ?? 2;
31
+ this.onUpdateAvailable = options.onUpdateAvailable;
32
+ this.onDownloadProgress = options.onDownloadProgress;
33
+ this.onDownloadComplete = options.onDownloadComplete;
34
+ this.onError = options.onError;
35
+ this.onBeforeRestart = options.onBeforeRestart;
36
+ if (!fs.existsSync(this.versionsDir)) {
37
+ fs.mkdirSync(this.versionsDir, { recursive: true });
38
+ }
39
+ this.activeVersion = this.readCurrentVersionFromFile();
40
+ this.cleanOldVersions();
41
+ }
42
+ cleanOldVersions() {
43
+ try {
44
+ if (!fs.existsSync(this.versionsDir)) return;
45
+ const items = fs.readdirSync(this.versionsDir);
46
+ const versionDirs = [];
47
+ for (const item of items) {
48
+ if (item === "current.json") continue;
49
+ const fullPath = path.join(this.versionsDir, item);
50
+ if (fs.statSync(fullPath).isDirectory()) {
51
+ versionDirs.push(item);
52
+ }
53
+ }
54
+ versionDirs.sort((a, b) => this.compareVersions(b, a));
55
+ const activeIdx = versionDirs.indexOf(this.activeVersion);
56
+ const safeVersions = /* @__PURE__ */ new Set();
57
+ if (activeIdx !== -1) safeVersions.add(this.activeVersion);
58
+ let kept = 0;
59
+ for (const v of versionDirs) {
60
+ if (kept >= this.maxVersionsToKeep) break;
61
+ if (!safeVersions.has(v)) {
62
+ safeVersions.add(v);
63
+ kept++;
64
+ }
65
+ }
66
+ for (const v of versionDirs) {
67
+ if (!safeVersions.has(v)) {
68
+ console.info(`[RenderUpdater] Cleaning up old version: ${v}`);
69
+ fs.rmSync(path.join(this.versionsDir, v), { recursive: true, force: true });
70
+ }
71
+ }
72
+ } catch (e) {
73
+ console.error("[RenderUpdater] Failed to clean old versions:", e);
74
+ }
75
+ }
76
+ readCurrentVersionFromFile() {
77
+ if (fs.existsSync(this.currentVersionFile)) {
78
+ try {
79
+ const current = JSON.parse(fs.readFileSync(this.currentVersionFile, "utf-8"));
80
+ return current.version;
81
+ } catch {
82
+ return "0.0.0";
83
+ }
84
+ }
85
+ return "0.0.0";
86
+ }
87
+ hasAnyVersion() {
88
+ return fs.existsSync(this.currentVersionFile);
89
+ }
90
+ /**
91
+ * Returns the file:// URL to the latest renderer.asar index.html,
92
+ * or empty string if no update is available.
93
+ */
94
+ getLoadUrl() {
95
+ try {
96
+ if (fs.existsSync(this.currentVersionFile)) {
97
+ const current = JSON.parse(fs.readFileSync(this.currentVersionFile, "utf-8"));
98
+ const version = current.version;
99
+ const asarPath = path.join(this.versionsDir, version, "renderer.asar");
100
+ if (fs.existsSync(asarPath)) {
101
+ return `file://${asarPath}/index.html`;
102
+ }
103
+ }
104
+ } catch (e) {
105
+ console.error("[RenderUpdater] Failed to get load URL:", e);
106
+ }
107
+ return "";
108
+ }
109
+ async check() {
110
+ try {
111
+ const response = await fetch(`${this.baseUrl}/latest.json?t=${Date.now()}`);
112
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
113
+ const remoteInfo = await response.json();
114
+ const currentVersion = this.activeVersion;
115
+ if (this.compareVersions(remoteInfo.version, currentVersion) > 0) {
116
+ return { updateAvailable: true, version: remoteInfo.version, info: remoteInfo };
117
+ }
118
+ return { updateAvailable: false };
119
+ } catch (e) {
120
+ console.error("[RenderUpdater] Update check failed:", e);
121
+ throw e;
122
+ }
123
+ }
124
+ /**
125
+ * Downloads the update with resume support.
126
+ */
127
+ async download(onProgress) {
128
+ if (this.isDownloading) throw new Error("[RenderUpdater] Download is already in progress.");
129
+ this.isDownloading = true;
130
+ try {
131
+ const response = await fetch(`${this.baseUrl}/latest.json?t=${Date.now()}`);
132
+ if (!response.ok) throw new Error("[RenderUpdater] Cannot fetch latest.json for download");
133
+ const info = await response.json();
134
+ const versionDir = path.join(this.versionsDir, info.version);
135
+ const asarPath = path.join(versionDir, "renderer.asar");
136
+ if (fs.existsSync(asarPath) && this.verifyFile(asarPath, info)) {
137
+ onProgress?.(100);
138
+ this.useVersion(info.version);
139
+ return;
140
+ }
141
+ if (!fs.existsSync(versionDir)) {
142
+ fs.mkdirSync(versionDir, { recursive: true });
143
+ }
144
+ let downloadedBytes = 0;
145
+ if (fs.existsSync(asarPath)) {
146
+ downloadedBytes = fs.statSync(asarPath).size;
147
+ }
148
+ const fetchOptions = {};
149
+ if (downloadedBytes > 0) {
150
+ fetchOptions.headers = {
151
+ Range: `bytes=${downloadedBytes}-`
152
+ };
153
+ }
154
+ const downloadUrl = info.path ? `${this.baseUrl.replace(/\/$/, "")}/${info.path}` : info.url;
155
+ if (!downloadUrl) throw new Error("[RenderUpdater] Cannot determine download URL: info.path and info.url are both missing.");
156
+ const downloadResponse = await fetch(downloadUrl, fetchOptions);
157
+ if (downloadResponse.status !== 206 && downloadResponse.status !== 200) {
158
+ throw new Error(`Failed to download update: ${downloadResponse.statusText}`);
159
+ }
160
+ if (downloadResponse.status === 200) {
161
+ downloadedBytes = 0;
162
+ }
163
+ const contentLength = downloadResponse.headers.get("content-length");
164
+ const incomingTotal = contentLength ? parseInt(contentLength, 10) : 0;
165
+ const total = downloadedBytes + incomingTotal;
166
+ if (!downloadResponse.body) throw new Error("Response body is empty");
167
+ const fileStream = fs.createWriteStream(asarPath, { flags: downloadResponse.status === 206 ? "a" : "w" });
168
+ const progressTransform = new Transform({
169
+ transform(chunk, _encoding, callback) {
170
+ downloadedBytes += chunk.length;
171
+ if (total > 0) {
172
+ onProgress?.(downloadedBytes / total * 100);
173
+ }
174
+ callback(null, chunk);
175
+ }
176
+ });
177
+ await pipeline(
178
+ Readable.fromWeb(downloadResponse.body),
179
+ progressTransform,
180
+ fileStream
181
+ );
182
+ if (!this.verifyFile(asarPath, info)) {
183
+ fs.rmSync(versionDir, { recursive: true, force: true });
184
+ throw new Error("[RenderUpdater] Verification failed after download (SHA256 or RSA Mismatch)");
185
+ }
186
+ this.useVersion(info.version);
187
+ this.cleanOldVersions();
188
+ } finally {
189
+ this.isDownloading = false;
190
+ }
191
+ }
192
+ verifyFile(filePath, info) {
193
+ const fileBuffer = fs.readFileSync(filePath);
194
+ const hashSum = crypto.createHash("sha256");
195
+ hashSum.update(fileBuffer);
196
+ const sha256 = hashSum.digest("hex");
197
+ if (sha256 !== info.sha256) {
198
+ console.error(`[RenderUpdater] SHA256 mismatch! Expected ${info.sha256}, got ${sha256}`);
199
+ return false;
200
+ }
201
+ if (this.publicKey && info.signature) {
202
+ try {
203
+ const verify = crypto.createVerify("RSA-SHA256");
204
+ verify.update(fileBuffer);
205
+ verify.end();
206
+ const isValid = verify.verify(this.publicKey, info.signature, "base64");
207
+ if (!isValid) {
208
+ console.error("[RenderUpdater] RSA Signature verification failed.");
209
+ return false;
210
+ }
211
+ } catch (err) {
212
+ console.error("[RenderUpdater] RSA verification error:", err);
213
+ return false;
214
+ }
215
+ }
216
+ return true;
217
+ }
218
+ useVersion(version) {
219
+ fs.writeFileSync(
220
+ this.currentVersionFile,
221
+ JSON.stringify({ version, date: (/* @__PURE__ */ new Date()).toISOString() })
222
+ );
223
+ this.activeVersion = version;
224
+ }
225
+ compareVersions(v1, v2) {
226
+ const parts1 = v1.split(".").map(Number);
227
+ const parts2 = v2.split(".").map(Number);
228
+ for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
229
+ const n1 = parts1[i] || 0;
230
+ const n2 = parts2[i] || 0;
231
+ if (n1 > n2) return 1;
232
+ if (n1 < n2) return -1;
233
+ }
234
+ return 0;
235
+ }
236
+ /**
237
+ * One-stop method to check for updates, show dialogs (if autoPrompt=true),
238
+ * download, and restart application.
239
+ */
240
+ async checkForUpdatesAndNotify() {
241
+ try {
242
+ const checkResult = await this.check();
243
+ if (!checkResult.updateAvailable || !checkResult.info) {
244
+ return;
245
+ }
246
+ const info = checkResult.info;
247
+ const doDownload = async () => {
248
+ try {
249
+ await this.download(this.onDownloadProgress);
250
+ const doInstall = async () => {
251
+ if (this.onBeforeRestart) {
252
+ await this.onBeforeRestart();
253
+ }
254
+ app.relaunch();
255
+ app.quit();
256
+ };
257
+ if (this.onDownloadComplete) {
258
+ this.onDownloadComplete(info, () => {
259
+ doInstall().catch(console.error);
260
+ });
261
+ } else {
262
+ if (info.forceUpdate) {
263
+ if (info.forceUpdate === "prompt") {
264
+ dialog.showMessageBoxSync({
265
+ type: "warning",
266
+ title: "\u6700\u9AD8\u7EA7\u522B\u5F3A\u5236\u63A5\u7BA1",
267
+ message: "\u7D27\u6025\u707E\u96BE\u4FEE\u590D\u7EC4\u4EF6\u5DF2\u7ECF\u4E0B\u8F7D\u5C31\u7EEA\u3002\u5373\u5C06\u5F3A\u884C\u963B\u65AD\u5E76\u91CD\u542F\u5E94\u7528\u8FDB\u884C\u6302\u8F7D\u3002",
268
+ buttons: ["\u5F3A\u5236\u63A5\u7BA1"]
269
+ });
270
+ }
271
+ doInstall().catch(console.error);
272
+ } else if (this.autoPrompt) {
273
+ dialog.showMessageBoxSync({
274
+ type: "info",
275
+ title: "\u4E0B\u8F7D\u5B8C\u6210",
276
+ message: "\u6700\u65B0\u7248\u672C\u5DF2\u4E0B\u8F7D\u5B8C\u6BD5\u3002\u70B9\u51FB\u786E\u5B9A\u91CD\u542F\u5E94\u7528\u4EE5\u5E94\u7528\u66F4\u65B0\u3002",
277
+ buttons: ["\u786E\u5B9A"]
278
+ });
279
+ doInstall().catch(console.error);
280
+ } else {
281
+ }
282
+ }
283
+ } catch (e) {
284
+ if (this.onError) {
285
+ this.onError(e);
286
+ } else {
287
+ console.error("[RenderUpdater] Download error:", e);
288
+ }
289
+ }
290
+ };
291
+ if (this.onUpdateAvailable) {
292
+ this.onUpdateAvailable(info, doDownload);
293
+ } else {
294
+ if (info.forceUpdate) {
295
+ if (info.forceUpdate === "prompt") {
296
+ dialog.showMessageBoxSync({
297
+ type: "warning",
298
+ title: "\u{1F6A8} \u53D1\u73B0\u751F\u6B7B\u6538\u5173\u7684\u6838\u5FC3\u66F4\u65B0",
299
+ message: `\u63A2\u6D4B\u5230\u7CFB\u7EDF\u81F4\u547D Bug \u7684\u4FEE\u590D\u7248\u672C (v${info.version})\u3002
300
+ \u5E94\u7528\u73AF\u5883\u5373\u523B\u88AB\u9501\u5B9A\uFF0C\u5F00\u59CB\u5F3A\u5236\u6267\u884C\u5168\u91CF\u4E0B\u8F7D\uFF0C\u4EFB\u4F55\u4EBA\u90FD\u65E0\u6CD5\u4E2D\u6B62\uFF01
301
+
302
+ \u66F4\u65B0\u65E5\u5FD7\uFF1A
303
+ ${info.releaseNotes || "\u707E\u5907\u9884\u6848\u5F3A\u5236\u6FC0\u6D3B\uFF0C\u65E0\u53EF\u5949\u544A\u3002"}`,
304
+ buttons: ["\u4E56\u4E56\u5347\u7EA7"]
305
+ });
306
+ }
307
+ await doDownload();
308
+ } else if (this.autoDownload) {
309
+ await doDownload();
310
+ } else if (this.autoPrompt) {
311
+ const { response } = await dialog.showMessageBox({
312
+ type: "info",
313
+ title: "\u53D1\u73B0\u65B0\u7248\u672C",
314
+ message: `\u53D1\u73B0\u53EF\u7528\u66F4\u65B0 (v${info.version})\u3002\u662F\u5426\u7ACB\u5373\u4E0B\u8F7D\u66F4\u65B0\uFF1F
315
+
316
+ \u66F4\u65B0\u65E5\u5FD7\uFF1A
317
+ ${info.releaseNotes || "\u65E0\u8BE6\u7EC6\u8BB0\u5F55\u3002"}`,
318
+ buttons: ["\u7ACB\u5373\u66F4\u65B0", "\u7A0D\u540E\u518D\u8BF4"],
319
+ cancelId: 1
320
+ });
321
+ if (response === 0) {
322
+ await doDownload();
323
+ }
324
+ }
325
+ }
326
+ } catch (e) {
327
+ if (this.onError) {
328
+ this.onError(e);
329
+ } else {
330
+ console.error("[RenderUpdater] Error in checkForUpdatesAndNotify:", e);
331
+ }
332
+ }
333
+ }
334
+ };
335
+ export {
336
+ RenderUpdater
337
+ };
@@ -0,0 +1,105 @@
1
+ export interface UpdateInfo {
2
+ version: string;
3
+ url?: string;
4
+ path?: string;
5
+ sha256: string;
6
+ signature?: string;
7
+ date: string;
8
+ releaseNotes?: string;
9
+ /**
10
+ * Defines if this update is mandatory.
11
+ * 'prompt' - shows blocking dialogs to force the user to update.
12
+ * 'silent' - downloads and restarts invisibly on the background.
13
+ */
14
+ forceUpdate?: 'prompt' | 'silent';
15
+ }
16
+ export interface UpdaterOptions {
17
+ /**
18
+ * The base URL to check for updates (e.g. http://localhost:8000/updates)
19
+ */
20
+ updateUrl: string;
21
+ /**
22
+ * The local directory where versions will be stored
23
+ * (e.g. app.getPath('userData') + '/versions')
24
+ */
25
+ versionsDir: string;
26
+ /**
27
+ * Public key string (PEM format) to verify the RSA signature
28
+ */
29
+ publicKey?: string;
30
+ /**
31
+ * Whether to automatically download the update when one is found (if false, it prompts the user first if autoPrompt is true)
32
+ */
33
+ autoDownload?: boolean;
34
+ /**
35
+ * Whether to display built-in dialog prompts (in Chinese) for "Update Available" and "Update Downloaded"
36
+ * Defaults to true.
37
+ */
38
+ autoPrompt?: boolean;
39
+ /**
40
+ * Maximum number of recent historical versions to keep locally for fallback.
41
+ * Older versions will be automatically deleted to save disk space. Defaults to 2.
42
+ */
43
+ maxVersionsToKeep?: number;
44
+ /**
45
+ * Called when a new update is found.
46
+ * Provides the UpdateInfo and a callback function `doDownload` which triggers the actual download.
47
+ */
48
+ onUpdateAvailable?: (info: UpdateInfo, doDownload: () => Promise<void>) => void;
49
+ /**
50
+ * Called with current download progress [0, 100].
51
+ */
52
+ onDownloadProgress?: (percent: number) => void;
53
+ /**
54
+ * Called when the ASAR update has been successfully downloaded and verified.
55
+ * Provides `doInstall` callback that calls app.relaunch() and app.quit().
56
+ */
57
+ onDownloadComplete?: (info: UpdateInfo, doInstall: () => void) => void;
58
+ /**
59
+ * Called if any error occurs during check or download.
60
+ */
61
+ onError?: (error: Error) => void;
62
+ /**
63
+ * Called right before app.relaunch() and app.quit() is triggered.
64
+ * Useful for telling the Vue renderer to save state and cleanup.
65
+ */
66
+ onBeforeRestart?: () => void | Promise<void>;
67
+ }
68
+ export interface BuilderOptions {
69
+ /**
70
+ * The directory to pack into the ASAR (e.g. './dist')
71
+ */
72
+ outDir: string;
73
+ /**
74
+ * The output directory for the update package (e.g. './dist_updates')
75
+ */
76
+ updatesDir?: string;
77
+ /**
78
+ * Name of the ASAR file, defaults to 'renderer.asar'
79
+ */
80
+ asarName?: string;
81
+ /**
82
+ * Explicit version string to use (e.g. '1.2.3').
83
+ * Takes priority over packageJsonPath. If neither is provided, reads from process.cwd()/package.json.
84
+ */
85
+ version?: string;
86
+ /**
87
+ * Path to the package.json to read the version from.
88
+ * Optional. If omitted and version is not set, defaults to process.cwd()/package.json.
89
+ */
90
+ packageJsonPath?: string;
91
+ /**
92
+ * Path to the private key (PEM format) to sign the ASAR
93
+ */
94
+ privateKeyPath?: string;
95
+ /**
96
+ * Path to the release notes markdown file
97
+ */
98
+ releaseNotesPath?: string;
99
+ /**
100
+ * Mark this specific build as a mandatory check-in (forced update)
101
+ * 'prompt': Warn user before forcefully applying.
102
+ * 'silent': Stealthily apply it in the background.
103
+ */
104
+ forceUpdate?: 'prompt' | 'silent';
105
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "electron-updater-for-render",
3
+ "version": "1.0.0",
4
+ "description": "A lightweight incremental updater for Electron renderer processes",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/main/index.d.ts",
12
+ "import": "./dist/main/index.js",
13
+ "require": "./dist/main/index.cjs"
14
+ },
15
+ "./builder": {
16
+ "types": "./dist/builder/index.d.ts",
17
+ "import": "./dist/builder/index.js",
18
+ "require": "./dist/builder/index.cjs"
19
+ },
20
+ "./vite": {
21
+ "types": "./dist/builder/vite-plugin.d.ts",
22
+ "import": "./dist/builder/vite-plugin.js",
23
+ "require": "./dist/builder/vite-plugin.cjs"
24
+ }
25
+ },
26
+ "scripts": {
27
+ "build": "tsup && tsc --emitDeclarationOnly",
28
+ "dev": "tsup --watch"
29
+ },
30
+ "dependencies": {
31
+ "original-fs": "^1.2.0"
32
+ },
33
+ "peerDependencies": {
34
+ "asar": "^3.2.0",
35
+ "electron": ">=20.0.0",
36
+ "vite": ">=3.0.0"
37
+ },
38
+ "peerDependenciesMeta": {
39
+ "asar": {
40
+ "optional": true
41
+ },
42
+ "vite": {
43
+ "optional": true
44
+ }
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^22.0.0",
48
+ "asar": "^3.2.0",
49
+ "tsup": "^8.0.2",
50
+ "typescript": "^5.0.0",
51
+ "vite": "^5.0.0"
52
+ },
53
+ "keywords": [
54
+ "electron",
55
+ "updater",
56
+ "incremental",
57
+ "render",
58
+ "vue",
59
+ "vite"
60
+ ],
61
+ "author": "",
62
+ "license": "MIT"
63
+ }