electron-updater-for-render 2.0.0-beta.1 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.zh-CN.md CHANGED
@@ -11,9 +11,9 @@
11
11
  ## 🚀 核心特性
12
12
 
13
13
  - **双轨更新模式**:内置原生弹窗自动更新 + 暴露 IPC 接口支持前端自定义进度 UI,两种方案自由切换
14
- - **三态检查系统**:`check()` 返回 `idle / available / ready` 三种状态,UI 始终知道应该显示"下载"还是"重启",彻底消除重复下载弹窗
15
- - **就绪状态持久化**:用户点击"稍后安装"后,下载的更新会记录到磁盘。下次启动或刷新页面,UI 直接跳到"立即重启"——无需重新下载
16
- - **实时状态推送**:`onStatusChanged` 钩子让主进程在更新就绪时(如静默后台下载完成)主动通知渲染层,UI 即时刷新无需用户手动操作
14
+ - **三态检查系统**:`check()` 明确返回 `idle / available / ready` 三种状态,帮助前端 UI 精确区分“无更新”、“全网首发下载”与“已就绪待重启”阶段,实现丝滑的状态流转
15
+ - **就绪状态持久化**:智能识别本地已下载的更新缓存。即使用户暂不安装或页面发生刷新,更新状态依然保持“待重启”就绪态,保障断点体验的连续性
16
+ - **实时状态推送**:`onStatusChanged` 钩子让主进程在更新就绪时主动通知渲染层完成 UI 刷新
17
17
  - **强制更新控制**:云端下发 `forceUpdate: 'prompt'` 或 `'silent'`,应对 P0 级线上事故时一键锁死用户退路
18
18
  - **从容退场钩子**:`onBeforeRestart` 让渲染进程在 `app.relaunch()` 之前有充足时间保存草稿、提交日志
19
19
  - **并发互斥锁**:内置 `isDownloading` 单例锁,防止手动触发与开机自检同时争写同一 ASAR 文件
@@ -21,7 +21,12 @@
21
21
  - **Pipeline 流式下载**:Node.js `stream/promises pipeline` + `Transform` 管道,内存零泄漏,网络背压自动控制
22
22
  - **零配置版本探测**:默认自动读取 `process.cwd()/package.json` 中的版本号,无需任何额外配置
23
23
  - **独立打包命令**:普通 `npm run build` 不受任何影响,只有 `npm run build:update` 才会触发 ASAR 打包
24
- - **History 模式 SPA 支持**:内置自定义协议处理器(`app://`),支持 Vue/React History 路由模式,零配置零样板代码
24
+ - **History 模式支持**:内置支持 Vue/React History 路由模式。
25
+ - **多页应用 (MPA) 支持**:支持通过 `getLoadUrl(entry)` 加载不同的入口文件。
26
+ - **极简 SDK 接入**:提供 `/main`, `/preload`, `/renderer` 导出,内置 `getRouterBase` 路由基准路径探测工具。
27
+ - **精准灰度分发**:支持基于 `deviceId` 的环境身份匹配,配合 `updater.config.ts` 中的 `rolloutRule` 可实现百分百定点静默空投。
28
+ - **Semver 内测通道**:基于 `allowPrerelease` 提供生产环境与内测环境的天然逻辑隔离,保障正式服用户免受 Beta 版干扰。
29
+ - **动态网关鉴权**:通过 `requestOptions` 可在 HTTP 请求中自定义 Headers(如 Oauth / Bearer Token)及 Query 参数。
25
30
 
26
31
  ---
27
32
 
@@ -85,6 +90,9 @@ import { defineConfig } from 'electron-updater-for-render/builder'
85
90
  export default defineConfig({
86
91
  outDir: './dist', // 必填:您的构建输出目录
87
92
  updatesDir: './dist_updates', // 可选:更新包输出目录(默认 './dist_updates')
93
+ // rolloutRule: {
94
+ // deviceIds: ['YOUR_DEVICE_ID'] // 可选:灰度定向分发,仅指明白名单设备可更新
95
+ // },
88
96
  // version: '1.2.3' // 可选:显式指定版本号(优先级高于 package.json)
89
97
  // packageJsonPath: './package.json' // 可选:自定义 package.json 路径
90
98
  // forceUpdate: 'prompt' // 可选:'prompt' | 'silent',仅 P0 紧急修复时使用
@@ -148,62 +156,48 @@ rsync -avz dist_updates/ user@your-server:/var/www/auto-updates/
148
156
 
149
157
  ```typescript
150
158
  // src/main/index.ts
151
- import { app, BrowserWindow, ipcMain } from 'electron'
159
+ import { app, BrowserWindow } from 'electron'
152
160
  import { join } from 'path'
153
- import { RenderUpdater } from 'electron-updater-for-render'
161
+ import { RenderUpdater, setupUpdaterIPC } from 'electron-updater-for-render/main'
154
162
 
155
163
  const updater = new RenderUpdater({
156
164
  updateUrl: 'https://your-server.com/auto-updates', // 必填:更新服务器根地址
157
165
  versionsDir: join(app.getPath('userData'), 'versions'), // 必填:本地版本存储目录
166
+
167
+ // 身份标识:配合 rolloutRule 实现灰度定向空投
168
+ identity: { deviceId: 'YOUR_DEVICE_UUID' },
169
+ // 频道管理:是否接收内测包(版本号包含 -beta/-rc 等后缀)
170
+ allowPrerelease: true,
171
+ // 网关定制:在请求中挂载自定义 Header(如鉴权)
172
+ requestOptions: {
173
+ headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
174
+ },
175
+
158
176
  maxVersionsToKeep: 2, // 可选:保留最近 2 个旧版本供回滚(默认 2)
159
177
 
160
- // 可选:重启前给渲染层时间保存状态
178
+ // 可选:延缓重启时间。配合前端的 onBeforeRestart 钩子,给予 Vue 保存表单状态的时间
161
179
  onBeforeRestart: async () => {
162
- const [win] = BrowserWindow.getAllWindows()
163
- if (win) {
164
- win.webContents.send('updater:before-restart')
165
- await new Promise(resolve => setTimeout(resolve, 2000))
166
- }
167
- },
168
-
169
- // 可选:当更新状态发生变化时(如后台下载完成),主动推送给渲染层
170
- // 渲染层无需手动检查,按钮会自动切换到"立即重启"
171
- onStatusChanged: (data) => {
172
- const [win] = BrowserWindow.getAllWindows()
173
- if (win) {
174
- win.webContents.send('updater:status-changed', data)
175
- }
180
+ await new Promise(resolve => setTimeout(resolve, 2000))
176
181
  }
177
182
  })
178
183
 
184
+ // 🎉 一键自动打通全链路 IPC 总线
185
+ // 它不仅暴露方法,还会自动将主进程的 progress, status, restart 生命周期广播给所有渲染层
186
+ setupUpdaterIPC(updater)
187
+
179
188
  app.whenReady().then(async () => {
180
189
  const mainWindow = new BrowserWindow({
181
190
  webPreferences: { preload: join(__dirname, '../preload/index.js') }
182
191
  })
183
192
 
184
- // 优先加载已下载的热更新 ASAR,否则回退到开发服务器
185
- const loadUrl = updater.getLoadUrl()
186
- mainWindow.loadURL(loadUrl || 'http://localhost:5173')
193
+ // 加载最新下载好的 ASAR,若无更新则回退至本地 dist。
194
+ // 支持多页应用入口传入 (默认为 'index.html')
195
+ const loadUrl = updater.getLoadUrl('login/index.html')
196
+ mainWindow.loadURL(loadUrl || `file://${join(__dirname, '../renderer/login/index.html')}`)
187
197
 
188
198
  // 延迟触发自动检测(支持拦截强制更新,autoPrompt 为 true 时会弹窗提示)
189
199
  setTimeout(() => updater.checkForUpdatesAndNotify(), 3000)
190
200
  })
