Преглед изворни кода

feat(dist): self-update from the git repository's release tags

`npm run release -- x.y.z` tags the source and stores the game files
(app.asar, natives, assets) with a sha256 manifest in a parentless commit
at refs/builds/vX.Y.Z. The packaged game reads the repository's refs over
git smart HTTP, downloads the newer build through the raw file URLs and
runs it from userData on the next launch (main/boot.ts). The Gogs server
has no release API, and clones never fetch refs/builds, so no extra host
and no weight on the source history.

No new dependency: Electron's net and original-fs, and git, are enough.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idleman пре 4 дана
родитељ
комит
2be396f3f5

+ 3 - 0
electron.vite.config.ts

@@ -6,8 +6,11 @@ export default defineConfig({
     plugins: [externalizeDepsPlugin()],
     build: {
       rollupOptions: {
+        external: ['original-fs'],
         // The server is bundled next to main so it can be forked in a utilityProcess.
+        // boot picks the installed game or a downloaded update (src/main/updater.ts), then loads index.
         input: {
+          boot: resolve(__dirname, 'src/main/boot.ts'),
           index: resolve(__dirname, 'src/main/index.ts'),
           server: resolve(__dirname, 'src/server/index.ts'),
         },

+ 3 - 2
package.json

@@ -2,7 +2,7 @@
   "name": "goutermageddon",
   "version": "0.1.0",
   "description": "Jeu de baston 2D en LAN dans un goûter géant entièrement destructible.",
-  "main": "./out/main/index.js",
+  "main": "./out/main/boot.js",
   "private": true,
   "scripts": {
     "dev": "electron-vite dev",
@@ -13,7 +13,8 @@
     "lint": "eslint . && tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json",
     "build": "electron-vite build",
     "dist": "electron-vite build && electron-builder --win",
-    "gen:maps": "tsx scripts/gen-maps.ts"
+    "gen:maps": "tsx scripts/gen-maps.ts",
+    "release": "tsx scripts/release.ts"
   },
   "dependencies": {
     "msgpackr": "^1.12.1",

+ 14 - 0
readme.md

@@ -47,6 +47,7 @@ npm run gen:maps       # régénère les PNG placeholder des cartes intégrées
 npm run lint           # eslint + tsc --noEmit
 npm run build          # build de prod
 npm run dist           # versions Windows autonomes dans dist/ (electron-builder)
+npm run release -- 0.2.0   # publie la version 0.2.0 : tag, build, mise à jour automatique (voir plus bas)
 ```
 
 `npm run dist` produit trois fichiers dans `dist/`. Aucun d'eux n'a besoin de Node.js sur le PC qui joue :
@@ -55,6 +56,18 @@ npm run dist           # versions Windows autonomes dans dist/ (electron-builder
 - `Goutermageddon-<version>-x64.zip` : le jeu décompressé. On extrait le dossier et on lance `Goûtermageddon.exe`. Le démarrage est plus rapide, et l'autorisation du pare-feu reste valable d'une fois sur l'autre.
 - `Goutermageddon-<version>-setup.exe` : un installeur classique, avec un raccourci dans le menu Démarrer.
 
+### Mises à jour automatiques
+
+Le jeu installé se met à jour tout seul à partir du dépôt Git (`UPDATE_REPO_URL`), sans serveur en plus :
+
+- `npm run release -- <x.y.z>` vérifie que `main` est propre, lance les tests et le lint, change la version, construit les exe, puis crée le tag `v<x.y.z>` et un commit sans parent à la référence `refs/builds/v<x.y.z>`. Ce commit contient les fichiers du jeu (`app.asar`, ses modules natifs, `assets/`, environ 16 Mo) et un `manifest.json` (taille et sha256 de chaque fichier). Le script pousse ensuite `main`, le tag et le build (`--no-push` pour ne rien pousser). Un clone ne récupère jamais `refs/builds/*`, donc l'historique du code reste léger.
+- Au lancement, le jeu lit les références du dépôt (`info/refs`, public) et cherche un build plus récent que lui. Il télécharge les fichiers modifiés par l'URL brute du serveur Git, vérifie chaque sha256, et range la version dans `%APPDATA%\Goûtermageddon\game\<version>\`. Un bandeau en bas à gauche affiche la progression, puis un bouton « Relancer ».
+- Le point d'entrée (`main/boot.ts`) lance la version téléchargée la plus récente, sinon celle de l'installeur. Si la version téléchargée plante au démarrage, il revient à celle de l'installeur.
+- Seul le code du jeu est mis à jour, pas le runtime Electron. Si un build utilise une autre version majeure d'Electron, le jeu affiche qu'il faut réinstaller.
+- Ça marche avec l'installeur, le zip et l'exe portable (la version téléchargée est rangée dans le profil Windows du joueur, pas à côté de l'exe).
+- Le nouveau joueur reçoit une seule fois `Goutermageddon-<version>-setup.exe` (ou le portable) ; ensuite tout passe par les releases.
+- `GOUTER_NO_UPDATE=1` désactive la mise à jour, `GOUTER_UPDATE_REPO=<url>` la fait pointer sur un autre dépôt (tests).
+
 Les exe ne sont pas signés. Windows SmartScreen affiche donc « Windows a protégé votre ordinateur » : il faut cliquer sur « Informations complémentaires », puis « Exécuter quand même ». Au premier hébergement, le pare-feu demande l'accès : il faut cocher **Réseaux privés**, sinon les autres PC ne voient pas la partie.
  
 ---
@@ -357,6 +370,7 @@ Chaque étape doit être **jouable** avant de passer à la suivante.
     - Les signatures (prout, jet, sortie de terre, sorts, léchouille) passent par un événement `cue` commun, prêt pour l'audio.
   - **DA, première passe finale :** terrain et fonds nets à toutes les résolutions, personnages en volume et plus expressifs, ambiance onirique (§7).
   - **Versions Windows autonomes :** `npm run dist` produit un exe portable, un zip et un installeur (§2). Ils ne sont pas signés et n'ont pas encore d'icône.
+  - **Mises à jour automatiques :** `npm run release` publie une version dans le dépôt Git, et le jeu installé la télécharge au lancement (§2).
   - **Retours de la première vraie partie :**
     - **Équilibrage :** plus d'assommage en boucle (§4). Pyromanchot : pétards qui vont plus loin, explosion ratée aléatoire et moins forte. Captain Prout : 3 poussées avant de reposer le pied. Gégé : saute plus haut et creuse le chocolat. Bébé Pompier : son détrempage piège ceux qui marchent dessus et ralentit ceux qu'il arrose. Le Chat : bond griffu, coup de patte plus fort, léchouilles plus rares.
     - **Cuillère :** elle vise une corniche atteignable (§4).

+ 111 - 0
scripts/release.ts

@@ -0,0 +1,111 @@
+// Publishes a new version (readme §2): npm run release -- <x.y.z> [--no-push] [--skip-checks]
+//
+// 1. checks (clean tree on main, tests and lint), bumps the version;
+// 2. builds the Windows exes (npm run dist);
+// 3. stores the game files (app.asar, its unpacked natives, assets/) with a manifest.json in a
+//    parentless commit at refs/builds/v<x.y.z>, next to the tag v<x.y.z> of the source;
+// 4. pushes main, the tag and the build: installed games pick it up at their next launch.
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, relative, resolve } from 'node:path';
+import { BUILD_REF_PREFIX, compareVersions, isVersion, MANIFEST_FILE, type BuildManifest } from '../src/main/updates';
+
+const root = resolve(import.meta.dirname, '..');
+const resources = join(root, 'dist', 'win-unpacked', 'resources');
+/** What a build carries: everything else in resources/ belongs to the installed runtime. */
+const PAYLOAD = ['app.asar', 'app.asar.unpacked', 'assets'];
+
+const args = process.argv.slice(2);
+const version = args.find((a) => !a.startsWith('--')) ?? '';
+const push = !args.includes('--no-push');
+const checks = !args.includes('--skip-checks');
+
+function run(cmd: string, cmdArgs: string[], env?: NodeJS.ProcessEnv): string {
+  return execFileSync(cmd, cmdArgs, { cwd: root, encoding: 'utf8', env: { ...process.env, ...env }, stdio: ['pipe', 'pipe', 'inherit'] }).trim();
+}
+const git = (...a: string[]): string => run('git', a);
+function npm(...a: string[]): void {
+  console.log(`\n> npm ${a.join(' ')}`);
+  execFileSync('npm', a, { cwd: root, stdio: 'inherit', shell: true });
+}
+function fail(message: string): never {
+  console.error(`\n✗ ${message}`);
+  process.exit(1);
+}
+
+function listFiles(dir: string): string[] {
+  return readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
+    e.isDirectory() ? listFiles(join(dir, e.name)) : [join(dir, e.name)],
+  );
+}
+
+/** Writes the payload into the git object store and returns the commit of the build. */
+function commitBuild(): string {
+  const electron = (JSON.parse(readFileSync(join(root, 'node_modules', 'electron', 'package.json'), 'utf8')) as { version: string }).version;
+  const files = PAYLOAD.flatMap((p) => {
+    const abs = join(resources, p);
+    return statSync(abs).isDirectory() ? listFiles(abs) : [abs];
+  });
+  const manifest: BuildManifest = {
+    version,
+    electron,
+    files: files.map((abs) => ({
+      path: relative(resources, abs).split('\\').join('/'),
+      size: statSync(abs).size,
+      sha256: createHash('sha256').update(readFileSync(abs)).digest('hex'),
+    })),
+  };
+  // A throwaway index, so the working tree and the real index stay untouched.
+  const tmp = mkdtempSync(join(tmpdir(), 'goutermageddon-release-'));
+  const env = { GIT_INDEX_FILE: join(tmp, 'index') };
+  try {
+    const add = (path: string, blob: string): void => {
+      run('git', ['update-index', '--add', '--cacheinfo', `100644,${blob},${path}`], env);
+    };
+    for (const [i, f] of manifest.files.entries()) add(f.path, run('git', ['hash-object', '-w', '--', files[i]]));
+    const manifestBlob = execFileSync('git', ['hash-object', '-w', '--stdin'], { cwd: root, input: JSON.stringify(manifest, null, 1), encoding: 'utf8' }).trim();
+    add(MANIFEST_FILE, manifestBlob);
+    const tree = run('git', ['write-tree'], env);
+    const size = manifest.files.reduce((s, f) => s + f.size, 0);
+    console.log(`\nBuild v${version}: ${manifest.files.length} files, ${(size / 1e6).toFixed(1)} MB (Electron ${electron})`);
+    return git('commit-tree', tree, '-m', `build v${version}`);
+  } finally {
+    rmSync(tmp, { recursive: true, force: true });
+  }
+}
+
+const current = (JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }).version;
+if (!isVersion(version)) fail(`usage: npm run release -- <x.y.z>   (current version: ${current})`);
+if (compareVersions(version, current) <= 0) fail(`${version} must be newer than the current version ${current}`);
+if (git('rev-parse', '--abbrev-ref', 'HEAD') !== 'main') fail('releases are made from main');
+if (git('status', '--porcelain', '--untracked-files=no')) fail('commit or stash your changes first');
+if (git('tag', '--list', `v${version}`)) fail(`tag v${version} already exists`);
+
+if (checks) {
+  npm('test');
+  npm('run', 'lint');
+}
+npm('version', version, '--no-git-tag-version');
+try {
+  npm('run', 'dist');
+} catch {
+  git('checkout', '--', 'package.json', 'package-lock.json');
+  fail('build failed; version bump undone');
+}
+
+const build = commitBuild();
+git('commit', '-q', '-m', `chore: release v${version}`, '--', 'package.json', 'package-lock.json');
+git('tag', '-a', `v${version}`, '-m', `v${version}`);
+git('update-ref', `${BUILD_REF_PREFIX}${version}`, build);
+console.log(`Tag v${version} and ${BUILD_REF_PREFIX}${version} (${build.slice(0, 7)}) created.`);
+
+if (push) {
+  console.log('\n> git push');
+  execFileSync('git', ['push', 'origin', 'main', `v${version}`, `${BUILD_REF_PREFIX}${version}`], { cwd: root, stdio: 'inherit' });
+  console.log(`\n✓ v${version} published: installed games update at their next launch.`);
+} else {
+  console.log(`\nNot pushed. To publish: git push origin main v${version} ${BUILD_REF_PREFIX}${version}`);
+}
+console.log(`First install for a new player: dist/Goutermageddon-${version}-setup.exe (or -portable.exe).`);

+ 40 - 0
src/main/boot.ts

@@ -0,0 +1,40 @@
+// Electron entry point. Runs the newest game: the one shipped with the installer, or a newer
+// one downloaded by updater.ts into userData/game/<version>/ (readme §2).
+import { join } from 'node:path';
+import { app } from 'electron';
+import * as fs from 'original-fs';
+import { compareVersions, CURRENT_FILE, isVersion, MANIFEST_FILE } from './updates';
+
+/** Loads a CommonJS entry by absolute path, left as is by the bundler. */
+// eslint-disable-next-line @typescript-eslint/no-require-imports -- the game entry is chosen at runtime
+const load = (path: string): unknown => require(path);
+
+/** Folder of a downloaded game newer than the built-in one, if there is a complete one. */
+function downloadedGame(): string | null {
+  if (!app.isPackaged || process.env.GOUTER_NO_UPDATE) return null;
+  try {
+    const gamesDir = join(app.getPath('userData'), 'game');
+    const { version } = JSON.parse(fs.readFileSync(join(gamesDir, CURRENT_FILE), 'utf8')) as { version: string };
+    const root = join(gamesDir, version);
+    if (!isVersion(version) || compareVersions(version, app.getVersion()) <= 0) return null;
+    return fs.existsSync(join(root, MANIFEST_FILE)) ? root : null;
+  } catch {
+    return null;
+  }
+}
+
+const root = downloadedGame();
+let started = false;
+if (root) {
+  try {
+    process.env.GOUTER_GAME_ROOT = root;
+    load(join(root, 'app.asar', 'out', 'main', 'index.js'));
+    started = true;
+  } catch (err) {
+    // A broken download must not keep the game from starting: forget it and run the built-in one.
+    console.error('[boot] downloaded game failed, falling back to the built-in one:', err);
+    delete process.env.GOUTER_GAME_ROOT;
+    fs.rmSync(join(root, '..', CURRENT_FILE), { force: true });
+  }
+}
+if (!started) load(join(__dirname, 'index.js'));

+ 36 - 3
src/main/index.ts

@@ -1,13 +1,18 @@
 import { join } from 'node:path';
 import { app, BrowserWindow, ipcMain, utilityProcess, type UtilityProcess, type WebContents } from 'electron';
 import { readFileSync } from 'node:fs';
-import type { HostResult, MapFiles } from '../preload/api';
-import { DISCOVERY_INTERVAL_MS } from '../shared/constants';
+import type { HostResult, MapFiles, UpdateStatus } from '../preload/api';
+import { DISCOVERY_INTERVAL_MS, UPDATE_CHECK_DELAY_MS, UPDATE_REPO_URL } from '../shared/constants';
 import { loadCharacterStats, readMapFiles, readMapImage, resolveAssetsDir } from '../server/assets';
 import { CHARACTER_IDS } from '../shared/characters';
 import { DiscoveryListener } from './discovery';
+import { checkForUpdate } from './updater';
 
-const assetsDir = app.isPackaged ? join(process.resourcesPath, 'assets') : join(app.getAppPath(), 'assets');
+/** Folder holding app.asar and assets/: set by boot.ts for a downloaded version, else the installed one. */
+const gameRoot = process.env.GOUTER_GAME_ROOT ?? process.resourcesPath;
+const assetsDir = app.isPackaged ? join(gameRoot, 'assets') : join(app.getAppPath(), 'assets');
+/** Version of this game code (its own package.json, not the installer's). */
+const gameVersion = (JSON.parse(readFileSync(join(__dirname, '../../package.json'), 'utf8')) as { version: string }).version;
 process.env.GOUTER_ASSETS = assetsDir;
 
 let server: UtilityProcess | null = null;
@@ -84,8 +89,36 @@ ipcMain.handle('assets:map', (_event, mapId: string): MapFiles => {
   return { definition, terrain, background: readMapImage(dir, mapId, 'background.png') };
 });
 
+// Self-update: only for the packaged app; GOUTER_UPDATE_REPO points it at another server (tests).
+let updateStatus: UpdateStatus = { state: 'idle' };
+function setUpdateStatus(status: UpdateStatus): void {
+  updateStatus = status;
+  for (const win of BrowserWindow.getAllWindows()) win.webContents.send('updates:status', status);
+}
+function startUpdater(): void {
+  if (!app.isPackaged || process.env.GOUTER_NO_UPDATE) return;
+  setTimeout(() => {
+    void checkForUpdate({
+      repoUrl: process.env.GOUTER_UPDATE_REPO ?? UPDATE_REPO_URL,
+      current: gameVersion,
+      gamesDir: join(app.getPath('userData'), 'game'),
+      runningRoot: gameRoot,
+      onStatus: setUpdateStatus,
+    });
+  }, UPDATE_CHECK_DELAY_MS);
+}
+ipcMain.handle('updates:version', () => gameVersion);
+ipcMain.handle('updates:status', () => updateStatus);
+ipcMain.handle('updates:restart', () => {
+  // The portable exe unpacks itself to a temp folder: relaunch the exe the player clicked.
+  const portable = process.env.PORTABLE_EXECUTABLE_FILE;
+  app.relaunch(portable ? { execPath: portable } : undefined);
+  app.exit(0);
+});
+
 void app.whenReady().then(() => {
   createWindow();
+  startUpdater();
   app.on('activate', () => {
     if (BrowserWindow.getAllWindows().length === 0) createWindow();
   });

+ 111 - 0
src/main/updater.ts

@@ -0,0 +1,111 @@
+// Self-update of the packaged game (readme §2). Looks at the builds published in the git
+// repository, downloads the newest one into userData/game/<version>/ and points
+// userData/game/current.json at it. boot.ts runs it on the next launch; the Electron runtime
+// that came with the installer stays as is.
+import { createHash } from 'node:crypto';
+import { join } from 'node:path';
+import { net } from 'electron';
+// original-fs: plain fs, without Electron's asar handling (we write files named app.asar).
+import * as fs from 'original-fs';
+import type { UpdateStatus } from '../preload/api';
+import { CURRENT_FILE, MANIFEST_FILE, newestBuild, parseAdvertisedRefs, parseManifest, rawFileUrl, sameElectronMajor, type ManifestFile } from './updates';
+
+export interface UpdaterOptions {
+  repoUrl: string;
+  /** Version of the game running now. */
+  current: string;
+  /** userData/game: one folder per downloaded version. */
+  gamesDir: string;
+  /** Folder of the running game (app.asar, assets/...): files that did not change are copied from it. */
+  runningRoot: string;
+  onStatus(status: UpdateStatus): void;
+}
+
+async function fetchOk(url: string): Promise<Response> {
+  const res = await net.fetch(url, { headers: { 'Cache-Control': 'no-cache' } });
+  if (!res.ok) throw new Error(`${url}: HTTP ${res.status}`);
+  return res;
+}
+
+function sha256(data: Uint8Array): string {
+  return createHash('sha256').update(data).digest('hex');
+}
+
+/** The file of the running game when it is byte-identical to the one wanted, else null. */
+function reusable(runningRoot: string, file: ManifestFile): Buffer | null {
+  try {
+    const path = join(runningRoot, ...file.path.split('/'));
+    if (fs.statSync(path).size !== file.size) return null;
+    const data = fs.readFileSync(path);
+    return sha256(data) === file.sha256 ? data : null;
+  } catch {
+    return null;
+  }
+}
+
+/** Checks for a newer build and downloads it. Never throws: failures are reported as a status. */
+export async function checkForUpdate(opts: UpdaterOptions): Promise<void> {
+  try {
+    fs.mkdirSync(opts.gamesDir, { recursive: true });
+    const refs = parseAdvertisedRefs(await (await fetchOk(`${opts.repoUrl}.git/info/refs?service=git-upload-pack`)).text());
+    const build = newestBuild(refs, opts.current);
+    if (!build) return;
+    const target = join(opts.gamesDir, build.version);
+    if (fs.existsSync(join(target, MANIFEST_FILE))) {
+      // Already downloaded by an earlier launch.
+      writeCurrent(opts.gamesDir, build.version);
+      opts.onStatus({ state: 'ready', version: build.version });
+      return;
+    }
+
+    const manifest = parseManifest(await (await fetchOk(rawFileUrl(opts.repoUrl, build.sha, MANIFEST_FILE))).json(), build.version);
+    if (!sameElectronMajor(manifest.electron, process.versions.electron)) {
+      opts.onStatus({ state: 'manual', version: build.version });
+      return;
+    }
+
+    const partial = `${target}.part`;
+    fs.rmSync(partial, { recursive: true, force: true });
+    const total = manifest.files.reduce((sum, f) => sum + f.size, 0) || 1;
+    let done = 0;
+    opts.onStatus({ state: 'downloading', version: build.version, progress: 0 });
+    for (const file of manifest.files) {
+      let data = reusable(opts.runningRoot, file);
+      if (!data) {
+        data = Buffer.from(await (await fetchOk(rawFileUrl(opts.repoUrl, build.sha, file.path))).arrayBuffer());
+        if (data.length !== file.size || sha256(data) !== file.sha256) throw new Error(`${file.path} is corrupted`);
+      }
+      const path = join(partial, ...file.path.split('/'));
+      fs.mkdirSync(join(path, '..'), { recursive: true });
+      fs.writeFileSync(path, data);
+      done += file.size;
+      opts.onStatus({ state: 'downloading', version: build.version, progress: done / total });
+    }
+    // The manifest goes last: its presence marks a complete download.
+    fs.writeFileSync(join(partial, MANIFEST_FILE), JSON.stringify(manifest));
+    fs.rmSync(target, { recursive: true, force: true });
+    fs.renameSync(partial, target);
+    writeCurrent(opts.gamesDir, build.version);
+    removeOldVersions(opts.gamesDir, [build.version, opts.current]);
+    opts.onStatus({ state: 'ready', version: build.version });
+  } catch (err) {
+    console.error('[update]', err);
+    opts.onStatus({ state: 'error', message: err instanceof Error ? err.message : String(err) });
+  }
+}
+
+function writeCurrent(gamesDir: string, version: string): void {
+  fs.writeFileSync(join(gamesDir, CURRENT_FILE), JSON.stringify({ version }));
+}
+
+/** Deletes downloaded versions other than `keep` (the running one may be in use). */
+function removeOldVersions(gamesDir: string, keep: string[]): void {
+  for (const entry of fs.readdirSync(gamesDir, { withFileTypes: true })) {
+    if (!entry.isDirectory() || keep.includes(entry.name)) continue;
+    try {
+      fs.rmSync(join(gamesDir, entry.name), { recursive: true, force: true });
+    } catch {
+      // A folder still locked by another running instance: removed on a later update.
+    }
+  }
+}

+ 95 - 0
src/main/updates.ts

@@ -0,0 +1,95 @@
+// Pure helpers for self-updates (no Electron, no fs): parsing what the git server advertises
+// and the manifest of a published build. Tested in tests/updates.test.ts.
+//
+// A release is a tag `vX.Y.Z` on the source plus a parentless commit at `refs/builds/vX.Y.Z`
+// holding the game files (app.asar, its unpacked natives, assets/) and a manifest.json.
+// Clones never fetch refs/builds/*, so the builds don't weigh on the source history.
+
+export const BUILD_REF_PREFIX = 'refs/builds/v';
+export const MANIFEST_FILE = 'manifest.json';
+/** userData/game/current.json: the downloaded version boot.ts should run. */
+export const CURRENT_FILE = 'current.json';
+
+export interface ManifestFile {
+  /** Relative path with forward slashes, e.g. "assets/maps/roule/map.json". */
+  path: string;
+  size: number;
+  sha256: string;
+}
+
+export interface BuildManifest {
+  version: string;
+  /** Electron version the build was made with: a different one needs a new installer. */
+  electron: string;
+  files: ManifestFile[];
+}
+
+const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
+
+export function isVersion(v: string): boolean {
+  return VERSION_RE.test(v);
+}
+
+/** Compares two x.y.z versions: negative if a < b, 0 if equal, positive if a > b. */
+export function compareVersions(a: string, b: string): number {
+  const pa = VERSION_RE.exec(a);
+  const pb = VERSION_RE.exec(b);
+  if (!pa || !pb) throw new Error(`not a version: ${pa ? b : a}`);
+  for (let i = 1; i <= 3; i++) {
+    const d = Number(pa[i]) - Number(pb[i]);
+    if (d !== 0) return d;
+  }
+  return 0;
+}
+
+/** Refs advertised by `GET <repo>.git/info/refs?service=git-upload-pack`, as name -> commit sha. */
+export function parseAdvertisedRefs(body: string): Map<string, string> {
+  const refs = new Map<string, string>();
+  // Each pkt-line is "<4 hex length><sha> <ref>[\0capabilities]\n"; the length prefix is glued to the sha.
+  for (const m of body.matchAll(/([0-9a-f]{40}) (refs\/[^\0\n]+)/g)) refs.set(m[2], m[1]);
+  return refs;
+}
+
+/** The newest published build strictly newer than `current`, if any. */
+export function newestBuild(refs: Map<string, string>, current: string): { version: string; sha: string } | null {
+  let best: { version: string; sha: string } | null = null;
+  for (const [ref, sha] of refs) {
+    if (!ref.startsWith(BUILD_REF_PREFIX)) continue;
+    const version = ref.slice(BUILD_REF_PREFIX.length);
+    if (!isVersion(version) || compareVersions(version, current) <= 0) continue;
+    if (!best || compareVersions(version, best.version) > 0) best = { version, sha };
+  }
+  return best;
+}
+
+/** A manifest path must stay inside the game folder. */
+function isSafePath(path: string): boolean {
+  if (!path || path.startsWith('/') || path.includes('\\') || path.includes(':')) return false;
+  return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
+}
+
+/** Validates a downloaded manifest; throws with an explicit message when it is unusable. */
+export function parseManifest(raw: unknown, expectedVersion: string): BuildManifest {
+  const m = raw as Partial<BuildManifest> | null;
+  if (!m || typeof m !== 'object') throw new Error('manifest is not an object');
+  if (m.version !== expectedVersion) throw new Error(`manifest is for ${String(m.version)}, expected ${expectedVersion}`);
+  if (typeof m.electron !== 'string') throw new Error('manifest has no electron version');
+  if (!Array.isArray(m.files) || m.files.length === 0) throw new Error('manifest lists no file');
+  for (const f of m.files) {
+    if (!f || typeof f.path !== 'string' || !isSafePath(f.path)) throw new Error(`unsafe path in manifest: ${String(f?.path)}`);
+    if (!Number.isInteger(f.size) || f.size < 0) throw new Error(`bad size for ${f.path}`);
+    if (typeof f.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(f.sha256)) throw new Error(`bad sha256 for ${f.path}`);
+  }
+  if (!m.files.some((f) => f.path === 'app.asar')) throw new Error('manifest has no app.asar');
+  return { version: m.version, electron: m.electron, files: m.files };
+}
+
+/** URL of a file of a published build, served raw by the git web front (Gogs, Gitea, GitLab...). */
+export function rawFileUrl(repoUrl: string, sha: string, path: string): string {
+  return `${repoUrl}/raw/${sha}/${path.split('/').map(encodeURIComponent).join('/')}`;
+}
+
+/** Electron builds with the same major version share the same native ABI and runtime APIs. */
+export function sameElectronMajor(a: string, b: string): boolean {
+  return a.split('.')[0] === b.split('.')[0];
+}

+ 20 - 0
src/preload/api.ts

@@ -41,9 +41,29 @@ export interface AssetsApi {
   map(mapId: string): Promise<MapFiles>;
 }
 
+/** Where the self-update stands (src/main/updater.ts). */
+export type UpdateStatus =
+  | { state: 'idle' }
+  | { state: 'downloading'; version: string; progress: number }
+  /** Downloaded: runs on the next launch. */
+  | { state: 'ready'; version: string }
+  /** Needs a new installer (different Electron runtime). */
+  | { state: 'manual'; version: string }
+  | { state: 'error'; message: string };
+
+export interface UpdatesApi {
+  /** Version of the running game. */
+  version(): Promise<string>;
+  /** `onStatus` receives the current status at once, then every change. Returns an unsubscribe function. */
+  watch(onStatus: (status: UpdateStatus) => void): () => void;
+  /** Restarts the app on the downloaded version. */
+  restart(): void;
+}
+
 declare global {
   interface Window {
     lan: LanApi;
     assets: AssetsApi;
+    updates: UpdatesApi;
   }
 }

+ 13 - 1
src/preload/index.ts

@@ -1,5 +1,5 @@
 import { contextBridge, ipcRenderer, type IpcRendererEvent } from 'electron';
-import type { AssetsApi, LanApi, LanGame } from './api';
+import type { AssetsApi, LanApi, LanGame, UpdatesApi, UpdateStatus } from './api';
 
 const lan: LanApi = {
   host: (name) => ipcRenderer.invoke('lan:host', name),
@@ -20,5 +20,17 @@ const assets: AssetsApi = {
   map: (mapId) => ipcRenderer.invoke('assets:map', mapId),
 };
 
+const updates: UpdatesApi = {
+  version: () => ipcRenderer.invoke('updates:version'),
+  watch: (onStatus) => {
+    const listener = (_event: IpcRendererEvent, status: UpdateStatus): void => onStatus(status);
+    ipcRenderer.on('updates:status', listener);
+    void ipcRenderer.invoke('updates:status').then(onStatus);
+    return () => ipcRenderer.removeListener('updates:status', listener);
+  },
+  restart: () => void ipcRenderer.invoke('updates:restart'),
+};
+
 contextBridge.exposeInMainWorld('lan', lan);
 contextBridge.exposeInMainWorld('assets', assets);
+contextBridge.exposeInMainWorld('updates', updates);

+ 7 - 0
src/renderer/i18n/fr.ts

@@ -63,6 +63,13 @@ export const fr = {
     imposed: (leader: string, character: string) => `${leader} jouera ${character} !`,
   },
   stopped: 'L’hôte a arrêté la partie : retour à l’échauffement.',
+  updates: {
+    version: (v: string) => `v${v}`,
+    downloading: (v: string, percent: number) => `Nouvelle version ${v} : téléchargement… ${percent} %`,
+    ready: (v: string) => `Version ${v} prête !`,
+    restart: 'Relancer',
+    manual: (v: string) => `Version ${v} disponible : il faut réinstaller le jeu.`,
+  },
   options: {
     title: 'Options',
     master: 'Volume général',

+ 2 - 0
src/renderer/main.ts

@@ -49,6 +49,7 @@ import { ResultsScreen, RoundHud } from './ui/hud';
 import { LobbyPanel } from './ui/lobbyPanel';
 import { showMenu, type MenuChoice } from './ui/menu';
 import { OptionsScreen } from './ui/options';
+import { mountUpdateBanner } from './ui/updateBanner';
 import { settings } from './settings';
 
 const PORTRAIT_RADIUS = 34;
@@ -144,6 +145,7 @@ async function start(): Promise<void> {
   });
   host.appendChild(app.canvas);
 
+  mountUpdateBanner();
   const audio = new GameAudio();
   const options = new OptionsScreen();
   const { choice, address, port } = await chooseGame(app, options);

+ 23 - 0
src/renderer/style.css

@@ -851,3 +851,26 @@ input.invalid {
 .menu-buttons #menu-options {
   margin-bottom: 4px;
 }
+
+/* Game version and self-update progress, bottom-left corner. */
+#update-banner {
+  position: fixed;
+  left: 10px;
+  bottom: 8px;
+  z-index: 50;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 12px;
+  opacity: 0.55;
+  pointer-events: none;
+}
+#update-banner.active {
+  font-size: 15px;
+  opacity: 1;
+  padding: 6px 12px;
+  background: var(--paper);
+  border: 3px solid var(--ink);
+  border-radius: 14px;
+  pointer-events: auto;
+}

+ 34 - 0
src/renderer/ui/updateBanner.ts

@@ -0,0 +1,34 @@
+import type { UpdateStatus } from '../../preload/api';
+import { fr } from '../i18n/fr';
+
+/** Corner badge: the game version, and the self-update progress (src/main/updater.ts). */
+export function mountUpdateBanner(): void {
+  const box = document.createElement('div');
+  box.id = 'update-banner';
+  const text = document.createElement('span');
+  const restart = document.createElement('button');
+  restart.className = 'primary small';
+  restart.textContent = fr.updates.restart;
+  restart.onclick = () => window.updates.restart();
+  box.append(text, restart);
+  document.body.appendChild(box);
+
+  let version = '';
+  const render = (status: UpdateStatus): void => {
+    restart.hidden = status.state !== 'ready';
+    box.classList.toggle('active', status.state === 'downloading' || status.state === 'ready' || status.state === 'manual');
+    if (status.state === 'downloading') text.textContent = fr.updates.downloading(status.version, Math.round(status.progress * 100));
+    else if (status.state === 'ready') text.textContent = fr.updates.ready(status.version);
+    else if (status.state === 'manual') text.textContent = fr.updates.manual(status.version);
+    else text.textContent = version ? fr.updates.version(version) : '';
+  };
+  let last: UpdateStatus = { state: 'idle' };
+  void window.updates.version().then((v) => {
+    version = v;
+    render(last);
+  });
+  window.updates.watch((status) => {
+    last = status;
+    render(status);
+  });
+}

+ 5 - 0
src/shared/constants.ts

@@ -191,3 +191,8 @@ export const OUTLINE_PX = 4;
 export const RENDER_MARGIN = 12;
 /** Chunks within this distance of a modified pixel are marked dirty (contours need 1 px, rendering RENDER_MARGIN). */
 export const DIRTY_MARGIN = RENDER_MARGIN;
+
+/** Git repository whose tags and refs/builds/* publish the self-updates (src/main/updates.ts). */
+export const UPDATE_REPO_URL = 'https://git.idleman.fr/idleman/foodmageddon';
+/** Delay before the packaged app looks for an update, so startup stays smooth (ms). */
+export const UPDATE_CHECK_DELAY_MS = 3000;

+ 54 - 0
tests/updates.test.ts

@@ -0,0 +1,54 @@
+import { describe, expect, it } from 'vitest';
+import { compareVersions, newestBuild, parseAdvertisedRefs, parseManifest, rawFileUrl, sameElectronMajor } from '../src/main/updates';
+
+const sha = (c: string): string => c.repeat(40);
+
+describe('self-update helpers', () => {
+  it('compares versions numerically', () => {
+    expect(compareVersions('0.10.0', '0.9.9')).toBeGreaterThan(0);
+    expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
+    expect(compareVersions('1.2.3', '1.3.0')).toBeLessThan(0);
+    expect(() => compareVersions('v1.2.3', '1.0.0')).toThrow();
+  });
+
+  it('parses the refs advertised by git smart HTTP', () => {
+    const body =
+      '001e# service=git-upload-pack\n0000' +
+      `00fa${sha('a')} refs/heads/main\0multi_ack thin-pack agent=git/2.47.3\n` +
+      `003f${sha('b')} refs/builds/v0.2.0\n` +
+      `003f${sha('c')} refs/tags/v0.2.0\n0000`;
+    const refs = parseAdvertisedRefs(body);
+    expect(refs.get('refs/heads/main')).toBe(sha('a'));
+    expect(refs.get('refs/builds/v0.2.0')).toBe(sha('b'));
+    expect(refs.get('refs/tags/v0.2.0')).toBe(sha('c'));
+  });
+
+  it('picks the newest build above the running version', () => {
+    const refs = new Map([
+      ['refs/builds/v0.2.0', sha('a')],
+      ['refs/builds/v0.10.0', sha('b')],
+      ['refs/builds/vbroken', sha('c')],
+      ['refs/tags/v9.0.0', sha('d')],
+    ]);
+    expect(newestBuild(refs, '0.1.0')).toEqual({ version: '0.10.0', sha: sha('b') });
+    expect(newestBuild(refs, '0.10.0')).toBeNull();
+  });
+
+  it('rejects unsafe or incomplete manifests', () => {
+    const file = { path: 'app.asar', size: 3, sha256: 'f'.repeat(64) };
+    const ok = { version: '0.2.0', electron: '38.8.6', files: [file] };
+    expect(parseManifest(ok, '0.2.0').files).toHaveLength(1);
+    expect(() => parseManifest(ok, '0.3.0')).toThrow(/expected 0.3.0/);
+    expect(() => parseManifest({ ...ok, files: [file, { ...file, path: '../evil.exe' }] }, '0.2.0')).toThrow(/unsafe/);
+    expect(() => parseManifest({ ...ok, files: [file, { ...file, path: 'C:/evil.exe' }] }, '0.2.0')).toThrow(/unsafe/);
+    expect(() => parseManifest({ ...ok, files: [{ ...file, path: 'assets/a.png' }] }, '0.2.0')).toThrow(/app.asar/);
+  });
+
+  it('builds raw URLs and compares Electron runtimes', () => {
+    expect(rawFileUrl('https://git.example/u/r', 'abc', 'app.asar.unpacked/node_modules/@scope/x.node')).toBe(
+      'https://git.example/u/r/raw/abc/app.asar.unpacked/node_modules/%40scope/x.node',
+    );
+    expect(sameElectronMajor('38.8.6', '38.1.0')).toBe(true);
+    expect(sameElectronMajor('39.0.0', '38.8.6')).toBe(false);
+  });
+});