Add in-app update checker and one-click updater

Settings now shows when a newer version is available, with the real
version number read from the remote's package.json (via the git
binary already required for this deployment model, so it works
against any remote, not just GitHub), and an "Uppdatera nu" button
that stops the server, pulls, reinstalls dependencies and restarts
automatically - warning explicitly if a race is currently active.
The existing app-version poller already reloads the page once a
restart is detected, so no new client-side polling was needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
larssand
2026-09-09 22:05:13 +02:00
parent 2bbbd6e995
commit 4c463485e0
9 changed files with 335 additions and 8 deletions

View File

@@ -2,7 +2,7 @@ const fs = require("fs");
const path = require("path");
const os = require("os");
const net = require("net");
const { spawn } = require("child_process");
const { spawn, execFile } = require("child_process");
const express = require("express");
const QRCode = require("qrcode");
const Database = require("better-sqlite3");
@@ -40,6 +40,62 @@ const appVersion = {
updatedAt: new Date().toISOString(),
};
watchAppFiles();
// Everything below supports the in-app "an update is available" check and the one-click
// updater. It always shells out to the git binary already required for this deployment model
// (clone + git pull) rather than any git-hosting-specific API, so it works the same way against
// any remote (GitHub, GitLab, a self-hosted server) with no code that assumes which one is used.
// Overridable so tests can point this at a throwaway local repo/remote instead of this
// installation's real one - never at the real remote, and never touching the working tree.
const UPDATE_GIT_DIR = process.env.RC_UPDATE_GIT_DIR ? path.resolve(process.env.RC_UPDATE_GIT_DIR) : __dirname;
function runGitCommand(args, timeoutMs = 8000) {
return new Promise((resolve) => {
execFile("git", args, { cwd: UPDATE_GIT_DIR, timeout: timeoutMs }, (error, stdout, stderr) => {
resolve({ ok: !error, stdout: String(stdout || "").trim(), stderr: String(stderr || "").trim() });
});
});
}
const UPDATE_CHECK_CACHE_MS = 10 * 60 * 1000;
let updateStatusCache = { checkedAt: 0, result: null };
async function checkForUpdate() {
const branchResult = await runGitCommand(["rev-parse", "--abbrev-ref", "HEAD"]);
const branch = branchResult.ok && branchResult.stdout && branchResult.stdout !== "HEAD" ? branchResult.stdout : "main";
// A real network call (git fetch is one) can't be avoided for a genuine "is there something
// new" check, so this is cached and only ever triggered by a background poll or an explicit
// user click - never on every page load.
const fetchResult = await runGitCommand(["fetch", "--quiet", "origin", branch], 15000);
if (!fetchResult.ok) {
return { checked: false, available: false, currentVersion: appVersion.version, error: "Could not reach the update server" };
}
const localResult = await runGitCommand(["rev-parse", "HEAD"]);
const remoteResult = await runGitCommand(["rev-parse", `origin/${branch}`]);
if (!localResult.ok || !remoteResult.ok) {
return { checked: false, available: false, currentVersion: appVersion.version, error: "Not a git installation" };
}
const available = localResult.stdout !== remoteResult.stdout;
let remoteVersion = "";
if (available) {
const packageResult = await runGitCommand(["show", `origin/${branch}:package.json`]);
if (packageResult.ok) {
try {
remoteVersion = String(JSON.parse(packageResult.stdout)?.version || "");
} catch {
// leave remoteVersion empty if the remote package.json can't be parsed
}
}
}
return {
checked: true,
available,
currentVersion: appVersion.version,
remoteVersion,
localCommit: localResult.stdout.slice(0, 10),
remoteCommit: remoteResult.stdout.slice(0, 10),
};
}
function createAmmcChannelState() {
return {
process: null,
@@ -104,6 +160,31 @@ app.get("/api/app-version", (_req, res) => {
res.json(appVersion);
});
app.get("/api/update-status", async (req, res) => {
const force = req.query.force === "1";
if (!force && updateStatusCache.result && Date.now() - updateStatusCache.checkedAt < UPDATE_CHECK_CACHE_MS) {
res.json(updateStatusCache.result);
return;
}
const result = await checkForUpdate();
updateStatusCache = { checkedAt: Date.now(), result };
res.json(result);
});
app.post("/api/update/run", (req, res) => {
res.json({ ok: true, message: "Update starting - the server will restart shortly." });
// A detached child outlives this response and this process: it stops the currently running
// server (this one), pulls and installs the update, then starts a fresh server - the exact
// same stop/start machinery "npm run restart" already uses, with git pull/npm install
// sandwiched in between. See scripts/serverctl.js's "update" command.
const child = spawn(process.execPath, [path.join(__dirname, "scripts", "serverctl.js"), "update"], {
cwd: __dirname,
detached: true,
stdio: "ignore",
});
child.unref();
});
app.get("/api/network/public-address", async (_req, res) => {
try {
const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(5000) });