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!")