feat: desktop app auto updates with automatic, ask first or never modes

This commit is contained in:
LukeGus committed 2026-10-11 12:35:43 -05:00
1 parent 8403449669
commit 1aa1bde5f7
33 files changed
+1728 -177

No files matched your search

+323
View File
@@ -0,0 +1,323 @@
// Desktop self updates. The release is picked from the GitHub releases list
// instead of electron-updater's GitHub provider, because the repo also holds
// SDK and 2.9.x releases, and a new release exists a while before its files
// finish uploading. The channel file is uploaded last, so a release counts
// only once it has one for this platform.
const path = require("path");
const REPO = "Termix-SSH/Termix";
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases?per_page=30`;
const DOWNLOAD_BASE = `https://github.com/${REPO}/releases/download/`;
const MODES = ["automatic", "prompt", "never"];
const DEFAULT_MODE = "prompt";
const FIRST_CHECK_DELAY = 30 * 1000;
const CHECK_INTERVAL = 6 * 60 * 60 * 1000;
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/;
function parseVersion(value) {
const match = VERSION.exec(String(value || "").trim());
if (!match) return null;
return {
parts: match.slice(1, 4).map(Number),
beta: match[4] == null ? null : Number(match[4]),
};
}
// A beta sorts before the release it leads up to.
function compareVersions(a, b) {
for (let i = 0; i < 3; i++) {
if (a.parts[i] !== b.parts[i]) return a.parts[i] - b.parts[i];
}
if (a.beta === b.beta) return 0;
if (a.beta == null) return 1;
if (b.beta == null) return -1;
return a.beta - b.beta;
}
// Matches the names electron-builder writes and electron-updater reads.
function channelFile(channel, platform, arch) {
if (platform === "win32") return `${channel}.yml`;
if (platform === "darwin") return `${channel}-mac.yml`;
return `${channel}-linux${arch === "x64" ? "" : `-${arch}`}.yml`;
}
/**
* The newest release this install should move to, or null. Stable installs
* only see stable releases; a beta install also sees newer betas.
*/
function resolveRelease(releases, { currentVersion, platform, arch }) {
const current = parseVersion(currentVersion);
if (!current || !Array.isArray(releases)) return null;
const allowBeta = current.beta != null;
const newest = releases
.filter((release) => release && !release.draft)
.map((release) => ({ release, version: parseVersion(release.tag_name) }))
.filter(
({ release, version }) =>
version &&
release.tag_name.startsWith("v") &&
(allowBeta || version.beta == null) &&
compareVersions(version, current) > 0,
)
.sort((a, b) => compareVersions(b.version, a.version))[0];
if (!newest) return null;
const { release, version } = newest;
const channel = version.beta == null ? "latest" : "beta";
const file = channelFile(channel, platform, arch);
const assets = Array.isArray(release.assets) ? release.assets : [];
if (!assets.some((asset) => asset && asset.name === file)) return null;
return {
tag: release.tag_name,
version: release.tag_name.slice(1),
channel,
feedUrl: `${DOWNLOAD_BASE}${release.tag_name}`,
releaseUrl:
release.html_url ||
`https://github.com/${REPO}/releases/tag/${release.tag_name}`,
};
}
/**
* How this copy of the app was installed, which decides whether it can
* replace itself:
* - auto: NSIS, DMG, AppImage or deb, updated in place
* - manual: MSI, Chocolatey, Flatpak, portable builds and 32-bit Windows, told where to download
* - store: Mac App Store
* - disabled: dev builds
*/
function getInstallKind({
platform,
arch,
isPackaged,
mas,
env,
execPath,
resourcesPath,
exists,
readFile,
}) {
if (!isPackaged) return "disabled";
if (mas) return "store";
if (platform === "linux") {
if (env.FLATPAK_ID || exists("/.flatpak-info")) return "manual";
if (env.APPIMAGE) return "auto";
try {
const type = readFile(path.join(resourcesPath, "package-type")).trim();
// The AUR package reuses the deb's files, so ask dpkg if it owns us.
if (type === "deb" && exists("/var/lib/dpkg/info/termix.list")) {
return "auto";
}
} catch {
// Not a package install.
}
return "manual";
}
if (platform === "win32") {
if (arch !== "x64") return "manual";
const uninstaller = path.join(
path.dirname(execPath),
"Uninstall Termix.exe",
);
return exists(uninstaller) ? "auto" : "manual";
}
if (platform === "darwin") {
// Still running from the mounted DMG.
return execPath.startsWith("/Volumes/") ? "manual" : "auto";
}
return "manual";
}
function normalizeMode(mode) {
return MODES.includes(mode) ? mode : DEFAULT_MODE;
}
function createUpdater({
currentVersion,
platform,
arch,
kind,
getMode,
saveMode,
fetchReleases,
loadAutoUpdater,
send,
log,
beforeInstall,
}) {
const state = {
kind,
mode: normalizeMode(getMode()),
currentVersion,
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
};
const enabled = kind === "auto" || kind === "manual";
let updater = null;
let timer = null;
let checkedOnce = false;
function update(patch) {
Object.assign(state, patch);
send({ ...state });
}
function getUpdater() {
if (updater) return updater;
updater = loadAutoUpdater();
updater.logger = {
info: (...args) => log("[updater]", ...args),
warn: (...args) => log("[updater]", ...args),
error: (...args) => log("[updater]", ...args),
debug: () => {},
};
updater.autoInstallOnAppQuit = true;
updater.on("update-available", (info) =>
update({
status: updater.autoDownload ? "downloading" : "available",
version: info.version,
progress: null,
error: null,
}),
);
updater.on("update-not-available", () =>
update({ status: "up-to-date", error: null }),
);
updater.on("download-progress", (progress) =>
update({
status: "downloading",
progress: Math.round(progress.percent || 0),
}),
);
updater.on("update-downloaded", (info) =>
update({
status: "downloaded",
version: info.version,
progress: 100,
error: null,
}),
);
updater.on("error", (error) => {
log("[updater] error:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
});
return updater;
}
async function check() {
if (!enabled) return { ...state };
if (["checking", "downloading", "downloaded"].includes(state.status)) {
return { ...state };
}
checkedOnce = true;
update({ status: "checking", error: null });
let release;
try {
release = resolveRelease(await fetchReleases(), {
currentVersion,
platform,
arch,
});
} catch (error) {
log("[updater] release lookup failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
return { ...state };
}
if (!release) {
update({ status: "up-to-date" });
return { ...state };
}
update({ version: release.version, releaseUrl: release.releaseUrl });
if (kind === "manual") {
update({ status: "available" });
return { ...state };
}
try {
const auto = getUpdater();
auto.setFeedURL({ provider: "generic", url: release.feedUrl });
auto.channel = release.channel;
auto.allowDowngrade = false;
auto.autoDownload = state.mode === "automatic";
await auto.checkForUpdates();
} catch (error) {
log("[updater] check failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
}
return { ...state };
}
async function download() {
if (kind !== "auto" || state.status !== "available") return { ...state };
update({ status: "downloading", progress: 0 });
try {
await getUpdater().downloadUpdate();
} catch (error) {
log("[updater] download failed:", error?.message || String(error));
update({ status: "error", error: error?.message || String(error) });
}
return { ...state };
}
function install() {
if (kind !== "auto" || state.status !== "downloaded") return false;
// The tray keeps windows alive on close, so mark the quit first.
beforeInstall?.();
setImmediate(() => getUpdater().quitAndInstall());
return true;
}
function setMode(mode) {
if (!MODES.includes(mode)) return { ...state };
saveMode(mode);
update({ mode });
if (updater) updater.autoDownload = mode === "automatic";
if (mode !== "never" && !checkedOnce) void check();
return { ...state };
}
function start() {
if (!enabled || timer) return;
setTimeout(() => {
if (state.mode !== "never") void check();
}, FIRST_CHECK_DELAY).unref?.();
timer = setInterval(() => {
if (state.mode !== "never") void check();
}, CHECK_INTERVAL);
timer.unref?.();
}
return {
getState: () => ({ ...state }),
check,
download,
install,
setMode,
start,
};
}
module.exports = {
DEFAULT_MODE,
RELEASES_API,
channelFile,
compareVersions,
createUpdater,
getInstallKind,
normalizeMode,
parseVersion,
resolveRelease,
};
+1 -1
View File
@@ -32,7 +32,7 @@ function writeJson(filePath, value) {
}
}
const DEFAULTS = { defaultConnectionOrigin: "local" };
const DEFAULTS = { defaultConnectionOrigin: "local", updateMode: "prompt" };
function getDesktopSettings() {
return { ...DEFAULTS, ...readJson(dataPath("desktop-settings.json"), {}) };
+61
View File
@@ -29,6 +29,7 @@ const pty = require("node-pty");
const WebSocket = require("ws");
const linkedServer = require("./linked-server.cjs");
const desktopSettings = require("./desktop-settings.cjs");
const autoUpdates = require("./auto-updater.cjs");
const { launchNativeRdp } = require("./native-rdp.cjs");
const { isCloseActiveTabInput } = require("./keyboard-shortcuts.cjs");
const { quitApp } = require("./app-quit.cjs");
@@ -1752,6 +1753,65 @@ ipcMain.handle("save-desktop-settings", (_event, settings) => {
return desktopSettings.saveDesktopSettings(settings);
});
let appUpdater = null;
function startAppUpdater() {
const kind = autoUpdates.getInstallKind({
platform: process.platform,
arch: process.arch,
isPackaged: app.isPackaged,
mas: !!process.mas,
env: process.env,
execPath: process.execPath,
resourcesPath: process.resourcesPath,
exists: (target) => fs.existsSync(target),
readFile: (target) => fs.readFileSync(target, "utf8"),
});
logToFile("Updater install kind:", kind);
appUpdater = autoUpdates.createUpdater({
currentVersion: app.getVersion(),
platform: process.platform,
arch: process.arch,
kind,
getMode: () => desktopSettings.getDesktopSettings().updateMode,
saveMode: (mode) =>
desktopSettings.saveDesktopSettings({ updateMode: mode }),
fetchReleases: async () => {
const { net } = require("electron");
const response = await net.fetch(autoUpdates.RELEASES_API, {
headers: { Accept: "application/vnd.github+json" },
});
if (!response.ok) {
throw new Error(`GitHub releases returned ${response.status}`);
}
return response.json();
},
loadAutoUpdater: () => require("electron-updater").autoUpdater,
send: (state) => {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send("desktop-update-state", state);
}
},
log: logToFile,
beforeInstall: () => {
isQuitting = true;
},
});
appUpdater.start();
}
ipcMain.handle(
"desktop-update-get-state",
() => appUpdater?.getState() ?? null,
);
ipcMain.handle("desktop-update-check", () => appUpdater?.check() ?? null);
ipcMain.handle("desktop-update-download", () => appUpdater?.download() ?? null);
ipcMain.handle("desktop-update-install", () => appUpdater?.install() ?? false);
ipcMain.handle(
"desktop-update-set-mode",
(_event, mode) => appUpdater?.setMode(mode) ?? null,
);
// The server an older version of the app was pointed at, offered again when
// linking so upgraded installs do not have to look it up.
ipcMain.handle("get-previous-server-url", () => {
@@ -3804,6 +3864,7 @@ app.whenReady().then(async () => {
createTray();
createWindow();
startAppUpdater();
desktopSettings.removeOldSyncFiles();
logToFile("=== Startup complete ===");
});
+17
View File
@@ -26,6 +26,23 @@ contextBridge.exposeInMainWorld("electronAPI", {
getEmbeddedServerStatus: () =>
ipcRenderer.invoke("get-embedded-server-status"),
// App Store builds are updated by the store.
updates: process.mas
? undefined
: {
getState: () => ipcRenderer.invoke("desktop-update-get-state"),
check: () => ipcRenderer.invoke("desktop-update-check"),
download: () => ipcRenderer.invoke("desktop-update-download"),
install: () => ipcRenderer.invoke("desktop-update-install"),
setMode: (mode) => ipcRenderer.invoke("desktop-update-set-mode", mode),
onState: (callback) => {
const listener = (_event, state) => callback(state);
ipcRenderer.on("desktop-update-state", listener);
return () =>
ipcRenderer.removeListener("desktop-update-state", listener);
},
},
removeAllListeners: (channel) => ipcRenderer.removeAllListeners(channel),
isElectron: true,
isDev: process.env.NODE_ENV === "development",