191
-
192
- // 暴露 IPC 接口,供前端界面主动控制更新流程
193
- ipcMain.handle('updater:check', () => updater.check())
194
-
195
- // ⚠️ 重要:第一个参数必须是前端传来的 UpdateInfo 对象,第二个才是进度回调
196
- // 这样可以避免主进程重复请求 latest.json,也杜绝了参数错位导致的崩溃
197
- ipcMain.handle('updater:download', async (event, info) =>
198
- updater.download(info, (percent) => {
199
- BrowserWindow.getAllWindows()[0]?.webContents.send('updater:progress', percent)
200
- })
201
- )
202
-
203
- ipcMain.on('updater:install', () => {
204
- app.relaunch()
205
- app.quit()
206
- })
207
201
  ```
208
202
 
209
203
  ---
@@ -213,36 +207,10 @@ ipcMain.on('updater:install', () => {
213
207
  ```typescript
214
208
  // src/preload/index.ts
215
209
  import { contextBridge, ipcRenderer } from 'electron'
210
+ import { exposeUpdaterPreload } from 'electron-updater-for-render/preload'
216
211
 
217
- contextBridge.exposeInMainWorld('updater', {
218
- check: () => ipcRenderer.invoke('updater:check'),
219
-
220
- // 将 check() 返回的 UpdateInfo 对象透传给主进程
221
- // 主进程凭此对象直接下载,无需重新请求 latest.json
222
- download: (info?: any) => ipcRenderer.invoke('updater:download', info),
223
-
224
- install: () => ipcRenderer.send('updater:install'),
225
-
226
- onProgress: (callback: (percent: number) => void) => {
227
- const fn = (_: any, p: number) => callback(p)
228
- ipcRenderer.on('updater:progress', fn)
229
- return () => ipcRenderer.removeListener('updater:progress', fn)
230
- },
231
-
232
- onBeforeRestart: (callback: () => void) => {
233
- const fn = () => callback()
234
- ipcRenderer.on('updater:before-restart', fn)
235
- return () => ipcRenderer.removeListener('updater:before-restart', fn)
236
- },
237
-
238
- // 监听主进程的主动状态推送
239
- // 当后台下载完成(如用户点"稍后安装"之后)时,UI 会自动更新到"立即重启"
240
- onStatusChanged: (callback: (data: { status: string; version: string }) => void) => {
241
- const fn = (_: any, data: any) => callback(data)
242
- ipcRenderer.on('updater:status-changed', fn)
243
- return () => ipcRenderer.removeListener('updater:status-changed', fn)
244
- }
245
- })
212
+ // 必须暴露至 window.updaterAPI 命名空间,因为这是 getUpdater() 的默认查找路径
213
+ contextBridge.exposeInMainWorld('updaterAPI', exposeUpdaterPreload(ipcRenderer))
246
214
  ```
247
215
 
248
216
  ---
@@ -257,6 +225,11 @@ contextBridge.exposeInMainWorld('updater', {
257
225
  ```vue
258
226
  <script setup lang="ts">
259
227
  import { ref, onMounted, onUnmounted } from 'vue'
228
+ import { getUpdater, getRouterBase } from 'electron-updater-for-render/renderer'
229
+
230
+ // 自动探测当前 Window 上下文对应的 History Base
231
+ const routerBase = getRouterBase()
232
+ const updater = getUpdater()
260
233
 
261
234
  // 状态:'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'no-update'
262
235
  const status = ref<'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'no-update'>('idle')
@@ -269,15 +242,12 @@ let removeProgress: (() => void) | null = null
269
242
  let removeRestart: (() => void) | null = null
270
243
  let removeStatusChanged: (() => void) | null = null
271
244
 
272
- const getUpdater = () => (window as any).updater
245
+ const updater = getUpdater()
273
246
 
274
247
  // ── 检查更新 ───────────────────────────────────────────────────────────────
275
248
  // check() 返回 { status: 'idle' | 'available' | 'ready', version, info }
276
249
  // 若状态为 'ready',说明更新已下载完毕只待重启,直接跳过下载流程
