Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/dm/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -55,6 +57,22 @@ export interface UpdaterInfo {
/** Set when the server has an updater container, which enables the update button. */
export const updaterInfo = ref<UpdaterInfo>();

/** 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<boolean> {
try {
Expand Down
5 changes: 5 additions & 0 deletions apps/dm/src/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions apps/dm/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions apps/dm/src/views/SettingsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
} from '../lib/auth';
import { languageOptions } from '../lib/languages';
import {
checkForUpdates,
dataDir,
setAutoUpdate,
startUpdate,
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -252,6 +265,17 @@ function onLocale(event: Event) {
: t('settings.currentVersion', { version: updateInfo.current })
}}
</p>
<template v-if="updateInfo.canCheck && !updateInfo.latest">
<div class="row">
<button type="button" :disabled="checking" @click="checkNow">
<RefreshCw :size="16" aria-hidden="true" />
{{ t('settings.checkForUpdates') }}
</button>
</div>
<p v-if="checkResult" class="hint" role="status">
{{ t(`settings.checkResult.${checkResult}`) }}
</p>
</template>
<template v-if="updateInfo.latest">
<p>
<strong>{{ t('settings.updateAvailable', { version: updateInfo.latest }) }}</strong>{{ ' ' }}
Expand Down
2 changes: 1 addition & 1 deletion docs/self-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions packages/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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);
Expand Down
27 changes: 23 additions & 4 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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<CheckResult>;
}

interface Connection {
Expand Down Expand Up @@ -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. */
Expand All @@ -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<number> {
Expand Down Expand Up @@ -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') {
Expand All @@ -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<void> {
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,
Expand Down Expand Up @@ -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. */
Expand Down
27 changes: 21 additions & 6 deletions packages/server/src/updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CheckResult>;
stop(): void;
}

/** Splits "v1.2.3" into [1, 2, 3]; anything that is not a plain version is undefined. */
Expand Down Expand Up @@ -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<CheckResult> => {
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) };
}
41 changes: 41 additions & 0 deletions packages/server/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});

Expand Down
12 changes: 8 additions & 4 deletions packages/server/test/updates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading