diff --git a/.prettierignore b/.prettierignore index f0a286589..53e325ddd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ dist_electron src/routes.ts node_modules +# Agent worktrees hold whole copies of this repo, which the unscoped check would walk into. +.claude typed-router.d.ts vendor dist diff --git a/eslint.config.mjs b/eslint.config.mjs index 33ec41eda..eef373538 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,6 +3,9 @@ import pluginJs from "@eslint/js"; import tseslint from "typescript-eslint"; import pluginVue from "eslint-plugin-vue"; +import { contentLayerRules } from "./eslint/content-layer.mjs"; +import { restrictedSyntaxRules } from "./eslint/restricted-syntax.mjs"; + export default [ { files: ["**/*.{js,mjs,cjs,ts,vue}"] }, { @@ -13,6 +16,8 @@ export default [ "**/out", "**/dist_electron", "**/node_modules", + // Agent worktrees hold whole copies of this repo, which the unscoped lint would walk into. + "**/.claude", "**/working-files", "**/typed-router.d.ts", "**/vendor", @@ -54,17 +59,6 @@ export default [ "@typescript-eslint/no-unsafe-function-type": "warn", }, }, - { - files: ["src/renderer/**/*.{ts,vue}"], - ignores: ["src/renderer/api/shell.ts"], - rules: { - "no-restricted-syntax": [ - "error", - { - selector: "MemberExpression[object.name='window'][property.name='shell']", - message: "Use shellApi from @renderer/api/shell so failures get reported instead of reaching the fatal error modal.", - }, - ], - }, - }, + ...contentLayerRules, + ...restrictedSyntaxRules, ]; diff --git a/eslint/content-layer.mjs b/eslint/content-layer.mjs new file mode 100644 index 000000000..288e6c614 --- /dev/null +++ b/eslint/content-layer.mjs @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +// Lint rules that exist because of how the content layer is put together, rather than for style. Each one +// stands in for a boundary the language cannot express, so the reason it exists lives next to it. + +// TypeScript cannot scope an export to a directory, so the content providers stay exported for +// content-api.ts to import and this is what keeps anything else from reaching them. +const noContentProviderImports = [ + "error", + { + patterns: [ + { + group: ["**/content/engine/engine-provider", "**/content/game/game-provider", "**/content/maps/map-provider"], + message: "Import contentAPI from @main/content/content-api instead of reaching a content provider directly.", + }, + ], + }, +]; + +// content-state.ts is where the predicates are written, and content-api.ts is what assigns a status in +// the first place. +export const contentStatusScope = { + files: ["src/**/*.{ts,vue}"], + ignores: ["src/main/content/content-state.ts", "src/main/content/content-api.ts"], +}; + +// The predicates in content-state.ts answer different questions about a content status, and consumers +// reading the status themselves is how they ended up disagreeing about what counts as active. +// "failed" is left out of the pattern because tachyon responses and setup stages use that word too. +export const noContentStatusComparisons = { + selector: "BinaryExpression[operator=/^[!=]==$/] > Literal[value=/^(queued|acquiring|removing)$/]", + message: "Use isUnsettled or isInProgress from @main/content/content-state rather than comparing status.", +}; + +export const contentLayerRules = [ + { + files: ["src/**/*.{ts,vue}"], + ignores: ["src/main/content/**"], + rules: { + "no-restricted-imports": noContentProviderImports, + }, + }, +]; diff --git a/eslint/restricted-syntax.mjs b/eslint/restricted-syntax.mjs new file mode 100644 index 000000000..b27c73a4e --- /dev/null +++ b/eslint/restricted-syntax.mjs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { contentStatusScope, noContentStatusComparisons } from "./content-layer.mjs"; +import { noDirectShellCalls, shellApiScope } from "./shell-api.mjs"; + +// eslint replaces no-restricted-syntax rather than merging it, so two blocks matching one file means +// the later one silently drops the other's selectors. Every selector is assembled in this one place so +// that cannot happen, and each block repeats the selectors of every block it overlaps. +export const restrictedSyntaxRules = [ + { + ...contentStatusScope, + rules: { "no-restricted-syntax": ["error", noContentStatusComparisons] }, + }, + { + ...shellApiScope, + rules: { "no-restricted-syntax": ["error", noDirectShellCalls, noContentStatusComparisons] }, + }, +]; diff --git a/eslint/shell-api.mjs b/eslint/shell-api.mjs new file mode 100644 index 000000000..1bc89a184 --- /dev/null +++ b/eslint/shell-api.mjs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +// window.shell throws straight past the renderer into the fatal error modal, so the renderer goes +// through a wrapper that reports the failure instead. shell.ts is the wrapper. +export const shellApiScope = { + files: ["src/renderer/**/*.{ts,vue}"], + ignores: ["src/renderer/api/shell.ts"], +}; + +export const noDirectShellCalls = { + selector: "MemberExpression[object.name='window'][property.name='shell']", + message: "Use shellApi from @renderer/api/shell so failures get reported instead of reaching the fatal error modal.", +}; diff --git a/lang/dev/lobby.json b/lang/dev/lobby.json index f853ca1f7..1f1b320a4 100644 --- a/lang/dev/lobby.json +++ b/lang/dev/lobby.json @@ -252,7 +252,6 @@ }, "downloads": { "noDownloads": "No dlodnwoas avctie", - "moreDownloads": "and {count} more...", "starting": "Sattnrig...", "extracting": "Erixtanctg...", "progressMB": "{current} / {total} MB ({percent}%)", @@ -261,7 +260,8 @@ "speedBps": "{speed} B/s", "etaHoursMinutes": "{hours}h {minutes}m left", "etaMinutesSeconds": "{minutes}m {seconds}s left", - "etaSeconds": "{seconds}s left" + "etaSeconds": "{seconds}s left", + "queued": "Quueed" }, "serverStatus": { "playersOnline": "Plyreas Oinnle", @@ -442,7 +442,8 @@ "playOffline": "Paly Oniflfe", "stepFailed": "{step} — Failed", "stepRetrying": "{step} — Rtyneirg…", - "contentRequired": "Egnnie and gmae must be dodolnwaed borefe stnartig a btalte." + "contentRequired": "Egnnie and gmae must be dodolnwaed borefe stnartig a btalte.", + "pathUnusable": "That fdelor caonnt be used for gmae fleis. Pick a dnieferft one." } }, "battle": { diff --git a/lang/en/lobby.json b/lang/en/lobby.json index 20f59f440..029468a74 100644 --- a/lang/en/lobby.json +++ b/lang/en/lobby.json @@ -81,7 +81,6 @@ "customLobbies": "Custom Lobbies", "tournaments": "Tournaments" }, - "party": { "title": "Party", "partyTab": "Active Party", @@ -253,7 +252,6 @@ }, "downloads": { "noDownloads": "No downloads active", - "moreDownloads": "and {count} more...", "starting": "Starting...", "extracting": "Extracting...", "progressMB": "{current} / {total} MB ({percent}%)", @@ -262,7 +260,8 @@ "speedBps": "{speed} B/s", "etaHoursMinutes": "{hours}h {minutes}m left", "etaMinutesSeconds": "{minutes}m {seconds}s left", - "etaSeconds": "{seconds}s left" + "etaSeconds": "{seconds}s left", + "queued": "Queued" }, "serverStatus": { "playersOnline": "Players Online", @@ -443,7 +442,8 @@ "playOffline": "Play Offline", "stepFailed": "{step} — Failed", "stepRetrying": "{step} — Retrying…", - "contentRequired": "Engine and game must be downloaded before starting a battle." + "contentRequired": "Engine and game must be downloaded before starting a battle.", + "pathUnusable": "That folder cannot be used for game files. Pick a different one." } }, "battle": { diff --git a/package.json b/package.json index ebb8e40d3..83942fdc2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bar-lobby", "type": "module", - "version": "0.16.1", + "version": "0.16.2", "private": true, "description": "Lobby client for the RTS game Beyond All Reason", "author": "The BAR Lobby Authors", diff --git a/src/main/config/content-policy.ts b/src/main/config/content-policy.ts new file mode 100644 index 000000000..d86040d16 --- /dev/null +++ b/src/main/config/content-policy.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +// Held at one until an engine ships with the pr-downloader fix for concurrent invocations corrupting +// rapid's repo index. #423 asks for 2-4, which is what this goes back to once that lands. +// Counts invocations, not connections: a game is one slot and fans out inside prd. +export const MAX_CONCURRENT_DOWNLOADS = 1; + +// How long content nothing is holding on to is kept before a sweep will remove it. Deliberately not a +// user setting: nobody can reason about the right number, and getting it wrong deletes their content. +export const CONTENT_RETENTION_DAYS = 90; + +// Refuse to start acquiring with less than this free on the assets volume. A floor rather than a real +// size estimate, because rapid does not tell us how big a game is until it is fetching it; this is +// aimed at the disk that is already full, which is the case that actually fails. +export const MIN_FREE_BYTES_TO_ACQUIRE = 2 * 1024 * 1024 * 1024; diff --git a/src/main/content/auto-updater.ts b/src/main/content/auto-updater.ts index 9e3cd38ad..f6fc5bd4f 100644 --- a/src/main/content/auto-updater.ts +++ b/src/main/content/auto-updater.ts @@ -66,6 +66,7 @@ export class AutoUpdaterAPI extends Downloader { autoUpdater.on("download-progress", (progressInfo) => { this.downloadProgress({ type: "update", + id: this.updateInfo?.version ?? "unknown", name: this.updateInfo?.version ?? "unknown", currentBytes: progressInfo.transferred, totalBytes: progressInfo.total, @@ -77,6 +78,7 @@ export class AutoUpdaterAPI extends Downloader { // 'info' does not have download details, so we just falsify the values since it's complete anyway. this.downloadComplete({ type: "update", + id: this.updateInfo?.version ?? "unknown", name: this.updateInfo?.version ?? "unknown", currentBytes: 1, totalBytes: 1, @@ -90,6 +92,7 @@ export class AutoUpdaterAPI extends Downloader { // As above, 'info' does not have download details, so we just falsify the values since it's stopped already this.downloadFailed({ type: "update", + id: this.updateInfo?.version ?? "unknown", name: this.updateInfo?.version ?? "unknown", currentBytes: 0, totalBytes: 0, diff --git a/src/main/content/content-api.ts b/src/main/content/content-api.ts new file mode 100644 index 000000000..0d731f0c3 --- /dev/null +++ b/src/main/content/content-api.ts @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { Signal } from "$/jaz-ts-utils/signal"; +import { Downloader } from "@main/content/abstract-content"; +import { ClaimSource } from "@main/content/content-claims"; +import { ContentProvider } from "@main/content/content-provider"; +import { ContentQueue, ContentQueueEntry } from "@main/content/content-queue"; +import { ContentRef, contentRefKey, ContentType } from "@main/content/content-ref"; +import { ContentPresence, ContentReporter, ContentState, isUnsettled } from "@main/content/content-state"; +import { engineProvider } from "@main/content/engine/engine-provider"; +import { compareEngineVersions } from "@main/content/engine/engine-version-order"; +import { gameProvider } from "@main/content/game/game-provider"; +import { mapProvider } from "@main/content/maps/map-provider"; +import { contentUsage } from "@main/content/content-usage"; +import { findPrdBinary } from "@main/content/pr-downloader"; +import { CONTENT_RETENTION_DAYS, MIN_FREE_BYTES_TO_ACQUIRE } from "@main/config/content-policy"; +import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; +import { getAssetsPath } from "@main/config/app"; +import { formatBytes, freeBytes } from "@main/utils/disk-space"; +import { logger } from "@main/utils/logger"; + +const log = logger("content-api.ts"); + +// A provider dispatches progress for everything it is downloading on one signal, so acquisitions that +// run alongside each other hear the others and have to ignore what is not theirs. A pr-downloader batch +// is the only thing running on its provider and reports bytes for the whole set rather than per asset, +// so every ref in it gets the same figures. +async function acquireReporting(downloader: Downloader, ids: string[], report: ContentReporter, acquire: () => Promise) { + const isOurs = (infoId: string) => ids.length > 1 || infoId === ids[0]; + + const progress = downloader.onDownloadProgress.add((info) => { + if (!isOurs(info.id)) return; + + report.progress(ids, { currentBytes: info.currentBytes, totalBytes: info.totalBytes, progress: info.progress, phase: info.phase }); + }); + const retry = downloader.onDownloadRetry.add((info) => { + if (!isOurs(info.id)) return; + + report.attempt(ids); + }); + + try { + await acquire(); + } finally { + downloader.onDownloadProgress.dispose(progress); + downloader.onDownloadRetry.dispose(retry); + } +} + +/** + * Owns acquiring, availability and removal of engines, games and maps: one queue, one concurrency limit, + * one change stream, presence read from disk rather than remembered. + * + * Deliberately not everything that downloads. The pool archive is a bulk seed with no version and nothing + * to remove, and it runs alone because setup awaits it before the game download. App updates come from + * electron-updater. Neither is a ContentRef, so neither is queued, and both report on their own channel. + */ +class ContentAPI { + private readonly providers: Record = { + engine: { + type: "engine", + init: () => engineProvider.init().then(() => undefined), + reinit: () => engineProvider.reinit(), + isPresent: (id) => engineProvider.isVersionInstalled(id), + installed: () => + engineProvider.availableVersions + .values() + .filter((version) => version.installed) + .map((version) => version.id) + .toArray(), + acquire: async (ids, report) => { + for (const id of ids) { + await acquireReporting(engineProvider, [id], report, () => engineProvider.downloadEngine(id)); + } + }, + remove: async (ids) => { + for (const id of ids) { + await engineProvider.uninstallVersion(id); + } + }, + }, + game: { + type: "game", + init: () => gameProvider.init().then(() => undefined), + reinit: () => gameProvider.reinit(), + isPresent: (id) => gameProvider.isVersionInstalled(id), + installed: () => gameProvider.availableVersions.keys().toArray(), + acquire: (ids, report) => acquireReporting(gameProvider, ids, report, () => gameProvider.downloadGames(ids)), + remove: async (ids) => { + for (const id of ids) { + await gameProvider.uninstallVersionById(id); + } + }, + }, + map: { + type: "map", + init: () => mapProvider.init().then(() => undefined), + reinit: () => mapProvider.reinit(), + isPresent: (id) => mapProvider.isVersionInstalled(id), + installed: () => + Object.entries(mapProvider.mapNameFileNameLookup) + .filter(([, fileName]) => fileName !== undefined) + .map(([springName]) => springName), + acquire: (ids, report) => acquireReporting(mapProvider, ids, report, () => mapProvider.downloadMaps(ids)), + remove: async (ids) => { + for (const id of ids) { + await mapProvider.uninstallVersion(id); + } + }, + }, + }; + + private readonly states = new Map(); + private readonly claimSources: ClaimSource[] = []; + + private readonly queue = new ContentQueue( + (operation, type, ids, report) => { + const provider = this.providers[type]; + if (!provider) { + throw new Error(`No content provider for type '${type}'`); + } + + return operation === "acquire" ? provider.acquire(ids, report) : provider.remove(ids); + }, + (ref) => this.isPresent(ref), + { + progress: (ids, progress) => this.updateAcquiring(ids, (state) => ({ ...state, ...progress })), + attempt: (ids) => this.updateAcquiring(ids, (state) => ({ ...state, attempts: state.attempts + 1 })), + } + ); + + public readonly onChanged: Signal = new Signal(); + + // Fires when the queue is done with a ref, whichever operation it was, carrying whether the content + // is installed now. onChanged only ever describes work still outstanding. + public readonly onSettled: Signal = new Signal(); + + // Content appearing or going away without anyone asking, which only the maps directory is watched + // for today. Anything else growing a watcher reports through the same signal. + public readonly onPresenceChanged: Signal = new Signal(); + + public constructor() { + this.queue.onChanged.add((entries) => this.syncFromQueue(entries)); + mapProvider.onMapAdded.add((springName) => this.onPresenceChanged.dispatch({ type: "map", id: springName, present: true })); + mapProvider.onMapDeleted.add((springName) => this.onPresenceChanged.dispatch({ type: "map", id: springName, present: false })); + } + + // Engine first: the game and map scans read the installed engines to calculate checksums. + public async init() { + await this.providers.engine.init(); + await Promise.all([this.providers.game.init(), this.providers.map.init()]); + await contentUsage.init(); + } + + public async reinit() { + await this.providers.engine.reinit(); + await Promise.all([this.providers.game.reinit(), this.providers.map.reinit()]); + await contentUsage.init(); + } + + public registerClaimSource(source: ClaimSource) { + this.claimSources.push(source); + } + + public lastUsed(ref: ContentRef) { + return contentUsage.lastUsed(ref); + } + + // Nothing is worth failing over a note about when content was last wanted, and the store is + // missing entirely when content init failed, which is a state the app deliberately keeps running in. + public async markUsed(refs: ContentRef[]) { + try { + await contentUsage.markUsed(refs); + } catch (err) { + log.warn(`Could not record usage for ${refs.map(contentRefKey).join(", ")}`, err); + } + } + + public allInstalled() { + return (Object.keys(this.providers) as ContentType[]).flatMap((type) => this.installed(type)); + } + + /** + * Removes installed content nothing claims and nothing has wanted inside the retention window. + * + * Unstamped content is unseen, not old: a sweep stamps it and keeps it. + */ + public async sweep() { + const claimed = new Set(this.claimSources.flatMap((source) => source.claims()).map((ref) => contentRefKey(ref))); + const installed = this.allInstalled(); + await contentUsage.forgetAllExcept(installed); + + const cutoff = Date.now() - CONTENT_RETENTION_DAYS * 24 * 60 * 60 * 1000; + const unseen: ContentRef[] = []; + const stale: ContentRef[] = []; + + for (const ref of installed) { + if (claimed.has(contentRefKey(ref))) { + continue; + } + // Local games are the user's own files sitting in the games directory, not something that + // was acquired, so they are never ours to remove. + if (ref.type === "game" && ref.id.endsWith(".sdd")) { + continue; + } + + const used = contentUsage.lastUsed(ref); + if (!used) { + unseen.push(ref); + } else if (used.getTime() <= cutoff) { + stale.push(ref); + } + } + + await this.markUsed(unseen); + + // pr-downloader comes out of an engine, so removing the last one takes away the means of ever + // getting another. Keep the newest regardless of how long it has sat unused. + const survivingEngines = installed.filter((ref) => ref.type === "engine" && !stale.some((victim) => contentRefKey(victim) === contentRefKey(ref))); + if (survivingEngines.length === 0) { + // Picked from the doomed engines themselves: engineVersions() also lists versions that are + // not installed, so its newest can be one no sweep could have been about to remove. + const newest = stale + .filter((ref) => ref.type === "engine") + .sort((a, b) => compareEngineVersions(a.id, b.id)) + .at(-1); + + if (newest) { + stale.splice(stale.indexOf(newest), 1); + } + } + + if (stale.length === 0) { + return []; + } + + log.info(`Sweeping ${stale.length} unused content item(s): ${stale.map((ref) => contentRefKey(ref)).join(", ")}`); + await this.remove(stale); + + return stale; + } + + public installed(type: ContentType): ContentRef[] { + return (this.providers[type]?.installed() ?? []).map((id) => ({ type, id })); + } + + public engineVersions() { + return engineProvider.availableVersions + .values() + .toArray() + .sort((a, b) => compareEngineVersions(a.id, b.id)); + } + + public gameVersions() { + return gameProvider.availableVersions.values().toArray(); + } + + public gameVersion(gameVersion: string) { + return gameProvider.getVersion(gameVersion); + } + + public state() { + return [...this.states.values()]; + } + + // Everything except an engine is fetched by pr-downloader, which arrives as part of an engine. An + // engine is not, so a missing downloader is fetched here rather than failing and leaving the user to + // find the startup flow that would have done it. Checked without awaiting first, so the usual case + // still enqueues in the same tick as the caller asked. + public async ensure(refs: ContentRef[]) { + if (this.missing(refs).length > 0) { + await this.assertRoomToAcquire(); + } + + if (this.missing(refs).some((ref) => ref.type !== "engine") && !findPrdBinary()) { + log.info("No pr-downloader available, acquiring the default engine first"); + await this.ensure([{ type: "engine", id: DEFAULT_ENGINE_VERSION }]); + } + + const acquisitions = await Promise.allSettled(this.missing(refs).map((ref) => this.track(this.queue.enqueue("acquire", ref), ref))); + + // Stamped by what is on disk now rather than by what was asked for, so content that landed is + // not left unrecorded because something else in the same call did not. + await this.markUsed(refs.filter((ref) => this.isPresent(ref))); + + const failed = acquisitions.find((acquisition) => acquisition.status === "rejected"); + if (failed) { + throw failed.reason; + } + } + + // Refusing up front beats a transport failing partway with whatever error the filesystem gave it. + // A volume we cannot measure is not treated as full: being unable to check is not a reason to stop + // someone downloading. + private async assertRoomToAcquire() { + const assetsPath = getAssetsPath(); + let free: number; + + try { + free = await freeBytes(assetsPath); + } catch (err) { + log.warn(`Could not read free space for ${assetsPath}, continuing anyway`, err); + + return; + } + + if (free < MIN_FREE_BYTES_TO_ACQUIRE) { + throw new Error(`Not enough free space in ${assetsPath}: ${formatBytes(free)} available, ${formatBytes(MIN_FREE_BYTES_TO_ACQUIRE)} needed.`); + } + } + + // Not filtered by what is currently installed: a ref being acquired right now would look absent + // and skip the queue, which is the one thing keeping the two operations from overlapping. The + // queue settles a removal on the content being gone, so removing what was never there is fine. + public async remove(refs: ContentRef[]) { + this.assertAnEngineSurvives(refs); + await Promise.all(refs.map((ref) => this.track(this.queue.enqueue("remove", ref), ref))); + } + + // pr-downloader ships inside an engine, so a client with none cannot fetch a game or a map. Enforced + // on the operation rather than left to each caller to remember. + private assertAnEngineSurvives(refs: ContentRef[]) { + const doomed = new Set(refs.filter((ref) => ref.type === "engine").map((ref) => contentRefKey(ref))); + if (doomed.size === 0) { + return; + } + + // Engines already queued for removal are still installed but will not be, so they count as gone + // here. Two calls looking only at what is installed would each find the other's engine and both + // go ahead. + for (const entry of this.queue.snapshot()) { + if (entry.type === "engine" && entry.operation === "remove") { + doomed.add(contentRefKey(entry)); + } + } + + const surviving = this.installed("engine").filter((ref) => !doomed.has(contentRefKey(ref))); + if (surviving.length === 0) { + throw new Error("Refusing to remove the last installed engine: pr-downloader comes with it."); + } + } + + public isPresent(ref: ContentRef) { + const provider = this.providers[ref.type]; + + // Reached over IPC, so an unknown type is a caller bug rather than something to trust. Say + // it is absent instead of throwing, otherwise one bad ref loses the answer for every ref. + if (!provider) { + log.warn(`Unknown content type '${ref.type}' for '${ref.id}', treating it as absent`); + + return false; + } + + return provider.isPresent(ref.id); + } + + public missing(refs: ContentRef[]) { + return refs.filter((ref) => !this.isPresent(ref)); + } + + private async track(settled: Promise, ref: ContentRef) { + try { + await settled; + } catch (err) { + const key = contentRefKey(ref); + const existing = this.states.get(key); + const failed: ContentState = { ...ref, currentBytes: 0, totalBytes: 0, progress: 0, attempts: 1, ...existing, status: "failed" }; + this.states.set(key, failed); + this.onChanged.dispatch(this.state()); + + log.error(`Gave up on ${key} after ${failed.attempts} attempt(s), ${failed.currentBytes} of ${failed.totalBytes} bytes`, err); + + throw err; + } + } + + // Dispatched once for the whole set: the figures describe one invocation, and sending them a ref at a + // time makes listeners redraw for each one and briefly see the set disagreeing with itself. + private updateAcquiring(ids: string[], update: (state: ContentState) => ContentState) { + const running = [...this.states.values()].filter((state) => ids.includes(state.id) && state.status === "acquiring"); + if (running.length === 0) { + return; + } + + for (const state of running) { + this.states.set(contentRefKey(state), update(state)); + } + this.onChanged.dispatch(this.state()); + } + + private syncFromQueue(entries: ContentQueueEntry[]) { + const outstanding = new Set(entries.map((entry) => contentRefKey(entry))); + + for (const entry of entries) { + const key = contentRefKey(entry); + const existing = this.states.get(key); + const status = entry.status === "queued" ? "queued" : entry.operation === "acquire" ? "acquiring" : "removing"; + // Asking for content again is a fresh set of attempts, so a leftover failure does not carry + // its count into the new request. + const attempts = !existing || existing.status === "failed" ? 1 : existing.attempts; + this.states.set(key, { currentBytes: 0, totalBytes: 0, progress: 0, ...existing, type: entry.type, id: entry.id, status, attempts }); + } + + // A failure is kept until the ref is asked for again, so the reason a download stopped does not + // vanish the moment the queue moves on. + const settled: ContentPresence[] = []; + for (const [key, state] of this.states) { + if (!outstanding.has(key) && isUnsettled(state)) { + const ref = { type: state.type, id: state.id }; + settled.push({ ...ref, present: this.isPresent(ref) }); + this.states.delete(key); + } + } + + this.onChanged.dispatch(this.state()); + + if (settled.length > 0) { + this.onSettled.dispatch(settled); + } + } +} + +export const contentAPI = new ContentAPI(); diff --git a/src/main/content/content-claims.ts b/src/main/content/content-claims.ts new file mode 100644 index 000000000..5aea8f419 --- /dev/null +++ b/src/main/content/content-claims.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { ContentRef } from "@main/content/content-ref"; + +/** + * A live reason not to delete content, asked for at the moment it matters rather than recorded when it + * begins and forgotten when it ends. Nothing is stored, so a holder that goes away without saying so + * cannot leave content protected forever, and there is no release to get wrong. + */ +export type ClaimSource = { + readonly name: string; + claims(): ContentRef[]; +}; + +// Nothing registers a claim source yet and nothing calls sweep, so no policy about what is worth +// keeping has been decided. These sketch the shape that discussion would take rather than settling it, +// and are left disconnected so they are not mistaken for a decision that was made. +// +// export const replayClaims: ClaimSource = { +// name: "replays", +// claims: () => +// replaysAPI.cachedReplays().flatMap((replay) => [ +// { type: "engine", id: replay.engineVersion }, +// { type: "game", id: replay.gameVersion }, +// { type: "map", id: replay.mapSpringName }, +// ]), +// }; +// +// export const defaultVersionClaims: ClaimSource = { +// name: "defaults", +// claims: () => [{ type: "engine", id: DEFAULT_ENGINE_VERSION }], +// }; +// +// export const runningGameClaims: ClaimSource = { +// name: "running game", +// claims: () => (gameAPI.isGameRunning() ? gameAPI.launchedContent() : []), +// }; diff --git a/src/main/content/content-progress.ts b/src/main/content/content-progress.ts new file mode 100644 index 000000000..9a1830c6d --- /dev/null +++ b/src/main/content/content-progress.ts @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { ContentState, hasFailed, isInProgress } from "@main/content/content-state"; +import { contentRefKey } from "@main/content/content-ref"; + +/** + * Counts content this run is done with, for anything showing one figure for a set of downloads. + * + * The change stream only ever describes work still outstanding, so content vanishes from it the moment + * it lands. A figure averaged over what is left restarts from nothing every time the queue moves to its + * next batch, which is why what landed has to be remembered and counted on both sides of the fraction. + */ +export function createSettledCounter() { + let landed = new Set(); + let previous = new Set(); + + return (states: ContentState[]) => { + const outstanding = new Set(states.filter(isInProgress).map(contentRefKey)); + const failed = new Set(states.filter(hasFailed).map(contentRefKey)); + + for (const key of previous) { + // A failure is not content that landed, and it keeps its own place in the figure instead. + if (!outstanding.has(key) && !failed.has(key)) { + landed.add(key); + } + } + previous = outstanding; + + // Nothing moving and nothing failed means this run is over, so the next one starts from zero. + if (outstanding.size === 0 && failed.size === 0) { + landed = new Set(); + } + + return landed.size; + }; +} diff --git a/src/main/content/content-provider.ts b/src/main/content/content-provider.ts new file mode 100644 index 000000000..5ff4ecf49 --- /dev/null +++ b/src/main/content/content-provider.ts @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { ContentType } from "@main/content/content-ref"; +import { ContentReporter } from "@main/content/content-state"; + +export interface ContentProvider { + readonly type: ContentType; + init(): Promise; + reinit(): Promise; + isPresent(id: string): boolean; + installed(): string[]; + // Batched signature because prd takes repeatable --download-* flags. The queue still passes one id + // at a time: a batched rapid invocation parallelises inside prd, past the slot count. + // Progress is per id from the provider - prd rewrites the name it reports mid-download. + acquire(ids: string[], report: ContentReporter): Promise; + remove(ids: string[]): Promise; +} diff --git a/src/main/content/content-queue.ts b/src/main/content/content-queue.ts new file mode 100644 index 000000000..19ecc901e --- /dev/null +++ b/src/main/content/content-queue.ts @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { Signal } from "$/jaz-ts-utils/signal"; +import { removeFromArray } from "$/jaz-ts-utils/object"; +import { ContentRef, contentRefKey, ContentType } from "@main/content/content-ref"; +import { ContentReporter } from "@main/content/content-state"; +import { downloadSlots } from "@main/content/download-slots"; +import { logger } from "@main/utils/logger"; + +const log = logger("content-queue.ts"); + +export type ContentOperation = "acquire" | "remove"; + +export type ContentQueueEntry = ContentRef & { + operation: ContentOperation; + status: "queued" | "running"; +}; + +type PendingOperation = { + operation: ContentOperation; + ref: ContentRef; + resolve: () => void; + reject: (error: unknown) => void; +}; + +function operationKey(operation: ContentOperation, ref: ContentRef) { + return `${operation}:${contentRefKey(ref)}`; +} + +// pr-downloader rewrites rapid's repo index on every invocation, maps included, so two of it at once +// land on the same repos.gz and one dies. Engines come down over plain http and share nothing. +function usesPrd(ref: ContentRef) { + return ref.type === "game" || ref.type === "map"; +} + +// Rapid takes several game versions per invocation and resolves them together, which is worth doing even +// when only one invocation runs at a time. Maps are fetched one per invocation: nothing is gained by +// putting them together, and doing so leaves pr-downloader reporting one set of figures for the lot. +function batchesTogether(ref: ContentRef) { + return ref.type === "game"; +} + +/** + * Orders content operations and holds a download slot for each one it runs, so acquisitions share the + * client's limit with everything else that downloads rather than having a private one. + */ +export class ContentQueue { + public readonly onChanged: Signal = new Signal(); + + private readonly queued: PendingOperation[] = []; + private readonly pending = new Map>(); + private active: PendingOperation[] = []; + + public constructor( + private readonly run: (operation: ContentOperation, type: ContentType, ids: string[], report: ContentReporter) => Promise, + private readonly isPresent: (ref: ContentRef) => boolean, + private readonly report: ContentReporter = { progress: () => {}, attempt: () => {} } + ) {} + + public snapshot(): ContentQueueEntry[] { + return [ + ...this.active.map((entry) => ({ ...entry.ref, operation: entry.operation, status: "running" as const })), + ...this.queued.map((entry) => ({ ...entry.ref, operation: entry.operation, status: "queued" as const })), + ]; + } + + public enqueue(operation: ContentOperation, ref: ContentRef) { + const key = operationKey(operation, ref); + const inFlight = this.pending.get(key); + if (inFlight) { + return inFlight; + } + + let resolve!: () => void; + let reject!: (error: unknown) => void; + const settled = new Promise((resolveOperation, rejectOperation) => { + resolve = resolveOperation; + reject = rejectOperation; + }); + + this.pending.set(key, settled); + this.queued.push({ operation, ref, resolve, reject }); + this.onChanged.dispatch(this.snapshot()); + + // Deferred so a burst enqueued in the same tick is all visible before workers pick from it, + // rather than the order depending on when each enqueue happened to land. + queueMicrotask(() => this.drain()); + + return settled; + } + + // FIFO, skipping any ref already being worked on so a removal never overlaps an acquisition of the + // same content, and any pr-downloader ref while another one is running. + private takeNext() { + const prdBusy = this.active.some((running) => usesPrd(running.ref)); + const index = this.queued.findIndex((entry) => { + const sameRef = this.active.some((running) => contentRefKey(running.ref) === contentRefKey(entry.ref)); + + return !sameRef && !(prdBusy && usesPrd(entry.ref)); + }); + + return index === -1 ? undefined : this.queued.splice(index, 1)[0]; + } + + // One invocation covering every game version waiting on it, which costs one slot however many it + // covers, because it is one process either way. + private takeBatchWith(first: PendingOperation) { + const batch = [first]; + if (!batchesTogether(first.ref)) { + return batch; + } + + for (;;) { + const index = this.queued.findIndex((entry) => entry.operation === first.operation && entry.ref.type === first.ref.type); + if (index === -1) { + return batch; + } + + batch.push(...this.queued.splice(index, 1)); + } + } + + private succeeded(entry: PendingOperation) { + try { + return this.isPresent(entry.ref) === (entry.operation === "acquire"); + } catch (err) { + log.error(`Could not tell whether ${operationKey(entry.operation, entry.ref)} took effect`, err); + + return false; + } + } + + private drain() { + while (downloadSlots.tryTake()) { + const next = this.takeNext(); + if (!next) { + downloadSlots.give(); + + return; + } + + void this.work(next); + } + } + + private async work(first: PendingOperation) { + let next: PendingOperation | undefined = first; + try { + while (next) { + // Nothing may await before this, or the next worker takeNext picks from a stale active + // list and two operations on one ref can start together. + const batch = this.takeBatchWith(next); + this.active.push(...batch); + this.onChanged.dispatch(this.snapshot()); + + const failure = await this.runBatch(batch); + + for (const entry of batch) { + // What is on disk afterwards decides the outcome rather than the transport's exit + // code, which can report success for content that never landed and the reverse. + removeFromArray(this.active, entry); + this.pending.delete(operationKey(entry.operation, entry.ref)); + + if (this.succeeded(entry)) { + entry.resolve(); + } else { + const complaint = entry.operation === "acquire" ? "is still missing after acquiring it" : "is still installed after removing it"; + entry.reject(failure ?? new Error(`${entry.ref.type} '${entry.ref.id}' ${complaint}`)); + } + } + + this.onChanged.dispatch(this.snapshot()); + next = this.takeNext(); + } + } finally { + downloadSlots.give(); + // Finishing may have unblocked work this worker was not allowed to pick up. + this.drain(); + } + } + + // pr-downloader abandons the whole invocation when one asset cannot be resolved, taking assets that + // would have downloaded fine with it, so a failed batch is retried one at a time to find out which + // of them was the problem. + private async runBatch(batch: PendingOperation[]) { + const { operation, ref } = batch[0]; + const ids = batch.map((entry) => entry.ref.id); + + try { + await this.run(operation, ref.type, ids, this.report); + + return undefined; + } catch (err) { + if (batch.length === 1) { + return err; + } + + log.warn(`Batched ${operation} of ${batch.length} ${ref.type}s failed, retrying them separately`, err); + } + + let failure: unknown; + for (const entry of batch) { + try { + await this.run(operation, ref.type, [entry.ref.id], this.report); + } catch (err) { + failure = err; + } + } + + return failure; + } +} diff --git a/src/main/content/content-ref.ts b/src/main/content/content-ref.ts new file mode 100644 index 000000000..fc8ff26e0 --- /dev/null +++ b/src/main/content/content-ref.ts @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +export type ContentType = "engine" | "game" | "map"; + +// The id is whatever that type of content is named by upstream: an engine version, a game springname +// or rapid tag, a map springname. It matches how tachyon describes queue assets. +export type ContentRef = { + type: ContentType; + id: string; +}; + +export function contentRefKey(ref: ContentRef) { + return `${ref.type}:${ref.id}`; +} diff --git a/src/main/content/content-state.ts b/src/main/content/content-state.ts new file mode 100644 index 000000000..ec0935de8 --- /dev/null +++ b/src/main/content/content-state.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { ContentRef } from "@main/content/content-ref"; + +export type ContentStatus = "queued" | "acquiring" | "removing" | "failed"; + +export type ContentProgress = { + currentBytes: number; + totalBytes: number; + progress: number; + phase?: "downloading" | "extracting"; +}; + +// Only content with something happening to it appears here: absent means settled, installed comes from +// isPresent. attempts counts tries at the current download, reset when the content is asked for again. +export type ContentState = ContentRef & ContentProgress & { status: ContentStatus; attempts: number }; + +// Whether content is installed, read from disk at the moment it is reported. Shared by both signals +// that announce a change so a listener does not have to know which one it heard. +export type ContentPresence = ContentRef & { present: boolean }; + +// Three different questions get asked of a snapshot and the answers differ, so each one is named. Reading +// status directly at a call site is how they get confused for each other. + +// The queue still has this in hand, whichever operation. A failure is a result kept for the user to see, +// not work in progress. +export function isUnsettled(state: ContentState) { + return state.status !== "failed"; +} + +// Bytes are moving, or about to. +export function isInProgress(state: ContentState) { + return state.status === "queued" || state.status === "acquiring"; +} + +// Accepted but not started. Nothing is known about how it will be fetched yet, including whether it +// will come down on its own or as part of a batch. +export function isQueued(state: ContentState) { + return state.status === "queued"; +} + +// Given up on, and kept that way until the content is asked for again. +export function hasFailed(state: ContentState) { + return state.status === "failed"; +} + +export type ContentReporter = { + progress(ids: string[], progress: ContentProgress): void; + attempt(ids: string[]): void; +}; diff --git a/src/main/content/content-usage.ts b/src/main/content/content-usage.ts new file mode 100644 index 000000000..41e364d00 --- /dev/null +++ b/src/main/content/content-usage.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { getAssetsPath } from "@main/config/app"; +import { ContentRef, contentRefKey } from "@main/content/content-ref"; +import { FileStore } from "@main/json/file-store"; +import { contentUsageSchema } from "@main/json/model/content-usage"; +import path from "path"; + +/** + * Last-wanted timestamps, kept beside the content so repointing the assets directory carries its history. + * + * No entry means unseen, not old. + */ +class ContentUsage { + private store?: FileStore; + + // Re-read rather than reused, because the assets directory can be repointed while running. + public async init() { + this.store = await new FileStore(path.join(getAssetsPath(), "content-usage.json"), contentUsageSchema).init(); + } + + public lastUsed(ref: ContentRef) { + const entry = this.entries().find((candidate) => this.matches(candidate, ref)); + const at = entry ? new Date(entry.lastUsed) : undefined; + + return at && !Number.isNaN(at.getTime()) ? at : undefined; + } + + public async markUsed(refs: ContentRef[], at = new Date()) { + if (refs.length === 0) { + return; + } + + const kept = this.entries().filter((entry) => !refs.some((ref) => this.matches(entry, ref))); + const added = refs.map((ref) => ({ type: ref.type, id: ref.id, lastUsed: at.toISOString() })); + + await this.write({ usage: [...kept, ...added] }); + } + + // Drops everything about content that is no longer installed, so the file does not grow forever + // with entries nothing can act on. + public async forgetAllExcept(installed: ContentRef[]) { + const keys = new Set(installed.map((ref) => contentRefKey(ref))); + const usage = this.entries().filter((entry) => keys.has(`${entry.type}:${entry.id}`)); + + if (usage.length === this.entries().length) { + return; + } + + await this.write({ usage }); + } + + private entries() { + return this.store?.model.usage ?? []; + } + + private matches(entry: { type: string; id: string }, ref: ContentRef) { + return entry.type === ref.type && entry.id === ref.id; + } + + private async write(data: Partial) { + if (!this.store) { + throw new Error("Content usage store has not been initialised."); + } + + await this.store.update(data); + } +} + +export const contentUsage = new ContentUsage(); diff --git a/src/main/content/download-slots.ts b/src/main/content/download-slots.ts new file mode 100644 index 000000000..a1304f3ae --- /dev/null +++ b/src/main/content/download-slots.ts @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { MAX_CONCURRENT_DOWNLOADS } from "@main/config/content-policy"; + +// Bounds invocations across every transport, not connections: a game is one slot and fans out inside +// prd. Cannot be prd's own limit, which is per invocation and which the pool archive never goes through. +class DownloadSlots { + private taken = 0; + private readonly waiting: (() => void)[] = []; + + public tryTake() { + if (this.taken >= MAX_CONCURRENT_DOWNLOADS) { + return false; + } + + this.taken++; + + return true; + } + + public take(): Promise { + if (this.tryTake()) { + return Promise.resolve(); + } + + return new Promise((resolve) => this.waiting.push(resolve)); + } + + // Handed straight to whoever is waiting rather than counted down and re-taken, so a slot cannot be + // claimed by a new arrival ahead of something already queued for it. + public give() { + const next = this.waiting.shift(); + if (next) { + next(); + + return; + } + + this.taken--; + } + + public async use(work: () => Promise): Promise { + await this.take(); + + try { + return await work(); + } finally { + this.give(); + } + } +} + +export const downloadSlots = new DownloadSlots(); diff --git a/src/main/content/downloads.ts b/src/main/content/downloads.ts index fd6903acd..996cb3f7d 100644 --- a/src/main/content/downloads.ts +++ b/src/main/content/downloads.ts @@ -3,7 +3,10 @@ // SPDX-License-Identifier: MIT export type DownloadInfo = { - type: "engine" | "game" | "map" | "replay" | "update"; + type: "engine" | "game" | "map" | "update"; + // What was asked for. Separate from name because pr-downloader replaces that with a name of its + // own choosing partway through, and progress from concurrent downloads has to stay told apart. + id: string; name: string; currentBytes: number; totalBytes: number; diff --git a/src/main/content/engine/engine-content.ts b/src/main/content/engine/engine-provider.ts similarity index 88% rename from src/main/content/engine/engine-content.ts rename to src/main/content/engine/engine-provider.ts index e90f63dbc..60200e0c9 100644 --- a/src/main/content/engine/engine-content.ts +++ b/src/main/content/engine/engine-provider.ts @@ -17,14 +17,12 @@ import { getEngineReleaseInfo } from "@main/config/content-sources"; import { AbstractContentAPI } from "@main/content/abstract-content"; import { getEnginePath } from "@main/config/app"; import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; +import { compareEngineVersions, isCompatibleEngineVersion } from "@main/content/engine/engine-version-order"; +import { holdChecksums } from "@main/utils/checksums"; -const log = logger("engine-content.ts"); +const log = logger("engine-provider.ts"); -// TODO: add support for old engine version tag naming scheme, careful it is not string sortable (!) -// Regex matching new engine version tags (e.g. "2025.01.3", "2025.01.3-rc1") -const compatibleVersionRegex = /^\d{4}\.\d{2}\.\d{1,2}(-rc\d+)?$/; - -export class EngineContentAPI extends AbstractContentAPI { +export class EngineProvider extends AbstractContentAPI { protected get engineDirs() { return getEnginePath(); } @@ -36,7 +34,7 @@ export class EngineContentAPI extends AbstractContentAPI const dirs = files .filter((file) => file.isDirectory() || file.isSymbolicLink()) .map((dir) => dir.name) - .filter((dir) => compatibleVersionRegex.test(dir) || dir.includes("local")); + .filter((dir) => isCompatibleEngineVersion(dir)); log.info(`Found ${dirs.length} installed engine versions`); for (const dir of dirs) { log.info(`-- Engine ${dir}`); @@ -67,6 +65,18 @@ export class EngineContentAPI extends AbstractContentAPI return this.availableVersions.get(DEFAULT_ENGINE_VERSION); } + public getInstalledVersionsNewestFirst() { + return this.availableVersions + .values() + .filter((version) => version.installed) + .toArray() + .sort((a, b) => compareEngineVersions(b.id, a.id)); + } + + public getNewestInstalledVersion() { + return this.getInstalledVersionsNewestFirst().at(0); + } + protected checkIfDefaultIsNew() { if (!this.availableVersions.has(DEFAULT_ENGINE_VERSION)) { this.availableVersions.set(DEFAULT_ENGINE_VERSION, { @@ -86,6 +96,7 @@ export class EngineContentAPI extends AbstractContentAPI const engineInfo = await getEngineReleaseInfo(engineVersion); const downloadInfo: DownloadInfo = { type: "engine", + id: engineVersion, name: engineVersion, currentBytes: 0, totalBytes: 1, @@ -131,7 +142,8 @@ export class EngineContentAPI extends AbstractContentAPI version = version.id; } const engineDir = path.join(this.engineDirs, version); - await fs.promises.rm(engineDir, { force: true, recursive: true }); + // Checksums run the engine out of this directory, so deleting it underneath one fails on Windows. + await holdChecksums(() => fs.promises.rm(engineDir, { force: true, recursive: true })); this.availableVersions.delete(version); } @@ -198,4 +210,4 @@ export class EngineContentAPI extends AbstractContentAPI // } } -export const engineContentAPI = new EngineContentAPI(); +export const engineProvider = new EngineProvider(); diff --git a/src/main/content/engine/engine-version-order.ts b/src/main/content/engine/engine-version-order.ts new file mode 100644 index 000000000..eba79060f --- /dev/null +++ b/src/main/content/engine/engine-version-order.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +// TODO: add support for old engine version tag naming scheme, careful it is not string sortable (!) +// Matches new engine version tags (e.g. "2025.01.3", "2025.01.3-rc1") +const versionPattern = /^(\d{4})\.(\d{2})\.(\d{1,2})(?:-rc(\d+))?$/; + +export function isCompatibleEngineVersion(id: string) { + return versionPattern.test(id) || id.includes("local"); +} + +function parseVersion(id: string) { + const match = versionPattern.exec(id); + if (!match) { + return undefined; + } + + const [, year, month, patch, releaseCandidate] = match; + + return [Number(year), Number(month), Number(patch), releaseCandidate === undefined ? Infinity : Number(releaseCandidate)]; +} + +// Ascending. The patch component is not zero padded and a release candidate precedes its release, +// so neither survives a string comparison. +export function compareEngineVersions(a: string, b: string) { + const left = parseVersion(a); + const right = parseVersion(b); + + if (!left || !right) { + return (left ? 1 : 0) - (right ? 1 : 0); + } + + for (let i = 0; i < left.length; i++) { + if (left[i] !== right[i]) { + return left[i] - right[i]; + } + } + + return 0; +} diff --git a/src/main/content/game/game-content.ts b/src/main/content/game/game-content.ts deleted file mode 100644 index 344bea357..000000000 --- a/src/main/content/game/game-content.ts +++ /dev/null @@ -1,385 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors -// -// SPDX-License-Identifier: MIT - -import * as fs from "fs"; -import { glob } from "glob"; -import { Minimatch } from "minimatch"; -import { removeFromArray } from "$/jaz-ts-utils/object"; -import * as path from "path"; -import util, { promisify } from "util"; -import zlib from "zlib"; -import { GameAI, GameVersion } from "@main/content/game/game-version"; -import { parseLuaTable } from "@main/utils/parse-lua-table"; -import { parseLuaOptions } from "@main/utils/parse-lua-options"; -import { BufferStream } from "@main/utils/buffer-stream"; -import { logger } from "@main/utils/logger"; -import assert from "assert"; -import { contentSources } from "@main/config/content-sources"; -import { DownloadInfo } from "@main/content/downloads"; -import { LuaOptionSection } from "@main/content/game/lua-options"; -import { Scenario } from "@main/content/game/scenario"; -import { SdpFileMeta, SdpFile } from "@main/content/game/sdp"; -import { PrDownloaderAPI } from "@main/content/pr-downloader"; -import { getRapidIndexPath, getPackagePath, getPoolPath, getGamePaths, SCENARIO_IMAGE_PATH } from "@main/config/app"; -import { PoolCdnDownloader } from "@main/content/game/pool-cdn"; -import { fileExists } from "@main/utils/file"; -import { engineContentAPI } from "@main/content/engine/engine-content"; -import { calcChecksum } from "@main/utils/checksums"; - -const log = logger("game-content.ts"); -const gunzip = util.promisify(zlib.gunzip); - -export class GameContentAPI extends PrDownloaderAPI { - public packageGameVersionLookup: { [md5: string]: string | undefined } = {}; - public gameVersionPackageLookup: { [gameVersion: string]: string | undefined } = {}; - - public override async init() { - await this.initLookupTables(); - await this.scanPackagesDir(); - await this.scanLocalGames(); - - engineContentAPI.onDownloadComplete.add((downloadInfo) => { - for (const gameVersion of this.availableVersions.keys()) { - calcChecksum(downloadInfo.name, gameVersion); - } - }); - - return this; - } - - public async reinit() { - for (const dir of [getPackagePath(), getPoolPath(), getRapidIndexPath()]) { - await fs.promises.mkdir(dir, { recursive: true }); - } - this.availableVersions.clear(); - this.packageGameVersionLookup = {}; - this.gameVersionPackageLookup = {}; - await this.init(); - } - - // Reading all existing game versions from rapid versions index so - // we can easily check if a version is installed from its md5 - protected async initLookupTables() { - try { - const versionsGzPath = path.join(getRapidIndexPath(), contentSources.rapid.host, contentSources.rapid.game, "versions.gz"); - const versionsGz = await fs.promises.readFile(versionsGzPath); - const versions = await promisify(zlib.gunzip)(versionsGz); - const versionsStr = versions.toString().trim(); - const versionsParts = versionsStr.split("\n"); - for (const versionLine of versionsParts) { - const [, packageMd5, , version] = versionLine.split(","); - this.packageGameVersionLookup[packageMd5] = version; - this.gameVersionPackageLookup[version] = packageMd5; - } - } catch (err) { - log.warn(`Couldn't initialize lookup tables (is this the first startup ?): ${err}`); - } - } - - protected async scanPackagesDir() { - const packagesDir = getPackagePath(); - let packages: string[]; - try { - packages = await fs.promises.readdir(packagesDir); - } catch { - return; - } - // Refersh lookup tables in case new versions.gz file was downloaded. - await this.initLookupTables(); - for (const packageFile of packages) { - if (!packageFile.endsWith(".sdp")) { - // skip non-sdp files - // one case is pr-downloader interruption, which makes .sdp.incomplete files - log.debug(`Skipping non-sdp file: ${packageFile}!`); - continue; - } - const packageMd5 = packageFile.split(".")[0]; - const gameVersion = this.packageGameVersionLookup[packageMd5]; - const luaOptionSections = await this.getGameOptions(packageMd5); - const ais = await this.getAis(packageMd5); - if (gameVersion) { - this.availableVersions.set(gameVersion, { gameVersion, packageMd5, luaOptionSections, ais }); - } else { - log.warn(`Not found matching game version for ${packageFile}`); - } - } - log.info(`Found ${this.availableVersions.size} installed game versions`); - this.availableVersions.forEach((version) => { - log.info(`-- ${version.gameVersion}`); - }); - } - - // Load local/custom game files from .sdd folders in the games directory - protected async scanLocalGames() { - async function* findLocalGames() { - // We apply toReversed to keep the precedence order: higher precedence visited later. - for (const gamesDir of getGamePaths().toReversed()) { - if (await fileExists(gamesDir)) { - for (const entry of await fs.promises.readdir(gamesDir, { withFileTypes: true })) { - if ((entry.isDirectory() || entry.isSymbolicLink()) && entry.name.endsWith(".sdd")) { - yield [gamesDir, entry.name] as const; - } - } - } - } - } - - log.info("Scanning for local games"); - for await (const [gamesDir, gameDirName] of findLocalGames()) { - log.info(`-- Game ${gameDirName}`); - try { - const modOptionsLua = await fs.promises.readFile(path.join(gamesDir, gameDirName, "modoptions.lua")); - const luaOptionSections = parseLuaOptions(modOptionsLua); - const aiInfoLua = await fs.promises.readFile(path.join(gamesDir, gameDirName, "luaai.lua")); - const ais = await this.parseAis(aiInfoLua); - const gameVersion = gameDirName; - this.availableVersions.set(gameVersion, { - gameVersion, - packageMd5: gameDirName, // kinda hacky since this doesn't have a packageMd5 - luaOptionSections, - ais, - }); - } catch (err) { - console.error(err); - } - } - } - - public override isVersionInstalled(version: string) { - if (version === "byar:test") { - return false; - } - return this.availableVersions.values().some((installedVersion) => installedVersion.gameVersion === version); - } - - /** - * Downloads the actual game files, will update to latest if no specific gameVersion is specified - * @param gameVersion e.g. "Beyond All Reason test-16289-b154c3d" - */ - public async downloadGame(gameVersion = `${contentSources.rapid.game}:test`) { - // skip download if already installed - if (this.isVersionInstalled(gameVersion)) { - return; - } - log.info(`Downloading game version: ${gameVersion}`); - const downloadInfo = await this.downloadContent("game", gameVersion); - await this.downloadComplete(downloadInfo); - removeFromArray(this.currentDownloads, downloadInfo); - log.debug(`Downloaded ${downloadInfo.name}`); - } - - protected async getGameOptions(packageMd5: string): Promise { - const gameFiles = await this.getGameFiles(packageMd5, "modoptions.lua", true); - const modoptions = gameFiles[0].data; - return parseLuaOptions(modoptions); - } - - protected async getAis(packageMd5: string): Promise { - const gameFiles = await this.getGameFiles(packageMd5, "luaai.lua", true); - const luaai = gameFiles[0].data; - return this.parseAis(luaai); - } - - public async getScenarios(gameVersion?: string) { - try { - const version = this.availableVersions.values().find((version) => version.gameVersion === gameVersion); - assert(version, `No installed version found for game version: ${gameVersion}`); - - const scenarioImages = await this.getGameFiles(version.packageMd5, "singleplayer/scenarios/*.{jpg,png}", false); - const scenarioDefinitions = (await this.getGameFiles(version.packageMd5, "singleplayer/scenarios/*.lua", true)).filter(({ fileName }) => /[^/]*scenario[^/]*$/.test(fileName)); - const cacheDir = SCENARIO_IMAGE_PATH; - - await fs.promises.mkdir(cacheDir, { recursive: true }); - for (const scenarioImage of scenarioImages) { - let buffer: Buffer; - if (scenarioImage.archivePath.endsWith(".gz")) { - const data = await fs.promises.readFile(scenarioImage.archivePath); - buffer = await gunzip(data); - } else { - buffer = await fs.promises.readFile(scenarioImage.archivePath); - } - const fileName = path.parse(scenarioImage.fileName).base; - await fs.promises.writeFile(path.join(cacheDir, fileName), buffer); - } - const scenarios: Scenario[] = []; - for (const scenarioDefinition of scenarioDefinitions) { - try { - const scenario = parseLuaTable(scenarioDefinition.data) as Scenario; - if (scenario.imagepath) { - log.debug(`Imagepath: ${scenario.imagepath}`); - scenario.imagepath = path.join(cacheDir, scenario.imagepath).replaceAll("\\", "/"); - } else { - log.warn(`No imagepath for scenario: ${scenario.title}`); - } - scenario.summary = scenario.summary.replace(/\[|\]/g, ""); - scenario.briefing = scenario.briefing.replace(/\[|\]/g, ""); - scenario.allowedsides = Array.isArray(scenario.allowedsides) && scenario.allowedsides[0] !== "" ? scenario.allowedsides : ["Armada", "Cortext", "Random"]; - scenario.startscript = scenario.startscript.slice(1, -1); - scenarios.push(scenario); - } catch (err) { - console.error(`error parsing scenario lua file: ${scenarioDefinition.fileName}`, err); - } - } - scenarios.sort((a, b) => a.index - b.index); - return scenarios; - } catch (err) { - log.error(`Error getting scenarios: ${err}`); - return []; - } - } - - public async uninstallVersionById(gameVersion: string | undefined) { - if (!gameVersion) { - throw new Error("Game Version is not specified"); - } - const version = this.availableVersions.values().find((version) => version.gameVersion === gameVersion); - if (version) await this.uninstallVersion(version); - } - - public async uninstallVersion(version: GameVersion) { - if (!version) { - throw new Error("Game Version is not specified"); - } - - // TODO: Uninstall game version through prd when prd supports it - assert(!version.packageMd5.endsWith(".sdd"), "Cannot uninstall local/custom game versions"); - await fs.promises.rm(path.join(getPackagePath(), `${version.packageMd5}.sdp`)); - this.availableVersions.delete(version.gameVersion); - } - - /** - * @param filePatterns glob pattern for which files to retrieve - * @example getGameFiles("Beyond All Reason test-16289-b154c3d", ["units/CorAircraft/T2/*.lua"]) - * @todo make this work for custom .sdd versions - */ - protected async getGameFiles(packageMd5: string, filePattern: string, parseData?: false): Promise; - protected async getGameFiles(packageMd5: string, filePattern: string, parseData?: true): Promise; - protected async getGameFiles(packageMd5: string, filePattern: string, parseData = false): Promise { - // Custom game versions are stored in the games directory - if (packageMd5.endsWith(".sdd")) { - const gameDirName = packageMd5; - const sdpFiles: Array = []; - const customGameDir = await (async () => { - for (const gamesDir of getGamePaths()) { - const customGameDir = path.join(gamesDir, gameDirName); - if (await fileExists(customGameDir)) { - return customGameDir; - } - } - throw new Error(`Custom game directory not found for: ${gameDirName}`); - })(); - const files = await glob(path.join(customGameDir, filePattern), { windowsPathsNoEscape: true }); - for (const file of files) { - const sdpData = { - archivePath: file, - fileName: path.parse(file).base, - crc32: "", - md5: "", - filesizeBytes: 0, - }; - if (parseData) { - const data = await fs.promises.readFile(file); - sdpFiles.push({ ...sdpData, data }); - } else { - sdpFiles.push(sdpData); - } - } - return sdpFiles; - } - // Normal game versions are stored in the packages directory - const sdpFileName = `${packageMd5}.sdp`; - const filePath = path.join(getPackagePath(), sdpFileName); - const sdpEntries = await this.parseSdpFile(filePath, filePattern); - const sdpFiles: Array = []; - for (const sdpEntry of sdpEntries) { - const poolDir = sdpEntry.md5.slice(0, 2); - const archiveFileName = `${sdpEntry.md5.slice(2)}.gz`; - const archiveFilePath = path.join(getPoolPath(), poolDir, archiveFileName); - const archiveFile = await fs.promises.readFile(archiveFilePath); - if (parseData) { - const data = await gunzip(archiveFile); - sdpFiles.push({ ...sdpEntry, data }); - } else { - sdpFiles.push(sdpEntry); - } - } - return sdpFiles; - } - - protected async parseSdpFile(sdpFilePath: string, filePattern?: string): Promise { - const sdpFileZipped = await fs.promises.readFile(sdpFilePath); - const sdpFile = zlib.gunzipSync(sdpFileZipped); - const bufferStream = new BufferStream(sdpFile, true); - const fileData: SdpFileMeta[] = []; - let matcher: Minimatch | undefined; - if (filePattern) { - matcher = new Minimatch(filePattern); - } - while (bufferStream.readStream.readableLength > 0) { - const fileNameLength = bufferStream.readInt(1, true); - const fileName = bufferStream.readString(fileNameLength); - const md5 = bufferStream.read(16).toString("hex"); - const crc32 = bufferStream.read(4).toString("hex"); - const filesizeBytes = bufferStream.readInt(4, true); - const archivePath = path.join(getPoolPath(), md5.slice(0, 2), `${md5.slice(2)}.gz`); - if (matcher && matcher.match(fileName)) { - fileData.push({ fileName, md5, crc32, filesizeBytes, archivePath }); - } else if (!matcher) { - fileData.push({ fileName, md5, crc32, filesizeBytes, archivePath }); - } - } - return fileData; - } - - protected override async downloadComplete(downloadInfo: DownloadInfo) { - const gameVersion = downloadInfo.name; - await this.scanPackagesDir(); - const packageMd5 = this.gameVersionPackageLookup[gameVersion]; - if (!packageMd5) { - throw new Error(`No packageMd5 found for game version: ${gameVersion}`); - } - const luaOptionSections = await this.getGameOptions(packageMd5); - const ais = await this.getAis(packageMd5); - this.availableVersions.set(gameVersion, { gameVersion, packageMd5, luaOptionSections, ais }); - super.downloadComplete(downloadInfo); - - const defaultEngine = engineContentAPI.getDefaultEngine(); - if (defaultEngine?.installed) { - calcChecksum(defaultEngine.id, gameVersion); - } - } - - protected async parseAis(aiInfo: Buffer): Promise { - const ais: GameAI[] = []; - const aiDefinitions = parseLuaTable(aiInfo); - for (const def of aiDefinitions) { - ais.push({ - name: def.name, - shortName: def.name, - description: def.desc, - }); - } - return ais; - } - - public async preloadPoolData() { - log.debug("Preloading pool data"); - const poolCdn = new PoolCdnDownloader(this); - await poolCdn.preloadPoolData(); - } - - // TODO reimplement a cleanup function - // protected async cleanupOldVersions() { - // const maxDays = 90; - // const oldestDate = new Date(); - // oldestDate.setDate(oldestDate.getDate() - maxDays); - // const versionsToRemove = await cacheDb.selectFrom("gameVersion").where("lastLaunched", "<", oldestDate).select("id").execute(); - // for (const version of versionsToRemove) { - // // TODO: needs https://github.com/beyond-all-reason/pr-downloader/issues/21 - // // await this.uninstallVersion(version.id); - // } - // } -} - -export const gameContentAPI = new GameContentAPI(); diff --git a/src/main/content/game/game-files.ts b/src/main/content/game/game-files.ts new file mode 100644 index 000000000..250907d25 --- /dev/null +++ b/src/main/content/game/game-files.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { getGamePaths, getPackagePath, getPoolPath } from "@main/config/app"; +import { SdpFile, SdpFileMeta } from "@main/content/game/sdp"; +import { BufferStream } from "@main/utils/buffer-stream"; +import { fileExists } from "@main/utils/file"; +import * as fs from "fs"; +import { glob } from "glob"; +import { Minimatch } from "minimatch"; +import * as path from "path"; +import util from "util"; +import zlib from "zlib"; + +const gunzip = util.promisify(zlib.gunzip); + +export async function parseSdpFile(sdpFilePath: string, filePattern?: string): Promise { + const sdpFileZipped = await fs.promises.readFile(sdpFilePath); + const sdpFile = zlib.gunzipSync(sdpFileZipped); + const bufferStream = new BufferStream(sdpFile, true); + const fileData: SdpFileMeta[] = []; + let matcher: Minimatch | undefined; + if (filePattern) { + matcher = new Minimatch(filePattern); + } + while (bufferStream.readStream.readableLength > 0) { + const fileNameLength = bufferStream.readInt(1, true); + const fileName = bufferStream.readString(fileNameLength); + const md5 = bufferStream.read(16).toString("hex"); + const crc32 = bufferStream.read(4).toString("hex"); + const filesizeBytes = bufferStream.readInt(4, true); + const archivePath = path.join(getPoolPath(), md5.slice(0, 2), `${md5.slice(2)}.gz`); + if (matcher && matcher.match(fileName)) { + fileData.push({ fileName, md5, crc32, filesizeBytes, archivePath }); + } else if (!matcher) { + fileData.push({ fileName, md5, crc32, filesizeBytes, archivePath }); + } + } + return fileData; +} + +/** + * @param filePattern glob pattern for which files to retrieve + * @example getGameFiles("Beyond All Reason test-16289-b154c3d", "units/CorAircraft/T2/*.lua") + */ +export async function getGameFiles(packageMd5: string, filePattern: string, parseData?: false): Promise; +export async function getGameFiles(packageMd5: string, filePattern: string, parseData?: true): Promise; +export async function getGameFiles(packageMd5: string, filePattern: string, parseData = false): Promise { + // Custom game versions are stored in the games directory + if (packageMd5.endsWith(".sdd")) { + const gameDirName = packageMd5; + const sdpFiles: Array = []; + const customGameDir = await (async () => { + for (const gamesDir of getGamePaths()) { + const customGameDir = path.join(gamesDir, gameDirName); + if (await fileExists(customGameDir)) { + return customGameDir; + } + } + throw new Error(`Custom game directory not found for: ${gameDirName}`); + })(); + const files = await glob(path.join(customGameDir, filePattern), { windowsPathsNoEscape: true }); + for (const file of files) { + const sdpData = { + archivePath: file, + fileName: path.parse(file).base, + crc32: "", + md5: "", + filesizeBytes: 0, + }; + if (parseData) { + const data = await fs.promises.readFile(file); + sdpFiles.push({ ...sdpData, data }); + } else { + sdpFiles.push(sdpData); + } + } + return sdpFiles; + } + // Normal game versions are stored in the packages directory + const filePath = path.join(getPackagePath(), `${packageMd5}.sdp`); + const sdpEntries = await parseSdpFile(filePath, filePattern); + const sdpFiles: Array = []; + for (const sdpEntry of sdpEntries) { + const archiveFilePath = path.join(getPoolPath(), sdpEntry.md5.slice(0, 2), `${sdpEntry.md5.slice(2)}.gz`); + const archiveFile = await fs.promises.readFile(archiveFilePath); + if (parseData) { + const data = await gunzip(archiveFile); + sdpFiles.push({ ...sdpEntry, data }); + } else { + sdpFiles.push(sdpEntry); + } + } + return sdpFiles; +} diff --git a/src/main/content/game/game-provider.ts b/src/main/content/game/game-provider.ts new file mode 100644 index 000000000..1c193ce1d --- /dev/null +++ b/src/main/content/game/game-provider.ts @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import * as fs from "fs"; +import { removeFromArray } from "$/jaz-ts-utils/object"; +import * as path from "path"; +import { promisify } from "util"; +import zlib from "zlib"; +import { GameAI, GameVersion } from "@main/content/game/game-version"; +import { parseLuaTable } from "@main/utils/parse-lua-table"; +import { parseLuaOptions } from "@main/utils/parse-lua-options"; +import { logger } from "@main/utils/logger"; +import assert from "assert"; +import { contentSources } from "@main/config/content-sources"; +import { DownloadInfo } from "@main/content/downloads"; +import { getGameFiles } from "@main/content/game/game-files"; +import { LuaOptionSection } from "@main/content/game/lua-options"; +import { PrDownloaderAPI } from "@main/content/pr-downloader"; +import { getRapidIndexPath, getPackagePath, getPoolPath, getGamePaths } from "@main/config/app"; +import { fileExists } from "@main/utils/file"; +import { engineProvider } from "@main/content/engine/engine-provider"; +import { calcChecksum } from "@main/utils/checksums"; + +const log = logger("game-provider.ts"); + +export class GameProvider extends PrDownloaderAPI { + public packageGameVersionLookup: { [md5: string]: string | undefined } = {}; + public gameVersionPackageLookup: { [gameVersion: string]: string | undefined } = {}; + + public override async init() { + await this.initLookupTables(); + await this.scanPackagesDir(); + await this.scanLocalGames(); + + engineProvider.onDownloadComplete.add((downloadInfo) => { + for (const gameVersion of this.availableVersions.keys()) { + calcChecksum(downloadInfo.name, gameVersion); + } + }); + + return this; + } + + public async reinit() { + for (const dir of [getPackagePath(), getPoolPath(), getRapidIndexPath()]) { + await fs.promises.mkdir(dir, { recursive: true }); + } + this.availableVersions.clear(); + this.packageGameVersionLookup = {}; + this.gameVersionPackageLookup = {}; + await this.init(); + } + + // Reading all existing game versions from rapid versions index so + // we can easily check if a version is installed from its md5 + protected async initLookupTables() { + try { + const versionsGzPath = path.join(getRapidIndexPath(), contentSources.rapid.host, contentSources.rapid.game, "versions.gz"); + const versionsGz = await fs.promises.readFile(versionsGzPath); + const versions = await promisify(zlib.gunzip)(versionsGz); + const versionsStr = versions.toString().trim(); + const versionsParts = versionsStr.split("\n"); + for (const versionLine of versionsParts) { + const [tag, packageMd5, , version] = versionLine.split(","); + this.packageGameVersionLookup[packageMd5] = version; + this.gameVersionPackageLookup[version] = packageMd5; + // The tag names whichever build it currently points at, so it has to resolve too or + // asking for one can never be answered. + this.gameVersionPackageLookup[tag] = packageMd5; + } + } catch (err) { + log.warn(`Couldn't initialize lookup tables (is this the first startup ?): ${err}`); + } + } + + protected async scanPackagesDir() { + const packagesDir = getPackagePath(); + let packages: string[]; + try { + packages = await fs.promises.readdir(packagesDir); + } catch { + return; + } + // Refersh lookup tables in case new versions.gz file was downloaded. + await this.initLookupTables(); + for (const packageFile of packages) { + if (!packageFile.endsWith(".sdp")) { + // skip non-sdp files + // one case is pr-downloader interruption, which makes .sdp.incomplete files + log.debug(`Skipping non-sdp file: ${packageFile}!`); + continue; + } + const packageMd5 = packageFile.split(".")[0]; + const gameVersion = this.packageGameVersionLookup[packageMd5]; + const luaOptionSections = await this.getGameOptions(packageMd5); + const ais = await this.getAis(packageMd5); + if (gameVersion) { + this.availableVersions.set(gameVersion, { gameVersion, packageMd5, luaOptionSections, ais }); + } else { + log.warn(`Not found matching game version for ${packageFile}`); + } + } + log.info(`Found ${this.availableVersions.size} installed game versions`); + this.availableVersions.forEach((version) => { + log.info(`-- ${version.gameVersion}`); + }); + } + + // Load local/custom game files from .sdd folders in the games directory + protected async scanLocalGames() { + async function* findLocalGames() { + // We apply toReversed to keep the precedence order: higher precedence visited later. + for (const gamesDir of getGamePaths().toReversed()) { + if (await fileExists(gamesDir)) { + for (const entry of await fs.promises.readdir(gamesDir, { withFileTypes: true })) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && entry.name.endsWith(".sdd")) { + yield [gamesDir, entry.name] as const; + } + } + } + } + } + + log.info("Scanning for local games"); + for await (const [gamesDir, gameDirName] of findLocalGames()) { + log.info(`-- Game ${gameDirName}`); + try { + const modOptionsLua = await fs.promises.readFile(path.join(gamesDir, gameDirName, "modoptions.lua")); + const luaOptionSections = parseLuaOptions(modOptionsLua); + const aiInfoLua = await fs.promises.readFile(path.join(gamesDir, gameDirName, "luaai.lua")); + const ais = await this.parseAis(aiInfoLua); + const gameVersion = gameDirName; + this.availableVersions.set(gameVersion, { + gameVersion, + packageMd5: gameDirName, // kinda hacky since this doesn't have a packageMd5 + luaOptionSections, + ais, + }); + } catch (err) { + console.error(err); + } + } + } + + // A rapid tag names whichever build it points at now, so it has to go through the index to mean + // anything. Anything the index does not know is already a build name, or a local game. + private resolveVersion(version: string) { + const packageMd5 = this.gameVersionPackageLookup[version]; + + return packageMd5 ? (this.packageGameVersionLookup[packageMd5] ?? version) : version; + } + + public override isVersionInstalled(version: string) { + const resolved = this.resolveVersion(version); + + return this.availableVersions.values().some((installedVersion) => installedVersion.gameVersion === resolved); + } + + /** + * Downloads the actual game files, will update to latest if no specific gameVersion is specified + * @param gameVersion e.g. "Beyond All Reason test-16289-b154c3d" + */ + public async downloadGames(gameVersions: string[]) { + const wanted = gameVersions.filter((gameVersion) => !this.isVersionInstalled(gameVersion)); + if (wanted.length === 0) { + return; + } + + log.info(`Downloading game versions: ${wanted.join(", ")}`); + const downloadInfo = await this.downloadContent("game", wanted); + await this.downloadComplete(downloadInfo); + removeFromArray(this.currentDownloads, downloadInfo); + + // One invocation covers every version it was given and can only name one of them, so each is + // confirmed against the packages directory instead of against what the download called itself. + const defaultEngine = engineProvider.getDefaultEngine(); + for (const requested of wanted) { + const gameVersion = this.resolveVersion(requested); + if (!this.availableVersions.has(gameVersion)) { + throw new Error(`No package found for game version: ${requested}`); + } + + log.debug(`Downloaded ${gameVersion}`); + if (defaultEngine?.installed) { + calcChecksum(defaultEngine.id, gameVersion); + } + } + } + + public async downloadGame(gameVersion = `${contentSources.rapid.game}:test`) { + return this.downloadGames([gameVersion]); + } + + public getVersion(gameVersion: string) { + return this.availableVersions.values().find((version) => version.gameVersion === gameVersion); + } + + protected async getGameOptions(packageMd5: string): Promise { + const gameFiles = await getGameFiles(packageMd5, "modoptions.lua", true); + const modoptions = gameFiles[0].data; + return parseLuaOptions(modoptions); + } + + protected async getAis(packageMd5: string): Promise { + const gameFiles = await getGameFiles(packageMd5, "luaai.lua", true); + const luaai = gameFiles[0].data; + return this.parseAis(luaai); + } + + public async uninstallVersionById(gameVersion: string | undefined) { + if (!gameVersion) { + throw new Error("Game Version is not specified"); + } + const version = this.availableVersions.values().find((version) => version.gameVersion === gameVersion); + if (version) await this.uninstallVersion(version); + } + + public async uninstallVersion(version: GameVersion) { + if (!version) { + throw new Error("Game Version is not specified"); + } + + assert(!version.packageMd5.endsWith(".sdd"), "Cannot uninstall local/custom game versions"); + + await this.uninstallContent(version.packageMd5); + this.availableVersions.delete(version.gameVersion); + } + + // The scan reads every package present, which covers whatever this download added without needing to + // be told which versions those were. + protected override async downloadComplete(downloadInfo: DownloadInfo) { + await this.scanPackagesDir(); + super.downloadComplete(downloadInfo); + } + + protected async parseAis(aiInfo: Buffer): Promise { + const ais: GameAI[] = []; + const aiDefinitions = parseLuaTable(aiInfo); + for (const def of aiDefinitions) { + ais.push({ + name: def.name, + shortName: def.name, + description: def.desc, + }); + } + return ais; + } + + // TODO reimplement a cleanup function + // protected async cleanupOldVersions() { + // const maxDays = 90; + // const oldestDate = new Date(); + // oldestDate.setDate(oldestDate.getDate() - maxDays); + // const versionsToRemove = await cacheDb.selectFrom("gameVersion").where("lastLaunched", "<", oldestDate).select("id").execute(); + // for (const version of versionsToRemove) { + // // TODO: needs https://github.com/beyond-all-reason/pr-downloader/issues/21 + // // await this.uninstallVersion(version.id); + // } + // } +} + +export const gameProvider = new GameProvider(); diff --git a/src/main/content/game/game-scenarios.ts b/src/main/content/game/game-scenarios.ts new file mode 100644 index 000000000..980906903 --- /dev/null +++ b/src/main/content/game/game-scenarios.ts @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { SCENARIO_IMAGE_PATH } from "@main/config/app"; +import { getGameFiles } from "@main/content/game/game-files"; +import { Scenario } from "@main/content/game/scenario"; +import { logger } from "@main/utils/logger"; +import { parseLuaTable } from "@main/utils/parse-lua-table"; +import * as fs from "fs"; +import * as path from "path"; +import util from "util"; +import zlib from "zlib"; + +const log = logger("game-scenarios.ts"); +const gunzip = util.promisify(zlib.gunzip); + +export async function getScenarios(packageMd5: string): Promise { + try { + const scenarioImages = await getGameFiles(packageMd5, "singleplayer/scenarios/*.{jpg,png}", false); + const scenarioDefinitions = (await getGameFiles(packageMd5, "singleplayer/scenarios/*.lua", true)).filter(({ fileName }) => /[^/]*scenario[^/]*$/.test(fileName)); + const cacheDir = SCENARIO_IMAGE_PATH; + + await fs.promises.mkdir(cacheDir, { recursive: true }); + for (const scenarioImage of scenarioImages) { + let buffer: Buffer; + if (scenarioImage.archivePath.endsWith(".gz")) { + const data = await fs.promises.readFile(scenarioImage.archivePath); + buffer = await gunzip(data); + } else { + buffer = await fs.promises.readFile(scenarioImage.archivePath); + } + const fileName = path.parse(scenarioImage.fileName).base; + await fs.promises.writeFile(path.join(cacheDir, fileName), buffer); + } + const scenarios: Scenario[] = []; + for (const scenarioDefinition of scenarioDefinitions) { + try { + const scenario = parseLuaTable(scenarioDefinition.data) as Scenario; + if (scenario.imagepath) { + log.debug(`Imagepath: ${scenario.imagepath}`); + scenario.imagepath = path.join(cacheDir, scenario.imagepath).replaceAll("\\", "/"); + } else { + log.warn(`No imagepath for scenario: ${scenario.title}`); + } + scenario.summary = scenario.summary.replace(/\[|\]/g, ""); + scenario.briefing = scenario.briefing.replace(/\[|\]/g, ""); + scenario.allowedsides = Array.isArray(scenario.allowedsides) && scenario.allowedsides[0] !== "" ? scenario.allowedsides : ["Armada", "Cortext", "Random"]; + scenario.startscript = scenario.startscript.slice(1, -1); + scenarios.push(scenario); + } catch (err) { + console.error(`error parsing scenario lua file: ${scenarioDefinition.fileName}`, err); + } + } + scenarios.sort((a, b) => a.index - b.index); + return scenarios; + } catch (err) { + log.error(`Error getting scenarios: ${err}`); + return []; + } +} diff --git a/src/main/content/game/pool-cdn.ts b/src/main/content/game/pool-cdn.ts index 2ec48c6f3..a344e3acc 100644 --- a/src/main/content/game/pool-cdn.ts +++ b/src/main/content/game/pool-cdn.ts @@ -10,8 +10,8 @@ import { logger } from "@main/utils/logger"; import type { DownloadInfo } from "@main/content/downloads"; import { Downloader } from "@main/content/abstract-content"; +import { downloadSlots } from "@main/content/download-slots"; import { removeFromArray } from "$/jaz-ts-utils/object"; -import { GameContentAPI } from "@main/content/game/game-content"; import { extract7z } from "@main/utils/extract-7z"; const log = logger("pool-cdn.ts"); @@ -20,33 +20,22 @@ export class PoolCdnDownloader extends Downloader { cdnUrl = "https://pool-init.beyondallreason.dev"; poolDataUrl = `${this.cdnUrl}/data.7z`; - constructor(gameApi: GameContentAPI) { - super(); - this.onDownloadStart.add((downloadInfo) => { - gameApi.onDownloadStart.dispatch(downloadInfo); - }); - this.onDownloadComplete.add((downloadInfo) => { - gameApi.onDownloadComplete.dispatch(downloadInfo); - }); - this.onDownloadProgress.add((downloadInfo) => { - gameApi.onDownloadProgress.dispatch(downloadInfo); - }); - this.onDownloadFail.add((downloadInfo) => { - gameApi.onDownloadFail.dispatch(downloadInfo); - }); - } - /** * Download and extract pool data from the pool CDN. * * Will try to reuse an existing archive download if it exists (DownloadHelper will resume download). */ - public async preloadPoolData() { + public preloadPoolData() { + return downloadSlots.use(() => this.downloadPoolData()); + } + + private async downloadPoolData() { log.info("Downloading pool data"); await fs.promises.mkdir(getPoolPath(), { recursive: true }); const downloadInfo: DownloadInfo = { type: "game", + id: "pool-data", name: "pool-data", currentBytes: 0, totalBytes: 1, @@ -103,3 +92,5 @@ export class PoolCdnDownloader extends Downloader { this.downloadComplete(downloadInfo); } } + +export const poolCdnDownloader = new PoolCdnDownloader(); diff --git a/src/main/content/maps/map-content.ts b/src/main/content/maps/map-content.ts deleted file mode 100644 index 97913782b..000000000 --- a/src/main/content/maps/map-content.ts +++ /dev/null @@ -1,176 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors -// -// SPDX-License-Identifier: MIT - -import * as fs from "fs"; -import * as path from "path"; - -import { MapData } from "@main/content/maps/map-data"; -import { logger } from "@main/utils/logger"; -import { Signal } from "$/jaz-ts-utils/signal"; -import { PrDownloaderAPI } from "@main/content/pr-downloader"; -import { getMapsPaths } from "@main/config/app"; -import chokidar, { FSWatcher } from "chokidar"; -import { UltraSimpleMapParser } from "$/map-parser/ultrasimple-map-parser"; -import { removeFromArray } from "$/jaz-ts-utils/object"; -import { engineContentAPI } from "@main/content/engine/engine-content"; -import { calcChecksum } from "@main/utils/checksums"; - -const log = logger("map-content.ts"); - -/** - * @todo replace queue method with syncMapCache function once prd returns map file name - */ -export class MapContentAPI extends PrDownloaderAPI { - public mapNameFileNameLookup: { [springName: string]: string | undefined } = {}; - public fileNameMapNameLookup: { [fileName: string]: string | undefined } = {}; - - public readonly onMapAdded: Signal = new Signal(); - public readonly onMapDeleted: Signal = new Signal(); - - private watcher?: FSWatcher; - - protected cachingMaps = false; - protected readonly mapCacheQueue: Set = new Set(); - - public override async init() { - this.initLookupMaps(); - this.startWatchingMapFolder(); - - engineContentAPI.onDownloadComplete.add((downloadInfo) => { - for (const [mapName, fileName] of Object.entries(this.mapNameFileNameLookup)) { - if (fileName) { - calcChecksum(downloadInfo.name, mapName); - } - } - }); - - return super.init(); - } - - public async reinit() { - for (const mapsDir of getMapsPaths()) { - await fs.promises.mkdir(mapsDir, { recursive: true }); - } - this.mapNameFileNameLookup = {}; - this.fileNameMapNameLookup = {}; - this.availableVersions.clear(); - await this.init(); - } - - protected async initLookupMaps() { - async function* findMaps() { - // We apply toReversed to keep the precedence order: higher precedence visited later. - for (const mapsDir of getMapsPaths().toReversed()) { - try { - yield* (await fs.promises.readdir(mapsDir)).filter((f) => f.endsWith(".sd7")).map((f) => path.join(mapsDir, f)); - } catch { - // dir may not exist yet (e.g. before first path confirmation) - } - } - } - log.debug("Scanning for maps"); - for await (const filePath of findMaps()) { - try { - const mapName = await this.getMapNameFromFile(filePath); - const fileName = path.basename(filePath); - - this.mapNameFileNameLookup[mapName] = fileName; - this.fileNameMapNameLookup[fileName] = mapName; - } catch (err) { - log.error(`File may be corrupted, removing ${filePath}: ${err}`); - fs.promises.rm(filePath); - } - } - log.info(`Found ${Object.keys(this.mapNameFileNameLookup).length} maps`); - } - - ultraSimpleMapParser = new UltraSimpleMapParser(); - protected async getMapNameFromFile(filePath: string) { - const parsedMap = await this.ultraSimpleMapParser.parseMap(filePath); - return parsedMap.springName; - } - - protected async startWatchingMapFolder() { - //using chokidar to watch for changes in the maps folder - await this.watcher?.close(); - this.watcher = chokidar - .watch(getMapsPaths().slice(), { - ignoreInitial: true, //ignore the initial scan - awaitWriteFinish: true, //wait for the file to be fully written before emitting the event - }) - .on("add", (filepath) => { - if (!filepath.endsWith("sd7")) { - return; - } - log.debug(`Chokidar -=- Map added: ${filepath}`); - const filename = path.basename(filepath); - // Update the lookup maps - this.getMapNameFromFile(filepath).then((mapName) => { - this.mapNameFileNameLookup[mapName] = filename; - this.fileNameMapNameLookup[filename] = mapName; - this.onMapAdded.dispatch(mapName); - - const defaultEngine = engineContentAPI.getDefaultEngine(); - if (defaultEngine?.installed) { - calcChecksum(defaultEngine.id, mapName); - } - }); - }) - .on("unlink", (filepath) => { - if (!filepath.endsWith("sd7")) { - return; - } - log.debug(`Chokidar -=- Map removed: ${filepath}`); - - const pathBaseName = path.basename(filepath); - - if (pathBaseName) { - const springName = this.fileNameMapNameLookup[pathBaseName]; - this.fileNameMapNameLookup[pathBaseName] = undefined; - if (springName) { - this.mapNameFileNameLookup[springName] = undefined; - this.onMapDeleted.dispatch(springName); - } - } - }); - } - - public isVersionInstalled(springName: string): boolean { - return this.mapNameFileNameLookup[springName] !== undefined; - } - - public async downloadMaps(springNames: string[]) { - return Promise.all(springNames.map((springName) => this.downloadMap(springName))); - } - - public async downloadMap(springName: string) { - if (this.isVersionInstalled(springName)) return; - if (this.currentDownloads.some((download) => download.name === springName)) { - return await new Promise((resolve) => { - this.onDownloadComplete.addOnce((mapData) => { - if (mapData.name === springName) { - resolve(); - } - }); - }); - } - const downloadInfo = await this.downloadContent("map", springName); - removeFromArray(this.currentDownloads, downloadInfo); - this.onDownloadComplete.dispatch(downloadInfo); - } - - public async attemptCacheErrorMaps() { - throw new Error("Method not implemented."); - } - - public async uninstallVersion(version: MapData) { - for (const mapsDir of getMapsPaths()) { - const mapFile = path.join(mapsDir, version.filename); - await fs.promises.rm(mapFile, { force: true }); - } - log.debug(`Map removed: ${version.springName}`); - } -} - -export const mapContentAPI = new MapContentAPI(); diff --git a/src/main/content/maps/map-provider.ts b/src/main/content/maps/map-provider.ts new file mode 100644 index 000000000..b59d25bb7 --- /dev/null +++ b/src/main/content/maps/map-provider.ts @@ -0,0 +1,190 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import * as fs from "fs"; +import * as path from "path"; + +import { MapData } from "@main/content/maps/map-data"; +import { logger } from "@main/utils/logger"; +import { Signal } from "$/jaz-ts-utils/signal"; +import { PrDownloaderAPI } from "@main/content/pr-downloader"; +import { getMapsPaths } from "@main/config/app"; +import chokidar, { FSWatcher } from "chokidar"; +import { UltraSimpleMapParser } from "$/map-parser/ultrasimple-map-parser"; +import { removeFromArray } from "$/jaz-ts-utils/object"; +import { engineProvider } from "@main/content/engine/engine-provider"; +import { calcChecksum, holdChecksums } from "@main/utils/checksums"; + +const log = logger("map-provider.ts"); + +export class MapProvider extends PrDownloaderAPI { + public mapNameFileNameLookup: { [springName: string]: string | undefined } = {}; + public fileNameMapNameLookup: { [fileName: string]: string | undefined } = {}; + + public readonly onMapAdded: Signal = new Signal(); + public readonly onMapDeleted: Signal = new Signal(); + + private watcher?: FSWatcher; + private mapsWork: Promise = Promise.resolve(); + + public override async init() { + await this.syncMaps(); + await this.startWatchingMapFolder(); + + engineProvider.onDownloadComplete.add((downloadInfo) => { + for (const [mapName, fileName] of Object.entries(this.mapNameFileNameLookup)) { + if (fileName) { + calcChecksum(downloadInfo.name, mapName); + } + } + }); + + return super.init(); + } + + public async reinit() { + for (const mapsDir of getMapsPaths()) { + await fs.promises.mkdir(mapsDir, { recursive: true }); + } + this.mapNameFileNameLookup = {}; + this.fileNameMapNameLookup = {}; + this.availableVersions.clear(); + await this.init(); + } + + // Only writer of the springName lookups. Our own downloads and removals need it visible the moment + // they return; the watcher calls it for files the user added or deleted behind our back. + public syncMaps(): Promise { + return this.serialise(() => this.reconcileMaps()); + } + + // One piece of work at a time, never inside a pass that started earlier: a reconcile that already + // listed the directory cannot report a later change, and identifying an archive holds it open, so a + // removal overlapping a reconcile fails on Windows. + private serialise(work: () => Promise): Promise { + const result = this.mapsWork.then(work); + this.mapsWork = result.then( + () => undefined, + (err) => log.error("Maps directory work failed", err) + ); + + return result; + } + + private async reconcileMaps() { + const onDisk = new Map(); + + // toReversed keeps the precedence order: higher precedence visited later, so it wins. + for (const mapsDir of getMapsPaths().toReversed()) { + let entries: string[]; + try { + entries = await fs.promises.readdir(mapsDir); + } catch { + continue; // dir may not exist yet (e.g. before first path confirmation) + } + + for (const fileName of entries.filter((entry) => entry.endsWith(".sd7"))) { + onDisk.set(fileName, path.join(mapsDir, fileName)); + } + } + + for (const [fileName, springName] of Object.entries(this.fileNameMapNameLookup)) { + if (springName === undefined || onDisk.has(fileName)) { + continue; + } + + delete this.fileNameMapNameLookup[fileName]; + delete this.mapNameFileNameLookup[springName]; + log.debug(`Map gone: ${springName}`); + this.onMapDeleted.dispatch(springName); + } + + for (const [fileName, filePath] of onDisk) { + if (this.fileNameMapNameLookup[fileName] !== undefined) { + continue; + } + + let springName: string; + try { + springName = await this.getMapNameFromFile(filePath); + } catch (err) { + // May be a download still being written - leave it for a later pass. + log.warn(`Could not identify ${filePath} yet: ${err}`); + continue; + } + + this.mapNameFileNameLookup[springName] = fileName; + this.fileNameMapNameLookup[fileName] = springName; + log.debug(`Map found: ${springName}`); + this.onMapAdded.dispatch(springName); + + const defaultEngine = engineProvider.getDefaultEngine(); + if (defaultEngine?.installed) { + calcChecksum(defaultEngine.id, springName); + } + } + + log.info(`${Object.keys(this.mapNameFileNameLookup).length} maps installed`); + } + + ultraSimpleMapParser = new UltraSimpleMapParser(); + protected async getMapNameFromFile(filePath: string) { + const parsedMap = await this.ultraSimpleMapParser.parseMap(filePath); + return parsedMap.springName; + } + + protected async startWatchingMapFolder() { + await this.watcher?.close(); + this.watcher = chokidar + .watch(getMapsPaths().slice(), { + ignoreInitial: true, //ignore the initial scan + awaitWriteFinish: true, //wait for the file to be fully written before emitting the event + }) + .on("all", (_event, filepath) => { + if (!filepath.endsWith("sd7")) { + return; + } + + void this.syncMaps(); + }); + } + + public isVersionInstalled(springName: string): boolean { + return this.mapNameFileNameLookup[springName] !== undefined; + } + + public async downloadMaps(springNames: string[]) { + const wanted = springNames.filter((springName) => !this.isVersionInstalled(springName)); + if (wanted.length === 0) return; + + const downloadInfo = await this.downloadContent("map", wanted); + removeFromArray(this.currentDownloads, downloadInfo); + await this.syncMaps(); + this.onDownloadComplete.dispatch(downloadInfo); + } + + public async downloadMap(springName: string) { + return this.downloadMaps([springName]); + } + + public async uninstallVersion(version: MapData | string) { + const springName = typeof version === "string" ? version : version.springName; + const fileName = typeof version === "string" ? this.mapNameFileNameLookup[springName] : version.filename; + + if (!fileName) { + throw new Error(`No installed map file for: ${springName}`); + } + + await this.serialise(() => + holdChecksums(async () => { + for (const mapsDir of getMapsPaths()) { + await fs.promises.rm(path.join(mapsDir, fileName), { force: true }); + } + }) + ); + await this.syncMaps(); + } +} + +export const mapProvider = new MapProvider(); diff --git a/src/main/content/pr-downloader.ts b/src/main/content/pr-downloader.ts index 2f405f77d..98cfadda2 100644 --- a/src/main/content/pr-downloader.ts +++ b/src/main/content/pr-downloader.ts @@ -2,19 +2,21 @@ // // SPDX-License-Identifier: MIT -import { spawn } from "child_process"; +import { execFile, spawn } from "child_process"; +import fs from "fs"; import os from "os"; import path from "path"; import { DownloadInfo } from "./downloads"; import { AbstractContentAPI } from "./abstract-content"; -import { engineContentAPI } from "./engine/engine-content"; +import { engineProvider } from "./engine/engine-provider"; import { logger } from "@main/utils/logger"; -import { getAssetsPath, getEnginePath, getCaCertPath } from "@main/config/app"; +import { getAssetsPath, getEnginePath, getCaCertPath, getPackagePath } from "@main/config/app"; +import { holdChecksums } from "@main/utils/checksums"; const log = logger("pr-downloader.ts"); -export type PrdDownloadType = "engine" | "game" | "map"; +export type PrdDownloadType = "game" | "map"; export type PrdProgressMessage = { downloadType: PrdDownloadType; @@ -30,28 +32,54 @@ export type RapidVersion = { version: string; }; +// pr-downloader ships inside the engine, so any installed engine can drive downloads. Preferring the +// default and then newest-first keeps content downloadable after a default version bump, when the new +// default is not installed yet, and skips engines whose install did not produce a binary. +export function findPrdBinary() { + const binaryName = process.platform === "win32" ? "pr-downloader.exe" : "pr-downloader"; + const defaultEngine = engineProvider.getDefaultEngine(); + const candidates = [...(defaultEngine?.installed ? [defaultEngine] : []), ...engineProvider.getInstalledVersionsNewestFirst()]; + + for (const engine of candidates) { + const binaryPath = path.join(getEnginePath(), engine.id, binaryName); + if (fs.existsSync(binaryPath)) { + return binaryPath; + } + log.warn(`Engine ${engine.id} has no ${binaryName}, trying the next one`); + } + + return undefined; +} + /** * https://github.com/beyond-all-reason/pr-downloader * https://springrts.com/wiki/Pr-downloader * https://springrts.com/wiki/Rapid */ export abstract class PrDownloaderAPI extends AbstractContentAPI { - protected downloadContent(type: "game" | "map", name: string) { + protected getPrdBinaryPath() { + const binaryPath = findPrdBinary(); + if (!binaryPath) { + throw new Error("No installed engine ships a pr-downloader binary."); + } + + return binaryPath; + } + + // One invocation per set of assets rather than one each: pr-downloader rewrites rapid's repo index + // every time it runs, so concurrent invocations fight over it, and it downloads the assets it was + // given in parallel anyway. Progress then covers the whole set, not any one asset in it. + protected downloadContent(type: "game" | "map", names: string[]) { return new Promise((resolve, reject) => { try { + const name = names.join(", "); log.debug(`Downloading ${name}...`); - const defaultEngine = engineContentAPI.getDefaultEngine(); - - // These two errors should in theory never happen... - if (!defaultEngine) throw new Error("No default engine version."); - if (defaultEngine.installed === false) throw new Error("Default engine is not installed."); - - const binaryName = process.platform === "win32" ? "pr-downloader.exe" : "pr-downloader"; - const prBinaryPath = path.join(getEnginePath(), defaultEngine.id, binaryName); + const prBinaryPath = this.getPrdBinaryPath(); const downloadArg = type === "game" ? "--download-game" : "--download-map"; const caCertPath = getCaCertPath(); - const prdProcess = spawn(`${prBinaryPath}`, ["--filesystem-writepath", getAssetsPath(), downloadArg, name], { + const downloadArgs = names.flatMap((asset) => [downloadArg, asset]); + const prdProcess = spawn(`${prBinaryPath}`, ["--filesystem-writepath", getAssetsPath(), ...downloadArgs], { env: { ...process.env, PRD_RAPID_USE_STREAMER: "false", @@ -62,6 +90,7 @@ export abstract class PrDownloaderAPI extends AbstractContentAPI { }); const downloadInfo: DownloadInfo = { type, + id: name, name, currentBytes: 0, totalBytes: 0, @@ -127,6 +156,76 @@ export abstract class PrDownloaderAPI extends AbstractContentAPI { }); } + // --uninstall is newer than several engine releases and the binary travels with the engine rather + // than with this app, so ask the resolved binary instead of guessing from a version number. + private static readonly uninstallSupport = new Map>(); + + private static supportsUninstall(binaryPath: string) { + let probe = PrDownloaderAPI.uninstallSupport.get(binaryPath); + + if (!probe) { + probe = new Promise((resolve) => { + execFile(binaryPath, ["--help"], (err, stdout) => resolve(!err && stdout.includes("--uninstall"))); + }); + PrDownloaderAPI.uninstallSupport.set(binaryPath, probe); + } + + return probe; + } + + // Resolution happens against the local install, so an md5 is the only name that cannot come back + // ambiguous or go missing once a rapid tag stops being published. + protected async uninstallContent(packageMd5: string) { + const binaryPath = this.getPrdBinaryPath(); + + if (!(await PrDownloaderAPI.supportsUninstall(binaryPath))) { + log.warn(`${binaryPath} does not support --uninstall, removing the sdp and leaving its pool files behind`); + await holdChecksums(() => fs.promises.rm(path.join(getPackagePath(), `${packageMd5}.sdp`))); + + return; + } + + // Checksums read the pool files and the sdp this is about to take away. + return holdChecksums(() => this.runUninstall(binaryPath, packageMd5)); + } + + private runUninstall(binaryPath: string, packageMd5: string) { + return new Promise((resolve, reject) => { + try { + log.debug(`Uninstalling ${packageMd5}...`); + + const prdProcess = spawn(binaryPath, ["--filesystem-writepath", getAssetsPath(), "--uninstall", packageMd5]); + const errors: string[] = []; + + prdProcess.stdout?.on("data", (stdout: Buffer) => { + log.debug(stdout.toString().trim()); + }); + + prdProcess.stderr?.on("data", (stderr: Buffer) => { + const output = stderr.toString().trim(); + log.error(output); + errors.push(output); + }); + + prdProcess.on("error", (err) => { + log.error(err); + reject(err); + }); + + prdProcess.on("exit", (code, signal) => { + if (code !== 0) { + reject(new Error(`pr-downloader exited with code ${code}, signal ${signal}: ${errors.join(" ")}`)); + } else { + resolve(); + } + }); + } catch (err) { + log.error(err); + reject(err); + } + }); + } + protected parseProgressMessage(message: string): Omit { const parts = message.split(" "); const bytes = parts[parts.length - 1].split("/"); diff --git a/src/main/game/game.ts b/src/main/game/game.ts index 22980541a..354b0f8e3 100644 --- a/src/main/game/game.ts +++ b/src/main/game/game.ts @@ -8,14 +8,13 @@ import * as fs from "fs"; import { Signal } from "$/jaz-ts-utils/signal"; import * as path from "path"; -import { engineContentAPI } from "@main/content/engine/engine-content"; +import { contentAPI } from "@main/content/content-api"; import { applyDefaultSpringsettings } from "@main/game/springsettings"; import { startScriptConverter } from "@main/utils/start-script-converter"; import { logger } from "@main/utils/logger"; -import { gameContentAPI } from "@main/content/game/game-content"; import { WRITE_DATA_PATH, REPLAYS_PATH, getEnginePath, getAssetsPath } from "@main/config/app"; import { BattleWithMetadata } from "@main/game/battle/battle-types"; -import { Replay } from "@main/content/replays/replay"; +import { Replay } from "@main/replays/replay"; const log = logger("main/game/game.ts"); const engineLogger = logger("[RECOIL ENGINE]", { separator: "\n", level: "info" }); @@ -46,6 +45,7 @@ export class GameAPI { await this.launch({ engineVersion: battle.battleOptions.engineVersion, gameVersion: battle.battleOptions.gameVersion, + mapSpringName: battle.battleOptions.map?.springName, launchArg: scriptPath, }); } @@ -54,6 +54,7 @@ export class GameAPI { return this.launch({ engineVersion: replay.engineVersion, gameVersion: replay.gameVersion, + mapSpringName: replay.mapSpringName, launchArg: replay.filePath ? replay.filePath : path.join(REPLAYS_PATH, replay.fileName), }); } @@ -70,6 +71,7 @@ export class GameAPI { await this.launch({ engineVersion, gameVersion: scriptGameVersion || gameVersion, // using script's game version if available + mapSpringName, launchArg: scriptPath, }); } @@ -82,12 +84,16 @@ export class GameAPI { }); } - public async launch({ engineVersion, gameVersion, launchArg }: { engineVersion?: string; gameVersion?: string; launchArg?: string }): Promise { + public async launch({ engineVersion, gameVersion, mapSpringName, launchArg }: { engineVersion?: string; gameVersion?: string; mapSpringName?: string; launchArg?: string }): Promise { if (!engineVersion || !gameVersion || !launchArg) { throw new Error("Engine Version, Game Version and launch Arguments need to be specified"); } log.info(`Launching game with engine: ${engineVersion}, game: ${gameVersion}`); + + // Playing something is the clearest sign it is still wanted, and it is the one thing every + // launch path has in common. + void contentAPI.markUsed([{ type: "engine", id: engineVersion }, { type: "game", id: gameVersion }, ...(mapSpringName ? [{ type: "map" as const, id: mapSpringName }] : [])]); await this.fetchMissingContent(engineVersion, gameVersion); // TODO preload anything needed through the UI before launching. Remove this step applyDefaultSpringsettings(); @@ -167,17 +173,10 @@ export class GameAPI { throw new Error("Engine Version and Game Version need to be specified"); } - const isEngineInstalled = engineContentAPI.isVersionInstalled(engineVersion); - const isGameInstalled = gameContentAPI.isVersionInstalled(gameVersion); - if (!isEngineInstalled || !isGameInstalled) { - //|| !isMapInstalled) { - //TODO replace with an event - // api.notifications.alert({ - // text: "Downloading missing content - the game will auto-launch when downloads complete", - // }); - return Promise.all([engineContentAPI.downloadEngine(engineVersion), gameContentAPI.downloadGame(gameVersion)]); - } - return; + await contentAPI.ensure([ + { type: "engine", id: engineVersion }, + { type: "game", id: gameVersion }, + ]); } } diff --git a/src/main/game/springsettings.ts b/src/main/game/springsettings.ts index 9ac0efb4f..ced6d5bf7 100644 --- a/src/main/game/springsettings.ts +++ b/src/main/game/springsettings.ts @@ -101,7 +101,7 @@ const BAR_SPRINGSETTINGS_DEFAULTS: Record = { LinkIncomingSustainedBandwidth: 1048576, LinkIncomingPeakBandwidth: 1048576, LinkIncomingMaxPacketRate: 2048, - DemoFileExtension: "barreplay", + DemoFileExtension: "barreplay,sdfz", }; function readSettings(filePath: string): Map { diff --git a/src/main/json/model/content-usage.ts b/src/main/json/model/content-usage.ts new file mode 100644 index 000000000..e2f488d09 --- /dev/null +++ b/src/main/json/model/content-usage.ts @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { Type } from "@sinclair/typebox"; + +// type is a plain string rather than a union of the known content types on purpose: a file written by +// a version that knew about a type this one does not must still validate, otherwise the whole record +// is rejected and every timestamp in it is lost. +const contentRef = Type.Object({ + type: Type.String(), + id: Type.String(), +}); + +export const contentUsageSchema = Type.Object({ + usage: Type.Array(Type.Composite([contentRef, Type.Object({ lastUsed: Type.String() })]), { default: [] }), +}); diff --git a/src/main/main-window.ts b/src/main/main-window.ts index 8c5c5e904..7b2529dc4 100644 --- a/src/main/main-window.ts +++ b/src/main/main-window.ts @@ -10,7 +10,7 @@ import icon from "@main/resources/icon.png"; import { purgeLogFiles } from "@main/services/log.service"; import { typedWebContents, ipcMain } from "@main/typed-ipc"; import { gameAPI } from "@main/game/game"; -import downloadsService from "@main/services/downloads.service"; +import contentService from "@main/services/content.service"; const ZOOM_FACTOR_BASELINE_HEIGHT = 1080; @@ -135,7 +135,7 @@ export function createWindow() { }); // Get download progress updates to update the dock/taskbar - downloadsService.registerProgressHandler(mainWindow); + contentService.registerProgressHandler(mainWindow); ///////////////////////////////////////////// // Subscribe to game events diff --git a/src/main/main.ts b/src/main/main.ts index f8d5647de..d150cd9bb 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -13,13 +13,14 @@ import { createWindow } from "@main/main-window"; import { settingsService } from "./services/settings.service"; import { infoService } from "./services/info.service"; import { logService } from "@main/services/log.service"; +import contentService from "./services/content.service"; +import { contentAPI } from "@main/content/content-api"; import engineService from "./services/engine.service"; import mapsService from "./services/maps.service"; import gameService from "./services/game.service"; import { logger } from "./utils/logger"; import { APP_NAME, SCENARIO_IMAGE_PATH, setAssetsPath } from "./config/app"; import { shellService } from "@main/services/shell.service"; -import downloadsService from "@main/services/downloads.service"; import replaysService from "@main/services/replays.service"; import { miscService } from "@main/services/news.service"; import autoUpdaterService from "@main/services/auto-updater.service"; @@ -128,8 +129,14 @@ app.whenReady().then(async () => { if (savedAssetsPath && !process.env.BAR_ASSETS_PATH) { setAssetsPath(savedAssetsPath); } - await engineService.init(); - await Promise.all([authService.init(), replaysService.init(), gameService.init(), mapsService.init(), autoUpdaterService.init()]); + // A saved assets path can point somewhere unavailable, and the screen that lets the user repoint it + // cannot appear if this throws on the way to creating the window. + try { + await contentAPI.init(); + } catch (err) { + log.error("Content initialisation failed, starting anyway so the assets path can be changed", err); + } + await Promise.all([authService.init(), replaysService.init(), autoUpdaterService.init()]); const mainWindow = createWindow(); const webContents = typedWebContents(mainWindow.webContents); @@ -140,11 +147,11 @@ app.whenReady().then(async () => { authService.registerIpcHandlers(webContents); tachyonService.registerIpcHandlers(webContents); replaysService.registerIpcHandlers(webContents); + contentService.registerIpcHandlers(webContents); engineService.registerIpcHandlers(); gameService.registerIpcHandlers(webContents); mapsService.registerIpcHandlers(webContents); shellService.registerIpcHandlers(); - downloadsService.registerIpcHandlers(webContents); miscService.registerIpcHandlers(); autoUpdaterService.registerIpcHandlers(); navigationService.registerIpcHandlers(webContents); diff --git a/src/main/content/replays/online-replays.ts b/src/main/replays/online-replays.ts similarity index 100% rename from src/main/content/replays/online-replays.ts rename to src/main/replays/online-replays.ts diff --git a/src/main/content/replays/parse-replay-worker.ts b/src/main/replays/parse-replay-worker.ts similarity index 98% rename from src/main/content/replays/parse-replay-worker.ts rename to src/main/replays/parse-replay-worker.ts index c9cef35f0..098caf81e 100644 --- a/src/main/content/replays/parse-replay-worker.ts +++ b/src/main/replays/parse-replay-worker.ts @@ -3,7 +3,7 @@ // SPDX-License-Identifier: MIT import { DemoParser } from "sdfz-demo-parser"; -import { Replay } from "@main/content/replays/replay"; +import { Replay } from "@main/replays/replay"; import path from "path"; import { isMainThread, parentPort } from "worker_threads"; diff --git a/src/main/content/replays/parse-replay.ts b/src/main/replays/parse-replay.ts similarity index 96% rename from src/main/content/replays/parse-replay.ts rename to src/main/replays/parse-replay.ts index 43ff20882..4b542f0e7 100644 --- a/src/main/content/replays/parse-replay.ts +++ b/src/main/replays/parse-replay.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -import { Replay } from "@main/content/replays/replay"; +import { Replay } from "@main/replays/replay"; import path from "path"; import { Worker } from "worker_threads"; import { logger } from "@main/utils/logger"; diff --git a/src/main/content/replays/replay.ts b/src/main/replays/replay.ts similarity index 100% rename from src/main/content/replays/replay.ts rename to src/main/replays/replay.ts diff --git a/src/main/content/replays/replay-content.ts b/src/main/replays/replays.ts similarity index 87% rename from src/main/content/replays/replay-content.ts rename to src/main/replays/replays.ts index 6d0ce4514..e3026c822 100644 --- a/src/main/content/replays/replay-content.ts +++ b/src/main/replays/replays.ts @@ -4,9 +4,9 @@ import { Signal } from "$/jaz-ts-utils/signal"; import { REPLAYS_PATH } from "@main/config/app"; -import { mapContentAPI } from "@main/content/maps/map-content"; -import { asyncParseReplay } from "@main/content/replays/parse-replay"; -import { Replay } from "@main/content/replays/replay"; +import { contentAPI } from "@main/content/content-api"; +import { asyncParseReplay } from "@main/replays/parse-replay"; +import { Replay } from "@main/replays/replay"; import { gameAPI } from "@main/game/game"; import { isReplayFile } from "@main/config/replay-extensions"; import { logger } from "@main/utils/logger"; @@ -17,7 +17,7 @@ import path from "path"; const log = logger("replay-content.ts"); -export class ReplayContentAPI { +export class ReplaysAPI { public readonly onReplayCachingStarted: Signal = new Signal(); public readonly onReplayCached: Signal = new Signal(); public readonly onReplayDeleted: Signal = new Signal(); @@ -73,9 +73,9 @@ export class ReplayContentAPI { try { await fs.promises.copyFile(filePath, replayPath); const replay = await asyncParseReplay(replayPath); - await mapContentAPI.downloadMap(replay.mapSpringName); this.onReplayCached.dispatch(replay); + await this.fetchReplayMap(replay); } finally { // Always remove from the set, even if there's an error this.filesBeingCopied.delete(fileName); @@ -83,7 +83,17 @@ export class ReplayContentAPI { } else { // File is already in the replays folder, just parse it const replay = await asyncParseReplay(replayPath); - await mapContentAPI.downloadMap(replay.mapSpringName); + await this.fetchReplayMap(replay); + } + } + + // Having the map makes the replay previewable, but the replay is what the caller asked to add, so a + // map that will not download must not keep it out of the list. + private async fetchReplayMap(replay: Replay) { + try { + await contentAPI.ensure([{ type: "map", id: replay.mapSpringName }]); + } catch (err) { + log.error(`Could not fetch map ${replay.mapSpringName} for replay ${replay.fileName}`, err); } } @@ -148,4 +158,4 @@ export class ReplayContentAPI { } } -export const replayContentAPI = new ReplayContentAPI(); +export const replaysAPI = new ReplaysAPI(); diff --git a/src/main/services/content.service.ts b/src/main/services/content.service.ts new file mode 100644 index 000000000..fabdf1e04 --- /dev/null +++ b/src/main/services/content.service.ts @@ -0,0 +1,96 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { autoUpdaterAPI } from "@main/content/auto-updater"; +import { contentAPI } from "@main/content/content-api"; +import { ContentRef } from "@main/content/content-ref"; +import { DownloadInfo } from "@main/content/downloads"; +import { createSettledCounter } from "@main/content/content-progress"; +import { isInProgress } from "@main/content/content-state"; +import { poolCdnDownloader } from "@main/content/game/pool-cdn"; +import { BarIpcWebContents, ipcMain } from "@main/typed-ipc"; + +function registerIpcHandlers(webContents: BarIpcWebContents) { + ipcMain.handle("content:missing", (_, refs: ContentRef[]) => contentAPI.missing(refs)); + ipcMain.handle("content:state", () => contentAPI.state()); + ipcMain.handle("content:ensure", (_, refs: ContentRef[]) => contentAPI.ensure(refs)); + ipcMain.handle("content:remove", (_, refs: ContentRef[]) => contentAPI.remove(refs)); + + contentAPI.onChanged.add((state) => { + webContents.send("content:changed", state); + }); + contentAPI.onSettled.add((refs) => { + webContents.send("content:settled", refs); + }); + + ipcMain.handle("content:preloadPool", () => poolCdnDownloader.preloadPoolData()); + + // Warming the pool is not an operation on a piece of content, so it has no ref and cannot be + // queued or checked for presence. It reports separately and the UI lists it alongside the rest. + for (const signal of [poolCdnDownloader.onDownloadStart, poolCdnDownloader.onDownloadProgress]) { + signal.add((downloadInfo) => webContents.send("content:poolPrefetch", downloadInfo)); + } + for (const signal of [poolCdnDownloader.onDownloadComplete, poolCdnDownloader.onDownloadFail]) { + signal.add(() => webContents.send("content:poolPrefetch", null)); + } + + // The updater sits outside the content layer and reports on its own channel, which the download list + // totals alongside content. + autoUpdaterAPI.onDownloadProgress.add((downloadInfo) => webContents.send("downloads:update:progress", downloadInfo)); + for (const signal of [autoUpdaterAPI.onDownloadComplete, autoUpdaterAPI.onDownloadFail]) { + signal.add(() => webContents.send("downloads:update:progress", null)); + } +} + +// The taskbar shows a single bar for everything downloading, and app updates come from outside the +// content layer, so both sources are totalled here rather than either one owning the window. +function registerProgressHandler(mainWindow: Electron.CrossProcessExports.BrowserWindow) { + const countSettled = createSettledCounter(); + const updaterDownloads: Map = new Map(); + let contentDone = 0; + let contentCount = 0; + + // A reading can come back over its own total, because pr-downloader reports file counts and bytes + // down the same channel. + const fraction = (progress: number) => Math.min(1, progress); + + function refresh() { + const updates = [...updaterDownloads.values()]; + const done = contentDone + updates.reduce((total, download) => total + fraction(download.progress), 0); + const count = contentCount + updates.length; + + mainWindow.setProgressBar(count === 0 ? -1 : done / count); + } + + contentAPI.onChanged.add((state) => { + const outstanding = state.filter(isInProgress); + const landed = countSettled(state); + + // A failure outlives the run it belongs to, so the figure has to go by whether anything is still + // moving rather than by whether the change stream has emptied. + contentDone = outstanding.length === 0 ? 0 : landed + outstanding.reduce((total, entry) => total + fraction(entry.progress), 0); + contentCount = outstanding.length === 0 ? 0 : landed + outstanding.length; + refresh(); + }); + + autoUpdaterAPI.onDownloadProgress.add((downloadInfo) => { + updaterDownloads.set(downloadInfo.name, downloadInfo); + refresh(); + }); + autoUpdaterAPI.onDownloadComplete.add((downloadInfo) => { + updaterDownloads.delete(downloadInfo.name); + refresh(); + }); + autoUpdaterAPI.onDownloadFail.add((downloadInfo) => { + updaterDownloads.delete(downloadInfo.name); + refresh(); + }); +} + +const contentService = { + registerIpcHandlers, + registerProgressHandler, +}; + +export default contentService; diff --git a/src/main/services/downloads.service.ts b/src/main/services/downloads.service.ts deleted file mode 100644 index cb4f7d4ae..000000000 --- a/src/main/services/downloads.service.ts +++ /dev/null @@ -1,107 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors -// -// SPDX-License-Identifier: MIT - -import { autoUpdaterAPI } from "@main/content/auto-updater"; -import { DownloadInfo } from "@main/content/downloads"; -import { engineContentAPI } from "@main/content/engine/engine-content"; -import { gameContentAPI } from "@main/content/game/game-content"; -import { mapContentAPI } from "@main/content/maps/map-content"; -import { BarIpcWebContents } from "@main/typed-ipc"; - -function registerIpcHandlers(webContents: BarIpcWebContents) { - engineContentAPI.onDownloadStart.add((downloadInfo) => { - webContents.send("downloads:engine:start", downloadInfo); - }); - engineContentAPI.onDownloadComplete.add((downloadInfo) => { - webContents.send("downloads:engine:complete", downloadInfo); - }); - engineContentAPI.onDownloadProgress.add((downloadInfo) => { - webContents.send("downloads:engine:progress", downloadInfo); - }); - engineContentAPI.onDownloadFail.add((downloadInfo) => { - webContents.send("downloads:engine:fail", downloadInfo); - }); - - gameContentAPI.onDownloadStart.add((downloadInfo) => { - webContents.send("downloads:game:start", downloadInfo); - }); - gameContentAPI.onDownloadComplete.add((downloadInfo) => { - webContents.send("downloads:game:complete", downloadInfo); - }); - gameContentAPI.onDownloadProgress.add((downloadInfo) => { - webContents.send("downloads:game:progress", downloadInfo); - }); - gameContentAPI.onDownloadFail.add((downloadInfo) => { - webContents.send("downloads:game:fail", downloadInfo); - }); - gameContentAPI.onDownloadRetry.add((downloadInfo) => { - webContents.send("downloads:game:retry", downloadInfo); - }); - - mapContentAPI.onDownloadStart.add((downloadInfo) => { - webContents.send("downloads:map:start", downloadInfo); - }); - mapContentAPI.onDownloadComplete.add((downloadInfo) => { - webContents.send("downloads:map:complete", downloadInfo); - }); - mapContentAPI.onDownloadProgress.add((downloadInfo) => { - webContents.send("downloads:map:progress", downloadInfo); - }); - mapContentAPI.onDownloadFail.add((downloadInfo) => { - webContents.send("downloads:map:fail", downloadInfo); - }); - mapContentAPI.onDownloadRetry.add((downloadInfo) => { - webContents.send("downloads:map:retry", downloadInfo); - }); - - autoUpdaterAPI.onDownloadProgress.add((downloadInfo) => { - webContents.send("downloads:update:progress", downloadInfo); - }); -} - -const downloads: Map = new Map(); - -function registerProgressHandler(mainWindow: Electron.CrossProcessExports.BrowserWindow) { - const apiList = [engineContentAPI, gameContentAPI, mapContentAPI, autoUpdaterAPI]; - apiList.forEach((api) => { - api.onDownloadStart.add((downloadInfo) => { - downloads.set(downloadInfo.name, downloadInfo); - updateProgressBar(mainWindow); - }); - api.onDownloadComplete.add((downloadInfo) => { - downloads.delete(downloadInfo.name); - updateProgressBar(mainWindow); - }); - api.onDownloadProgress.add((downloadInfo) => { - downloads.set(downloadInfo.name, downloadInfo); - updateProgressBar(mainWindow); - }); - api.onDownloadFail.add((downloadInfo) => { - downloads.delete(downloadInfo.name); - updateProgressBar(mainWindow); - }); - }); -} - -function updateProgressBar(mainWindow: Electron.CrossProcessExports.BrowserWindow) { - // If we have no active downloads, clear the progress bar. - if (downloads.size == 0) { - mainWindow.setProgressBar(-1); - return; - } - // Get the total progress of all downloads and the number of downloads. - let totalProgress = 0; - downloads.forEach((d) => { - totalProgress += d.progress; - }); - // Set the progress percent to the average progress of all downloads. - mainWindow.setProgressBar(totalProgress / downloads.size); -} - -const downloadsService = { - registerIpcHandlers, - registerProgressHandler, -}; - -export default downloadsService; diff --git a/src/main/services/engine.service.ts b/src/main/services/engine.service.ts index cc1b79691..e8e044505 100644 --- a/src/main/services/engine.service.ts +++ b/src/main/services/engine.service.ts @@ -2,33 +2,19 @@ // // SPDX-License-Identifier: MIT +import { contentAPI } from "@main/content/content-api"; import { EngineVersion } from "@main/content/engine/engine-version"; -import { engineContentAPI } from "@main/content/engine/engine-content"; +import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; import { ipcMain } from "@main/typed-ipc"; -async function init() { - await engineContentAPI.init(); -} - -async function reinit() { - await engineContentAPI.reinit(); -} - function registerIpcHandlers() { - ipcMain.handle("engine:listAvailableVersions", () => - engineContentAPI.availableVersions - .values() - .toArray() - .sort((a, b) => a.id.localeCompare(b.id)) - ); - ipcMain.handle("engine:downloadEngine", (_, version?: string) => engineContentAPI.downloadEngine(version)); - ipcMain.handle("engine:isVersionInstalled", (_, id: string) => engineContentAPI.isVersionInstalled(id)); - ipcMain.handle("engine:uninstallVersion", (_, version: EngineVersion) => engineContentAPI.uninstallVersion(version)); + ipcMain.handle("engine:listAvailableVersions", () => contentAPI.engineVersions()); + ipcMain.handle("engine:downloadEngine", (_, version?: string) => contentAPI.ensure([{ type: "engine", id: version ?? DEFAULT_ENGINE_VERSION }])); + ipcMain.handle("engine:isVersionInstalled", (_, id: string) => contentAPI.isPresent({ type: "engine", id })); + ipcMain.handle("engine:uninstallVersion", (_, version: EngineVersion) => contentAPI.remove([{ type: "engine", id: version.id }])); } const engineService = { - init, - reinit, registerIpcHandlers, }; diff --git a/src/main/services/game.service.ts b/src/main/services/game.service.ts index f18f19d35..35424a057 100644 --- a/src/main/services/game.service.ts +++ b/src/main/services/game.service.ts @@ -2,29 +2,25 @@ // // SPDX-License-Identifier: MIT -import { gameContentAPI } from "@main/content/game/game-content"; +import { contentAPI } from "@main/content/content-api"; +import { getScenarios } from "@main/content/game/game-scenarios"; import { gameAPI, MultiplayerLaunchSettings } from "@main/game/game"; import { ipcMain, BarIpcWebContents } from "@main/typed-ipc"; -import { Replay } from "@main/content/replays/replay"; +import { Replay } from "@main/replays/replay"; import { BattleWithMetadata } from "@main/game/battle/battle-types"; -import { replayContentAPI } from "@main/content/replays/replay-content"; - -async function init() { - await gameContentAPI.init(); -} - -async function reinit() { - await gameContentAPI.reinit(); -} +import { replaysAPI } from "@main/replays/replays"; function registerIpcHandlers(webContents: BarIpcWebContents) { // Content - ipcMain.handle("game:downloadGame", (_, version: string) => gameContentAPI.downloadGame(version)); - ipcMain.handle("game:getScenarios", (_, version: string) => gameContentAPI.getScenarios(version)); - ipcMain.handle("game:getInstalledVersions", () => gameContentAPI.availableVersions.values().toArray()); - ipcMain.handle("game:isVersionInstalled", (_, id: string) => gameContentAPI.isVersionInstalled(id)); - ipcMain.handle("game:uninstallVersion", (_, version: string) => gameContentAPI.uninstallVersionById(version)); - ipcMain.handle("game:preloadPoolData", () => gameContentAPI.preloadPoolData()); + ipcMain.handle("game:downloadGame", (_, version: string) => contentAPI.ensure([{ type: "game", id: version }])); + ipcMain.handle("game:getScenarios", (_, version: string) => { + const installed = contentAPI.gameVersion(version); + + return installed ? getScenarios(installed.packageMd5) : []; + }); + ipcMain.handle("game:getInstalledVersions", () => contentAPI.gameVersions()); + ipcMain.handle("game:isVersionInstalled", (_, id: string) => contentAPI.isPresent({ type: "game", id })); + ipcMain.handle("game:uninstallVersion", (_, version: string) => contentAPI.remove([{ type: "game", id: version }])); // Game ipcMain.handle("game:launchMultiplayer", (_, settings: MultiplayerLaunchSettings) => gameAPI.launchMultiplayer(settings)); @@ -40,13 +36,11 @@ function registerIpcHandlers(webContents: BarIpcWebContents) { }); gameAPI.onGameClosed.add(() => { webContents.send("game:closed"); - replayContentAPI.cacheReplaysInQueue(); + replaysAPI.cacheReplaysInQueue(); }); } const gameService = { - init, - reinit, registerIpcHandlers, }; diff --git a/src/main/services/maps.service.ts b/src/main/services/maps.service.ts index a1d847e48..223a54e33 100644 --- a/src/main/services/maps.service.ts +++ b/src/main/services/maps.service.ts @@ -3,18 +3,11 @@ // SPDX-License-Identifier: MIT import { MapData, MapDownloadData } from "@main/content/maps/map-data"; -import { mapContentAPI } from "@main/content/maps/map-content"; +import { contentAPI } from "@main/content/content-api"; import { ipcMain, BarIpcWebContents } from "@main/typed-ipc"; import { MapMetadata } from "@main/content/maps/map-metadata"; import { fetchMapImages } from "@main/content/maps/map-image"; -async function init() { - await mapContentAPI.init(); -} - -async function reinit() { - await mapContentAPI.reinit(); -} const FETCH_MAPS_TIMEOUT_MS = 15_000; async function fetchAllMaps(): Promise<[MapData[], MapDownloadData[]]> { @@ -28,52 +21,46 @@ async function fetchAllMaps(): Promise<[MapData[], MapDownloadData[]]> { // transform the map object to a MapData object return { ...map, - isInstalled: mapContentAPI.isVersionInstalled(map.springName), + isInstalled: contentAPI.isPresent({ type: "map", id: map.springName }), } satisfies MapData; }); const liveMapsSet = new Set(liveMaps.map((m) => m.springName)); - const nonLiveMaps = Object.entries(mapContentAPI.mapNameFileNameLookup) - .map(([springName]) => { - if (liveMapsSet.has(springName)) { - return; - } - + const nonLiveMaps = contentAPI + .installed("map") + .filter((ref) => !liveMapsSet.has(ref.id)) + .map((ref) => { return { - springName: springName, + springName: ref.id, isDownloading: false, - isInstalled: mapContentAPI.isVersionInstalled(springName), + isInstalled: true, } satisfies MapDownloadData; - }) - .filter((v) => v != undefined); + }); return [liveMaps, nonLiveMaps]; } function registerIpcHandlers(webContents: BarIpcWebContents) { - ipcMain.handle("maps:downloadMap", (_, springName: string) => mapContentAPI.downloadMap(springName)); - ipcMain.handle("maps:downloadMaps", (_, springNames: string[]) => mapContentAPI.downloadMaps(springNames)); - ipcMain.handle("maps:getInstalledMapNames", () => Object.keys(mapContentAPI.mapNameFileNameLookup)); - ipcMain.handle("maps:getInstalledVersions", () => mapContentAPI.availableVersions); - ipcMain.handle("maps:isVersionInstalled", (_, id: string) => mapContentAPI.isVersionInstalled(id)); - ipcMain.handle("maps:attemptCacheErrorMaps", () => mapContentAPI.attemptCacheErrorMaps()); + ipcMain.handle("maps:downloadMap", (_, springName: string) => contentAPI.ensure([{ type: "map", id: springName }])); + ipcMain.handle("maps:downloadMaps", (_, springNames: string[]) => contentAPI.ensure(springNames.map((springName) => ({ type: "map" as const, id: springName })))); + ipcMain.handle("maps:getInstalledMapNames", () => contentAPI.installed("map").map((ref) => ref.id)); + ipcMain.handle("maps:isVersionInstalled", (_, id: string) => contentAPI.isPresent({ type: "map", id })); ipcMain.handle("maps:online:fetchAllMaps", () => fetchAllMaps()); ipcMain.handle("maps:online:fetchMapImages", (_, imageSource: string) => fetchMapImages(imageSource)); // Events - mapContentAPI.onMapAdded.add((filename: string) => { - webContents.send("maps:mapAdded", filename); - }); - mapContentAPI.onMapDeleted.add((filename: string) => { - webContents.send("maps:mapDeleted", filename); + contentAPI.onPresenceChanged.add(({ type, id, present }) => { + if (type !== "map") { + return; + } + + webContents.send(present ? "maps:mapAdded" : "maps:mapDeleted", id); }); } const mapsService = { - init, - reinit, registerIpcHandlers, }; diff --git a/src/main/services/navigation.service.ts b/src/main/services/navigation.service.ts index 3a0d43334..dd200e793 100644 --- a/src/main/services/navigation.service.ts +++ b/src/main/services/navigation.service.ts @@ -4,7 +4,7 @@ import { app, BrowserWindow } from "electron"; import { BarIpcWebContents } from "@main/typed-ipc"; import { logger } from "@main/utils/logger"; -import { replayContentAPI } from "@main/content/replays/replay-content"; +import { replaysAPI } from "@main/replays/replays"; import { typedWebContents } from "@main/typed-ipc"; import { isReplayFile } from "@main/config/replay-extensions"; @@ -54,7 +54,7 @@ function openReplays(replayFiles: string[], webContents: BarIpcWebContents) { for (const filePath of replayFiles) { const fileName = path.basename(filePath); replayFileNames.push(fileName); - replayContentAPI.copyParseReplay(filePath).catch((err) => { + replaysAPI.copyParseReplay(filePath).catch((err) => { log.error(`Failed to copy and parse replay file ${filePath}:`, err); webContents.send("notifications:showAlert", { text: `Failed to open replay file: ${fileName}`, diff --git a/src/main/services/paths.service.ts b/src/main/services/paths.service.ts index aa5f398d6..4c17cf0d8 100644 --- a/src/main/services/paths.service.ts +++ b/src/main/services/paths.service.ts @@ -5,9 +5,7 @@ import { dialog } from "electron"; import { setAssetsPath, getAssetsPath, STATE_PATH } from "@main/config/app"; import { settingsService } from "./settings.service"; -import engineService from "./engine.service"; -import mapsService from "./maps.service"; -import gameService from "./game.service"; +import { contentAPI } from "@main/content/content-api"; import { ipcMain, BarIpcWebContents } from "@main/typed-ipc"; import fs from "fs"; import path from "path"; @@ -85,9 +83,7 @@ async function applyNewPath(newAssetsPath: string) { } await settingsService.updateSettings({ assetsPath: newAssetsPath }); setAssetsPath(newAssetsPath); - await engineService.reinit(); - await mapsService.reinit(); - await gameService.reinit(); + await contentAPI.reinit(); } function registerIpcHandlers(webContents: BarIpcWebContents) { diff --git a/src/main/services/replays.service.ts b/src/main/services/replays.service.ts index f515d9ae4..63db2e61c 100644 --- a/src/main/services/replays.service.ts +++ b/src/main/services/replays.service.ts @@ -2,29 +2,29 @@ // // SPDX-License-Identifier: MIT -import { Replay } from "@main/content/replays/replay"; -import { replayContentAPI } from "@main/content/replays/replay-content"; -import { getOnlineReplay, searchOnlineReplaysByPlayer } from "@main/content/replays/online-replays"; +import { Replay } from "@main/replays/replay"; +import { replaysAPI } from "@main/replays/replays"; +import { getOnlineReplay, searchOnlineReplaysByPlayer } from "@main/replays/online-replays"; import { ipcMain, BarIpcWebContents } from "@main/typed-ipc"; async function init() { - await replayContentAPI.init(); + await replaysAPI.init(); } function registerIpcHandlers(webContents: BarIpcWebContents) { - ipcMain.handle("replays:sync", (_, replays: string[]) => replayContentAPI.sync(replays)); - ipcMain.handle("replays:delete", (_, fileName: string) => replayContentAPI.deleteReplay(fileName)); + ipcMain.handle("replays:sync", (_, replays: string[]) => replaysAPI.sync(replays)); + ipcMain.handle("replays:delete", (_, fileName: string) => replaysAPI.deleteReplay(fileName)); ipcMain.handle("replays:searchOnlineByPlayer", (_, username: string, limit: number) => searchOnlineReplaysByPlayer(username, limit)); ipcMain.handle("replays:getOnline", (_, replayId: string) => getOnlineReplay(replayId)); // Events - replayContentAPI.onReplayCachingStarted.add((filename: string) => { + replaysAPI.onReplayCachingStarted.add((filename: string) => { webContents.send("replays:replayCachingStarted", filename); }); - replayContentAPI.onReplayCached.add((replay: Replay) => { + replaysAPI.onReplayCached.add((replay: Replay) => { webContents.send("replays:replayCached", replay); }); - replayContentAPI.onReplayDeleted.add((filename: string) => { + replaysAPI.onReplayDeleted.add((filename: string) => { webContents.send("replays:replayDeleted", filename); }); } diff --git a/src/main/tachyon/tachyon.handlers.ts b/src/main/tachyon/tachyon.handlers.ts index a29a50240..2b531c341 100644 --- a/src/main/tachyon/tachyon.handlers.ts +++ b/src/main/tachyon/tachyon.handlers.ts @@ -7,9 +7,7 @@ import { logger } from "@main/utils/logger"; import { TachyonClientRequestHandlers } from "./tachyon-client"; import { GetCommandData, GetCommandIds, GetCommands } from "tachyon-protocol"; import { BattleStartRequestData, MatchmakingCheckAssetsRequestData } from "tachyon-protocol/types"; -import { gameContentAPI } from "@main/content/game/game-content"; -import { mapContentAPI } from "@main/content/maps/map-content"; -import { engineContentAPI } from "@main/content/engine/engine-content"; +import { contentAPI } from "@main/content/content-api"; const log = logger("tachyon-handlers"); @@ -43,9 +41,12 @@ function createBattleHandlers(webContents: BarIpcWebContents) { createTypedTachyonRequestHandler<"battle/start">()(async (data: BattleStartRequestData) => { // data carries the join password, so it is summarised rather than dumped. log.info(`Received battle start request for ${data.ip}:${data.port}`); - const itemsRequired = - !gameContentAPI.isVersionInstalled(data.game.springName) || !mapContentAPI.isVersionInstalled(data.map.springName) || !engineContentAPI.isVersionInstalled(data.engine.version); - if (itemsRequired) { + const missing = contentAPI.missing([ + { type: "game", id: data.game.springName }, + { type: "map", id: data.map.springName }, + { type: "engine", id: data.engine.version }, + ]); + if (missing.length > 0) { webContents.send("notifications:showAlert", { text: `Unable to join match, required assets are missing.`, severity: "error", @@ -70,11 +71,12 @@ function createMatchmakingHandlers(webContents: BarIpcWebContents) { "matchmaking/checkAssets", createTypedTachyonRequestHandler<"matchmaking/checkAssets">()(async (data: MatchmakingCheckAssetsRequestData) => { log.info(`Received matchmaking check assets request: ${JSON.stringify(data)}`); - const itemsRequired = - !gameContentAPI.isVersionInstalled(data.game) || - data.maps.some((map) => !mapContentAPI.isVersionInstalled(map)) || - data.engines.some((engine) => !engineContentAPI.isVersionInstalled(engine)); - if (itemsRequired) { + const missing = contentAPI.missing([ + { type: "game", id: data.game }, + ...data.maps.map((id) => ({ type: "map" as const, id })), + ...data.engines.map((id) => ({ type: "engine" as const, id })), + ]); + if (missing.length > 0) { // TODO: This should be through i18n for localization webContents.send("notifications:showAlert", { text: `Party queue rejected, missing assets required for queue ${data.queueId}.`, @@ -86,7 +88,7 @@ function createMatchmakingHandlers(webContents: BarIpcWebContents) { // Technically we can return "downloading" also, but for now we just return "missing" or "complete" status: "success", data: { - assetStatus: itemsRequired ? "missing" : "complete", + assetStatus: missing.length > 0 ? "missing" : "complete", }, } satisfies ResponseBody<"matchmaking/checkAssets">; }) diff --git a/src/main/typed-ipc.ts b/src/main/typed-ipc.ts index 556920bf1..cae3e2111 100644 --- a/src/main/typed-ipc.ts +++ b/src/main/typed-ipc.ts @@ -6,6 +6,8 @@ import type { AuthState } from "@main/services/auth.service"; import type { StoredIdentity } from "@main/model/user"; import type { BattleWithMetadata } from "@main/game/battle/battle-types"; import type { BattleStartRequestData } from "tachyon-protocol/types"; +import type { ContentRef } from "@main/content/content-ref"; +import type { ContentPresence, ContentState } from "@main/content/content-state"; import type { DownloadInfo } from "@main/content/downloads"; import type { EngineVersion } from "@main/content/engine/engine-version"; import type { GameVersion } from "@main/content/game/game-version"; @@ -15,8 +17,8 @@ import type { logLevels } from "@main/services/log.service"; import type { MapData, MapDownloadData } from "@main/content/maps/map-data"; import type { MultiplayerLaunchSettings } from "@main/game/game"; import type { NewsFeedData } from "@main/services/news.service"; -import type { OnlineReplayDetails, OnlineReplayOverview } from "@main/content/replays/online-replays"; -import type { Replay } from "@main/content/replays/replay"; +import type { OnlineReplayDetails, OnlineReplayOverview } from "@main/replays/online-replays"; +import type { Replay } from "@main/replays/replay"; import type { Scenario } from "@main/content/game/scenario"; import type { Settings } from "@main/services/settings.service"; import type { TachyonEvent, TachyonResponse } from "tachyon-protocol"; @@ -29,21 +31,10 @@ import { ipcRenderer as electronIpcRenderer, ipcMain as electronIpcMain } from " export type IpcResult = { status: "success"; data: T } | { status: "failed"; reason: string; details?: string }; export type IPCEvents = { - "downloads:update:progress": (downloadInfo: DownloadInfo) => void; - "downloads:engine:complete": (downloadInfo: DownloadInfo) => void; - "downloads:engine:fail": (downloadInfo: DownloadInfo) => void; - "downloads:engine:progress": (downloadInfo: DownloadInfo) => void; - "downloads:engine:start": (downloadInfo: DownloadInfo) => void; - "downloads:game:complete": (downloadInfo: DownloadInfo) => void; - "downloads:game:fail": (downloadInfo: DownloadInfo) => void; - "downloads:game:progress": (downloadInfo: DownloadInfo) => void; - "downloads:game:retry": (downloadInfo: DownloadInfo) => void; - "downloads:game:start": (downloadInfo: DownloadInfo) => void; - "downloads:map:complete": (downloadInfo: DownloadInfo) => void; - "downloads:map:fail": (downloadInfo: DownloadInfo) => void; - "downloads:map:progress": (downloadInfo: DownloadInfo) => void; - "downloads:map:retry": (downloadInfo: DownloadInfo) => void; - "downloads:map:start": (downloadInfo: DownloadInfo) => void; + "content:changed": (state: ContentState[]) => void; + "content:poolPrefetch": (downloadInfo: DownloadInfo | null) => void; + "content:settled": (refs: ContentPresence[]) => void; + "downloads:update:progress": (downloadInfo: DownloadInfo | null) => void; "game:closed": () => void; "game:launched": () => void; "maps:mapAdded": (filename: string) => void; @@ -72,6 +63,11 @@ export type IPCCommands = { "autoUpdater:downloadUpdate": () => void; "autoUpdater:installUpdates": () => void; "autoUpdater:quitAndInstall": () => void; + "content:ensure": (refs: ContentRef[]) => void; + "content:missing": (refs: ContentRef[]) => ContentRef[]; + "content:preloadPool": () => void; + "content:remove": (refs: ContentRef[]) => void; + "content:state": () => ContentState[]; "engine:downloadEngine": (version?: string) => string | void; "engine:isVersionInstalled": (id: string) => boolean; "engine:listAvailableVersions": () => EngineVersion[]; @@ -84,7 +80,6 @@ export type IPCCommands = { "game:launchMultiplayer": (settings: MultiplayerLaunchSettings) => void; "game:launchReplay": (replay: Replay) => Promise; "game:launchScript": (script: string, gameVersion: string, engineVersion: string) => void; - "game:preloadPoolData": () => void; "game:uninstallVersion": (version: string) => void; "info:get": () => Info; "log:log": (fileName: string, level: logLevels, msg: string) => void; @@ -97,11 +92,9 @@ export type IPCCommands = { "mainWindow:setSize": (size: number) => void; "mainWindow:minimize": () => void; "mainWindow:isFullscreen": () => boolean; - "maps:attemptCacheErrorMaps": () => void; "maps:downloadMap": (springName: string) => void; - "maps:downloadMaps": (springNames: string[]) => void[]; + "maps:downloadMaps": (springNames: string[]) => void; "maps:getInstalledMapNames": () => string[]; - "maps:getInstalledVersions": () => Map; "maps:isVersionInstalled": (springName: string) => boolean; "maps:online:fetchAllMaps": () => [MapData[], MapDownloadData[]]; "maps:online:fetchMapImages": (imageSource: string) => ArrayBuffer; diff --git a/src/main/utils/checksums.ts b/src/main/utils/checksums.ts index ae007885a..0e4a09b6c 100644 --- a/src/main/utils/checksums.ts +++ b/src/main/utils/checksums.ts @@ -9,11 +9,29 @@ import { getEnginePath, WRITE_DATA_PATH, getAssetsPath } from "@main/config/app" const log = logger("checksums.ts"); -let checksumQueue: Promise = Promise.resolve(); +let checksumQueue: Promise = Promise.resolve(); + +// A rejection must not poison the chain, or one failed removal would stop every later checksum. +function enqueue(task: () => Promise): Promise { + const result = checksumQueue.then(task); + checksumQueue = result.catch(() => undefined); + + return result; +} export function calcChecksum(engineVersion: string, archiveName: string): Promise { - checksumQueue = checksumQueue.then(() => runChecksumProcess(engineVersion, archiveName)); - return checksumQueue; + return enqueue(() => runChecksumProcess(engineVersion, archiveName)); +} + +/** + * Runs work with the checksum queue held, so nothing is checksumming while it does. + * + * The engine holds an archive open while checksumming it, so deleting that archive fails outright on + * Windows. Waiting for the queue to drain is not enough: anything may queue a checksum the moment the + * wait resolves, so removal takes its turn in the queue instead of watching it. + */ +export function holdChecksums(work: () => Promise): Promise { + return enqueue(work); } function runChecksumProcess(engineVersion: string, archiveName: string): Promise { diff --git a/src/main/utils/disk-space.ts b/src/main/utils/disk-space.ts new file mode 100644 index 000000000..65856f926 --- /dev/null +++ b/src/main/utils/disk-space.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import fs from "fs"; + +// bavail rather than bfree: the reserved blocks bfree counts are not ours to write into. +export async function freeBytes(directory: string) { + const stats = await fs.promises.statfs(directory); + + return stats.bavail * stats.bsize; +} + +export function formatBytes(bytes: number) { + const units = ["B", "KB", "MB", "GB", "TB"]; + let value = bytes; + let unit = 0; + + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + + return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`; +} diff --git a/src/preload/interface.d.ts b/src/preload/interface.d.ts index 92d577930..a73459dbe 100644 --- a/src/preload/interface.d.ts +++ b/src/preload/interface.d.ts @@ -6,7 +6,7 @@ import type { AuthApi, AutoUpdaterApi, BarNavigationApi, - DownloadsApi, + ContentApi, EngineApi, GameApi, InfoApi, @@ -30,10 +30,10 @@ declare global { replays: ReplaysApi; auth: AuthApi; settings: SettingsApi; + content: ContentApi; engine: EngineApi; game: GameApi; maps: MapsApi; - downloads: DownloadsApi; misc: MiscApi; tachyon: TachyonApi; log: LogApi; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 1298a9b08..5473224d7 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -4,8 +4,10 @@ import { contextBridge } from "electron"; import { ipcRenderer, IpcResult } from "@main/typed-ipc"; -import { Replay } from "@main/content/replays/replay"; -import { OnlineReplayDetails, OnlineReplayOverview } from "@main/content/replays/online-replays"; +import { ContentRef } from "@main/content/content-ref"; +import { ContentPresence, ContentState } from "@main/content/content-state"; +import { Replay } from "@main/replays/replay"; +import { OnlineReplayDetails, OnlineReplayOverview } from "@main/replays/online-replays"; import { Settings } from "@main/services/settings.service"; import { EngineVersion } from "@main/content/engine/engine-version"; import { GameVersion } from "@main/content/game/game-version"; @@ -95,6 +97,21 @@ const authApi = { export type AuthApi = typeof authApi; contextBridge.exposeInMainWorld("auth", authApi); +const contentApi = { + missing: (refs: ContentRef[]): Promise => ipcRenderer.invoke("content:missing", refs), + state: (): Promise => ipcRenderer.invoke("content:state"), + ensure: (refs: ContentRef[]): Promise => ipcRenderer.invoke("content:ensure", refs), + remove: (refs: ContentRef[]): Promise => ipcRenderer.invoke("content:remove", refs), + + onChanged: (callback: (state: ContentState[]) => void) => ipcRenderer.on("content:changed", (_event, state) => callback(state)), + onSettled: (callback: (refs: ContentPresence[]) => void) => ipcRenderer.on("content:settled", (_event, refs) => callback(refs)), + + preloadPool: (): Promise => ipcRenderer.invoke("content:preloadPool"), + onPoolPrefetch: (callback: (downloadInfo: DownloadInfo | null) => void) => ipcRenderer.on("content:poolPrefetch", (_event, downloadInfo) => callback(downloadInfo)), +}; +export type ContentApi = typeof contentApi; +contextBridge.exposeInMainWorld("content", contentApi); + const engineApi = { listAvailableVersions: (): Promise => ipcRenderer.invoke("engine:listAvailableVersions"), downloadEngine: (version?: string): Promise => ipcRenderer.invoke("engine:downloadEngine", version), @@ -111,7 +128,6 @@ const gameApi = { getInstalledVersions: (): Promise => ipcRenderer.invoke("game:getInstalledVersions"), isVersionInstalled: (version: string): Promise => ipcRenderer.invoke("game:isVersionInstalled", version), uninstallVersion: (version: string): Promise => ipcRenderer.invoke("game:uninstallVersion", version), - preloadPoolData: (): Promise => ipcRenderer.invoke("game:preloadPoolData"), // Game launchMultiplayer: (settings: MultiplayerLaunchSettings): Promise => ipcRenderer.invoke("game:launchMultiplayer", settings), @@ -129,9 +145,8 @@ contextBridge.exposeInMainWorld("game", gameApi); const mapsApi = { // Content downloadMap: (springName: string): Promise => ipcRenderer.invoke("maps:downloadMap", springName), - downloadMaps: (springNames: string[]): Promise => ipcRenderer.invoke("maps:downloadMaps", springNames), + downloadMaps: (springNames: string[]): Promise => ipcRenderer.invoke("maps:downloadMaps", springNames), getInstalledMapNames: (): Promise => ipcRenderer.invoke("maps:getInstalledMapNames"), - getInstalledVersions: (): Promise> => ipcRenderer.invoke("maps:getInstalledVersions"), isVersionInstalled: (springName: string): Promise => ipcRenderer.invoke("maps:isVersionInstalled", springName), // Online features @@ -145,29 +160,6 @@ const mapsApi = { export type MapsApi = typeof mapsApi; contextBridge.exposeInMainWorld("maps", mapsApi); -const downloadsApi = { - // Events - // Engine - onDownloadEngineStart: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:engine:start", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadEngineComplete: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:engine:complete", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadEngineProgress: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:engine:progress", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadEngineFail: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:engine:fail", (_event, downloadInfo) => callback(downloadInfo)), - // Game - onDownloadGameStart: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:game:start", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadGameComplete: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:game:complete", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadGameProgress: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:game:progress", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadGameFail: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:game:fail", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadGameRetry: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:game:retry", (_event, downloadInfo) => callback(downloadInfo)), - // Maps - onDownloadMapStart: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:map:start", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadMapComplete: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:map:complete", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadMapProgress: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:map:progress", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadMapFail: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:map:fail", (_event, downloadInfo) => callback(downloadInfo)), - onDownloadMapRetry: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:map:retry", (_event, downloadInfo) => callback(downloadInfo)), -}; -export type DownloadsApi = typeof downloadsApi; -contextBridge.exposeInMainWorld("downloads", downloadsApi); - const miscApi = { getNewsRssFeed: (numberOfNews) => ipcRenderer.invoke("misc:getNewsRssFeed", numberOfNews), getDevlogRssFeed: (numberOfNews) => ipcRenderer.invoke("misc:getDevlogRssFeed", numberOfNews), @@ -260,7 +252,8 @@ const autoUpdaterApi = { installUpdates: (): Promise => ipcRenderer.invoke("autoUpdater:installUpdates"), // Events - onDownloadUpdateProgress: (callback: (downloadInfo: DownloadInfo) => void) => ipcRenderer.on("downloads:update:progress", (_event, downloadInfo: DownloadInfo) => callback(downloadInfo)), + onDownloadUpdateProgress: (callback: (downloadInfo: DownloadInfo | null) => void) => + ipcRenderer.on("downloads:update:progress", (_event, downloadInfo: DownloadInfo | null) => callback(downloadInfo)), }; export type AutoUpdaterApi = typeof autoUpdaterApi; contextBridge.exposeInMainWorld("autoUpdater", autoUpdaterApi); diff --git a/src/renderer/assets/languages/cs.json b/src/renderer/assets/languages/cs.json index 393368701..55228ad8e 100644 --- a/src/renderer/assets/languages/cs.json +++ b/src/renderer/assets/languages/cs.json @@ -2235,7 +2235,6 @@ }, "downloads": { "noDownloads": null, - "moreDownloads": null, "starting": null, "extracting": null, "progressMB": null, @@ -2244,7 +2243,8 @@ "speedBps": null, "etaHoursMinutes": null, "etaMinutesSeconds": null, - "etaSeconds": null + "etaSeconds": null, + "queued": null }, "serverStatus": { "playersOnline": null, @@ -2425,7 +2425,8 @@ "playOffline": null, "stepFailed": null, "stepRetrying": null, - "contentRequired": null + "contentRequired": null, + "pathUnusable": null } }, "battle": { diff --git a/src/renderer/assets/languages/de.json b/src/renderer/assets/languages/de.json index 5215224a1..b92d6d637 100644 --- a/src/renderer/assets/languages/de.json +++ b/src/renderer/assets/languages/de.json @@ -2106,7 +2106,6 @@ }, "downloads": { "noDownloads": null, - "moreDownloads": null, "starting": null, "extracting": null, "progressMB": null, @@ -2115,7 +2114,8 @@ "speedBps": null, "etaHoursMinutes": null, "etaMinutesSeconds": null, - "etaSeconds": null + "etaSeconds": null, + "queued": null }, "serverStatus": { "playersOnline": null, @@ -2296,7 +2296,8 @@ "playOffline": null, "stepFailed": null, "stepRetrying": null, - "contentRequired": null + "contentRequired": null, + "pathUnusable": null } }, "battle": { diff --git a/src/renderer/assets/languages/dev.json b/src/renderer/assets/languages/dev.json index a04ef68da..ed8affad4 100644 --- a/src/renderer/assets/languages/dev.json +++ b/src/renderer/assets/languages/dev.json @@ -252,7 +252,6 @@ }, "downloads": { "noDownloads": "No dlodnwoas avctie", - "moreDownloads": "and {count} more...", "starting": "Sattnrig...", "extracting": "Erixtanctg...", "progressMB": "{current} / {total} MB ({percent}%)", @@ -261,7 +260,8 @@ "speedBps": "{speed} B/s", "etaHoursMinutes": "{hours}h {minutes}m left", "etaMinutesSeconds": "{minutes}m {seconds}s left", - "etaSeconds": "{seconds}s left" + "etaSeconds": "{seconds}s left", + "queued": "Quueed" }, "serverStatus": { "playersOnline": "Plyreas Oinnle", @@ -442,7 +442,8 @@ "playOffline": "Paly Oniflfe", "stepFailed": "{step} — Failed", "stepRetrying": "{step} — Rtyneirg…", - "contentRequired": "Egnnie and gmae must be dodolnwaed borefe stnartig a btalte." + "contentRequired": "Egnnie and gmae must be dodolnwaed borefe stnartig a btalte.", + "pathUnusable": "That fdelor caonnt be used for gmae fleis. Pick a dnieferft one." } }, "battle": { diff --git a/src/renderer/assets/languages/en.json b/src/renderer/assets/languages/en.json index 1f347870a..e7ce23d7d 100644 --- a/src/renderer/assets/languages/en.json +++ b/src/renderer/assets/languages/en.json @@ -2083,7 +2083,6 @@ }, "downloads": { "noDownloads": "No downloads active", - "moreDownloads": "and {count} more...", "starting": "Starting...", "extracting": "Extracting...", "progressMB": "{current} / {total} MB ({percent}%)", @@ -2092,7 +2091,8 @@ "speedBps": "{speed} B/s", "etaHoursMinutes": "{hours}h {minutes}m left", "etaMinutesSeconds": "{minutes}m {seconds}s left", - "etaSeconds": "{seconds}s left" + "etaSeconds": "{seconds}s left", + "queued": "Queued" }, "serverStatus": { "playersOnline": "Players Online", @@ -2273,7 +2273,8 @@ "playOffline": "Play Offline", "stepFailed": "{step} — Failed", "stepRetrying": "{step} — Retrying…", - "contentRequired": "Engine and game must be downloaded before starting a battle." + "contentRequired": "Engine and game must be downloaded before starting a battle.", + "pathUnusable": "That folder cannot be used for game files. Pick a different one." } }, "battle": { diff --git a/src/renderer/assets/languages/fr.json b/src/renderer/assets/languages/fr.json index 0d6b7697d..1ee274bca 100644 --- a/src/renderer/assets/languages/fr.json +++ b/src/renderer/assets/languages/fr.json @@ -4088,7 +4088,6 @@ }, "downloads": { "noDownloads": null, - "moreDownloads": null, "starting": null, "extracting": null, "progressMB": null, @@ -4097,7 +4096,8 @@ "speedBps": null, "etaHoursMinutes": null, "etaMinutesSeconds": null, - "etaSeconds": null + "etaSeconds": null, + "queued": null }, "serverStatus": { "playersOnline": null, @@ -4278,7 +4278,8 @@ "playOffline": null, "stepFailed": null, "stepRetrying": null, - "contentRequired": null + "contentRequired": null, + "pathUnusable": null } }, "battle": { diff --git a/src/renderer/assets/languages/ru.json b/src/renderer/assets/languages/ru.json index b2c891ef6..28d466776 100644 --- a/src/renderer/assets/languages/ru.json +++ b/src/renderer/assets/languages/ru.json @@ -4057,7 +4057,6 @@ }, "downloads": { "noDownloads": null, - "moreDownloads": null, "starting": null, "extracting": null, "progressMB": null, @@ -4066,7 +4065,8 @@ "speedBps": null, "etaHoursMinutes": null, "etaMinutesSeconds": null, - "etaSeconds": null + "etaSeconds": null, + "queued": null }, "serverStatus": { "playersOnline": null, @@ -4247,7 +4247,8 @@ "playOffline": null, "stepFailed": null, "stepRetrying": null, - "contentRequired": null + "contentRequired": null, + "pathUnusable": null } }, "battle": { diff --git a/src/renderer/assets/languages/zh.json b/src/renderer/assets/languages/zh.json index 80e3b3a3c..58fc92d55 100644 --- a/src/renderer/assets/languages/zh.json +++ b/src/renderer/assets/languages/zh.json @@ -4199,7 +4199,6 @@ }, "downloads": { "noDownloads": null, - "moreDownloads": null, "starting": null, "extracting": null, "progressMB": null, @@ -4208,7 +4207,8 @@ "speedBps": null, "etaHoursMinutes": null, "etaMinutesSeconds": null, - "etaSeconds": null + "etaSeconds": null, + "queued": null }, "serverStatus": { "playersOnline": null, @@ -4389,7 +4389,8 @@ "playOffline": null, "stepFailed": null, "stepRetrying": null, - "contentRequired": null + "contentRequired": null, + "pathUnusable": null } }, "battle": { diff --git a/src/renderer/components/battle/BattlePreview.vue b/src/renderer/components/battle/BattlePreview.vue index 59e44a0d1..592d02b30 100644 --- a/src/renderer/components/battle/BattlePreview.vue +++ b/src/renderer/components/battle/BattlePreview.vue @@ -49,7 +49,7 @@ import { useTypedI18n } from "@renderer/i18n"; import BattlePreviewParticipant from "@renderer/components/battle/BattlePreviewParticipant.vue"; import MapOverviewCard from "@renderer/components/maps/MapOverviewCard.vue"; -import { OngoingBattle } from "@main/content/replays/replay"; +import { OngoingBattle } from "@main/replays/replay"; import { useDexieLiveQueryWithDeps } from "@renderer/composables/useDexieLiveQuery"; import { db } from "@renderer/store/db"; diff --git a/src/renderer/components/battle/OfflineBattleComponent.vue b/src/renderer/components/battle/OfflineBattleComponent.vue index 30f944cd7..ef2334c65 100644 --- a/src/renderer/components/battle/OfflineBattleComponent.vue +++ b/src/renderer/components/battle/OfflineBattleComponent.vue @@ -67,7 +67,7 @@ SPDX-License-Identifier: MIT