277
250
  const checkUpdate = async () => {
278
- const updater = getUpdater()
279
- if (!updater) return
280
-
281
251
  status.value = 'checking'
282
252
  try {
283
253
  const res = await updater.check()
@@ -298,13 +268,10 @@ const checkUpdate = async () => {
298
268
  // ── 下载更新 ───────────────────────────────────────────────────────────────
299
269
  // 将 check() 返回的 info 对象传给 download(),避免主进程重复请求 latest.json
300
270
  const downloadUpdate = async () => {
301
- const updater = getUpdater()
302
- if (!updater) return
303
-
304
271
  status.value = 'downloading'
305
272
  progress.value = 0
306
273
 
307
- removeProgress = updater.onProgress((percent: number) => {
274
+ removeProgress = updater.onDownloadProgress((percent: number) => {
308
275
  progress.value = percent
309
276
  })
310
277
 
@@ -321,14 +288,13 @@ const downloadUpdate = async () => {
321
288
 
322
289
  // ── 安装并重启 ─────────────────────────────────────────────────────────────
323
290
  const installUpdate = () => {
324
- getUpdater()?.install()
291
+ updater.installAndRestart()
325
292
  }
326
293
 
327
294
  // ── 监听主进程主动推送的状态变更 ───────────────────────────────────────────
328
295
  // 场景:用户点击"稍后安装"后,日后主进程 onStatusChanged 触发时
329
296
  // UI 无需用户做任何操作,按钮自动变为"立即重启"
330
297
  const initStatusListener = () => {
331
- const updater = getUpdater()
332
298
  if (updater?.onStatusChanged) {
333
299
  return updater.onStatusChanged((data: { status: string; version: string }) => {
334
300
  if (data.status === 'ready') {
@@ -342,7 +308,6 @@ const initStatusListener = () => {
342
308
 
343
309
  // ── 监听重启前通知 ─────────────────────────────────────────────────────────
344
310
  const initRestartListener = () => {
345
- const updater = getUpdater()
346
311
  if (updater?.onBeforeRestart) {
347
312
  return updater.onBeforeRestart(() => {
348
313
  // 在此保存未提交的表单状态
@@ -407,31 +372,49 @@ onUnmounted(() => {
407
372
 
408
373
  本库完美支持 Electron 常用的文件加载模式(Hash)和现代单页应用路由模式(History)。
409
374
 
410
- ### 1. Hash 模式(默认推荐)
411
- 这是最稳健的选择,使用 Electron 标准的 `file://` 协议。
375
+ ### 1. Hash 模式 (推荐方案:零配置、零侵入)
376
+ 这是 Electron 环境下的**工业级标准实践**。它对物理路径不敏感,不需要配置任何 Base,前端项目完全不需要引入插件的任何 JS 逻辑。
412
377
 
413
- - **主进程**:无需任何额外配置(`routerMode` 默认为 `'hash'`)。
378
+ - **主进程**:无需额外配置(`routerMode` 默认为 `'hash'`)。
414
379
  - **前端 (Vite)**:`vite.config.ts` 中的 `base` 设为 `'./'`(或不填)。
415
380
  - **前端 (Router)**:使用 `createWebHashHistory()`。
381
+ - **优点**:真正的“零感知”,前端代码与热更新逻辑完全解耦。
416
382
 
417
- ### 2. History 模式(企业级方案)
418
- 支持美观的 URL 和标准浏览器重载行为。由于 `file://` 不支持标准路由解析,本库会自动启用自定义协议(默认 `app://`)进行转发。
383
+ ### 2. History 模式 (高级方案)
384
+ 支持美观的 URL 和标准浏览器刷新行为。由于 MPA 存在物理路径认知偏差,需要对齐基准路径(Base)。
419
385
 
420
386
  - **主进程**:
421
387
  ```typescript
422
388
  new RenderUpdater({
423
- updateUrl: '...',
424
- versionsDir: '...',
425
- routerMode: 'history', // 开启 History 路由支持
426
- protocol: 'my-app' // 可选:自定义协议名 (默认 'app')
389
+ // ...
390
+ routerMode: 'history',
391
+ protocol: 'my-app' // 可选:自定义协议名 (默认 'app')
392
+ })
393
+ ```
394
+
395
+ - **渲染层 (Router) - 选项 A:使用自动化助手 (推荐)**
396
+ 引入插件提供的轻量级工具函数,自动探测当前窗口的物理基准:
397
+ ```typescript
398
+ import { getRouterBase } from 'electron-updater-for-render/renderer'
399
+ const router = createRouter({
400
+ history: createWebHistory(getRouterBase()), // 自动对齐 app:// 协议下的物理路径
401
+ routes: [...]
402
+ })
403
+ ```
404
+
405
+ - **渲染层 (Router) - 选项 B:手动对齐 (零侵入)**
406
+ 如果您不想在前端引入任何插件代码,可以手动指定与 `getLoadUrl()` 传入路径一致的字符串(需以 `.html/` 结尾):
407
+ ```typescript
408
+ const router = createRouter({
409
+ history: createWebHistory('/index.html/'),
410
+ routes: [...]
427
411
  })
428
412
  ```
429
- > 💡 **全自动化**:协议注册和特权赋权逻辑已在库内部**全自动处理**,您无需编写任何 `registerSchemesAsPrivileged` 等繁琐代码。
430
413
 
431
- - **前端构建工具(Vite、Webpack 等)**:**关键配置** —— 必须将 **公共路径 (Public Path / Base)** 设置为 `/`,确保资源引用为绝对路径,防止在深层路由下刷新出现 404。
432
- - **Vite**:`base: '/'`
433
- - **Webpack**:`output.publicPath: '/'`
434
- - **前端路由 (Router)**:使用 `createWebHistory()`。
414
+ ### 3. 多页应用 (MPA) 支持
415
+ - **多窗口多入口**:支持为不同窗口指定不同的 HTML 入口。
416
+ - **History 模式刷新**:支持在 History 模式下进行页面刷新。
417
+ - **基准路径探测**:配合 `getRouterBase()` 自动识别当前的路由基准路径。
435
418
 
436
419
  ---
437
420
 
@@ -443,6 +426,7 @@ onUnmounted(() => {
443
426
  |---|---|---|---|
444
427
  | `outDir` | `string` | ✅ | 构建输出目录 |
445
428
  | `updatesDir` | `string` | — | 更新包输出目录,默认 `./dist_updates` |
429
+ | `rolloutRule` | `object` | — | 灰度分发规则字典。如 `{ deviceIds: ['UUID'] }` |
446
430
  | `version` | `string` | — | 显式指定版本号,优先级高于 `packageJsonPath` |
447
431
  | `packageJsonPath` | `string` | — | 自定义 `package.json` 路径,不填则自动读取 `process.cwd()/package.json` |
448
432
  | `asarName` | `string` | — | ASAR 文件名,默认 `renderer.asar` |
@@ -456,6 +440,9 @@ onUnmounted(() => {
456
440
  |---|---|---|---|
457
441
  | `updateUrl` | `string` | ✅ | 更新服务器根地址 |
458
442
  | `versionsDir` | `string` | ✅ | 本地 ASAR 版本存储目录 |
443
+ | `identity` | `object` | — | 客户端身份标识,包含 `deviceId`,用于请求与灰度匹配 |
444
+ | `allowPrerelease`| `boolean` | — | 是否允许检查预发布版本(遵循 semver 规则,如带 `-beta` 后缀) |
445
+ | `requestOptions` | `object` | — | 自定义 HTTP 请求配置,包含 `headers` 和 `query` 参数 |
459
446
  | `publicKey` | `string` | — | RSA 公钥(PEM 格式),用于验证 ASAR 签名 |
460
447
  | `autoDownload` | `boolean` | — | 发现更新后自动下载,不弹窗询问。默认 `false` |
461
448
  | `autoPrompt` | `boolean` | — | 使用内置原生弹窗引导更新。默认 `true` |
@@ -471,14 +458,14 @@ onUnmounted(() => {
471
458
 
472
459
  ### `RenderUpdater` 实例方法
473
460
 
474
- | 方法 | 返回值 | 说明 |
461
+ | 方法 | 返回值 | 描述 |
475
462
  |---|---|---|
476
- | `check()` | `Promise<CheckResult>` | 检查更新。返回 `{ updateAvailable, status, version, info }`,其中 `status` 为 `'idle' \| 'available' \| 'ready'` |
477
- | `download(info?, onProgress?)` | `Promise<void>` | 下载更新。将 `check()` 返回的 `info` 传入,可避免重复请求 latest.json |
478
- | `getLoadUrl()` | `string` | 返回最新 ASAR 的加载地址(`app://renderer/` 或 `file://...`),未安装任何更新时返回空字符串 |
479
- | `installAndRestart()` | `Promise<void>` | 应用待安装更新并重启应用 |
480
- | `checkForUpdatesAndNotify()` | `Promise<void>` | 一键式:检查 + 弹窗 + 下载 + 安装,使用内置原生弹窗 |
481
- | `setUpdatePending(version)` | `void` | 将某版本标记为已下载待重启,同时触发 `onStatusChanged` |
463
+ | `check()` | `Promise<CheckResult>` | 检查更新。返回 `{ updateAvailable, status, version, info }`。状态为 `'idle' \| 'available' \| 'ready'` |
464
+ | `download(info?, onProgress?)` | `Promise<void>` | 下载更新。建议传入 `check()` 返回的 `info` 对象以防止版本竞争 |
465
+ | `getLoadUrl(entry?)` | `string` | 返回加载最新 ASAR 的 URL (`app://renderer/` 或 `file://...`)。`entry` 默认为 `index.html`。若无更新则返回空字符串 |
466
+ | `installAndRestart()` | `Promise<void>` | 应用更新并重启应用 |
467
+ | `checkForUpdatesAndNotify()` | `Promise<void>` | 一站式:检查 + 提示 + 下载 + 安装(使用内置原生对话框) |
468
+ | `setUpdatePending(version)` | `void` | 将某版本标记为已就绪并等待重启。会触发 `onStatusChanged` |
482
469
  | `useVersion(version)` | `void` | 立即切换磁盘和内存双重版本指针 |
483
470
  | `activeVersion` | `string`(getter) | 当前内存中正在运行的版本(本次会话) |
484
471
  | `pendingVersion` | `string`(getter) | 磁盘上最新已下载版本 |
package/Readme.md CHANGED
@@ -11,9 +11,9 @@ Unlike `electron-updater` which re-downloads the entire app installer, this libr
11
11
  ## 🚀 Features
12
12
 
13
13
  - **Dual-Track Update Mode**: Built-in native OS dialogs *or* a fully custom frontend progress UI via IPC — your choice
14
- - **Three-State Check System**: `check()` returns `idle / available / ready` the UI always knows whether to show "Download" or "Restart", eliminating redundant download prompts
15
- - **Ready-to-Install State**: When a user clicks "Later", the downloaded update is remembered on disk. On next launch/refresh, the UI automatically jumps to "Restart Now" no re-download needed
16
- - **Real-time Status Push**: `onStatusChanged` hook lets the main process proactively notify the renderer when an update becomes ready (e.g. after silent background download)
14
+ - **Three-State Check System**: `check()` distinctly returns `idle / available / ready`, allowing the UI to accurately distinguish between "No Updates", "Download Required", and "Ready to Restart" for a seamless state transition flow
15
+ - **Ready-State Persistence**: Intelligently detects locally cached complete downloads. Even if the user delays installation or refreshes the page, the update state remains "Ready", ensuring an uninterrupted user journey
16
+ - **Real-time Status Push**: The `onStatusChanged` hook enables the main process to proactively notify the renderer layer to refresh the UI when updates are locally ready
17
17
  - **Force Update**: Push `forceUpdate: 'prompt'` or `'silent'` from your server for P0 hotfixes that bypass the "Later" button entirely
18
18
  - **Graceful Restart Hook**: `onBeforeRestart` async hook lets the renderer save unsaved state before `app.relaunch()`
19
19
  - **Concurrency Lock**: Prevents race conditions between auto-check-on-boot and manual-trigger-on-click
@@ -21,7 +21,12 @@ Unlike `electron-updater` which re-downloads the entire app installer, this libr
21
21
  - **Stream Pipeline Download**: Node.js `stream/promises pipeline` + `Transform` — zero memory leaks, full backpressure support
22
22
  - **Zero-config Version Detection**: Auto-reads version from `process.cwd()/package.json` — no extra config required
23
23
  - **Separate Build Commands**: Normal `npm run build` is untouched. Only `npm run build:update` triggers ASAR packaging
24
- - **History Mode SPA Support**: Built-in custom protocol handler (`app://`) for Vue/React History mode routing — zero boilerplate
24
+ - **History Mode Support**: Built-in support for Vue/React History router mode.
25
+ - **Multi-Page Application (MPA)**: Support for loading multiple entry points via `getLoadUrl(entry)`.
26
+ - **Simplified SDK**: Unified exports for `/main`, `/preload`, and `/renderer`, including `getRouterBase`.
27
+ - **Precision Canary Releases**: Use `identity.deviceId` and `rolloutRule` to drop updates silently to a whitelist of device IDs.
28
+ - **Semver Beta Channels**: Built-in Beta/RC channel toggling via `allowPrerelease` guarantees Stable users are isolated from beta patches.
29
+ - **Dynamic Request Gateways**: Send custom Authenticated Headers (e.g., Oauth/Bearer tokens) and Queries via `requestOptions` when checking for updates.
25
30
 
26
31
  ---
27
32
 
@@ -84,6 +89,9 @@ import { defineConfig } from 'electron-updater-for-render/builder'
84
89
  export default defineConfig({
85
90
  outDir: './dist', // Required: Your build output directory
86
91
  updatesDir: './dist_updates', // Optional: where to write update packages (default: './dist_updates')
92
+ // rolloutRule: {
93
+ // deviceIds: ['YOUR_DEVICE_ID'] // Optional: Whitelist of device IDs for Canary/Staged rollouts
94
+ // },
87
95
  // version: '1.2.3' // Optional: explicit version (overrides package.json)
88
96
  // packageJsonPath: './package.json' // Optional: custom package.json path
89
97
  // forceUpdate: 'prompt' // Optional: 'prompt' | 'silent' — for P0 mandatory rollouts ONLY
@@ -146,63 +154,48 @@ The server must serve these files over HTTP(S). The client will fetch:
146
154
 
147
155
  ```typescript
148
156
  // src/main/index.ts
149
- import { app, BrowserWindow, ipcMain } from 'electron'
157
+ import { app, BrowserWindow } from 'electron'
150
158
  import { join } from 'path'
151
- import { RenderUpdater } from 'electron-updater-for-render'
159
+ import { RenderUpdater, setupUpdaterIPC } from 'electron-updater-for-render/main'
152
160
 
153
161
  const updater = new RenderUpdater({
154
162
  updateUrl: 'https://your-server.com/auto-updates', // Required: base URL for updates
155
163
  versionsDir: join(app.getPath('userData'), 'versions'), // Required: local version storage
164
+
165
+ // Cloud Rollout Rules Support
166
+ identity: { deviceId: 'USER_LOCAL_UUID' },
167
+ // Beta Channel Support
168
+ allowPrerelease: true,
169
+ // Custom Authenticated Gateway
170
+ requestOptions: {
171
+ headers: { 'Authorization': 'Bearer YOUR_TOKEN' }
172
+ },
173
+
156
174
  maxVersionsToKeep: 2, // Optional: keep 2 old versions for rollback (default: 2)
157
175
 
158
- // Optional: give the renderer time to save state before restart
176
+ // Optional: Delay the restart by 2 seconds to give the Vue renderer time to save state
159
177
  onBeforeRestart: async () => {
160
- const [win] = BrowserWindow.getAllWindows()
161
- if (win) {
162
- win.webContents.send('updater:before-restart')
163
- await new Promise(resolve => setTimeout(resolve, 2000))
164
- }
165
- },
166
-
167
- // Optional: proactively push status changes to the renderer UI
168
- // Triggered when a pending update is recorded (e.g. after "Later" is clicked)
169
- onStatusChanged: (data) => {
170
- const [win] = BrowserWindow.getAllWindows()
171
- if (win) {
172
- win.webContents.send('updater:status-changed', data)
173
- }
178
+ await new Promise(resolve => setTimeout(resolve, 2000))
174
179
  }
175
180
  })
176
181
 
182
+ // 🎉 Automatically sets up the entire IPC bus in one line
183
+ // It exposes API handles and automatically broadcasts progress, status, and restart events to all webContents
184
+ setupUpdaterIPC(updater)
185
+
177
186
  app.whenReady().then(async () => {
178
187
  const mainWindow = new BrowserWindow({
179
188
  webPreferences: { preload: join(__dirname, '../preload/index.js') }
180
- // ...
181
189
  })
182
190
 
183
- // Load the latest downloaded ASAR, fallback to dev server if no update downloaded yet
184
- const loadUrl = updater.getLoadUrl()
185
- mainWindow.loadURL(loadUrl || 'http://localhost:5173')
191
+ // Load the latest downloaded ASAR, fallback to local dist if no update is available
192
+ // Supports multi-page entries (default: 'index.html')
193
+ const loadUrl = updater.getLoadUrl('login/index.html')
194
+ mainWindow.loadURL(loadUrl || `file://${join(__dirname, '../renderer/login/index.html')}`)
186
195
 
187
196
  // Auto-check on startup (respects forceUpdate, shows dialogs if autoPrompt: true)
188
197
  setTimeout(() => updater.checkForUpdatesAndNotify(), 3000)
189
198
  })
190
-
191
- // Expose manual update controls for custom frontend UI
192
- ipcMain.handle('updater:check', () => updater.check())
193
-
194
- // ⚠️ IMPORTANT: The first argument must be UpdateInfo (forwarded from the renderer),
195
- // and the second argument is the progress callback.
196
- ipcMain.handle('updater:download', async (event, info) =>
197
- updater.download(info, (percent) => {
198
- BrowserWindow.getAllWindows()[0]?.webContents.send('updater:progress', percent)
199
- })
200
- )
201
-
202
- ipcMain.on('updater:install', () => {
203
- app.relaunch()
204
- app.quit()
205
- })
206
199
  ```
207
200
 
208
201
  ---
@@ -212,36 +205,10 @@ ipcMain.on('updater:install', () => {
212
205
  ```typescript
213
206
  // src/preload/index.ts
214
207
  import { contextBridge, ipcRenderer } from 'electron'
208
+ import { exposeUpdaterPreload } from 'electron-updater-for-render/preload'
215
209
 
216
- contextBridge.exposeInMainWorld('updater', {
217
- check: () => ipcRenderer.invoke('updater:check'),
218
-
219
- // Forward the UpdateInfo object returned by check() so the main process
220
- // knows which version to download without re-fetching latest.json
221
- download: (info?: any) => ipcRenderer.invoke('updater:download', info),
222
-
223
- install: () => ipcRenderer.send('updater:install'),
224
-
225
- onProgress: (callback: (percent: number) => void) => {
226
- const fn = (_: any, p: number) => callback(p)
227
- ipcRenderer.on('updater:progress', fn)
228
- return () => ipcRenderer.removeListener('updater:progress', fn)
229
- },
230
-
231
- onBeforeRestart: (callback: () => void) => {
232
- const fn = () => callback()
233
- ipcRenderer.on('updater:before-restart', fn)
234
- return () => ipcRenderer.removeListener('updater:before-restart', fn)
235
- },
236
-
237
- // Listen to proactive status pushes from the main process
238
- // (e.g. when a background download finishes after user clicked "Later")
239
- onStatusChanged: (callback: (data: { status: string; version: string }) => void) => {
240
- const fn = (_: any, data: any) => callback(data)
241
- ipcRenderer.on('updater:status-changed', fn)
242
- return () => ipcRenderer.removeListener('updater:status-changed', fn)
243
- }
244
- })
210
+ // Expose the API under the 'updaterAPI' namespace, as expected by getUpdater()
211
+ contextBridge.exposeInMainWorld('updaterAPI', exposeUpdaterPreload(ipcRenderer))
245
212
  ```
246
213
 
247
214
  ---
@@ -256,6 +223,11 @@ The following example demonstrates the complete update flow with:
256
223
  ```vue
257
224
  <script setup lang="ts">
258
225
  import { ref, onMounted, onUnmounted } from 'vue'
226
+ import { getUpdater, getRouterBase } from 'electron-updater-for-render/renderer'
227
+
228
+ // Automatically detect the correct context-aware History base for MPA
229
+ const routerBase = getRouterBase()
230
+ const updater = getUpdater()
259
231
 
260
232
  // Status: 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'no-update'
261
233
  const status = ref<'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'no-update'>('idle')
@@ -268,15 +240,12 @@ let removeProgress: (() => void) | null = null
268
240
  let removeRestart: (() => void) | null = null
269
241
  let removeStatusChanged: (() => void) | null = null
270
242
 
271
- const getUpdater = () => (window as any).updater
243
+ const updater = getUpdater()
272
244
 
273
245
  // ── Check for updates ──────────────────────────────────────────────────────
274
246
  // check() returns { status: 'idle' | 'available' | 'ready', version, info }
275
247
  // 'ready' means an update is already downloaded and waiting for restart.
276
248
  const checkUpdate = async () => {
277
- const updater = getUpdater()
278
- if (!updater) return
279
-
280
249
  status.value = 'checking'
281
250
  try {
282
251
  const res = await updater.check()
@@ -298,13 +267,10 @@ const checkUpdate = async () => {
298
267
  // Pass the UpdateInfo object from check() so the main process doesn't need
299
268
  // to re-fetch latest.json, preventing version mismatches.
300
269
  const downloadUpdate = async () => {
301
- const updater = getUpdater()
302
- if (!updater) return
303
-
304
270
  status.value = 'downloading'
305
271
  progress.value = 0
306
272
 
307
- removeProgress = updater.onProgress((percent: number) => {
273
+ removeProgress = updater.onDownloadProgress((percent: number) => {
308
274
  progress.value = percent
309
275
  })
310
276
 
@@ -321,14 +287,13 @@ const downloadUpdate = async () => {
321
287
 
322
288
  // ── Install & restart ──────────────────────────────────────────────────────
323
289
  const installUpdate = () => {
324
- getUpdater()?.install()
290
+ updater.installAndRestart()
325
291
  }
326
292
 
327
293
  // ── Real-time status push from main process ───────────────────────────────
328
294
  // When a download completes in the background, the main process fires
329
295
  // onStatusChanged. We update the UI immediately without any user action.
330
296
  const initStatusListener = () => {
331
- const updater = getUpdater()
332
297
  if (updater?.onStatusChanged) {
333
298
  return updater.onStatusChanged((data: { status: string; version: string }) => {
334
299
  if (data.status === 'ready') {
@@ -342,7 +307,6 @@ const initStatusListener = () => {
342
307
 
343
308
  // ── Graceful restart ───────────────────────────────────────────────────────
344
309
  const initRestartListener = () => {
345
- const updater = getUpdater()
346
310
  if (updater?.onBeforeRestart) {
347
311
  return updater.onBeforeRestart(() => {
348
312
  // Save any unsaved state here before the app relaunches
@@ -407,31 +371,49 @@ onUnmounted(() => {
407
371
 
408
372
  The library supports both standard Electron file-loading (Hash) and modern SPA routing (History) via custom protocols.
409
373
 
410
- ### 1. Hash Mode (Default)
411
- Recommended for simple apps. It uses standard `file://` protocol.
374
+ ### 1. Hash Mode (Recommended: Zero-Config, Zero-Intrusion)
375
+ The **industry standard** for Electron applications. It is resilient to physical path changes, requires no base configuration, and needs zero plugin-related JS in your frontend.
412
376
 
413
- - **Main Process**: No extra config needed (defaults to `hash`).
377
+ - **Main Process**: No extra config needed (defaults to `'hash'`).
414
378
  - **Renderer (Vite)**: Set `base: './'` (or omit) in `vite.config.ts`.
415
379
  - **Renderer (Router)**: Use `createWebHashHistory()`.
380
+ - **Pros**: Pure decoupling; your frontend project remains completely unaware of the updater's existence.
416
381
 
417
- ### 2. History Mode (Modern)
418
- Supports clean URLs and standard browser refresh behavior. Uses a custom protocol (default `app://`) to bypass `file://` limitations.
382
+ ### 2. History Mode (Advanced)
383
+ Supports clean URLs and native browser refresh behavior. Due to physical path context in MPAs, the router needs to be aligned with the base path.
419
384
 
420
385
  - **Main Process**:
421
386
  ```typescript
422
387
  new RenderUpdater({
423
- updateUrl: '...',
424
- versionsDir: '...',
425
- routerMode: 'history', // Enable history mode
426
- protocol: 'my-app' // Optional: custom protocol name (default: 'app')
388
+ // ...
389
+ routerMode: 'history',
390
+ protocol: 'my-app' // Optional: custom protocol name (default: 'app')
391
+ })
392
+ ```
393
+
394
+ - **Renderer (Router) - Option A: Use Automated Helper (Recommended)**
395
+ Use our lightweight helper to automatically sense the physical base of the current window:
396
+ ```typescript
397
+ import { getRouterBase } from 'electron-updater-for-render/renderer'
398
+ const router = createRouter({
399
+ history: createWebHistory(getRouterBase()), // Auto-aligns with the app:// protocol path
400
+ routes: [...]
401
+ })
402
+ ```
403
+
404
+ - **Renderer (Router) - Option B: Manual Alignment (Zero-Intrusion)**
405
+ If you prefer not to import any plugin JS, you can manually provide a string that matches the path passed to `getLoadUrl()` (must end with `.html/`):
406
+ ```typescript
407
+ const router = createRouter({
408
+ history: createWebHistory('/index.html/'),
409
+ routes: [...]
427
410
  })
428
411
  ```
429
- > 💡 **No Boilerplate**: Protocol registration and privileged scheme setup are **automated** internally. You don't need to call `registerSchemesAsPrivileged` manually.
430
412
 
431
- - **Renderer (Build Tool — Vite, Webpack, etc.)**: **CRITICAL** — set your build tool's public base path to `/` for absolute asset URLs. This prevents 404 errors when refreshing on deep-nested routes.
432
- - **Vite**: `base: '/'`
433
- - **Webpack**: `output.publicPath: '/'`
434
- - **Renderer (Router)**: Use `createWebHistory()`.
413
+ ### 3. Multi-Page Application (MPA)
414
+ - **Multi-window Support**: Load different entry points for different windows.
415
+ - **History Refresh Support**: Supports browser refresh in History mode.
416
+ - **Base Path Detection**: Use `getRouterBase()` to automatically identify the current routing base.
435
417
 
436
418
  ---
437
419
 
@@ -443,6 +425,7 @@ Supports clean URLs and standard browser refresh behavior. Uses a custom protoco
443
425
  |---|---|---|---|
444
426
  | `outDir` | `string` | ✅ | Vite/Webpack build output directory |
445
427
  | `updatesDir` | `string` | — | Output directory for update packages. Default: `./dist_updates` |
428
+ | `rolloutRule` | `object` | — | Target whitelists: `{ deviceIds: string[] }`. |
446
429
  | `version` | `string` | — | Explicit version string, overrides `packageJsonPath` |
447
430
  | `packageJsonPath` | `string` | — | Custom path to `package.json`. Defaults to `process.cwd()/package.json` |
448
431
  | `asarName` | `string` | — | ASAR filename. Default: `renderer.asar` |
@@ -456,6 +439,9 @@ Supports clean URLs and standard browser refresh behavior. Uses a custom protoco
456
439
  |---|---|---|---|
457
440
  | `updateUrl` | `string` | ✅ | Base URL where update files are hosted |
458
441
  | `versionsDir` | `string` | ✅ | Local directory to store downloaded ASAR versions |
442
+ | `identity` | `object` | — | Fingerprint for rollouts logic (must include `deviceId`). |
443
+ | `allowPrerelease` | `boolean` | — | Skips prerelease tags if false. |
444
+ | `requestOptions` | `object` | — | Custom HTTP headers and query params. |
459
445
  | `publicKey` | `string` | — | RSA public key (PEM) for signature verification |
460
446
  | `autoDownload` | `boolean` | — | Auto-download without asking. Default: `false` |
461
447
  | `autoPrompt` | `boolean` | — | Show built-in native dialogs. Default: `true` |
@@ -475,7 +461,7 @@ Supports clean URLs and standard browser refresh behavior. Uses a custom protoco
475
461
  |---|---|---|
476
462
  | `check()` | `Promise<CheckResult>` | Check for updates. Returns `{ updateAvailable, status, version, info }`. Status is `'idle' \| 'available' \| 'ready'` |
477
463
  | `download(info?, onProgress?)` | `Promise<void>` | Download the update. Pass the `info` object from `check()` to avoid redundant network requests |
478
- | `getLoadUrl()` | `string` | Returns the URL to load the latest ASAR (`app://renderer/` or `file://...`). Empty string if no update downloaded |
464
+ | `getLoadUrl(entry?)` | `string` | Returns the URL to load the latest ASAR (`app://renderer/` or `file://...`). `entry` defaults to `index.html`. Returns empty string if no update downloaded |
479
465
  | `installAndRestart()` | `Promise<void>` | Apply the pending update and relaunch the app |
480
466
  | `checkForUpdatesAndNotify()` | `Promise<void>` | All-in-one: check + prompt + download + install with built-in native dialogs |
481
467
  | `setUpdatePending(version)` | `void` | Mark a version as downloaded and pending restart. Also fires `onStatusChanged` |
@@ -103,13 +103,42 @@ var RouterHandler = class _RouterHandler {
103
103
  return new Response("ASAR Not Found", { status: 404 });
104
104
  }
105
105
  let targetFile = import_path.default.join(asarPath, relativePath);
106
- const ext = import_path.default.extname(relativePath);
107
- if (!relativePath || relativePath === "/") {
108
- targetFile = import_path.default.join(asarPath, "index.html");
109
- } else if (ext === "") {
110
- targetFile = import_path.default.join(asarPath, "index.html");
111
- } else if (!import_fs.default.existsSync(targetFile)) {
112
- targetFile = import_path.default.join(asarPath, "index.html");
106
+ const isFileExist = import_fs.default.existsSync(targetFile) && import_fs.default.statSync(targetFile).isFile();
107
+ if (!isFileExist) {
108
+ let rawPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath;
109
+ let ext = import_path.default.extname(rawPath).toLowerCase();
110
+ if (ext && ext !== ".html") {
111
+ return new Response("Not Found", { status: 404 });
112
+ }
113
+ let fallbackSearchPath = rawPath;
114
+ let foundFallback = false;
115
+ while (true) {
116
+ let checkFile = "";
117
+ if (fallbackSearchPath !== "") {
118
+ checkFile = import_path.default.join(asarPath, fallbackSearchPath.endsWith(".html") ? fallbackSearchPath : fallbackSearchPath + ".html");
119
+ if (import_fs.default.existsSync(checkFile) && import_fs.default.statSync(checkFile).isFile()) {
120
+ targetFile = checkFile;
121
+ foundFallback = true;
122
+ break;
123
+ }
124
+ }
125
+ checkFile = import_path.default.join(asarPath, fallbackSearchPath, "index.html");
126
+ if (import_fs.default.existsSync(checkFile) && import_fs.default.statSync(checkFile).isFile()) {
127
+ targetFile = checkFile;
128
+ foundFallback = true;
129
+ break;
130
+ }
131
+ if (fallbackSearchPath === "") break;
132
+ const lastSlashIndex = fallbackSearchPath.lastIndexOf("/");
133
+ if (lastSlashIndex === -1) {
134
+ fallbackSearchPath = "";
135
+ } else {
136
+ fallbackSearchPath = fallbackSearchPath.substring(0, lastSlashIndex);
137
+ }
138
+ }
139
+ if (!foundFallback) {
140
+ return new Response("Not Found", { status: 404 });
141
+ }
113
142
  }
114
143
  const response = await import_electron.net.fetch((0, import_url.pathToFileURL)(targetFile).toString());
115
144
  const headers = new Headers(response.headers);
@@ -322,15 +351,24 @@ var RenderUpdater = class {
322
351
  get isUpdatePending() {
323
352
  return import_semver.default.gt(this._diskVersion || "0.0.0", this._activeVersion || "0.0.0");
324
353
  }
325
- getLoadUrl() {
354
+ /**
355
+ * 获取加载 URL。
356
+ * @param entry 入口文件路径,默认为 'index.html'。
357
+ * 对于多页应用 (MPA),可以传入 'login/index.html' 等。
358
+ */
359
+ getLoadUrl(entry = "index.html") {
326
360
  try {
327
361
  if (this._activeVersion && this._activeVersion !== "0.0.0") {
328
362
  const asarPath = import_path2.default.join(this.versionsDir, this._activeVersion, "renderer.asar");
329
363
  if (import_original_fs.default.existsSync(asarPath)) {
330
364
  if (this.routerMode === "history") {
331
- return `${this.protocolName}://renderer/`;
365
+ if (entry === "index.html" || entry === "/index.html") {
366
+ return `${this.protocolName}://renderer/`;
367
+ }
368
+ const normalizedEntry = entry.startsWith("/") ? entry.slice(1) : entry;
369
+ return `${this.protocolName}://renderer/${normalizedEntry}/`;
332
370
  }
333
- return `file://${asarPath}/index.html`;
371
+ return `file://${asarPath}/${entry}`;
334
372
  }
335
373
  }
336
374
  } catch (e) {
@@ -628,22 +666,28 @@ ${info.releaseNotes || "\u65E0\u8BE6\u7EC6\u8BB0\u5F55\u3002"}`,
628
666
  };
629
667
  function setupUpdaterIPC(updater) {
630
668
  import_electron3.ipcMain.handle("updater:check", () => updater.check());
631
- import_electron3.ipcMain.handle("updater:download", () => updater.download());
669
+ import_electron3.ipcMain.handle("updater:download", (_, info) => updater.download(info));
632
670
  import_electron3.ipcMain.handle("updater:installAndRestart", () => updater.installAndRestart());
633
671
  import_electron3.ipcMain.on("updater:useVersion", (_, version) => updater.useVersion(version));
672
+ const originalOnDownloadProgress = updater.onDownloadProgress;
634
673
  updater.onDownloadProgress = (percent) => {
674
+ if (originalOnDownloadProgress) originalOnDownloadProgress(percent);
635
675
  const { webContents } = require("electron");
636
676
  webContents.getAllWebContents().forEach((wc) => {
637
677
  wc.send("updater:onDownloadProgress", percent);
638
678
  });
639
679
  };
680
+ const originalOnStatusChanged = updater.onStatusChanged;
640
681
  updater.onStatusChanged = (status) => {
682
+ if (originalOnStatusChanged) originalOnStatusChanged(status);
641
683
  const { webContents } = require("electron");
642
684
  webContents.getAllWebContents().forEach((wc) => {
643
685
  wc.send("updater:onStatusChanged", status);
644
686
  });
645
687
  };
688
+ const originalOnBeforeRestart = updater.onBeforeRestart;
646
689
  updater.onBeforeRestart = () => {
690
+ if (originalOnBeforeRestart) originalOnBeforeRestart();
647
691
  const { webContents } = require("electron");
648
692
  webContents.getAllWebContents().forEach((wc) => {
649
693
  wc.send("updater:onBeforeRestart");
@@ -30,7 +30,12 @@ export declare class RenderUpdater {
30
30
  get activeVersion(): string;
31
31
  get pendingVersion(): string;
32
32
  get isUpdatePending(): boolean;
33
- getLoadUrl(): string;
33
+ /**
34
+ * 获取加载 URL。
35
+ * @param entry 入口文件路径,默认为 'index.html'。
36
+ * 对于多页应用 (MPA),可以传入 'login/index.html' 等。
37
+ */
38
+ getLoadUrl(entry?: string): string;
34
39
  check(): Promise<{
35
40
  updateAvailable: boolean;
36
41
  version?: string;
@@ -75,13 +75,42 @@ var RouterHandler = class _RouterHandler {
75
75
  return new Response("ASAR Not Found", { status: 404 });
76
76
  }
77
77
  let targetFile = path.join(asarPath, relativePath);
78
- const ext = path.extname(relativePath);
79
- if (!relativePath || relativePath === "/") {
80
- targetFile = path.join(asarPath, "index.html");
81
- } else if (ext === "") {
82
- targetFile = path.join(asarPath, "index.html");
83
- } else if (!fs.existsSync(targetFile)) {
84
- targetFile = path.join(asarPath, "index.html");
78
+ const isFileExist = fs.existsSync(targetFile) && fs.statSync(targetFile).isFile();
79
+ if (!isFileExist) {
80
+ let rawPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath;
81
+ let ext = path.extname(rawPath).toLowerCase();
82
+ if (ext && ext !== ".html") {
83
+ return new Response("Not Found", { status: 404 });
84
+ }
85
+ let fallbackSearchPath = rawPath;
86
+ let foundFallback = false;
87
+ while (true) {
88
+ let checkFile = "";
89
+ if (fallbackSearchPath !== "") {
90
+ checkFile = path.join(asarPath, fallbackSearchPath.endsWith(".html") ? fallbackSearchPath : fallbackSearchPath + ".html");
91
+ if (fs.existsSync(checkFile) && fs.statSync(checkFile).isFile()) {
92
+ targetFile = checkFile;
93
+ foundFallback = true;
94
+ break;
95
+ }
96
+ }
97
+ checkFile = path.join(asarPath, fallbackSearchPath, "index.html");
98
+ if (fs.existsSync(checkFile) && fs.statSync(checkFile).isFile()) {
99
+ targetFile = checkFile;
100
+ foundFallback = true;
101
+ break;
102
+ }
103
+ if (fallbackSearchPath === "") break;
104
+ const lastSlashIndex = fallbackSearchPath.lastIndexOf("/");
105
+ if (lastSlashIndex === -1) {
106
+ fallbackSearchPath = "";
107
+ } else {
108
+ fallbackSearchPath = fallbackSearchPath.substring(0, lastSlashIndex);
109
+ }
110
+ }
111
+ if (!foundFallback) {
112
+ return new Response("Not Found", { status: 404 });
113
+ }
85
114
  }
86
115
  const response = await net.fetch(pathToFileURL(targetFile).toString());
87
116
  const headers = new Headers(response.headers);
@@ -294,15 +323,24 @@ var RenderUpdater = class {
294
323
  get isUpdatePending() {
295
324
  return semver.gt(this._diskVersion || "0.0.0", this._activeVersion || "0.0.0");
296
325
  }
297
- getLoadUrl() {
326
+ /**
327
+ * 获取加载 URL。
328
+ * @param entry 入口文件路径,默认为 'index.html'。
329
+ * 对于多页应用 (MPA),可以传入 'login/index.html' 等。
330
+ */
331
+ getLoadUrl(entry = "index.html") {
298
332
  try {
299
333
  if (this._activeVersion && this._activeVersion !== "0.0.0") {
300
334
  const asarPath = path2.join(this.versionsDir, this._activeVersion, "renderer.asar");
301
335
  if (fs2.existsSync(asarPath)) {
302
336
  if (this.routerMode === "history") {
303
- return `${this.protocolName}://renderer/`;
337
+ if (entry === "index.html" || entry === "/index.html") {
338
+ return `${this.protocolName}://renderer/`;
339
+ }
340
+ const normalizedEntry = entry.startsWith("/") ? entry.slice(1) : entry;
341
+ return `${this.protocolName}://renderer/${normalizedEntry}/`;
304
342
  }
305
- return `file://${asarPath}/index.html`;
343
+ return `file://${asarPath}/${entry}`;
306
344
  }
307
345
  }
308
346
  } catch (e) {
@@ -600,22 +638,28 @@ ${info.releaseNotes || "\u65E0\u8BE6\u7EC6\u8BB0\u5F55\u3002"}`,
600
638
  };
601
639
  function setupUpdaterIPC(updater) {
602
640
  ipcMain.handle("updater:check", () => updater.check());
603
- ipcMain.handle("updater:download", () => updater.download());
641
+ ipcMain.handle("updater:download", (_, info) => updater.download(info));
604
642
  ipcMain.handle("updater:installAndRestart", () => updater.installAndRestart());
605
643
  ipcMain.on("updater:useVersion", (_, version) => updater.useVersion(version));
644
+ const originalOnDownloadProgress = updater.onDownloadProgress;
606
645
  updater.onDownloadProgress = (percent) => {
646
+ if (originalOnDownloadProgress) originalOnDownloadProgress(percent);
607
647
  const { webContents } = __require("electron");
608
648
  webContents.getAllWebContents().forEach((wc) => {
609
649
  wc.send("updater:onDownloadProgress", percent);
610
650
  });
611
651
  };
652
+ const originalOnStatusChanged = updater.onStatusChanged;
612
653
  updater.onStatusChanged = (status) => {
654
+ if (originalOnStatusChanged) originalOnStatusChanged(status);
613
655
  const { webContents } = __require("electron");
614
656
  webContents.getAllWebContents().forEach((wc) => {
615
657
  wc.send("updater:onStatusChanged", status);
616
658
  });
617
659
  };
660
+ const originalOnBeforeRestart = updater.onBeforeRestart;
618
661
  updater.onBeforeRestart = () => {
662
+ if (originalOnBeforeRestart) originalOnBeforeRestart();
619
663
  const { webContents } = __require("electron");
620
664
  webContents.getAllWebContents().forEach((wc) => {
621
665
  wc.send("updater:onBeforeRestart");
@@ -26,18 +26,24 @@ module.exports = __toCommonJS(preload_exports);
26
26
  function exposeUpdaterPreload(ipcRenderer) {
27
27
  return {
28
28
  check: () => ipcRenderer.invoke("updater:check"),
29
- download: () => ipcRenderer.invoke("updater:download"),
29
+ download: (info) => ipcRenderer.invoke("updater:download", info),
30
30
  installAndRestart: () => ipcRenderer.invoke("updater:installAndRestart"),
31
31
  useVersion: (version) => ipcRenderer.send("updater:useVersion", version),
32
32
  // 监听事件
33
33
  onDownloadProgress: (callback) => {
34
- ipcRenderer.on("updater:onDownloadProgress", (_, percent) => callback(percent));
34
+ const fn = (_, percent) => callback(percent);
35
+ ipcRenderer.on("updater:onDownloadProgress", fn);
36
+ return () => ipcRenderer.removeListener("updater:onDownloadProgress", fn);
35
37
  },
36
38
  onStatusChanged: (callback) => {
37
- ipcRenderer.on("updater:onStatusChanged", (_, data) => callback(data));
39
+ const fn = (_, data) => callback(data);
40
+ ipcRenderer.on("updater:onStatusChanged", fn);
41
+ return () => ipcRenderer.removeListener("updater:onStatusChanged", fn);
38
42
  },
39
43
  onBeforeRestart: (callback) => {
40
- ipcRenderer.on("updater:onBeforeRestart", () => callback());
44
+ const fn = () => callback();
45
+ ipcRenderer.on("updater:onBeforeRestart", fn);
46
+ return () => ipcRenderer.removeListener("updater:onBeforeRestart", fn);
41
47
  }
42
48
  };
43
49
  }
@@ -1,13 +1,13 @@
1
1
  import type { IpcRenderer } from 'electron';
2
2
  export declare function exposeUpdaterPreload(ipcRenderer: IpcRenderer): {
3
3
  check: () => Promise<any>;
4
- download: () => Promise<any>;
4
+ download: (info?: any) => Promise<any>;
5
5
  installAndRestart: () => Promise<any>;
6
6
  useVersion: (version: string) => void;
7
- onDownloadProgress: (callback: (percent: number) => void) => void;
7
+ onDownloadProgress: (callback: (percent: number) => void) => () => Electron.IpcRenderer;
8
8
  onStatusChanged: (callback: (data: {
9
9
  status: "ready" | "available" | "idle";
10
10
  version: string;
11
- }) => void) => void;
12
- onBeforeRestart: (callback: () => void) => void;
11
+ }) => void) => () => Electron.IpcRenderer;
12
+ onBeforeRestart: (callback: () => void) => () => Electron.IpcRenderer;
13
13
  };
@@ -2,18 +2,24 @@
2
2
  function exposeUpdaterPreload(ipcRenderer) {
3
3
  return {
4
4
  check: () => ipcRenderer.invoke("updater:check"),
5
- download: () => ipcRenderer.invoke("updater:download"),
5
+ download: (info) => ipcRenderer.invoke("updater:download", info),
6
6
  installAndRestart: () => ipcRenderer.invoke("updater:installAndRestart"),
7
7
  useVersion: (version) => ipcRenderer.send("updater:useVersion", version),
8
8
  // 监听事件
9
9
  onDownloadProgress: (callback) => {
10
- ipcRenderer.on("updater:onDownloadProgress", (_, percent) => callback(percent));
10
+ const fn = (_, percent) => callback(percent);
11
+ ipcRenderer.on("updater:onDownloadProgress", fn);
12
+ return () => ipcRenderer.removeListener("updater:onDownloadProgress", fn);
11
13
  },
12
14
  onStatusChanged: (callback) => {
13
- ipcRenderer.on("updater:onStatusChanged", (_, data) => callback(data));
15
+ const fn = (_, data) => callback(data);
16
+ ipcRenderer.on("updater:onStatusChanged", fn);
17
+ return () => ipcRenderer.removeListener("updater:onStatusChanged", fn);
14
18
  },
15
19
  onBeforeRestart: (callback) => {
16
- ipcRenderer.on("updater:onBeforeRestart", () => callback());
20
+ const fn = () => callback();
21
+ ipcRenderer.on("updater:onBeforeRestart", fn);
22
+ return () => ipcRenderer.removeListener("updater:onBeforeRestart", fn);
17
23
  }
18
24
  };
19
25
  }
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/renderer/index.ts
21
21
  var renderer_exports = {};
22
22
  __export(renderer_exports, {
23
+ getRouterBase: () => getRouterBase,
23
24
  getUpdater: () => getUpdater
24
25
  });
25
26
  module.exports = __toCommonJS(renderer_exports);
@@ -33,7 +34,17 @@ function getUpdater(namespace = "updaterAPI") {
33
34
  }
34
35
  return api;
35
36
  }
37
+ function getRouterBase() {
38
+ if (typeof window === "undefined") return "/";
39
+ const pathname = window.location.pathname;
40
+ const htmlIndex = pathname.toLowerCase().lastIndexOf(".html");
41
+ if (htmlIndex !== -1) {
42
+ return pathname.substring(0, htmlIndex + 6);
43
+ }
44
+ return "/";
45
+ }
36
46
  // Annotate the CommonJS export names for ESM import in node:
37
47
  0 && (module.exports = {
48
+ getRouterBase,
38
49
  getUpdater
39
50
  });
@@ -6,18 +6,25 @@ export interface UpdaterClientAPI {
6
6
  info?: UpdateInfo;
7
7
  status: 'idle' | 'available' | 'ready';
8
8
  }>;
9
- download(): Promise<void>;
9
+ download(info?: UpdateInfo): Promise<void>;
10
10
  installAndRestart(): Promise<void>;
11
11
  useVersion(version: string): void;
12
- onDownloadProgress(callback: (percent: number) => void): void;
12
+ onDownloadProgress(callback: (percent: number) => void): () => void;
13
13
  onStatusChanged(callback: (data: {
14
14
  status: 'ready' | 'available' | 'idle';
15
15
  version: string;
16
- }) => void): void;
17
- onBeforeRestart(callback: () => void): void;
16
+ }) => void): () => void;
17
+ onBeforeRestart(callback: () => void): () => void;
18
18
  }
19
19
  /**
20
20
  * Ensures the updater preload API is accessible and typed.
21
21
  * This should only be used in the renderer process (e.g. standard Vue/React contexts).
22
22
  */
23
23
  export declare function getUpdater(namespace?: string): UpdaterClientAPI;
24
+ /**
25
+ * 自动探测当前页面的路由基准路径 (Base URL)
26
+ * 专门用于解决多页面 (MPA) 在 History 模式下刷新后 404 的问题。
27
+ * 例如:当 Electron 加载的是 app://renderer/login/index.html/ 时,
28
+ * 本函数将返回 "/login/index.html/",您可以直接将其传给 createWebHistory()。
29
+ */
30
+ export declare function getRouterBase(): string;
@@ -9,6 +9,16 @@ function getUpdater(namespace = "updaterAPI") {
9
9
  }
10
10
  return api;
11
11
  }
12
+ function getRouterBase() {
13
+ if (typeof window === "undefined") return "/";
14
+ const pathname = window.location.pathname;
15
+ const htmlIndex = pathname.toLowerCase().lastIndexOf(".html");
16
+ if (htmlIndex !== -1) {
17
+ return pathname.substring(0, htmlIndex + 6);
18
+ }
19
+ return "/";
20
+ }
12
21
  export {
22
+ getRouterBase,
13
23
  getUpdater
14
24
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "electron-updater-for-render",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.1",
4
4
  "description": "A lightweight incremental updater for Electron renderer processes",
5
5
  "type": "module",
6
6
  "bin": {