| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495 |
- // 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];
- }
|