feat(electron): clarify desktop update prompts (#5160)

## Related issue

N/A

## Summary

- Show the installed and available Omnigent Desktop versions in the shell-owned update prompt, with session-safety copy on its own line and unclipped card chrome.
- Carry the effective desktop version through updater status events and keep the native up-to-date dialog consistent.
- Add a development-only semantic-version override that checks the production update feed, making the real update flow reproducible without changing packaged behavior.

ELI5: development can pretend the installed desktop app is older, compare it with the production feed, and pass that same version into the update card.

```text
OMNIGENT_DESKTOP_VERSION_OVERRIDE
                |
                v
      electron-updater baseline ---> production update feed
                |
                v
      update status + current version ---> shell overlay
```

## Test Plan

- `node --test web/electron/test/desktop_updater.test.js`
- `node --test web/electron/test/update-main.test.js`
- `pnpm --filter web exec vitest run src/components/UpdateBanner.test.tsx`
- `pnpm --filter web type-check`
- Targeted `oxlint` over the changed Electron and web sources/tests.
- `pnpm --filter web build:overlay`
- Development flow: `OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start`, then **Server → Check for Updates…** against the production feed.

## Demo

N/A — the UI is rendered in an Electron-owned transparent child window; the focused component assertions and overlay production build cover the final copy and layout contract.

## Type of change

- [ ] Bug fix
- [x] Feature
- [x] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [ ] Manual verification completed
- [x] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

The updater unit harness covers version propagation, development override isolation, and production-feed configuration. The UpdateBanner test covers available, downloading, and downloaded copy.

## Changelog

Desktop update prompts now show the installed and available versions with clearer copy and polished overlay spacing.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
This commit is contained in:
Zeyi (Rice) Fan
2026-08-20 22:57:33 -07:00
committed by GitHub
parent 733234c303
commit 2c2afac192
10 changed files with 218 additions and 32 deletions
+16
View File
@@ -387,6 +387,22 @@ server (see below), Connect, and you're in.
> **not** run the Vite dev server. To develop the web UI itself with hot
> reload, run `pnpm run dev` (plain Vite in a browser) from `web/` as usual.
### Test desktop updates
To override the current version used by development update checks, launch the
unpackaged app with a valid semantic version:
```bash
OMNIGENT_DESKTOP_VERSION_OVERRIDE=0.9.0 pnpm start
```
The override controls both the **Current version** shown in update prompts and
the baseline `electron-updater` uses to decide whether a production release is
newer. It does not change Electron's real app/package version. Packaged builds
ignore it. `pnpm start` rebuilds the shell-owned update overlay before launching
it. Unpackaged runs read `dev-app-update.yml`, which intentionally checks the
same production HTTPS update server as packaged builds.
## Build a distributable
From `web/electron/`:
+1 -1
View File
@@ -1,3 +1,3 @@
provider: generic
url: http://127.0.0.1:8765/
url: https://omnigent.ai/_desktop/updates/
updaterCacheDirName: omnigent-desktop-electron-updater-dev
+9 -4
View File
@@ -80,8 +80,10 @@ function isUpdateSecurityError(message) {
* @param {(win: Electron.BrowserWindow | null | undefined) => string | null} deps.pinnedOrigin
* The origin a window is pinned to (used for the consent dialog copy).
* @param {string} deps.iconPath Absolute path to the app icon PNG.
* @param {boolean} [deps.forceDevUpdateConfig] Force the dev feed on in an
* unpackaged build (main.js sets this from !app.isPackaged).
* @param {boolean} [deps.forceDevUpdateConfig] Enable the development update
* config in an unpackaged build (main.js sets this from !app.isPackaged).
* @param {() => string} [deps.getCurrentVersion] Version shown in update UI.
* Defaults to Electron's real app version.
* @returns {{
* getConfig: () => { mode: string, autoInstall: boolean, skippedVersion: string | null },
* setConfig: (patch?: object) => { mode: string, autoInstall: boolean, skippedVersion: string | null },
@@ -107,6 +109,7 @@ function createDesktopUpdater({
pinnedOrigin,
iconPath,
forceDevUpdateConfig = false,
getCurrentVersion = () => app.getVersion(),
}) {
let updateCheckTimer = null;
let currentUpdateStatus = { state: "idle" };
@@ -195,7 +198,7 @@ function createDesktopUpdater({
autoUpdater.on("checking-for-update", () => broadcast({ state: "checking" }));
autoUpdater.on("update-available", (info) => {
manualCheckInFlight = false;
broadcast({ state: "available", info });
broadcast({ state: "available", currentVersion: getCurrentVersion(), info });
});
autoUpdater.on("update-not-available", () => {
manualCheckInFlight = false;
@@ -204,7 +207,9 @@ function createDesktopUpdater({
autoUpdater.on("download-progress", (progress) =>
broadcast({ state: "downloading", progress }),
);
autoUpdater.on("update-downloaded", (info) => broadcast({ state: "downloaded", info }));
autoUpdater.on("update-downloaded", (info) =>
broadcast({ state: "downloaded", currentVersion: getCurrentVersion(), info }),
);
autoUpdater.on("error", (err) => {
const msg = String(err?.message ?? err);
const isSecurity = isUpdateSecurityError(msg);
+34 -8
View File
@@ -682,6 +682,33 @@ function activeWindow() {
return windows.keys().next().value ?? null;
}
/**
* Effective version for development update checks and UI. Packaged builds
* always use Electron's real app version.
*/
function configureDesktopVersion() {
const override = !app.isPackaged
? process.env.OMNIGENT_DESKTOP_VERSION_OVERRIDE?.trim()
: undefined;
if (!override) return app.getVersion();
try {
// electron-updater stores a SemVer instance here and reads it when deciding
// eligibility. Reuse its constructor so comparisons keep the expected type.
const Version = autoUpdater.currentVersion.constructor;
const version = new Version(override);
autoUpdater.currentVersion = version;
return version.version;
} catch (err) {
throw new Error(
`OMNIGENT_DESKTOP_VERSION_OVERRIDE must be a valid semantic version (received ${JSON.stringify(override)})`,
{ cause: err },
);
}
}
const currentDesktopVersion = configureDesktopVersion();
// Desktop auto-update orchestration lives in its own module; the main process
// only composes it with its main-process dependencies and wires the four thin
// seams below (startup init, the Updates menu, the update IPC surface, and the
@@ -700,12 +727,11 @@ const updater = createDesktopUpdater({
isPinnedOriginSender,
pinnedOrigin,
iconPath: ICON_PNG,
// Dev builds always use the local dev feed (dev-app-update.yml ->
// 127.0.0.1:8765); packaged builds always use the baked app-update.yml.
// Tying this to !app.isPackaged — not an env var — closes a redirect attack:
// an OMNIGENT_FORCE_DEV_UPDATE_CONFIG-style env var could otherwise point a
// packaged (production) app at an untrusted HTTP local feed and push a
// malicious update. A packaged build can never be redirected to the dev feed.
getCurrentVersion: () => currentDesktopVersion,
// Dev builds use dev-app-update.yml, which mirrors the production HTTPS
// endpoint; packaged builds always use their baked app-update.yml. Tying
// this to !app.isPackaged — not an env var — ensures a packaged app can
// never be redirected to a repository-local update configuration.
forceDevUpdateConfig: !app.isPackaged,
});
@@ -1870,9 +1896,9 @@ function buildMenu() {
if (status.state === "none") {
await dialog.showMessageBox(activeWindow(), {
type: "info",
title: "Omnigent",
title: "Omnigent Desktop",
message: "You're up to date!",
detail: `Omnigent ${app.getVersion()} is the latest version.`,
detail: `Omnigent Desktop ${currentDesktopVersion} is the latest version.`,
buttons: ["OK"],
});
}
+47 -3
View File
@@ -10,6 +10,9 @@
const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const fs = require("node:fs");
const path = require("node:path");
const yaml = require("js-yaml");
const {
createDesktopUpdater,
@@ -27,9 +30,10 @@ const PINNED_ORIGIN = "https://server.example";
* @param {object} [opts]
* @param {Record<string, unknown>} [opts.settings] Initial persisted settings.
* @param {boolean} [opts.isPackaged] Simulate a packaged build.
* @param {boolean} [opts.forceDevUpdateConfig] Force the dev feed on.
* @param {boolean} [opts.forceDevUpdateConfig] Enable the development update config.
* @param {boolean} [opts.pinnedSender] Whether IPC calls count as trusted.
* @param {Array<{response: number}>} [opts.dialogResponses] Queued dialog answers.
* @param {() => string} [opts.getCurrentVersion] Display-version override.
*/
function makeUpdater({
settings = {},
@@ -37,6 +41,7 @@ function makeUpdater({
forceDevUpdateConfig = false,
pinnedSender = true,
dialogResponses = [{ response: 1 }],
getCurrentVersion,
} = {}) {
let store = { ...settings };
const calls = {
@@ -75,6 +80,7 @@ function makeUpdater({
const deps = {
app: {
isPackaged,
getVersion: () => "0.3.0",
quit: () => {
calls.appQuit += 1;
},
@@ -104,6 +110,7 @@ function makeUpdater({
pinnedOrigin: () => PINNED_ORIGIN,
iconPath: "/icons/icon.png",
forceDevUpdateConfig,
getCurrentVersion,
};
const updater = createDesktopUpdater(deps);
@@ -145,6 +152,16 @@ describe("desktop_updater — pure helpers", () => {
assert.match(UPDATES_UNAVAILABLE_IN_DEV, /unavailable in development/);
});
it("uses the production update endpoint in development", () => {
const devConfig = yaml.load(
fs.readFileSync(path.join(__dirname, "../dev-app-update.yml"), "utf8"),
);
const packageConfig = require("../package.json");
assert.equal(devConfig.url, packageConfig.build.publish[0].url);
assert.match(devConfig.url, /^https:\/\//);
});
it("classifies signing/integrity errors as security errors", () => {
assert.equal(isUpdateSecurityError("sha512 mismatch"), true);
assert.equal(isUpdateSecurityError("app is not signed"), true);
@@ -197,14 +214,41 @@ describe("desktop_updater — event wiring + broadcast", () => {
assert.equal(h.autoUpdater.forceDevUpdateConfig, true);
assert.deepEqual(plain(h.updater.getStatus()), {
state: "available",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
});
assert.deepEqual(plain(h.calls.sent), [
{
channel: "omnigent:update-status",
payload: { state: "available", info: { version: "0.4.0" } },
payload: {
state: "available",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
},
},
]);
h.autoUpdater.emit("update-downloaded", { version: "0.4.0" });
assert.deepEqual(plain(h.updater.getStatus()), {
state: "downloaded",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
});
});
it("uses an injected display version for update prompts", () => {
const h = makeUpdater({
forceDevUpdateConfig: true,
settings: { update_mode: "manual" },
getCurrentVersion: () => "0.2.0",
});
h.updater.init();
h.autoUpdater.emit("update-available", { version: "0.4.0" });
assert.equal(h.updater.getStatus().currentVersion, "0.2.0");
h.autoUpdater.emit("update-downloaded", { version: "0.4.0" });
assert.equal(h.updater.getStatus().currentVersion, "0.2.0");
});
it("start mode kicks off a check with no lingering periodic timer", () => {
@@ -248,7 +292,7 @@ describe("desktop_updater — manual check errors", () => {
});
});
describe("desktop_updater — dev feed gating", () => {
describe("desktop_updater — development update config gating", () => {
it("blocks manual paths when the feed is unavailable in development", async () => {
const h = makeUpdater({ settings: { update_mode: "manual" } });
h.updater.registerIpc();
+58 -1
View File
@@ -15,6 +15,7 @@ function loadMainHarness({
isPackaged = false,
platform = process.platform,
developerMode = false,
desktopVersionOverride,
} = {}) {
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "omnigent-update-test-"));
fs.writeFileSync(path.join(userData, "settings.json"), JSON.stringify(settings), "utf8");
@@ -46,7 +47,21 @@ function loadMainHarness({
focus: () => {},
};
class FakeSemVer {
constructor(version) {
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) {
throw new Error(`Invalid version: ${version}`);
}
this.version = version;
}
format() {
return this.version;
}
}
const autoUpdater = new EventEmitter();
autoUpdater.currentVersion = new FakeSemVer("0.3.0");
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.forceDevUpdateConfig = forceDevUpdateConfig;
@@ -66,6 +81,7 @@ function loadMainHarness({
app: {
isPackaged,
getPath: (name) => (name === "userData" ? userData : userData),
getVersion: () => "0.3.0",
setName: () => {},
requestSingleInstanceLock: () => true,
on: (name, listener) => appEvents.set(name, listener),
@@ -164,6 +180,7 @@ function loadMainHarness({
platform,
env: {
...process.env,
OMNIGENT_DESKTOP_VERSION_OVERRIDE: desktopVersionOverride,
// No OMNIGENT_FORCE_DEV_UPDATE_CONFIG injection: main.js now derives
// forceDevUpdateConfig from !app.isPackaged (always true in this
// harness), not an env var. The harness still controls the
@@ -632,6 +649,41 @@ describe("auto-update main-process wiring", () => {
assert.equal(harness.api.updater.installPending, false);
});
it("allows only development builds to override the effective desktop version", async (t) => {
const development = loadMainHarness({
settings: { update_mode: "manual" },
desktopVersionOverride: " 0.2.0 ",
});
t.after(development.cleanup);
assert.equal(development.autoUpdater.currentVersion.version, "0.2.0");
assert.equal(development.autoUpdater.currentVersion.format(), "0.2.0");
development.api.updater.init();
development.autoUpdater.emit("update-available", { version: "0.4.0" });
assert.equal(development.api.updater.getStatus().currentVersion, "0.2.0");
development.autoUpdater.emit("update-not-available");
development.api.buildMenu();
await findMenuItem(development.calls.setApplicationMenu.at(-1), "check_for_updates").click();
assert.equal(development.calls.showMessageBox.at(-1).options.title, "Omnigent Desktop");
assert.equal(
development.calls.showMessageBox.at(-1).options.detail,
"Omnigent Desktop 0.2.0 is the latest version.",
);
const packaged = loadMainHarness({
isPackaged: true,
settings: { update_mode: "manual" },
desktopVersionOverride: "0.2.0",
});
t.after(packaged.cleanup);
assert.equal(packaged.autoUpdater.currentVersion.version, "0.3.0");
packaged.api.updater.init();
packaged.autoUpdater.emit("update-available", { version: "0.4.0" });
assert.equal(packaged.api.updater.getStatus().currentVersion, "0.3.0");
});
it("supports forceDevUpdateConfig and broadcasts updater events", (t) => {
const harness = loadMainHarness({
forceDevUpdateConfig: true,
@@ -645,12 +697,17 @@ describe("auto-update main-process wiring", () => {
assert.equal(harness.autoUpdater.forceDevUpdateConfig, true);
assert.deepEqual(plain(harness.api.updater.getStatus()), {
state: "available",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
});
assert.deepEqual(plain(harness.calls.sent), [
{
channel: "omnigent:update-status",
payload: { state: "available", info: { version: "0.4.0" } },
payload: {
state: "available",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
},
},
]);
});
+18 -8
View File
@@ -55,11 +55,14 @@ describe("UpdateBanner", () => {
it("renders the correct controls for available, downloading, and downloaded states", async () => {
const { bridge, emit } = installBridge({
state: "available",
currentVersion: "0.3.0",
info: { version: "0.4.0", releaseNotes: "Fixes and polish." },
});
render(<UpdateBanner />);
expect(await screen.findByText("Omnigent 0.4.0 is available")).toBeInTheDocument();
expect(await screen.findByText("Omnigent Desktop 0.4.0 is available")).toBeInTheDocument();
expect(screen.getByText("Current version: 0.3.0.")).toBeInTheDocument();
expect(screen.getByText("Updating wont interrupt existing sessions.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Skip this version" })).toBeInTheDocument();
expect(screen.getByText("Release notes")).toBeInTheDocument();
@@ -68,12 +71,16 @@ describe("UpdateBanner", () => {
);
emit({ state: "downloading", progress: { percent: 42 } });
expect(await screen.findByText("Downloading Omnigent update… 42%")).toBeInTheDocument();
expect(await screen.findByText("Downloading Omnigent Desktop update… 42%")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).toBeNull();
expect(screen.queryByRole("button", { name: "Skip this version" })).toBeNull();
emit({ state: "downloaded", info: { version: "0.4.0" } });
expect(await screen.findByText("Omnigent 0.4.0 is ready to install")).toBeInTheDocument();
emit({ state: "downloaded", currentVersion: "0.3.0", info: { version: "0.4.0" } });
expect(
await screen.findByText("Omnigent Desktop 0.4.0 is ready to install"),
).toBeInTheDocument();
expect(screen.getByText("Current version: 0.3.0.")).toBeInTheDocument();
expect(screen.getByText("Updating wont interrupt existing sessions.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart to update" })).toBeInTheDocument();
expect(screen.getByText("Installs automatically on next quit.")).toBeInTheDocument();
});
@@ -82,6 +89,7 @@ describe("UpdateBanner", () => {
installBridge(
{
state: "downloaded",
currentVersion: "0.3.0",
info: { version: "0.4.0" },
},
{ ...DEFAULT_CONFIG, autoInstall: false },
@@ -89,7 +97,9 @@ describe("UpdateBanner", () => {
render(<UpdateBanner />);
expect(await screen.findByText("Omnigent 0.4.0 is ready to install")).toBeInTheDocument();
expect(
await screen.findByText("Omnigent Desktop 0.4.0 is ready to install"),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart to update" })).toBeInTheDocument();
expect(screen.queryByText("Installs automatically on next quit.")).toBeNull();
});
@@ -101,7 +111,7 @@ describe("UpdateBanner", () => {
});
const { unmount } = render(<UpdateBanner />);
expect(await screen.findByText("Omnigent 0.4.0 is available")).toBeInTheDocument();
expect(await screen.findByText("Omnigent Desktop 0.4.0 is available")).toBeInTheDocument();
unmount();
@@ -120,12 +130,12 @@ describe("UpdateBanner", () => {
vi.mocked(bridge.setConfig).mockResolvedValueOnce(skippedConfig);
render(<UpdateBanner />);
expect(await screen.findByText("Omnigent 0.4.0 is available")).toBeInTheDocument();
expect(await screen.findByText("Omnigent Desktop 0.4.0 is available")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Skip this version" }));
await waitFor(() => {
expect(bridge.setConfig).toHaveBeenCalledWith({ skippedVersion: "0.4.0" });
expect(screen.queryByText("Omnigent 0.4.0 is available")).toBeNull();
expect(screen.queryByText("Omnigent Desktop 0.4.0 is available")).toBeNull();
});
});
});
+23 -5
View File
@@ -151,14 +151,24 @@ export function UpdateBanner({ variant = "floating" }: { variant?: "floating" |
<div className="min-w-0 flex-1">
{visibleStatus.state === "available" && (
<p className="font-medium text-foreground">
Omnigent {visibleStatus.info?.version ?? "update"} is available
</p>
<>
<p className="font-medium text-foreground">
Omnigent Desktop {visibleStatus.info?.version ?? "update"} is available
</p>
{visibleStatus.currentVersion && (
<p className="mt-0.5 text-sm text-muted-foreground">
Current version: {visibleStatus.currentVersion}.
</p>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
Updating wont interrupt existing sessions.
</p>
</>
)}
{visibleStatus.state === "downloading" && (
<>
<p className="font-medium text-foreground">
Downloading Omnigent update {progress}%
Downloading Omnigent Desktop update {progress}%
</p>
<Progress
value={progress}
@@ -170,7 +180,15 @@ export function UpdateBanner({ variant = "floating" }: { variant?: "floating" |
{visibleStatus.state === "downloaded" && (
<>
<p className="font-medium text-foreground">
Omnigent {visibleStatus.info?.version ?? "update"} is ready to install
Omnigent Desktop {visibleStatus.info?.version ?? "update"} is ready to install
</p>
{visibleStatus.currentVersion && (
<p className="mt-0.5 text-sm text-muted-foreground">
Current version: {visibleStatus.currentVersion}.
</p>
)}
<p className="mt-0.5 text-sm text-muted-foreground">
Updating wont interrupt existing sessions.
</p>
{autoInstall && (
<p className="mt-0.5 text-sm text-muted-foreground">
+6 -2
View File
@@ -240,7 +240,10 @@ export interface UpdateConfig {
skippedVersion: string | null;
}
export type UpdateStatus =
export type UpdateStatus = {
/** Installed Electron app version; absent on older desktop shells. */
currentVersion?: string;
} & (
| {
state: "idle" | "checking" | "none";
info?: undefined;
@@ -264,7 +267,8 @@ export type UpdateStatus =
info?: { version: string; releaseNotes?: string };
progress?: undefined;
lastError?: string;
};
}
);
export interface ElectronUpdateBridge {
getConfig: () => Promise<UpdateConfig>;
+6
View File
@@ -14,6 +14,12 @@
background: transparent;
overflow: hidden;
}
/* Keep the card and its shadow off the transparent window boundary.
Empty overlays stay at zero height so the shell remains click-through. */
#update-overlay-root:not(:empty) {
padding: 12px;
}
</style>
</head>
<body>