mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-03 01:50:23 +08:00
feat: add automatic update functionality for Electron app
Closes #613 - Add electron-updater for Windows NSIS and Linux AppImage auto-update - macOS and Linux DEB fall back to GitHub API check with download link - Add "Check for Updates" menu item with i18n (en, zh, ja, zh-Hant) - Fix Windows CI workflow to publish correct latest.yml after code signing - Add update toast notifications in renderer via sonner
This commit is contained in:
14
electron/electron.d.ts
vendored
14
electron/electron.d.ts
vendored
@@ -2,6 +2,14 @@
|
||||
* Type declarations for Electron API exposed via preload script
|
||||
*/
|
||||
|
||||
/** Update status data sent from main process */
|
||||
type UpdateStatusData =
|
||||
| { status: "available"; version: string }
|
||||
| { status: "available-manual"; version: string; url: string }
|
||||
| { status: "downloading"; percent: number }
|
||||
| { status: "downloaded" }
|
||||
| { status: "error"; message: string }
|
||||
|
||||
/** Configuration preset interface */
|
||||
interface ConfigPreset {
|
||||
id: string
|
||||
@@ -74,6 +82,12 @@ declare global {
|
||||
>
|
||||
/** Set user's preferred locale */
|
||||
setUserLocale: (locale: string) => Promise<SetUserLocaleResult>
|
||||
/** Listen for update status events from main process */
|
||||
onUpdateStatus: (
|
||||
callback: (data: UpdateStatusData) => void,
|
||||
) => () => void
|
||||
/** Start downloading the available update */
|
||||
startDownload: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Settings window Electron API */
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type MenuItemConstructorOptions,
|
||||
shell,
|
||||
} from "electron"
|
||||
import { checkForUpdatesManual } from "./auto-updater"
|
||||
import {
|
||||
applyPresetToEnv,
|
||||
getAllPresets,
|
||||
@@ -156,6 +157,11 @@ function getMenuTemplate(): MenuItemConstructorOptions[] {
|
||||
template.push({
|
||||
label: t.help,
|
||||
submenu: [
|
||||
{
|
||||
label: t.checkForUpdates,
|
||||
click: () => checkForUpdatesManual(),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: t.documentation,
|
||||
click: async () => {
|
||||
|
||||
279
electron/main/auto-updater.ts
Normal file
279
electron/main/auto-updater.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import https from "node:https"
|
||||
import { app, dialog, ipcMain } from "electron"
|
||||
import electronUpdater from "electron-updater"
|
||||
import { getMainWindow } from "./window-manager"
|
||||
|
||||
const { autoUpdater } = electronUpdater
|
||||
|
||||
const CHECK_INTERVAL = 4 * 60 * 60 * 1000 // 4 hours
|
||||
const STARTUP_DELAY = 10_000 // 10 seconds
|
||||
const GITHUB_API_URL =
|
||||
"https://api.github.com/repos/DayuanJiang/next-ai-draw-io/releases/latest"
|
||||
|
||||
let isChecking = false
|
||||
let updateDownloaded = false
|
||||
|
||||
/**
|
||||
* Whether this platform supports electron-updater auto-update.
|
||||
* macOS: disabled because builds are ad-hoc signed (no Apple Developer cert).
|
||||
*/
|
||||
function supportsAutoUpdate(): boolean {
|
||||
if (process.platform === "darwin") return false
|
||||
if (process.platform === "win32" && process.env.PORTABLE_EXECUTABLE_DIR)
|
||||
return false
|
||||
if (process.platform === "linux" && !process.env.APPIMAGE) return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two semver-like version strings numerically.
|
||||
* Returns true if remote > local.
|
||||
*/
|
||||
function isNewerVersion(remote: string, local: string): boolean {
|
||||
const r = remote.replace(/^v/, "").split(".").map(Number)
|
||||
const l = local.replace(/^v/, "").split(".").map(Number)
|
||||
const len = Math.max(r.length, l.length)
|
||||
for (let i = 0; i < len; i++) {
|
||||
const rv = r[i] || 0
|
||||
const lv = l[i] || 0
|
||||
if (rv > lv) return true
|
||||
if (rv < lv) return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Send update status to the renderer via IPC
|
||||
*/
|
||||
function sendStatus(data: Record<string, unknown>) {
|
||||
const win = getMainWindow()
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send("update-status", data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub API for latest release (used on macOS and Linux DEB)
|
||||
*/
|
||||
function checkGitHubRelease(manual: boolean) {
|
||||
const req = https.get(
|
||||
GITHUB_API_URL,
|
||||
{
|
||||
headers: { "User-Agent": "next-ai-draw-io" },
|
||||
timeout: 15000,
|
||||
},
|
||||
(res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
console.error(`GitHub API returned status ${res.statusCode}`)
|
||||
if (manual) {
|
||||
dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "Update Check Failed",
|
||||
message:
|
||||
"Could not check for updates. Please try again later.",
|
||||
})
|
||||
}
|
||||
isChecking = false
|
||||
return
|
||||
}
|
||||
|
||||
let body = ""
|
||||
res.on("data", (chunk: string) => {
|
||||
body += chunk
|
||||
})
|
||||
res.on("end", () => {
|
||||
try {
|
||||
const data = JSON.parse(body)
|
||||
const remoteVersion = data.tag_name || ""
|
||||
const localVersion = app.getVersion()
|
||||
|
||||
if (isNewerVersion(remoteVersion, localVersion)) {
|
||||
sendStatus({
|
||||
status: "available-manual",
|
||||
version: remoteVersion.replace(/^v/, ""),
|
||||
url: data.html_url,
|
||||
})
|
||||
} else if (manual) {
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "No Updates",
|
||||
message: "You're up to date!",
|
||||
detail: `Version ${localVersion} is the latest version.`,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse GitHub release:", err)
|
||||
if (manual) {
|
||||
dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "Update Check Failed",
|
||||
message:
|
||||
"Could not check for updates. Please try again later.",
|
||||
})
|
||||
}
|
||||
}
|
||||
isChecking = false
|
||||
})
|
||||
},
|
||||
)
|
||||
req.on("timeout", () => {
|
||||
req.destroy()
|
||||
isChecking = false
|
||||
})
|
||||
req.on("error", (err) => {
|
||||
console.error("GitHub API request failed:", err)
|
||||
if (manual) {
|
||||
dialog.showMessageBox({
|
||||
type: "error",
|
||||
title: "Update Check Failed",
|
||||
message: "Could not check for updates. Please try again later.",
|
||||
})
|
||||
}
|
||||
isChecking = false
|
||||
})
|
||||
req.end()
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up electron-updater event handlers (Windows NSIS / Linux AppImage)
|
||||
*/
|
||||
function setupAutoUpdater() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.autoInstallOnAppQuit = true
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
console.log("Update available:", info.version)
|
||||
sendStatus({ status: "available", version: info.version })
|
||||
isChecking = false
|
||||
})
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
console.log("No update available")
|
||||
isChecking = false
|
||||
})
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
sendStatus({
|
||||
status: "downloading",
|
||||
percent: progress.percent,
|
||||
})
|
||||
})
|
||||
|
||||
autoUpdater.on("update-downloaded", () => {
|
||||
updateDownloaded = true
|
||||
console.log("Update downloaded")
|
||||
sendStatus({ status: "downloaded" })
|
||||
showRestartDialog()
|
||||
})
|
||||
|
||||
autoUpdater.on("error", (err) => {
|
||||
console.error("Auto-update error:", err)
|
||||
sendStatus({ status: "error", message: String(err) })
|
||||
isChecking = false
|
||||
})
|
||||
|
||||
// IPC: renderer requests download
|
||||
ipcMain.handle("updater:start-download", () => {
|
||||
return autoUpdater.downloadUpdate()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Show restart dialog after update downloaded
|
||||
*/
|
||||
async function showRestartDialog() {
|
||||
const result = await dialog.showMessageBox({
|
||||
type: "info",
|
||||
buttons: ["Restart Now", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
title: "Update Ready",
|
||||
message: "A new version has been downloaded.",
|
||||
detail: "Restart the app to install the update.",
|
||||
})
|
||||
if (result.response === 0) {
|
||||
autoUpdater.quitAndInstall()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single update check
|
||||
*/
|
||||
function doCheck(manual: boolean) {
|
||||
if (isChecking) return
|
||||
isChecking = true
|
||||
|
||||
// If update already downloaded, just re-show restart dialog
|
||||
if (updateDownloaded && supportsAutoUpdate()) {
|
||||
isChecking = false
|
||||
showRestartDialog()
|
||||
return
|
||||
}
|
||||
|
||||
if (supportsAutoUpdate()) {
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
console.error("checkForUpdates failed:", err)
|
||||
isChecking = false
|
||||
})
|
||||
// For manual check, show "up to date" if no update found
|
||||
if (manual) {
|
||||
const onNotAvailable = () => {
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "No Updates",
|
||||
message: "You're up to date!",
|
||||
detail: `Version ${app.getVersion()} is the latest version.`,
|
||||
})
|
||||
cleanup()
|
||||
}
|
||||
// Clean up listeners when either event fires, preventing stale listeners
|
||||
const onAvailable = () => cleanup()
|
||||
const onError = () => cleanup()
|
||||
const cleanup = () => {
|
||||
autoUpdater.off("update-not-available", onNotAvailable)
|
||||
autoUpdater.off("update-available", onAvailable)
|
||||
autoUpdater.off("error", onError)
|
||||
}
|
||||
autoUpdater.once("update-not-available", onNotAvailable)
|
||||
autoUpdater.once("update-available", onAvailable)
|
||||
autoUpdater.once("error", onError)
|
||||
}
|
||||
} else {
|
||||
checkGitHubRelease(manual)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auto-updater. Call once after createWindow().
|
||||
*/
|
||||
export function initAutoUpdater() {
|
||||
if (!app.isPackaged) return
|
||||
|
||||
if (supportsAutoUpdate()) {
|
||||
setupAutoUpdater()
|
||||
} else {
|
||||
// Register no-op handler so renderer doesn't get an unhandled error
|
||||
ipcMain.handle("updater:start-download", () => {})
|
||||
}
|
||||
|
||||
// First check after startup delay
|
||||
setTimeout(() => doCheck(false), STARTUP_DELAY)
|
||||
|
||||
// Periodic checks
|
||||
setInterval(() => doCheck(false), CHECK_INTERVAL)
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual update check from menu item
|
||||
*/
|
||||
export function checkForUpdatesManual() {
|
||||
if (!app.isPackaged) {
|
||||
dialog.showMessageBox({
|
||||
type: "info",
|
||||
title: "Development Mode",
|
||||
message: "Auto-update is not available in development mode.",
|
||||
})
|
||||
return
|
||||
}
|
||||
doCheck(true)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow, dialog, shell } from "electron"
|
||||
import { buildAppMenu } from "./app-menu"
|
||||
import { initAutoUpdater } from "./auto-updater"
|
||||
import { getCurrentPresetEnv } from "./config-manager"
|
||||
import { loadEnvFile } from "./env-loader"
|
||||
import { registerIpcHandlers } from "./ipc-handlers"
|
||||
@@ -58,6 +59,11 @@ if (!gotTheLock) {
|
||||
|
||||
// Create main window
|
||||
createWindow(serverUrl)
|
||||
|
||||
// Initialize auto-updater (production only)
|
||||
if (!isDev) {
|
||||
initAutoUpdater()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start application:", error)
|
||||
dialog.showErrorBox(
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface MenuTranslations {
|
||||
help: string
|
||||
documentation: string
|
||||
reportIssue: string
|
||||
checkForUpdates: string
|
||||
}
|
||||
|
||||
const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
@@ -62,6 +63,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
help: "Help",
|
||||
documentation: "Documentation",
|
||||
reportIssue: "Report Issue",
|
||||
checkForUpdates: "Check for Updates...",
|
||||
},
|
||||
|
||||
zh: {
|
||||
@@ -90,6 +92,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
help: "帮助",
|
||||
documentation: "文档",
|
||||
reportIssue: "报告问题",
|
||||
checkForUpdates: "检查更新...",
|
||||
},
|
||||
|
||||
ja: {
|
||||
@@ -118,6 +121,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
help: "ヘルプ",
|
||||
documentation: "ドキュメント",
|
||||
reportIssue: "問題を報告",
|
||||
checkForUpdates: "アップデートを確認...",
|
||||
},
|
||||
|
||||
"zh-Hant": {
|
||||
@@ -146,6 +150,7 @@ const translations: Record<MenuLocale, MenuTranslations> = {
|
||||
help: "說明",
|
||||
documentation: "文件",
|
||||
reportIssue: "回報問題",
|
||||
checkForUpdates: "檢查更新...",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { contextBridge, ipcRenderer } from "electron"
|
||||
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron"
|
||||
|
||||
/**
|
||||
* Expose safe APIs to the renderer process
|
||||
@@ -31,4 +31,13 @@ contextBridge.exposeInMainWorld("electronAPI", {
|
||||
getUserLocale: () => ipcRenderer.invoke("get-user-locale"),
|
||||
setUserLocale: (locale: string) =>
|
||||
ipcRenderer.invoke("set-user-locale", locale),
|
||||
|
||||
// Auto-update
|
||||
onUpdateStatus: (callback: (data: UpdateStatusData) => void) => {
|
||||
const handler = (_event: IpcRendererEvent, data: UpdateStatusData) =>
|
||||
callback(data)
|
||||
ipcRenderer.on("update-status", handler)
|
||||
return () => ipcRenderer.removeListener("update-status", handler)
|
||||
},
|
||||
startDownload: () => ipcRenderer.invoke("updater:start-download"),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user