// 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 .git/info/refs?service=git-upload-pack`, as name -> commit sha. */ export function parseAdvertisedRefs(body: string): Map { const refs = new Map(); // Each pkt-line is "<4 hex length> [\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, 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 | 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]; }