From e27afe6f0ddd263d43b66f5ce6549a9f1f4509ae Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Fri, 3 Apr 2026 17:46:27 +0900 Subject: [PATCH] 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 --- .github/workflows/electron-release.yml | 9 +- components/chat-panel.tsx | 13 ++ components/update-toast.tsx | 124 +++++++++++ electron/electron.d.ts | 14 ++ electron/main/app-menu.ts | 6 + electron/main/auto-updater.ts | 279 +++++++++++++++++++++++++ electron/main/index.ts | 6 + electron/main/menu-i18n.ts | 5 + electron/preload/index.ts | 11 +- hooks/use-auto-update.ts | 86 ++++++++ package-lock.json | 118 +++++++---- package.json | 1 + scripts/fix-latest-yml.mjs | 105 ++++++++++ 13 files changed, 739 insertions(+), 38 deletions(-) create mode 100644 components/update-toast.tsx create mode 100644 electron/main/auto-updater.ts create mode 100644 hooks/use-auto-update.ts create mode 100644 scripts/fix-latest-yml.mjs diff --git a/.github/workflows/electron-release.yml b/.github/workflows/electron-release.yml index 54bf705..c7a0862 100644 --- a/.github/workflows/electron-release.yml +++ b/.github/workflows/electron-release.yml @@ -105,9 +105,14 @@ jobs: wait-for-completion: true output-artifact-directory: release-signed - - name: Upload signed artifacts to release + - name: Fix latest.yml with signed exe hashes + run: node scripts/fix-latest-yml.mjs + + - name: Upload signed artifacts and update metadata to release uses: softprops/action-gh-release@v2 with: - files: release-signed/*.exe + files: | + release-signed/*.exe + release/latest*.yml env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/components/chat-panel.tsx b/components/chat-panel.tsx index bd763e1..e2a576b 100644 --- a/components/chat-panel.tsx +++ b/components/chat-panel.tsx @@ -25,6 +25,7 @@ import Image from "@/components/image-with-basepath" import { ModelConfigDialog } from "@/components/model-config-dialog" import { SettingsDialog } from "@/components/settings-dialog" import { useDiagram } from "@/contexts/diagram-context" +import { useAutoUpdate } from "@/hooks/use-auto-update" import { useDiagramToolHandlers } from "@/hooks/use-diagram-tool-handlers" import { useDictionary } from "@/hooks/use-dictionary" import { getSelectedAIConfig, useModelConfig } from "@/hooks/use-model-config" @@ -1253,10 +1254,22 @@ export default function ChatPanel({ sendChatMessage(newParts, savedXml, previousXml, sessionId) } + // Auto-update listener (must be before early return so it's always active) + useAutoUpdate() + // Collapsed view (desktop only) if (!isVisible && !isMobile) { return (
+ void + onDismiss: () => void +} + +interface UpdateToastManualProps { + variant: "manual" + version: string + url: string + onDismiss: () => void +} + +interface UpdateToastDownloadingProps { + variant: "downloading" + percent: number + onDismiss: () => void +} + +type UpdateToastProps = + | UpdateToastAvailableProps + | UpdateToastManualProps + | UpdateToastDownloadingProps + +export function UpdateToast(props: UpdateToastProps) { + const { variant, onDismiss } = props + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault() + onDismiss() + } + } + + return ( +
+
+ +
+ +
+ {variant === "downloading" ? ( + + Downloading update... {Math.round(props.percent)}% + + ) : ( + + Version {props.version} is available + + )} +
+ + {variant === "download" && ( + + )} + + {variant === "manual" && ( + + )} + + +
+ ) +} diff --git a/electron/electron.d.ts b/electron/electron.d.ts index ae49580..0fc04ae 100644 --- a/electron/electron.d.ts +++ b/electron/electron.d.ts @@ -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 + /** Listen for update status events from main process */ + onUpdateStatus: ( + callback: (data: UpdateStatusData) => void, + ) => () => void + /** Start downloading the available update */ + startDownload: () => Promise } /** Settings window Electron API */ diff --git a/electron/main/app-menu.ts b/electron/main/app-menu.ts index bd77829..74f8976 100644 --- a/electron/main/app-menu.ts +++ b/electron/main/app-menu.ts @@ -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 () => { diff --git a/electron/main/auto-updater.ts b/electron/main/auto-updater.ts new file mode 100644 index 0000000..862e6b9 --- /dev/null +++ b/electron/main/auto-updater.ts @@ -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) { + 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) +} diff --git a/electron/main/index.ts b/electron/main/index.ts index 6c613da..38d790c 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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( diff --git a/electron/main/menu-i18n.ts b/electron/main/menu-i18n.ts index e641f30..448a6e0 100644 --- a/electron/main/menu-i18n.ts +++ b/electron/main/menu-i18n.ts @@ -33,6 +33,7 @@ export interface MenuTranslations { help: string documentation: string reportIssue: string + checkForUpdates: string } const translations: Record = { @@ -62,6 +63,7 @@ const translations: Record = { help: "Help", documentation: "Documentation", reportIssue: "Report Issue", + checkForUpdates: "Check for Updates...", }, zh: { @@ -90,6 +92,7 @@ const translations: Record = { help: "帮助", documentation: "文档", reportIssue: "报告问题", + checkForUpdates: "检查更新...", }, ja: { @@ -118,6 +121,7 @@ const translations: Record = { help: "ヘルプ", documentation: "ドキュメント", reportIssue: "問題を報告", + checkForUpdates: "アップデートを確認...", }, "zh-Hant": { @@ -146,6 +150,7 @@ const translations: Record = { help: "說明", documentation: "文件", reportIssue: "回報問題", + checkForUpdates: "檢查更新...", }, } diff --git a/electron/preload/index.ts b/electron/preload/index.ts index b648ca1..cdb2010 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -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"), }) diff --git a/hooks/use-auto-update.ts b/hooks/use-auto-update.ts new file mode 100644 index 0000000..afc17ab --- /dev/null +++ b/hooks/use-auto-update.ts @@ -0,0 +1,86 @@ +import { createElement, useEffect, useRef } from "react" +import { toast } from "sonner" +import { UpdateToast } from "@/components/update-toast" + +export function useAutoUpdate() { + const downloadToastId = useRef(null) + + useEffect(() => { + const api = (window as Window).electronAPI + if (!api?.onUpdateStatus) return + + const cleanup = api.onUpdateStatus((data) => { + switch (data.status) { + case "available": + toast.custom( + (t) => + createElement(UpdateToast, { + variant: "download", + version: data.version, + onDownload: () => { + toast.dismiss(t) + api.startDownload().catch(() => { + // Error will come through update-status channel + }) + }, + onDismiss: () => toast.dismiss(t), + }), + { duration: 15000 }, + ) + break + + case "available-manual": + toast.custom( + (t) => + createElement(UpdateToast, { + variant: "manual", + version: data.version, + url: data.url, + onDismiss: () => toast.dismiss(t), + }), + { duration: Number.POSITIVE_INFINITY }, + ) + break + + case "downloading": { + const id = + downloadToastId.current ?? + `download-progress-${Date.now()}` + downloadToastId.current = id + toast.custom( + (t) => + createElement(UpdateToast, { + variant: "downloading", + percent: data.percent, + onDismiss: () => toast.dismiss(t), + }), + { id, duration: Number.POSITIVE_INFINITY }, + ) + break + } + + case "downloaded": + // Dismiss progress toast — native restart dialog handles the rest + if (downloadToastId.current) { + toast.dismiss(downloadToastId.current) + downloadToastId.current = null + } + break + + case "error": + if (downloadToastId.current) { + // Error during active download — inform the user + toast.dismiss(downloadToastId.current) + downloadToastId.current = null + toast.error( + "Update download failed. Please try again later.", + ) + } + // Otherwise: silent — background check failure, logged in main process + break + } + }) + + return cleanup + }, []) +} diff --git a/package-lock.json b/package-lock.json index 556ed88..35c726f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,10 +48,9 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "electron-updater": "^6.8.3", "idb": "^8.0.3", "jsonrepair": "^3.13.1", - "lightningcss": "^1.32.0", - "lightningcss-linux-x64-gnu": "^1.32.0", "lucide-react": "^0.577.0", "motion": "^12.23.25", "nanoid": "^5.0.0", @@ -2226,9 +2225,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2246,9 +2242,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2266,9 +2259,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -2286,9 +2276,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -8259,9 +8246,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -10062,7 +10046,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -10698,7 +10681,6 @@ "version": "9.5.1", "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz", "integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==", - "dev": true, "license": "MIT", "dependencies": { "debug": "^4.3.4", @@ -12797,6 +12779,69 @@ "dev": true, "license": "ISC" }, + "node_modules/electron-updater": { + "version": "6.8.3", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz", + "integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==", + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.5.1", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron-winstaller": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", @@ -14915,7 +14960,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/gzip-size": { @@ -16148,7 +16192,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -16366,7 +16409,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "dev": true, "license": "MIT" }, "node_modules/levn": { @@ -16567,9 +16609,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16753,9 +16792,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16776,9 +16812,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -16799,9 +16832,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -17069,6 +17099,19 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -21055,7 +21098,6 @@ "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", - "dev": true, "license": "BlueOak-1.0.0", "engines": { "node": ">=11.0.0" @@ -22475,6 +22517,12 @@ "semver": "bin/semver" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 965e11f..4e43536 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "electron-updater": "^6.8.3", "idb": "^8.0.3", "jsonrepair": "^3.13.1", "lucide-react": "^0.577.0", diff --git a/scripts/fix-latest-yml.mjs b/scripts/fix-latest-yml.mjs new file mode 100644 index 0000000..d39d250 --- /dev/null +++ b/scripts/fix-latest-yml.mjs @@ -0,0 +1,105 @@ +/** + * Fix latest.yml metadata after Windows code signing. + * + * electron-builder generates latest.yml with hashes of unsigned executables. + * After SignPath signs them, the hashes change. This script reads each + * latest*.yml in release/, finds the matching signed exe in release-signed/, + * and rewrites the sha512 and size fields. + * + * Usage: node scripts/fix-latest-yml.mjs + */ + +import { createHash } from "node:crypto" +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +const RELEASE_DIR = "release" +const SIGNED_DIR = "release-signed" + +// Find all latest*.yml files +const ymlFiles = readdirSync(RELEASE_DIR).filter( + (f) => f.startsWith("latest") && f.endsWith(".yml"), +) + +if (ymlFiles.length === 0) { + console.log("No latest*.yml files found in release/") + process.exit(0) +} + +// Build a map of signed exe filenames to their hash and size +const signedFiles = new Map() +for (const f of readdirSync(SIGNED_DIR)) { + if (!f.endsWith(".exe")) continue + const filePath = join(SIGNED_DIR, f) + const buffer = readFileSync(filePath) + const sha512 = createHash("sha512").update(buffer).digest("base64") + const size = statSync(filePath).size + signedFiles.set(f, { sha512, size }) +} + +if (signedFiles.size === 0) { + console.error("No signed .exe files found in release-signed/") + process.exit(1) +} + +console.log( + `Found ${signedFiles.size} signed exe(s):`, + [...signedFiles.keys()].join(", "), +) + +for (const ymlFile of ymlFiles) { + const ymlPath = join(RELEASE_DIR, ymlFile) + const content = readFileSync(ymlPath, "utf-8") + const lines = content.split("\n") + const outputLines = [] + + // Track the current file entry being processed (by url field) + let currentExeName = null + + for (const line of lines) { + // Match " url: SomeFile.exe" inside the files array + const urlMatch = line.match(/^\s+url:\s+(.+\.exe)\s*$/) + if (urlMatch) { + currentExeName = urlMatch[1] + outputLines.push(line) + continue + } + + // Match top-level "path: SomeFile.exe" + const pathMatch = line.match(/^path:\s+(.+\.exe)\s*$/) + if (pathMatch) { + currentExeName = pathMatch[1] + outputLines.push(line) + continue + } + + // Replace sha512 lines + const sha512Match = line.match(/^(\s*)sha512:\s+/) + if (sha512Match && currentExeName && signedFiles.has(currentExeName)) { + const indent = sha512Match[1] + outputLines.push( + `${indent}sha512: ${signedFiles.get(currentExeName).sha512}`, + ) + continue + } + + // Replace size lines + const sizeMatch = line.match(/^(\s*)size:\s+\d+/) + if (sizeMatch && currentExeName && signedFiles.has(currentExeName)) { + const indent = sizeMatch[1] + outputLines.push( + `${indent}size: ${signedFiles.get(currentExeName).size}`, + ) + // Reset current exe after processing both sha512 and size + currentExeName = null + continue + } + + outputLines.push(line) + } + + writeFileSync(ymlPath, outputLines.join("\n")) + console.log(`Updated ${ymlFile}`) +} + +console.log("Done!")