import fs from 'node:fs' import path from 'node:path' import type { SelectedGame } from '../../domain/models/SelectedGame' import { ZipArchive } from '../archive/ZipArchive' import type { StoreFileSystem } from '../files/StoreFileSystem' import type { CatalogClient } from './CatalogClient' /** Files that are never the program: data, libraries and documentation. */ const NEVER_A_PROGRAM: readonly string[] = ['.txt', '.md', '.json', '.so', '.dll', '.dylib', '.pck', '.dat'] export type ExecutableKind = 'bundle' | 'exe' export interface FoundProgram { readonly kind: ExecutableKind readonly executablePath: string } /** * Getting a native build onto the disk and finding what to launch in it. * * The archive is downloaded to a part file beside the payload and unpacked only once * it is complete, and the payload directory is replaced rather than merged: a build * that dropped a file between releases would otherwise keep the old one around and * the game would load it. */ export class PayloadInstaller { public constructor ( private readonly catalog: CatalogClient, private readonly files: StoreFileSystem, private readonly log: (line: string) => void ) {} /** Download and unpack into `destination`. Returns bytes downloaded. */ public async unpack (game: SelectedGame, destination: string): Promise { const parent = path.dirname(destination) fs.mkdirSync(parent, { recursive: true }) const archivePath = this.files.temporaryPath(parent, game.name, '.zip') try { const size = await this.catalog.downloadAsset(game.asset, archivePath) fs.rmSync(destination, { recursive: true, force: true }) fs.mkdirSync(destination, { recursive: true }) ZipArchive.open(archivePath).extractAll(destination) return size } finally { fs.rmSync(archivePath, { force: true }) } } /** * The thing to launch inside an unpacked payload. * * A `bundle` is a macOS `.app` the archive already contained — a LÖVE or Godot * build ships one — and then it *is* the launcher rather than something to wrap. * Otherwise the answer is a single executable, and "single" is the whole * difficulty: an archive holding one candidate is unambiguous, and where there are * several the one named after the game wins. Anything else is reported as not * found, because launching the wrong binary is worse than failing the install. */ public findProgram (root: string, name: string, operatingSystem: string): FoundProgram | null { if (operatingSystem === 'darwin') { const bundle = this.findBundle(root) if (bundle !== null) return { kind: 'bundle', executablePath: bundle } } const executables: string[] = [] const namedAfterTheGame: string[] = [] this.walk(root, (filePath: string): void => { const fileName = path.basename(filePath) const lowered = fileName.toLowerCase() if (NEVER_A_PROGRAM.some((extension: string): boolean => lowered.endsWith(extension))) return if (operatingSystem === 'windows') { if (lowered.endsWith('.exe')) executables.push(filePath) } else if (isExecutable(filePath)) { executables.push(filePath) } if (stem(fileName) === name) namedAfterTheGame.push(filePath) }) for (const candidates of [executables, namedAfterTheGame]) { if (candidates.length === 1 && candidates[0] !== undefined) { return { kind: 'exe', executablePath: candidates[0] } } const exact = candidates.filter((candidate: string): boolean => stem(path.basename(candidate)) === name) if (exact.length === 1 && exact[0] !== undefined) { return { kind: 'exe', executablePath: exact[0] } } } this.log(`found no single program to launch inside ${root}`) return null } /** The shallowest `.app`, without descending into one we have already found. */ private findBundle (root: string): string | null { let level = [root] while (level.length > 0) { const next: string[] = [] for (const directory of level) { const bundles = readDirectories(directory) .filter((entry: string): boolean => entry.endsWith('.app')).sort() const first = bundles[0] if (first !== undefined) return path.join(directory, first) for (const entry of readDirectories(directory)) next.push(path.join(directory, entry)) } level = next } return null } /** Every file under `root`, never entering a `.app` bundle. */ private walk (root: string, visit: (filePath: string) => void): void { const pending = [root] while (pending.length > 0) { const directory = pending.pop() if (directory === undefined) continue let entries: fs.Dirent[] try { entries = fs.readdirSync(directory, { withFileTypes: true }) } catch { continue } for (const entry of entries) { const full = path.join(directory, entry.name) if (entry.isDirectory()) { if (!entry.name.endsWith('.app')) pending.push(full) } else if (entry.isFile()) { visit(full) } } } } } function readDirectories (directory: string): readonly string[] { try { return fs.readdirSync(directory, { withFileTypes: true }) .filter((entry: fs.Dirent): boolean => entry.isDirectory()) .map((entry: fs.Dirent): string => entry.name) } catch { return [] } } function isExecutable (filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.X_OK) return true } catch { return false } } function stem (fileName: string): string { return path.basename(fileName, path.extname(fileName)) }