From 97c92f7343f4dbeed74cbd149efc7ef50056e727 Mon Sep 17 00:00:00 2001 From: Nazmus Samir Date: Fri, 19 Jun 2026 02:22:18 -0400 Subject: [PATCH] feat: add desktop update controls --- lat.md/desktop-updates.md | 9 +++ lat.md/lat.md | 1 + src/main/index.ts | 39 ++++++++++- src/preload/index.d.ts | 2 + src/preload/index.ts | 4 ++ src/renderer/src/screens/Layout/Layout.tsx | 64 +++++++++++++++---- .../src/screens/Settings/Settings.tsx | 39 ++++++++++- src/shared/i18n/locales/en/settings.ts | 3 + 8 files changed, 145 insertions(+), 16 deletions(-) create mode 100644 lat.md/desktop-updates.md diff --git a/lat.md/desktop-updates.md b/lat.md/desktop-updates.md new file mode 100644 index 000000000..852c9e5b7 --- /dev/null +++ b/lat.md/desktop-updates.md @@ -0,0 +1,9 @@ +# Desktop Updates + +Desktop updates use GitHub releases and expose both a startup upgrade action and a Settings auto-upgrade preference. + +The Electron main process configures `electron-updater` against the repository publisher metadata from `electron-builder.yml`, which points at `fathah/hermes-desktop`. [[src/main/index.ts#setupUpdater]] registers update IPC handlers, persists the auto-upgrade preference under Electron `userData`, and applies that preference to `autoUpdater.autoDownload`. + +When GitHub reports a newer release, [[src/renderer/src/screens/Layout/Layout.tsx#Layout]] shows an upgrade button in the sidebar footer as soon as the app reaches the main layout. The button downloads the update when needed, shows download progress, and changes into a restart action after the update is ready. + +[[src/renderer/src/screens/Settings/Settings.tsx#Settings]] exposes the auto-upgrade desktop app toggle in the Hermes Agent settings section. When enabled, the startup release check downloads the update automatically; when disabled, the startup button remains available but downloading waits for the user's click. diff --git a/lat.md/lat.md b/lat.md/lat.md index 241632106..c10c95b28 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -5,3 +5,4 @@ This directory defines the high-level concepts, business logic, and architecture - [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed. - [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts. - [[window-chrome]] — the browser-style title bar where open-conversation tabs sit on top of the window drag region, clickable while empty space still drags. +- [[desktop-updates]] — GitHub release checks, startup upgrade button behavior, and the Settings auto-upgrade preference. diff --git a/src/main/index.ts b/src/main/index.ts index 49a076e75..deb0a3075 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -9,8 +9,9 @@ import { clipboard, session, } from "electron"; -import { join, extname } from "path"; +import { dirname, join, extname } from "path"; import { randomUUID } from "crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { readdir, readFile, stat } from "fs/promises"; import { electronApp, optimizer, is } from "@electron-toolkit/utils"; import type { AppUpdater } from "electron-updater"; @@ -2679,9 +2680,42 @@ function buildMenu(): void { Menu.setApplicationMenu(menu); } +let autoUpdaterInstance: AppUpdater | null = null; + +function updatePreferencesPath(): string { + return join(app.getPath("userData"), "update-preferences.json"); +} + +function getAutoUpgradeEnabled(): boolean { + try { + const file = updatePreferencesPath(); + if (!existsSync(file)) return true; + const parsed = JSON.parse(readFileSync(file, "utf8")) as { + autoUpgrade?: unknown; + }; + return parsed.autoUpgrade !== false; + } catch { + return true; + } +} + +function setAutoUpgradeEnabled(enabled: boolean): void { + const file = updatePreferencesPath(); + mkdirSync(dirname(file), { recursive: true }); + writeFileSync(file, `${JSON.stringify({ autoUpgrade: enabled }, null, 2)}\n`); +} + function setupUpdater(): void { // IPC handlers must always be registered to avoid invoke errors ipcMain.handle("get-app-version", () => app.getVersion()); + ipcMain.handle("get-auto-upgrade-enabled", () => getAutoUpgradeEnabled()); + ipcMain.handle("set-auto-upgrade-enabled", (_event, enabled: boolean) => { + setAutoUpgradeEnabled(enabled); + if (autoUpdaterInstance) { + autoUpdaterInstance.autoDownload = enabled; + } + return true; + }); // Portable Windows builds set PORTABLE_EXECUTABLE_DIR. They have no // install location for electron-updater to replace in place, so an @@ -2703,13 +2737,14 @@ function setupUpdater(): void { const { autoUpdater } = require("electron-updater") as { autoUpdater: AppUpdater; }; + autoUpdaterInstance = autoUpdater; // Log the updater's own lifecycle to /logs/updater.log so a // failed update (e.g. issue #271) leaves something to diagnose. autoUpdater.logger = updaterLogger; // Auto-download as soon as an update is found, then surface a single // "Restart to Update" action once it's ready — no manual download step. - autoUpdater.autoDownload = true; + autoUpdater.autoDownload = getAutoUpgradeEnabled(); autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on("update-available", (info) => { diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index e48be45e6..c8383698e 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -807,6 +807,8 @@ interface HermesAPI { downloadUpdate: () => Promise; installUpdate: () => Promise; getAppVersion: () => Promise; + getAutoUpgradeEnabled: () => Promise; + setAutoUpgradeEnabled: (enabled: boolean) => Promise; onUpdateAvailable: ( callback: (info: { version: string; releaseNotes: string }) => void, ) => () => void; diff --git a/src/preload/index.ts b/src/preload/index.ts index 716d3dac6..1f54dd2fc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1052,6 +1052,10 @@ const hermesAPI = { downloadUpdate: (): Promise => ipcRenderer.invoke("download-update"), installUpdate: (): Promise => ipcRenderer.invoke("install-update"), getAppVersion: (): Promise => ipcRenderer.invoke("get-app-version"), + getAutoUpgradeEnabled: (): Promise => + ipcRenderer.invoke("get-auto-upgrade-enabled"), + setAutoUpgradeEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke("set-auto-upgrade-enabled", enabled), onUpdateAvailable: ( callback: (info: { version: string; releaseNotes: string }) => void, diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 3c453f31c..708ca8b23 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -257,13 +257,29 @@ function Layout({ const [updateState, setUpdateState] = useState< "available" | "downloading" | "ready" | "error" | null >(null); + const [updateVersion, setUpdateVersion] = useState(null); + const [updatePercent, setUpdatePercent] = useState(null); const [updateError, setUpdateError] = useState(null); useEffect(() => { - // Updates download silently in the background (autoDownload); we don't - // surface "available" or progress — only the ready/error end states. + // Surface a startup upgrade button as soon as GitHub reports a newer + // release. If auto-upgrade is enabled, electron-updater also downloads in + // the background and this state advances to downloading/ready. + const cleanupAvailable = window.hermesAPI.onUpdateAvailable((info) => { + setUpdateState("available"); + setUpdateVersion(info.version); + setUpdateError(null); + }); + const cleanupProgress = window.hermesAPI.onUpdateDownloadProgress( + (info) => { + setUpdateState("downloading"); + setUpdatePercent(info.percent); + setUpdateError(null); + }, + ); const cleanupDownloaded = window.hermesAPI.onUpdateDownloaded(() => { setUpdateState("ready"); + setUpdatePercent(null); setUpdateError(null); }); const cleanupError = window.hermesAPI.onUpdateError((message) => { @@ -271,6 +287,8 @@ function Layout({ setUpdateError(message); }); return () => { + cleanupAvailable(); + cleanupProgress(); cleanupDownloaded(); cleanupError(); }; @@ -280,10 +298,11 @@ function Layout({ if (updateState === "ready") { // The only user action: restart into the already-downloaded update. await window.hermesAPI.installUpdate(); - } else if (updateState === "error") { - // Retry the auto-download that failed. - // Set downloading state immediately to prevent re-entrancy (double-click). + } else if (updateState === "available" || updateState === "error") { + // Download the available update (or retry a failed auto-download). + // Set downloading state immediately to prevent re-entrancy. setUpdateState("downloading"); + setUpdatePercent(null); setUpdateError(null); try { const ok = await window.hermesAPI.downloadUpdate(); @@ -298,11 +317,17 @@ function Layout({ const updateButtonTitle = updateError ?? - (updateState === "ready" - ? t("common.restartToUpdate") - : updateState === "error" - ? t("common.updateFailed") - : undefined); + (updateState === "available" && updateVersion + ? t("common.updateAvailable", { version: updateVersion }) + : updateState === "downloading" + ? updatePercent === null + ? t("common.downloading", { percent: 0 }) + : t("common.downloading", { percent: updatePercent }) + : updateState === "ready" + ? t("common.restartToUpdate") + : updateState === "error" + ? t("common.updateFailed") + : undefined); const handleNewChat = useCallback(() => { // Open a fresh run WITHOUT aborting others — any in-flight session keeps @@ -567,18 +592,31 @@ function Layout({
- {/* Downloads happen silently in the background — only surface the - button once the update is ready (or if it failed to download). */} - {(updateState === "ready" || updateState === "error") && ( + {/* Show an upgrade affordance at startup when GitHub has a newer + release; it becomes a restart action once downloaded. */} + {updateState && (
+
+ +
+ {t("settings.autoUpgradeDesktopHint")} +
+
{updateResult && (