Wire desktop updates through a generic feed

electron-builder now publishes latest-linux.yml / latest.yml and embeds
app-update.yml plus package-type, so electron-updater runs pacman -U or
dpkg -i through pkexec for packages and updates the per-user NSIS install
without elevation. The app checks only when asked (menu item), asks before
downloading and before installing, and J621_UPDATE_URL overrides the feed
for tests or forks.

deploy/push_desktop.sh builds and publishes the artifacts to
deploy/data/desktop, which the frontend nginx mounts read-only at
/desktop/. Verified detection and up-to-date handling against a local feed
with the packaged Arch build.
This commit is contained in:
2026-09-20 17:32:41 -05:00
parent 84ec431441
commit 7c39383655
11 changed files with 312 additions and 14 deletions
+142 -1
View File
@@ -12,7 +12,8 @@
* the same way it does in a browser. The only desktop-specific bits are the
* origin, external-link handling and (later) updates.
*/
import { app, BrowserWindow, ipcMain, Menu, protocol, shell } from "electron";
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell } from "electron";
import { autoUpdater } from "electron-updater";
import { readFileSync } from "node:fs";
import { readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";
@@ -187,6 +188,124 @@ function isDownloadNavigation(url: URL): boolean {
return DOWNLOAD_EXTENSIONS.has(path.extname(url.pathname).toLowerCase());
}
/**
* Updates are manual by design: Linux packages install through pacman/dpkg
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
* downloading and again before installing. The feed comes from `publish` in
* electron-builder.yml and can be overridden with J621_UPDATE_URL.
*/
type UpdateEvent =
| { state: "available"; version: string }
| { state: "not-available" }
| { state: "downloaded"; version: string }
| { state: "error"; message: string };
let updateReporter: ((event: UpdateEvent) => void) | null = null;
let updateCheckRunning = false;
function setUpUpdates(win: BrowserWindow): void {
const override = process.env.J621_UPDATE_URL?.trim();
if (override) autoUpdater.setFeedURL({ provider: "generic", url: override });
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on("error", (error) => {
updateReporter?.({ state: "error", message: error.message });
});
autoUpdater.on("update-not-available", () => {
if (SMOKE) return updateReporter?.({ state: "not-available" });
void dialog.showMessageBox(win, {
type: "info",
title: "Updates",
message: `J621 ${app.getVersion()} is up to date.`,
});
});
autoUpdater.on("update-available", (info) => {
if (SMOKE) {
return updateReporter?.({ state: "available", version: info.version });
}
void (async () => {
const { response } = await dialog.showMessageBox(win, {
type: "info",
title: "Updates",
message: `J621 ${info.version} is available.`,
detail:
"Download it now? Installing it later needs administrator rights " +
"on Linux (pacman/dpkg); the Windows installer runs without them.",
buttons: ["Download", "Later"],
defaultId: 0,
cancelId: 1,
});
if (response !== 0) return;
try {
await autoUpdater.downloadUpdate();
} catch (error) {
await dialog.showMessageBox(win, {
type: "error",
title: "Update failed",
message: "The update could not be downloaded.",
detail: error instanceof Error ? error.message : String(error),
});
}
})();
});
autoUpdater.on("download-progress", (progress) => {
win.setProgressBar(Math.min(progress.percent / 100, 1));
});
autoUpdater.on("update-downloaded", (info) => {
win.setProgressBar(-1);
if (SMOKE) {
return updateReporter?.({ state: "downloaded", version: info.version });
}
void (async () => {
const { response } = await dialog.showMessageBox(win, {
type: "info",
title: "Updates",
message: `J621 ${info.version} is ready to install.`,
detail:
process.platform === "linux"
? "Installing asks for administrator rights and then restarts J621."
: "J621 will restart to finish installing.",
buttons: ["Restart and install", "Later"],
defaultId: 0,
cancelId: 1,
});
if (response === 0) {
autoUpdater.quitAndInstall(false, true);
} else {
// Linux packages elevate, so never install silently on quit there.
autoUpdater.autoInstallOnAppQuit = process.platform !== "linux";
}
})();
});
}
async function checkForUpdates(win: BrowserWindow): Promise<void> {
if (updateCheckRunning) return;
updateCheckRunning = true;
try {
await autoUpdater.checkForUpdates();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
updateReporter?.({ state: "error", message });
if (!SMOKE) {
await dialog.showMessageBox(win, {
type: "error",
title: "Updates",
message: "Could not check for updates.",
detail: message,
});
}
} finally {
updateCheckRunning = false;
}
}
let mainWindow: BrowserWindow | null = null;
function buildMenu(win: BrowserWindow): void {
@@ -202,6 +321,10 @@ function buildMenu(win: BrowserWindow): void {
label: "Open backend in browser",
click: () => win.webContents.send("j621:open-backend"),
},
{
label: "Check for updates…",
click: () => void checkForUpdates(win),
},
{ type: "separator" },
{ role: "quit" },
],
@@ -277,6 +400,7 @@ function createWindow(): BrowserWindow {
});
buildMenu(win);
setUpUpdates(win);
void win.loadURL(DEV_SERVER || `${APP_ORIGIN}/`);
if (SMOKE) runSmokeTest(win);
return win;
@@ -350,6 +474,23 @@ function runSmokeTest(win: BrowserWindow): void {
app.exit(1);
return;
}
if (process.env.J621_UPDATE_URL) {
const update = await new Promise<UpdateEvent>((resolve) => {
updateReporter = resolve;
const timer = setTimeout(
() => resolve({ state: "error", message: "update check timed out" }),
60_000,
);
void checkForUpdates(win).finally(() => clearTimeout(timer));
});
updateReporter = null;
console.log("[smoke] update:", JSON.stringify(update));
if (update.state === "error") {
console.error("[smoke] FAILED: update check");
app.exit(1);
return;
}
}
console.log("[smoke] OK");
app.exit(0);
} catch (error) {