Skip to content

Commit 97c92f7

Browse files
Nazmus SamirNazmus Samir
authored andcommitted
feat: add desktop update controls
1 parent 1dbfa65 commit 97c92f7

8 files changed

Lines changed: 145 additions & 16 deletions

File tree

lat.md/desktop-updates.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Desktop Updates
2+
3+
Desktop updates use GitHub releases and expose both a startup upgrade action and a Settings auto-upgrade preference.
4+
5+
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`.
6+
7+
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.
8+
9+
[[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.

lat.md/lat.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ This directory defines the high-level concepts, business logic, and architecture
55
- [[web-preview]] — the in-app split-screen webview and the `partition`-based gate that lets only it load remote HTTPS while staying sandboxed.
66
- [[code-blocks]] — collapsible long code blocks, and why expansion state is keyed on source position to survive react-markdown's streaming remounts.
77
- [[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.
8+
- [[desktop-updates]] — GitHub release checks, startup upgrade button behavior, and the Settings auto-upgrade preference.

src/main/index.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import {
99
clipboard,
1010
session,
1111
} from "electron";
12-
import { join, extname } from "path";
12+
import { dirname, join, extname } from "path";
1313
import { randomUUID } from "crypto";
14+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
1415
import { readdir, readFile, stat } from "fs/promises";
1516
import { electronApp, optimizer, is } from "@electron-toolkit/utils";
1617
import type { AppUpdater } from "electron-updater";
@@ -2679,9 +2680,42 @@ function buildMenu(): void {
26792680
Menu.setApplicationMenu(menu);
26802681
}
26812682

2683+
let autoUpdaterInstance: AppUpdater | null = null;
2684+
2685+
function updatePreferencesPath(): string {
2686+
return join(app.getPath("userData"), "update-preferences.json");
2687+
}
2688+
2689+
function getAutoUpgradeEnabled(): boolean {
2690+
try {
2691+
const file = updatePreferencesPath();
2692+
if (!existsSync(file)) return true;
2693+
const parsed = JSON.parse(readFileSync(file, "utf8")) as {
2694+
autoUpgrade?: unknown;
2695+
};
2696+
return parsed.autoUpgrade !== false;
2697+
} catch {
2698+
return true;
2699+
}
2700+
}
2701+
2702+
function setAutoUpgradeEnabled(enabled: boolean): void {
2703+
const file = updatePreferencesPath();
2704+
mkdirSync(dirname(file), { recursive: true });
2705+
writeFileSync(file, `${JSON.stringify({ autoUpgrade: enabled }, null, 2)}\n`);
2706+
}
2707+
26822708
function setupUpdater(): void {
26832709
// IPC handlers must always be registered to avoid invoke errors
26842710
ipcMain.handle("get-app-version", () => app.getVersion());
2711+
ipcMain.handle("get-auto-upgrade-enabled", () => getAutoUpgradeEnabled());
2712+
ipcMain.handle("set-auto-upgrade-enabled", (_event, enabled: boolean) => {
2713+
setAutoUpgradeEnabled(enabled);
2714+
if (autoUpdaterInstance) {
2715+
autoUpdaterInstance.autoDownload = enabled;
2716+
}
2717+
return true;
2718+
});
26852719

26862720
// Portable Windows builds set PORTABLE_EXECUTABLE_DIR. They have no
26872721
// install location for electron-updater to replace in place, so an
@@ -2703,13 +2737,14 @@ function setupUpdater(): void {
27032737
const { autoUpdater } = require("electron-updater") as {
27042738
autoUpdater: AppUpdater;
27052739
};
2740+
autoUpdaterInstance = autoUpdater;
27062741

27072742
// Log the updater's own lifecycle to <userData>/logs/updater.log so a
27082743
// failed update (e.g. issue #271) leaves something to diagnose.
27092744
autoUpdater.logger = updaterLogger;
27102745
// Auto-download as soon as an update is found, then surface a single
27112746
// "Restart to Update" action once it's ready — no manual download step.
2712-
autoUpdater.autoDownload = true;
2747+
autoUpdater.autoDownload = getAutoUpgradeEnabled();
27132748
autoUpdater.autoInstallOnAppQuit = true;
27142749

27152750
autoUpdater.on("update-available", (info) => {

src/preload/index.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -807,6 +807,8 @@ interface HermesAPI {
807807
downloadUpdate: () => Promise<boolean>;
808808
installUpdate: () => Promise<void>;
809809
getAppVersion: () => Promise<string>;
810+
getAutoUpgradeEnabled: () => Promise<boolean>;
811+
setAutoUpgradeEnabled: (enabled: boolean) => Promise<boolean>;
810812
onUpdateAvailable: (
811813
callback: (info: { version: string; releaseNotes: string }) => void,
812814
) => () => void;

src/preload/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1052,6 +1052,10 @@ const hermesAPI = {
10521052
downloadUpdate: (): Promise<boolean> => ipcRenderer.invoke("download-update"),
10531053
installUpdate: (): Promise<void> => ipcRenderer.invoke("install-update"),
10541054
getAppVersion: (): Promise<string> => ipcRenderer.invoke("get-app-version"),
1055+
getAutoUpgradeEnabled: (): Promise<boolean> =>
1056+
ipcRenderer.invoke("get-auto-upgrade-enabled"),
1057+
setAutoUpgradeEnabled: (enabled: boolean): Promise<boolean> =>
1058+
ipcRenderer.invoke("set-auto-upgrade-enabled", enabled),
10551059

10561060
onUpdateAvailable: (
10571061
callback: (info: { version: string; releaseNotes: string }) => void,

src/renderer/src/screens/Layout/Layout.tsx

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -257,20 +257,38 @@ function Layout({
257257
const [updateState, setUpdateState] = useState<
258258
"available" | "downloading" | "ready" | "error" | null
259259
>(null);
260+
const [updateVersion, setUpdateVersion] = useState<string | null>(null);
261+
const [updatePercent, setUpdatePercent] = useState<number | null>(null);
260262
const [updateError, setUpdateError] = useState<string | null>(null);
261263

262264
useEffect(() => {
263-
// Updates download silently in the background (autoDownload); we don't
264-
// surface "available" or progress — only the ready/error end states.
265+
// Surface a startup upgrade button as soon as GitHub reports a newer
266+
// release. If auto-upgrade is enabled, electron-updater also downloads in
267+
// the background and this state advances to downloading/ready.
268+
const cleanupAvailable = window.hermesAPI.onUpdateAvailable((info) => {
269+
setUpdateState("available");
270+
setUpdateVersion(info.version);
271+
setUpdateError(null);
272+
});
273+
const cleanupProgress = window.hermesAPI.onUpdateDownloadProgress(
274+
(info) => {
275+
setUpdateState("downloading");
276+
setUpdatePercent(info.percent);
277+
setUpdateError(null);
278+
},
279+
);
265280
const cleanupDownloaded = window.hermesAPI.onUpdateDownloaded(() => {
266281
setUpdateState("ready");
282+
setUpdatePercent(null);
267283
setUpdateError(null);
268284
});
269285
const cleanupError = window.hermesAPI.onUpdateError((message) => {
270286
setUpdateState("error");
271287
setUpdateError(message);
272288
});
273289
return () => {
290+
cleanupAvailable();
291+
cleanupProgress();
274292
cleanupDownloaded();
275293
cleanupError();
276294
};
@@ -280,10 +298,11 @@ function Layout({
280298
if (updateState === "ready") {
281299
// The only user action: restart into the already-downloaded update.
282300
await window.hermesAPI.installUpdate();
283-
} else if (updateState === "error") {
284-
// Retry the auto-download that failed.
285-
// Set downloading state immediately to prevent re-entrancy (double-click).
301+
} else if (updateState === "available" || updateState === "error") {
302+
// Download the available update (or retry a failed auto-download).
303+
// Set downloading state immediately to prevent re-entrancy.
286304
setUpdateState("downloading");
305+
setUpdatePercent(null);
287306
setUpdateError(null);
288307
try {
289308
const ok = await window.hermesAPI.downloadUpdate();
@@ -298,11 +317,17 @@ function Layout({
298317

299318
const updateButtonTitle =
300319
updateError ??
301-
(updateState === "ready"
302-
? t("common.restartToUpdate")
303-
: updateState === "error"
304-
? t("common.updateFailed")
305-
: undefined);
320+
(updateState === "available" && updateVersion
321+
? t("common.updateAvailable", { version: updateVersion })
322+
: updateState === "downloading"
323+
? updatePercent === null
324+
? t("common.downloading", { percent: 0 })
325+
: t("common.downloading", { percent: updatePercent })
326+
: updateState === "ready"
327+
? t("common.restartToUpdate")
328+
: updateState === "error"
329+
? t("common.updateFailed")
330+
: undefined);
306331

307332
const handleNewChat = useCallback(() => {
308333
// Open a fresh run WITHOUT aborting others — any in-flight session keeps
@@ -567,18 +592,31 @@ function Layout({
567592
</nav>
568593

569594
<div className="sidebar-footer">
570-
{/* Downloads happen silently in the background — only surface the
571-
button once the update is ready (or if it failed to download). */}
572-
{(updateState === "ready" || updateState === "error") && (
595+
{/* Show an upgrade affordance at startup when GitHub has a newer
596+
release; it becomes a restart action once downloaded. */}
597+
{updateState && (
573598
<button
574599
className={`sidebar-update-btn ${
575600
updateState === "error" ? "error" : ""
576601
}`}
577602
onClick={handleUpdate}
603+
disabled={updateState === "downloading"}
578604
title={updateButtonTitle}
579605
aria-label={updateButtonTitle}
580606
>
581607
<Download size={13} />
608+
{updateState === "available" && (
609+
<span>
610+
{updateVersion
611+
? t("common.updateAvailable", { version: updateVersion })
612+
: t("common.updateAvailable", { version: "" })}
613+
</span>
614+
)}
615+
{updateState === "downloading" && (
616+
<span>
617+
{t("common.downloading", { percent: updatePercent ?? 0 })}
618+
</span>
619+
)}
582620
{updateState === "ready" && (
583621
<span>{t("common.restartToUpdate")}</span>
584622
)}

src/renderer/src/screens/Settings/Settings.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ function Settings({ profile }: { profile?: string }): React.JSX.Element {
137137
const [updateResultType, setUpdateResultType] = useState<
138138
"success" | "error" | null
139139
>(null);
140+
const [autoUpgradeEnabled, setAutoUpgradeEnabled] = useState(true);
141+
const [autoUpgradeSaved, setAutoUpgradeSaved] = useState(false);
140142

141143
// OpenClaw migration — initialize from localStorage cache
142144
const cachedClaw = getCachedOpenClaw();
@@ -219,10 +221,11 @@ function Settings({ profile }: { profile?: string }): React.JSX.Element {
219221
setHermesVersion(null);
220222

221223
// Load fast config first (cached in main process)
222-
const [aVersion, conn, keyStatus] = await Promise.all([
224+
const [aVersion, conn, keyStatus, autoUpgrade] = await Promise.all([
223225
window.hermesAPI.getAppVersion(),
224226
window.hermesAPI.getConnectionConfig(),
225227
window.hermesAPI.getApiServerKeyStatus(profile),
228+
window.hermesAPI.getAutoUpgradeEnabled(),
226229
]);
227230

228231
if (requestId !== loadConfigRequestRef.current) return;
@@ -245,6 +248,7 @@ function Settings({ profile }: { profile?: string }): React.JSX.Element {
245248
setSshRemotePort(conn.ssh?.remotePort ? String(conn.ssh.remotePort) : "");
246249
setSshLocalPort(conn.ssh?.localPort ? String(conn.ssh.localPort) : "");
247250
setApiServerKeyMissing(!keyStatus.hasKey);
251+
setAutoUpgradeEnabled(autoUpgrade);
248252
connLoaded.current = true;
249253

250254
const homeResult = await Promise.resolve()
@@ -683,6 +687,13 @@ function Settings({ profile }: { profile?: string }): React.JSX.Element {
683687
}
684688
}
685689

690+
async function handleAutoUpgradeChange(enabled: boolean): Promise<void> {
691+
setAutoUpgradeEnabled(enabled);
692+
await window.hermesAPI.setAutoUpgradeEnabled(enabled);
693+
setAutoUpgradeSaved(true);
694+
setTimeout(() => setAutoUpgradeSaved(false), 2000);
695+
}
696+
686697
// Parse "Hermes Agent v0.7.0 (2026.4.3) Project: ... Python: 3.11.15 OpenAI SDK: 2.30.0 Update available: ..."
687698
const parsedVersion = (() => {
688699
if (!hermesVersion) return null;
@@ -819,6 +830,32 @@ function Settings({ profile }: { profile?: string }): React.JSX.Element {
819830
{dumpRunning ? t("settings.running") : t("settings.debugDump")}
820831
</button>
821832
</div>
833+
<div className="settings-field" style={{ marginTop: 12 }}>
834+
<label className="settings-field-label">
835+
{t("settings.autoUpgradeDesktop")}
836+
{autoUpgradeSaved && (
837+
<span className="settings-saved" style={{ marginLeft: 8 }}>
838+
{t("settings.saved")}
839+
</span>
840+
)}
841+
<label
842+
className="tools-toggle"
843+
style={{ marginLeft: 12, verticalAlign: "middle" }}
844+
>
845+
<input
846+
type="checkbox"
847+
checked={autoUpgradeEnabled}
848+
onChange={(e) =>
849+
void handleAutoUpgradeChange(e.target.checked)
850+
}
851+
/>
852+
<span className="tools-toggle-track" />
853+
</label>
854+
</label>
855+
<div className="settings-field-hint">
856+
{t("settings.autoUpgradeDesktopHint")}
857+
</div>
858+
</div>
822859
{updateResult && (
823860
<div
824861
className={`settings-hermes-result ${updateResultType || "error"}`}

src/shared/i18n/locales/en/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,9 @@ export default {
9494
updating: "Updating...",
9595
updateEngine: "Update Engine",
9696
latestVersion: "Already up to date",
97+
autoUpgradeDesktop: "Auto-upgrade desktop app",
98+
autoUpgradeDesktopHint:
99+
"Automatically download new Hermes One releases from GitHub when the app starts. Turn this off to show the startup upgrade button without downloading until you click it.",
97100
runningDiagnosis: "Running diagnosis...",
98101
runDiagnosis: "Run Diagnosis",
99102
running: "Running...",

0 commit comments

Comments
 (0)