From 467d01fdea389a036c056d2fb6a06de55f2cf42a Mon Sep 17 00:00:00 2001
From: Lampir <37238543+mLampir@users.noreply.github.com>
Date: Mon, 19 Oct 2026 08:47:57 +0000
Subject: [PATCH] Add a "Check for updates" button to the settings
The server asks GitHub at start and every 12 hours, so a new release
could take half a day to show up. The button checks right away, at
most once a minute, and says when Lorewarden is up to date or GitHub
cannot be reached.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
apps/dm/src/lib/server.ts | 18 ++++++++++++
apps/dm/src/locales/de.json | 5 ++++
apps/dm/src/locales/en.json | 5 ++++
apps/dm/src/views/SettingsView.vue | 24 ++++++++++++++++
docs/self-hosting.md | 2 +-
packages/server/src/main.ts | 10 +++++--
packages/server/src/server.ts | 27 +++++++++++++++---
packages/server/src/updates.ts | 27 ++++++++++++++----
packages/server/test/server.test.ts | 41 ++++++++++++++++++++++++++++
packages/server/test/updates.test.ts | 12 +++++---
10 files changed, 154 insertions(+), 17 deletions(-)
diff --git a/apps/dm/src/lib/server.ts b/apps/dm/src/lib/server.ts
index e543958..3a8140b 100644
--- a/apps/dm/src/lib/server.ts
+++ b/apps/dm/src/lib/server.ts
@@ -30,6 +30,8 @@ export interface UpdateInfo {
latest?: string;
/** Release notes of the newest release. */
url?: string;
+ /** Whether the server asks GitHub for new releases, so it can check now. */
+ canCheck?: boolean;
}
/** The server's version and whether a newer release is out. */
@@ -55,6 +57,22 @@ export interface UpdaterInfo {
/** Set when the server has an updater container, which enables the update button. */
export const updaterInfo = ref();
+/** Asks the server to look for a new release now; undefined if it could not ask. */
+export async function checkForUpdates(): Promise<'newer' | 'current' | 'unreachable' | undefined> {
+ try {
+ const response = await fetch('/api/updates/check', { method: 'POST' });
+ if (!response.ok) return undefined;
+ const data = (await response.json()) as {
+ result: 'newer' | 'current' | 'unreachable';
+ update: UpdateInfo;
+ };
+ updateInfo.value = data.update;
+ return data.result;
+ } catch {
+ return undefined;
+ }
+}
+
/** Asks the server to update itself; false if the updater could not start. */
export async function startUpdate(): Promise {
try {
diff --git a/apps/dm/src/locales/de.json b/apps/dm/src/locales/de.json
index a6b1e18..e4a8eee 100644
--- a/apps/dm/src/locales/de.json
+++ b/apps/dm/src/locales/de.json
@@ -18,6 +18,11 @@
"devVersion": "Lorewarden, aus dem Quellcode gebaut. Aktualisieren mit git pull.",
"updateAvailable": "Version {version} ist verfügbar.",
"releaseNotes": "Was ist neu",
+ "checkForUpdates": "Nach Updates suchen",
+ "checkResult": {
+ "current": "Lorewarden ist auf dem neuesten Stand.",
+ "unreachable": "GitHub war nicht erreichbar. Versuch es später noch einmal."
+ },
"howToUpdate": "Wenn du Lorewarden mit Docker Compose betreibst, aktualisierst du es auf dem Server so:",
"howToUpdateSource": "Wenn du es aus dem Quellcode startest, nimm git pull und pnpm start. Deine Abenteuer bleiben erhalten.",
"combat": "Kampf",
diff --git a/apps/dm/src/locales/en.json b/apps/dm/src/locales/en.json
index 0e99cdb..dec58d8 100644
--- a/apps/dm/src/locales/en.json
+++ b/apps/dm/src/locales/en.json
@@ -18,6 +18,11 @@
"devVersion": "Lorewarden, built from source. Update with git pull.",
"updateAvailable": "Version {version} is available.",
"releaseNotes": "What's new",
+ "checkForUpdates": "Check for updates",
+ "checkResult": {
+ "current": "Lorewarden is up to date.",
+ "unreachable": "GitHub could not be reached. Try again later."
+ },
"howToUpdate": "If you run Lorewarden with Docker Compose, update it on the server with:",
"howToUpdateSource": "If you run it from source, use git pull and pnpm start. Your adventures are kept.",
"combat": "Combat",
diff --git a/apps/dm/src/views/SettingsView.vue b/apps/dm/src/views/SettingsView.vue
index 6f24771..bc97a5c 100644
--- a/apps/dm/src/views/SettingsView.vue
+++ b/apps/dm/src/views/SettingsView.vue
@@ -15,6 +15,7 @@ import {
} from '../lib/auth';
import { languageOptions } from '../lib/languages';
import {
+ checkForUpdates,
dataDir,
setAutoUpdate,
startUpdate,
@@ -85,6 +86,18 @@ async function savePassword() {
repeatedPassword.value = '';
}
+const checking = ref(false);
+/** What the last "Check for updates" found, unless it found a new release. */
+const checkResult = ref<'current' | 'unreachable'>();
+
+async function checkNow() {
+ checking.value = true;
+ checkResult.value = undefined;
+ const result = await checkForUpdates();
+ checking.value = false;
+ if (result !== 'newer') checkResult.value = result ?? 'unreachable';
+}
+
const updateRefused = ref(false);
const updating = computed(() => updaterInfo.value?.status?.state === 'running');
@@ -252,6 +265,17 @@ function onLocale(event: Event) {
: t('settings.currentVersion', { version: updateInfo.current })
}}
+
{{ t('settings.updateAvailable', { version: updateInfo.latest }) }}{{ ' ' }}
diff --git a/docs/self-hosting.md b/docs/self-hosting.md
index 442b54d..a909cda 100644
--- a/docs/self-hosting.md
+++ b/docs/self-hosting.md
@@ -153,7 +153,7 @@ Pass both with `--server https://lorewarden.example.org --token ...`. Keep the t
## Updates
-The server asks GitHub when it starts and then every 12 hours whether a newer release is out. If so, the settings icon in the game master app gets a dot and the settings page shows the new version with a link to its release notes. To update by hand, run on the server:
+The server asks GitHub when it starts and then every 12 hours whether a newer release is out. If so, the settings icon in the game master app gets a dot and the settings page shows the new version with a link to its release notes. To look right away, for example just after a release, press **Check for updates** there. To update by hand, run on the server:
```sh
docker compose pull && docker compose up -d
diff --git a/packages/server/src/main.ts b/packages/server/src/main.ts
index dc4579e..c89805a 100644
--- a/packages/server/src/main.ts
+++ b/packages/server/src/main.ts
@@ -9,7 +9,7 @@ import { OAuth } from './oauth';
import { DEFAULT_SERVER_PORT, LorewardenServer } from './server';
import { Store } from './store';
import { Updater } from './updater';
-import { watchForUpdates } from './updates';
+import { type UpdateWatcher, watchForUpdates } from './updates';
const DEFAULT_UPDATER_URL = 'http://updater:47617';
@@ -119,6 +119,7 @@ const updater = updaterToken
const apiHost =
host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host.includes(':') ? `[${host}]` : host;
+let updates: UpdateWatcher | undefined;
const server = new LorewardenServer({
store: new Store(dir),
auth,
@@ -131,6 +132,9 @@ const server = new LorewardenServer({
allowedOrigins: values.origin,
version: __LOREWARDEN_VERSION__,
updater,
+ checkForUpdates: checkForUpdates
+ ? () => updates?.check() ?? Promise.resolve('unreachable')
+ : undefined,
});
try {
const listening = await server.listen(port, host);
@@ -143,7 +147,9 @@ try {
: 'No local network found. Players need to be in the same Wi-Fi as this computer.',
);
console.log(`Version ${__LOREWARDEN_VERSION__}. Press Ctrl+C to stop.`);
- if (checkForUpdates) watchForUpdates(__LOREWARDEN_VERSION__, (info) => server.setUpdate(info));
+ if (checkForUpdates) {
+ updates = watchForUpdates(__LOREWARDEN_VERSION__, (info) => server.setUpdate(info));
+ }
if (updater) {
void updater.refresh();
if (checkForUpdates) updater.watch(() => server.latestRelease);
diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts
index 1f59a22..a5f9d4c 100644
--- a/packages/server/src/server.ts
+++ b/packages/server/src/server.ts
@@ -26,7 +26,7 @@ import { Attempts, clientAddress } from './limits';
import { type AuthorizationRequest, type OAuth, OAuthError } from './oauth';
import { RevisionMismatch, type Store, type StoredAdventure } from './store';
import type { Updater } from './updater';
-import type { UpdateInfo } from './updates';
+import type { CheckResult, UpdateInfo } from './updates';
export { DEFAULT_SERVER_PORT };
@@ -95,6 +95,8 @@ export interface ServerOptions {
publicUrl?: string;
/** The optional updater container, which updates this server when asked. */
updater?: Updater;
+ /** Asks GitHub for a newer release right away; unset when the server does not check. */
+ checkForUpdates?: () => Promise;
}
interface Connection {
@@ -124,6 +126,8 @@ export class LorewardenServer {
private server: Server | undefined;
private port = DEFAULT_SERVER_PORT;
private update: UpdateInfo;
+ /** The last check the game master asked for, so pressing the button again waits a minute. */
+ private lastCheck: { at: number; result: CheckResult } | undefined;
/** Wrong passwords and setup links. */
private readonly signIns = new Attempts(5, 15 * 60_000);
/** Wrong join codes. */
@@ -132,7 +136,10 @@ export class LorewardenServer {
private readonly registrations = new Attempts(10, 60 * 60_000);
constructor(private readonly options: ServerOptions) {
- this.update = { current: options.version ?? 'dev' };
+ this.update = {
+ current: options.version ?? 'dev',
+ ...(options.checkForUpdates ? { canCheck: true } : {}),
+ };
}
listen(port = DEFAULT_SERVER_PORT, host = '0.0.0.0'): Promise {
@@ -702,6 +709,7 @@ export class LorewardenServer {
}
if (pathname.startsWith('/api/updater/')) return this.handleUpdater(req, res, pathname);
+ if (pathname === '/api/updates/check') return this.checkForUpdates(req, res);
const sessionMatch = /^\/api\/sessions\/([^/]+)$/.exec(pathname);
if (sessionMatch?.[1] && method === 'PUT') {
@@ -715,6 +723,17 @@ export class LorewardenServer {
res.writeHead(404).end('Not found');
}
+ /** "Check for updates" in the settings. GitHub allows few requests, so at most one a minute. */
+ private async checkForUpdates(req: IncomingMessage, res: ServerResponse): Promise {
+ const { checkForUpdates } = this.options;
+ if (!checkForUpdates) return void res.writeHead(404).end('This server does not check');
+ if (req.method !== 'POST') return void res.writeHead(405).end('Method not allowed');
+ if (!this.lastCheck || Date.now() - this.lastCheck.at > 60_000) {
+ this.lastCheck = { at: Date.now(), result: await checkForUpdates() };
+ }
+ return sendJson(res, { result: this.lastCheck.result, update: this.update });
+ }
+
/** The update button and the automatic updates in the settings. */
private async handleUpdater(
req: IncomingMessage,
@@ -752,8 +771,8 @@ export class LorewardenServer {
/** Tells the game master app that a newer release is out. */
setUpdate(update: UpdateInfo): void {
- this.update = update;
- this.broadcast('update', update);
+ this.update = { ...update, ...(this.update.canCheck ? { canCheck: true } : {}) };
+ this.broadcast('update', this.update);
}
/** The newest release, when it is newer than this server's version. */
diff --git a/packages/server/src/updates.ts b/packages/server/src/updates.ts
index c1c3cf0..848222a 100644
--- a/packages/server/src/updates.ts
+++ b/packages/server/src/updates.ts
@@ -9,6 +9,17 @@ export interface UpdateInfo {
latest?: string;
/** Release notes of the newest release. */
url?: string;
+ /** Whether the server asks GitHub, so the app can offer to check now. */
+ canCheck?: boolean;
+}
+
+/** How a check ended: a newer release is out, this is the newest, or GitHub did not answer. */
+export type CheckResult = 'newer' | 'current' | 'unreachable';
+
+export interface UpdateWatcher {
+ /** Checks right away, e.g. when the game master asks in the settings. */
+ check(): Promise;
+ stop(): void;
}
/** Splits "v1.2.3" into [1, 2, 3]; anything that is not a plain version is undefined. */
@@ -54,16 +65,20 @@ export function watchForUpdates(
current: string,
onChange: (info: UpdateInfo) => void,
check = fetchLatestRelease,
-): () => void {
+): UpdateWatcher {
let known: string | undefined;
- const run = async () => {
+ const run = async (): Promise => {
const release = await check();
- if (!release || !isNewer(release.version, current) || release.version === known) return;
- known = release.version;
- onChange({ current, latest: release.version, url: release.url });
+ if (!release) return 'unreachable';
+ if (!isNewer(release.version, current)) return 'current';
+ if (release.version !== known) {
+ known = release.version;
+ onChange({ current, latest: release.version, url: release.url });
+ }
+ return 'newer';
};
void run();
const timer = setInterval(() => void run(), CHECK_INTERVAL_MS);
timer.unref();
- return () => clearInterval(timer);
+ return { check: run, stop: () => clearInterval(timer) };
}
diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts
index da6a8a9..30a7c37 100644
--- a/packages/server/test/server.test.ts
+++ b/packages/server/test/server.test.ts
@@ -553,6 +553,47 @@ describe('Lorewarden server', () => {
const update = { current: '1.0.0', latest: '1.1.0', url: 'https://example.org/1.1.0' };
server.setUpdate(update);
expect(await gm.next('update')).toEqual(update);
+ // This server does not ask GitHub, so there is nothing to check now.
+ expect((await fetch(`${base}/api/updates/check`, { method: 'POST' })).status).toBe(404);
+ });
+});
+
+describe('Lorewarden server that checks for updates', () => {
+ it('checks when the game master asks, at most once a minute', async () => {
+ const dataDir = mkdtempSync(join(tmpdir(), 'lorewarden-data-'));
+ let checks = 0;
+ let server: LorewardenServer | undefined;
+ server = new LorewardenServer({
+ store: new Store(dataDir),
+ version: '1.0.0',
+ checkForUpdates: async () => {
+ checks++;
+ server?.setUpdate({ current: '1.0.0', latest: '1.1.0', url: 'https://example.org/1.1.0' });
+ return 'newer';
+ },
+ });
+ const base = `http://127.0.0.1:${await server.listen(0, '127.0.0.1')}`;
+ try {
+ const check = async () => {
+ const response = await fetch(`${base}/api/updates/check`, { method: 'POST' });
+ expect(response.status).toBe(200);
+ return response.json();
+ };
+ expect(await check()).toEqual({
+ result: 'newer',
+ update: {
+ current: '1.0.0',
+ latest: '1.1.0',
+ url: 'https://example.org/1.1.0',
+ canCheck: true,
+ },
+ });
+ expect(await check()).toMatchObject({ result: 'newer' });
+ expect(checks).toBe(1);
+ } finally {
+ await server.close();
+ rmSync(dataDir, { recursive: true, force: true });
+ }
});
});
diff --git a/packages/server/test/updates.test.ts b/packages/server/test/updates.test.ts
index 3fb9b10..9b10444 100644
--- a/packages/server/test/updates.test.ts
+++ b/packages/server/test/updates.test.ts
@@ -20,27 +20,31 @@ describe('watchForUpdates', () => {
it('reports a newer release once', async () => {
const onChange = vi.fn();
const check = vi.fn(async () => ({ version: '1.1.0', url: 'https://example.org/1.1.0' }));
- const stop = watchForUpdates('1.0.0', onChange, check);
+ const watcher = watchForUpdates('1.0.0', onChange, check);
await vi.waitFor(() => expect(onChange).toHaveBeenCalledOnce());
expect(onChange).toHaveBeenCalledWith({
current: '1.0.0',
latest: '1.1.0',
url: 'https://example.org/1.1.0',
});
- stop();
+ expect(await watcher.check()).toBe('newer');
+ expect(onChange).toHaveBeenCalledOnce();
+ watcher.stop();
});
it('stays quiet when the server is up to date or GitHub cannot be reached', async () => {
const onChange = vi.fn();
const upToDate = vi.fn(async () => ({ version: '1.0.0', url: 'https://example.org/1.0.0' }));
const offline = vi.fn(async () => undefined);
- const stops = [
+ const watchers = [
watchForUpdates('1.0.0', onChange, upToDate),
watchForUpdates('1.0.0', onChange, offline),
];
await vi.waitFor(() => expect(offline).toHaveBeenCalled());
await vi.waitFor(() => expect(upToDate).toHaveBeenCalled());
expect(onChange).not.toHaveBeenCalled();
- for (const stop of stops) stop();
+ expect(await watchers[0]?.check()).toBe('current');
+ expect(await watchers[1]?.check()).toBe('unreachable');
+ for (const watcher of watchers) watcher.stop();
});
});