updates.ts 4.4 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  1. // Pure helpers for self-updates (no Electron, no fs): parsing what the git server advertises
  2. // and the manifest of a published build. Tested in tests/updates.test.ts.
  3. //
  4. // A release is a tag `vX.Y.Z` on the source plus a parentless commit at `refs/builds/vX.Y.Z`
  5. // holding the game files (app.asar, its unpacked natives, assets/) and a manifest.json.
  6. // Clones never fetch refs/builds/*, so the builds don't weigh on the source history.
  7. export const BUILD_REF_PREFIX = 'refs/builds/v';
  8. export const MANIFEST_FILE = 'manifest.json';
  9. /** userData/game/current.json: the downloaded version boot.ts should run. */
  10. export const CURRENT_FILE = 'current.json';
  11. export interface ManifestFile {
  12. /** Relative path with forward slashes, e.g. "assets/maps/roule/map.json". */
  13. path: string;
  14. size: number;
  15. sha256: string;
  16. }
  17. export interface BuildManifest {
  18. version: string;
  19. /** Electron version the build was made with: a different one needs a new installer. */
  20. electron: string;
  21. files: ManifestFile[];
  22. }
  23. const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)$/;
  24. export function isVersion(v: string): boolean {
  25. return VERSION_RE.test(v);
  26. }
  27. /** Compares two x.y.z versions: negative if a < b, 0 if equal, positive if a > b. */
  28. export function compareVersions(a: string, b: string): number {
  29. const pa = VERSION_RE.exec(a);
  30. const pb = VERSION_RE.exec(b);
  31. if (!pa || !pb) throw new Error(`not a version: ${pa ? b : a}`);
  32. for (let i = 1; i <= 3; i++) {
  33. const d = Number(pa[i]) - Number(pb[i]);
  34. if (d !== 0) return d;
  35. }
  36. return 0;
  37. }
  38. /** Refs advertised by `GET <repo>.git/info/refs?service=git-upload-pack`, as name -> commit sha. */
  39. export function parseAdvertisedRefs(body: string): Map<string, string> {
  40. const refs = new Map<string, string>();
  41. // Each pkt-line is "<4 hex length><sha> <ref>[\0capabilities]\n"; the length prefix is glued to the sha.
  42. for (const m of body.matchAll(/([0-9a-f]{40}) (refs\/[^\0\n]+)/g)) refs.set(m[2], m[1]);
  43. return refs;
  44. }
  45. /** The newest published build strictly newer than `current`, if any. */
  46. export function newestBuild(refs: Map<string, string>, current: string): { version: string; sha: string } | null {
  47. let best: { version: string; sha: string } | null = null;
  48. for (const [ref, sha] of refs) {
  49. if (!ref.startsWith(BUILD_REF_PREFIX)) continue;
  50. const version = ref.slice(BUILD_REF_PREFIX.length);
  51. if (!isVersion(version) || compareVersions(version, current) <= 0) continue;
  52. if (!best || compareVersions(version, best.version) > 0) best = { version, sha };
  53. }
  54. return best;
  55. }
  56. /** A manifest path must stay inside the game folder. */
  57. function isSafePath(path: string): boolean {
  58. if (!path || path.startsWith('/') || path.includes('\\') || path.includes(':')) return false;
  59. return path.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
  60. }
  61. /** Validates a downloaded manifest; throws with an explicit message when it is unusable. */
  62. export function parseManifest(raw: unknown, expectedVersion: string): BuildManifest {
  63. const m = raw as Partial<BuildManifest> | null;
  64. if (!m || typeof m !== 'object') throw new Error('manifest is not an object');
  65. if (m.version !== expectedVersion) throw new Error(`manifest is for ${String(m.version)}, expected ${expectedVersion}`);
  66. if (typeof m.electron !== 'string') throw new Error('manifest has no electron version');
  67. if (!Array.isArray(m.files) || m.files.length === 0) throw new Error('manifest lists no file');
  68. for (const f of m.files) {
  69. if (!f || typeof f.path !== 'string' || !isSafePath(f.path)) throw new Error(`unsafe path in manifest: ${String(f?.path)}`);
  70. if (!Number.isInteger(f.size) || f.size < 0) throw new Error(`bad size for ${f.path}`);
  71. if (typeof f.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(f.sha256)) throw new Error(`bad sha256 for ${f.path}`);
  72. }
  73. if (!m.files.some((f) => f.path === 'app.asar')) throw new Error('manifest has no app.asar');
  74. return { version: m.version, electron: m.electron, files: m.files };
  75. }
  76. /** URL of a file of a published build, served raw by the git web front (Gogs, Gitea, GitLab...). */
  77. export function rawFileUrl(repoUrl: string, sha: string, path: string): string {
  78. return `${repoUrl}/raw/${sha}/${path.split('/').map(encodeURIComponent).join('/')}`;
  79. }
  80. /** Electron builds with the same major version share the same native ABI and runtime APIs. */
  81. export function sameElectronMajor(a: string, b: string): boolean {
  82. return a.split('.')[0] === b.split('.')[0];
  83. }