diff --git a/lat.md/desktop-updates.md b/lat.md/desktop-updates.md new file mode 100644 index 000000000..a84b86777 --- /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/app/updater.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 24888f44c..7139e3a32 100644 --- a/lat.md/lat.md +++ b/lat.md/lat.md @@ -6,6 +6,7 @@ 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. - [[sidebar-navigation]] — the recent-sessions list under the Chat nav item, capped at five with a "Show more" button that opens the full session list in a modal. - [[context-folder]] — the per-session linked working folder, persisted in a desktop-owned state.db table so a re-opened conversation restores its folder. - [[main-process]] — the Electron main-process entrypoint, app lifecycle modules, and centralized IPC registry. diff --git a/src/main/app/updater.ts b/src/main/app/updater.ts index 76ae4a944..ad8210ab9 100644 --- a/src/main/app/updater.ts +++ b/src/main/app/updater.ts @@ -1,16 +1,60 @@ import { app, ipcMain, type BrowserWindow } from "electron"; +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from "fs"; import type { AppUpdater } from "electron-updater"; +import { dirname, join } from "path"; import { updaterLogger } from "../updater-log"; interface UpdaterDeps { getMainWindow: () => BrowserWindow | null; } +let autoUpdaterInstance: AppUpdater | null = null; + +function updatePreferencesPath(): string { + return join(app.getPath("userData"), "update-preferences.json"); +} + +function getAutoUpgradeEnabled(): boolean { + const file = updatePreferencesPath(); + if (!existsSync(file)) { + return true; + } + + try { + 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`); +} + export function setupUpdater({ getMainWindow }: UpdaterDeps): void { 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; + }); const isPortableBuild = !!process.env.PORTABLE_EXECUTABLE_DIR; if (!app.isPackaged || isPortableBuild) { + autoUpdaterInstance = null; ipcMain.handle("check-for-updates", async () => null); ipcMain.handle("download-update", () => true); ipcMain.handle("install-update", () => {}); @@ -22,8 +66,9 @@ export function setupUpdater({ getMainWindow }: UpdaterDeps): void { autoUpdater: AppUpdater; }; + autoUpdaterInstance = autoUpdater; autoUpdater.logger = updaterLogger; - 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 fe0c5b2af..b83557966 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -820,6 +820,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 c6a6b9f61..ec4ec3aca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1073,6 +1073,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 cba840eaf..c621f785b 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -262,13 +262,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) => { @@ -276,6 +292,8 @@ function Layout({ setUpdateError(message); }); return () => { + cleanupAvailable(); + cleanupProgress(); cleanupDownloaded(); cleanupError(); }; @@ -285,10 +303,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(); @@ -303,11 +322,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 @@ -577,18 +602,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 && (