diff --git a/.github/workflows/config-serving-deploy.yml b/.github/workflows/config-serving-deploy.yml index f51959f6d..59df1a9ab 100644 --- a/.github/workflows/config-serving-deploy.yml +++ b/.github/workflows/config-serving-deploy.yml @@ -16,21 +16,16 @@ jobs: deploy-config: if: github.repository == 'beyond-all-reason/bar-lobby' runs-on: ubuntu-latest + env: + CONFIG_URL: https://lobby-config.beyondallreason.dev/config.json steps: + - uses: actions/checkout@v4 + - name: Push config - env: - CONFIG_URL: https://lobby-config.beyondallreason.dev/config.json run: | - # TODO: Just an empty config for now, to show that workflow works, - # replace with real path from validated config.json. - cat > config.json < { const archStr = process.platform === "win32" ? "engine_windows64" : "engine_linux64"; - const url = new URL("https://files-cdn.beyondallreason.dev/find"); + const url = new URL(configService.getConfig().engineReleaseUrl); url.searchParams.set("category", archStr); url.searchParams.set("springname", engineVersion); return url; diff --git a/src/main/config/default-maps.ts b/src/main/config/default-maps.ts deleted file mode 100644 index f42844c02..000000000 --- a/src/main/config/default-maps.ts +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors -// -// SPDX-License-Identifier: MIT - -/** - * The maps in this list will be downloaded sequentially in the background and in this order - */ -export const defaultMaps = ["Quicksilver Remake 1.24", "Red Comet Remake 1.8"]; diff --git a/src/main/config/default-versions.ts b/src/main/config/default-versions.ts deleted file mode 100644 index d4c0c0151..000000000 --- a/src/main/config/default-versions.ts +++ /dev/null @@ -1,17 +0,0 @@ -// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors -// -// SPDX-License-Identifier: MIT - -/** - * These hardcoded versions are only needed for singleplayer/offline games. Online battles simply fetch and use whichever version that autohosts are running. - * - * We need to define and change these manually because the latest engine and game version may be incompatible with each other, or could be untested and containing bugs. - * - * In the future these values should probably be set and fetched from the master server, so we don't need to deploy a new lobby release every time. - * - * Edit 5/20/2026: Bumped default engine version because it was not compatible with latest game versions, as was foretold. - Hectate - */ - -export const LATEST = "LATEST"; -export const LATEST_GAME_VERSION = "byar:test"; -export const DEFAULT_ENGINE_VERSION = "2026.07.04"; diff --git a/src/main/content/content-api.ts b/src/main/content/content-api.ts index 0d731f0c3..280eceecf 100644 --- a/src/main/content/content-api.ts +++ b/src/main/content/content-api.ts @@ -16,7 +16,7 @@ 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 { configService } from "@main/services/config.service"; import { getAssetsPath } from "@main/config/app"; import { formatBytes, freeBytes } from "@main/utils/disk-space"; import { logger } from "@main/utils/logger"; @@ -276,7 +276,7 @@ class ContentAPI { 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 }]); + await this.ensure([{ type: "engine", id: configService.getConfig().defaultEngineVersion }]); } const acquisitions = await Promise.allSettled(this.missing(refs).map((ref) => this.track(this.queue.enqueue("acquire", ref), ref))); diff --git a/src/main/content/engine/engine-provider.ts b/src/main/content/engine/engine-provider.ts index 60200e0c9..adb99c567 100644 --- a/src/main/content/engine/engine-provider.ts +++ b/src/main/content/engine/engine-provider.ts @@ -16,7 +16,7 @@ import { extract7z } from "@main/utils/extract-7z"; 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 { configService } from "@main/services/config.service"; import { compareEngineVersions, isCompatibleEngineVersion } from "@main/content/engine/engine-version-order"; import { holdChecksums } from "@main/utils/checksums"; @@ -62,7 +62,7 @@ export class EngineProvider extends AbstractContentAPI { } public getDefaultEngine() { - return this.availableVersions.get(DEFAULT_ENGINE_VERSION); + return this.availableVersions.get(configService.getConfig().defaultEngineVersion); } public getInstalledVersionsNewestFirst() { @@ -78,9 +78,9 @@ export class EngineProvider extends AbstractContentAPI { } protected checkIfDefaultIsNew() { - if (!this.availableVersions.has(DEFAULT_ENGINE_VERSION)) { - this.availableVersions.set(DEFAULT_ENGINE_VERSION, { - id: DEFAULT_ENGINE_VERSION, + if (!this.availableVersions.has(configService.getConfig().defaultEngineVersion)) { + this.availableVersions.set(configService.getConfig().defaultEngineVersion, { + id: configService.getConfig().defaultEngineVersion, ais: [], installed: false, }); @@ -88,7 +88,7 @@ export class EngineProvider extends AbstractContentAPI { } public async downloadEngine(version?: string) { - const engineVersion = version || DEFAULT_ENGINE_VERSION; + const engineVersion = version || configService.getConfig().defaultEngineVersion; try { if (this.isVersionInstalled(engineVersion)) { return; diff --git a/src/main/content/game/game-provider.ts b/src/main/content/game/game-provider.ts index 1c193ce1d..2effba02d 100644 --- a/src/main/content/game/game-provider.ts +++ b/src/main/content/game/game-provider.ts @@ -12,7 +12,7 @@ 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 { configService } from "@main/services/config.service"; import { DownloadInfo } from "@main/content/downloads"; import { getGameFiles } from "@main/content/game/game-files"; import { LuaOptionSection } from "@main/content/game/lua-options"; @@ -56,7 +56,7 @@ export class GameProvider extends PrDownloaderAPI { // 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 versionsGzPath = path.join(getRapidIndexPath(), configService.getConfig().rapidHost, configService.getConfig().rapidGame, "versions.gz"); const versionsGz = await fs.promises.readFile(versionsGzPath); const versions = await promisify(zlib.gunzip)(versionsGz); const versionsStr = versions.toString().trim(); @@ -188,7 +188,7 @@ export class GameProvider extends PrDownloaderAPI { } } - public async downloadGame(gameVersion = `${contentSources.rapid.game}:test`) { + public async downloadGame(gameVersion = `${configService.getConfig().rapidGame}:test`) { return this.downloadGames([gameVersion]); } diff --git a/src/main/content/game/pool-cdn.ts b/src/main/content/game/pool-cdn.ts index a344e3acc..3eee5fa4d 100644 --- a/src/main/content/game/pool-cdn.ts +++ b/src/main/content/game/pool-cdn.ts @@ -13,13 +13,11 @@ import { Downloader } from "@main/content/abstract-content"; import { downloadSlots } from "@main/content/download-slots"; import { removeFromArray } from "$/jaz-ts-utils/object"; import { extract7z } from "@main/utils/extract-7z"; +import { configService } from "@main/services/config.service"; const log = logger("pool-cdn.ts"); export class PoolCdnDownloader extends Downloader { - cdnUrl = "https://pool-init.beyondallreason.dev"; - poolDataUrl = `${this.cdnUrl}/data.7z`; - /** * Download and extract pool data from the pool CDN. * @@ -45,7 +43,7 @@ export class PoolCdnDownloader extends Downloader { this.downloadStarted(downloadInfo); const dlFilePath = path.join(getAssetsPath(), "data.7z"); - const dl = new DownloaderHelper(this.poolDataUrl, getAssetsPath(), { + const dl = new DownloaderHelper(`${configService.getConfig().initialPoolDataUrl}/data.7z`, getAssetsPath(), { fileName: "data.7z", timeout: 10000, retry: { maxRetries: 3, delay: 1000 }, diff --git a/src/main/content/pr-downloader.ts b/src/main/content/pr-downloader.ts index 98cfadda2..575547732 100644 --- a/src/main/content/pr-downloader.ts +++ b/src/main/content/pr-downloader.ts @@ -11,6 +11,7 @@ import { DownloadInfo } from "./downloads"; import { AbstractContentAPI } from "./abstract-content"; import { engineProvider } from "./engine/engine-provider"; import { logger } from "@main/utils/logger"; +import { configService } from "@main/services/config.service"; import { getAssetsPath, getEnginePath, getCaCertPath, getPackagePath } from "@main/config/app"; import { holdChecksums } from "@main/utils/checksums"; @@ -82,9 +83,9 @@ export abstract class PrDownloaderAPI extends AbstractContentAPI { const prdProcess = spawn(`${prBinaryPath}`, ["--filesystem-writepath", getAssetsPath(), ...downloadArgs], { env: { ...process.env, - PRD_RAPID_USE_STREAMER: "false", - PRD_RAPID_REPO_MASTER: "https://repos-cdn.beyondallreason.dev/repos.gz", - PRD_HTTP_SEARCH_URL: "https://files-cdn.beyondallreason.dev/find", + PRD_RAPID_USE_STREAMER: configService.getConfig().prdRapidUseStreamer, + PRD_RAPID_REPO_MASTER: configService.getConfig().prdRapidRepoMaster, + PRD_HTTP_SEARCH_URL: configService.getConfig().prdHttpSearchUrl, ...(caCertPath && !process.env.PRD_SSL_CERT_FILE && { PRD_SSL_CERT_FILE: caCertPath }), }, }); diff --git a/src/main/game/springsettings.ts b/src/main/game/springsettings.ts index ced6d5bf7..cd1674791 100644 --- a/src/main/game/springsettings.ts +++ b/src/main/game/springsettings.ts @@ -7,103 +7,12 @@ import * as os from "os"; import * as path from "path"; import { WRITE_DATA_PATH } from "@main/config/app"; import { logger } from "@main/utils/logger"; +import { configService } from "@main/services/config.service"; const log = logger("main/game/springsettings.ts"); const SPRINGSETTINGS_PATH = path.join(WRITE_DATA_PATH, "springsettings.cfg"); -// Default engine settings for BAR, sourced from: -// https://github.com/beyond-all-reason/BYAR-Chobby/blob/master/dist_cfg/config.json -const BAR_SPRINGSETTINGS_DEFAULTS: Record = { - AdvUnitShading: 1, - AdvMapShading: 1, - AdvSky: 0, - AllowDeferredMapRendering: 1, - AllowDeferredModelRendering: 1, - BuildWarnings: 0, - BumpWaterBlurReflection: 1, - BumpWaterDepthBits: 16, - BumpWaterShoreWaves: 1, - BumpWaterTexSizeReflection: 1024, - CamTimeExponent: 4.0, - CamTimeFactor: 1.0, - CrossAlpha: 0, - CubeTexSpecularExponent: 100, - CubeTexGenerateMipMaps: 1, - CubeTexSizeReflection: 1024, - DisplayDebugPrefixConsole: 0, - DualScreenMiniMapOnLeft: 1, - FPSFOV: 90, - FeatureDrawDistance: 10000, - FeatureFadeDistance: 10000, - ForceDisableShaders: 0, - Fullscreen: 0, - FullscreenEdgeMove: 1, - GrassDetail: 0, - GroundDecals: 2, - GroundDetail: 200, - GroundScarAlphaFade: 1, - HangTimeout: 30, - HardwareCursor: 1, - InitialNetworkTimeout: 0, - LODScale: 1.0, - LODScaleReflection: 1.0, - LODScaleRefraction: 1.0, - LODScaleShadow: 1.0, - LuaGarbageCollectionMemLoadMult: 2.0, - LogFlush: 0, - LuaShaders: 1, - LoadingMT: 0, - MaxParticles: 20000, - MaxSounds: 256, - MaximumTransmissionUnit: 0, - MaxTextureAtlasSizeX: 8192, - MaxTextureAtlasSizeZ: 8192, - MiddleClickScrollSpeed: -0.005, - MinimapOnLeft: 1, - MouseDragScrollThreshold: 0.3, - MoveWarnings: 0, - MSAA: 1, - MSAALevel: 4, - NormalMapping: 1, - ReconnectTimeout: 0, - Roam: 1, - ReflectiveWater: 3, - ScrollWheelSpeed: -20, - Shadows: 1, - ShadowMapSize: 2048, - ShowClock: 0, - ShowFps: 0, - ShowSpeed: 0, - SmoothLines: 1, - TreeRadius: 1000, - UnitIconDist: 160, - UnitLodDist: 999999, - UnitIconsAsUI: 1, - UnitIconScaleUI: 1.05, - UnitIconFadeVanish: 2700, - UnitIconFadeStart: 3000, - UnitIconFadeAmount: 0.1, - UsePBO: 1, - Water: 4, - WindowedEdgeMove: 1, - WindowPosX: 0, - WindowPosY: 0, - snd_general: 100, - snd_volmaster: 30, - snd_volmusic: 30, - snd_airAbsorption: 0.35, - ui_opacity: 0.6, - UseNetMessageSmoothingBuffer: 0, - NetworkLossFactor: 2, - MaxDynamicModelLights: 0, - LinkOutgoingBandwidth: 262144, - LinkIncomingSustainedBandwidth: 1048576, - LinkIncomingPeakBandwidth: 1048576, - LinkIncomingMaxPacketRate: 2048, - DemoFileExtension: "barreplay,sdfz", -}; - function readSettings(filePath: string): Map { const settings = new Map(); let fileContent = ""; @@ -135,7 +44,7 @@ export function applyDefaultSpringsettings(): void { fs.mkdirSync(path.dirname(SPRINGSETTINGS_PATH), { recursive: true }); const settings = readSettings(SPRINGSETTINGS_PATH); let changed = false; - for (const [key, value] of Object.entries(BAR_SPRINGSETTINGS_DEFAULTS)) { + for (const [key, value] of Object.entries(configService.getConfig().springSettings)) { if (!settings.has(key)) { settings.set(key, String(value)); changed = true; diff --git a/src/main/json/model/config.ts b/src/main/json/model/config.ts new file mode 100644 index 000000000..ccb3fc54a --- /dev/null +++ b/src/main/json/model/config.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +// Changes to this config schema's defaults will require a new config version to be deployed. +// CI should use tools/generate-diff-config.ts to create a config diff for this purpose. + +import { Type, Static } from "@sinclair/typebox"; + +export const configSchema = Type.Object({ + // Note, configUrl is required and must match the expected location or else Github Actions will not upload a new one. + configUrl: Type.String({ default: "https://lobby-config.beyondallreason.dev/config.json" }), + rapidHost: Type.String({ default: "repos-cdn.beyondallreason.dev" }), + rapidGame: Type.String({ default: "byar" }), + engineReleaseUrl: Type.String({ default: "https://files-cdn.beyondallreason.dev/find" }), + defaultMaps: Type.Array(Type.String(), { default: ["Quicksilver Remake 1.24", "Red Comet Remake 1.8"] }), + latestGameVersion: Type.String({ default: "byar:test" }), + defaultEngineVersion: Type.String({ default: "2026.07.04" }), + initialPoolDataUrl: Type.String({ default: "https://pool-init.beyondallreason.dev" }), + prdRapidUseStreamer: Type.String({ default: "false" }), + prdRapidRepoMaster: Type.String({ default: "https://repos-cdn.beyondallreason.dev/repos.gz" }), + prdHttpSearchUrl: Type.String({ default: "https://files-cdn.beyondallreason.dev/find" }), + logUploadUrl: Type.String({ default: "https://log.beyondallreason.dev/" }), + mapsMetadataUrl: Type.String({ default: "https://maps-metadata.beyondallreason.dev/latest/lobby_maps.validated.json" }), + newsRssUrl: Type.String({ default: "https://www.beyondallreason.info/news/rss.xml" }), + devlogRssUrl: Type.String({ default: "https://www.beyondallreason.info/microblogs/rss.xml" }), + allowedUrlLinks: Type.Array(Type.String(), { default: ["https://bar-rts.com/replays", "https://www.beyondallreason.info/news"] }), + replayServiceUrl: Type.String({ default: "https://bar-rts.com/replays" }), + onlineReplaysApiUrl: Type.String({ default: "https://api.bar-rts.com/replays" }), + defaultServers: Type.Array(Type.String(), { + default: ["wss://server4.beyondallreason.info", "wss://server5.beyondallreason.info", "wss://lobby-server-dev.beyondallreason.dev", "ws://localhost:4000"], + }), + // Default engine settings for BAR, sourced from: + // https://github.com/beyond-all-reason/BYAR-Chobby/blob/master/dist_cfg/config.json + springSettings: Type.Record(Type.String(), Type.Union([Type.String(), Type.Number()]), { + default: { + AdvUnitShading: 1, + AdvMapShading: 1, + AdvSky: 0, + AllowDeferredMapRendering: 1, + AllowDeferredModelRendering: 1, + BuildWarnings: 0, + BumpWaterBlurReflection: 1, + BumpWaterDepthBits: 16, + BumpWaterShoreWaves: 1, + BumpWaterTexSizeReflection: 1024, + CamTimeExponent: 4.0, + CamTimeFactor: 1.0, + CrossAlpha: 0, + CubeTexSpecularExponent: 100, + CubeTexGenerateMipMaps: 1, + CubeTexSizeReflection: 1024, + DisplayDebugPrefixConsole: 0, + DualScreenMiniMapOnLeft: 1, + FPSFOV: 90, + FeatureDrawDistance: 10000, + FeatureFadeDistance: 10000, + ForceDisableShaders: 0, + Fullscreen: 0, + FullscreenEdgeMove: 1, + GrassDetail: 0, + GroundDecals: 2, + GroundDetail: 200, + GroundScarAlphaFade: 1, + HangTimeout: 30, + HardwareCursor: 1, + InitialNetworkTimeout: 0, + LODScale: 1.0, + LODScaleReflection: 1.0, + LODScaleRefraction: 1.0, + LODScaleShadow: 1.0, + LuaGarbageCollectionMemLoadMult: 2.0, + LogFlush: 0, + LuaShaders: 1, + LoadingMT: 0, + MaxParticles: 20000, + MaxSounds: 256, + MaximumTransmissionUnit: 0, + MaxTextureAtlasSizeX: 8192, + MaxTextureAtlasSizeZ: 8192, + MiddleClickScrollSpeed: -0.005, + MinimapOnLeft: 1, + MouseDragScrollThreshold: 0.3, + MoveWarnings: 0, + MSAA: 1, + MSAALevel: 4, + NormalMapping: 1, + ReconnectTimeout: 0, + Roam: 1, + ReflectiveWater: 3, + ScrollWheelSpeed: -20, + Shadows: 1, + ShadowMapSize: 2048, + ShowClock: 0, + ShowFps: 0, + ShowSpeed: 0, + SmoothLines: 1, + TreeRadius: 1000, + UnitIconDist: 160, + UnitLodDist: 999999, + UnitIconsAsUI: 1, + UnitIconScaleUI: 1.05, + UnitIconFadeVanish: 2700, + UnitIconFadeStart: 3000, + UnitIconFadeAmount: 0.1, + UsePBO: 1, + Water: 4, + WindowedEdgeMove: 1, + WindowPosX: 0, + WindowPosY: 0, + snd_general: 100, + snd_volmaster: 30, + snd_volmusic: 30, + snd_airAbsorption: 0.35, + ui_opacity: 0.6, + UseNetMessageSmoothingBuffer: 0, + NetworkLossFactor: 2, + MaxDynamicModelLights: 0, + LinkOutgoingBandwidth: 262144, + LinkIncomingSustainedBandwidth: 1048576, + LinkIncomingPeakBandwidth: 1048576, + LinkIncomingMaxPacketRate: 2048, + DemoFileExtension: "barreplay,sdfz", + }, + }), +}); + +export const updateConfigSchema = Type.Partial(configSchema); +export type TUpdateConfigSchema = Static; diff --git a/src/main/json/model/settings.ts b/src/main/json/model/settings.ts index de53d54ce..e1ea67b4b 100644 --- a/src/main/json/model/settings.ts +++ b/src/main/json/model/settings.ts @@ -17,8 +17,7 @@ export const settingsSchema = Type.Object({ battlesHidePvE: Type.Boolean({ default: false }), battlesHideLocked: Type.Boolean({ default: false }), battlesHideEmpty: Type.Boolean({ default: true }), - logUploadUrl: Type.String({ default: "https://log.beyondallreason.dev/" }), - lobbyServer: Type.String({ default: "wss://server4.beyondallreason.info" }), + lobbyServer: Type.String({ default: "" }), customServerList: Type.Array(Type.String(), { default: [] }), endedNormallyFilter: Type.Union([Type.Literal("true"), Type.Literal("false"), Type.Literal("null")], { default: "null" }), assetsPath: Type.String({ default: "" }), diff --git a/src/main/main.ts b/src/main/main.ts index d150cd9bb..676d23087 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -29,6 +29,7 @@ import { tachyonService } from "@main/services/tachyon.service"; import { typedWebContents } from "@main/typed-ipc"; import { navigationService } from "@main/services/navigation.service"; import { pathsService } from "./services/paths.service"; +import { configService } from "./services/config.service"; // Enable happy eyeballs for IPv6/IPv4 dual stack. netFromNode.setDefaultAutoSelectFamily(true); @@ -124,6 +125,8 @@ app.whenReady().then(async () => { }); }); // Initialize services + // Config is fetched first because it contains URLs and other values that other services may depend on. + await configService.init(); await settingsService.init(); const savedAssetsPath = settingsService.getSettings().assetsPath; if (savedAssetsPath && !process.env.BAR_ASSETS_PATH) { @@ -141,6 +144,7 @@ app.whenReady().then(async () => { const mainWindow = createWindow(); const webContents = typedWebContents(mainWindow.webContents); // Handlers may need the webContents to send events + configService.registerIpcHandlers(); logService.registerIpcHandlers(); infoService.registerIpcHandlers(); settingsService.registerIpcHandlers(); diff --git a/src/main/replays/online-replays.ts b/src/main/replays/online-replays.ts index ec3228e95..f682468bc 100644 --- a/src/main/replays/online-replays.ts +++ b/src/main/replays/online-replays.ts @@ -4,11 +4,10 @@ import { IpcResult } from "@main/typed-ipc"; import { logger } from "@main/utils/logger"; +import { configService } from "@main/services/config.service"; const log = logger("online-replays.ts"); -export const ONLINE_REPLAYS_API_URL = "https://api.bar-rts.com/replays"; - export type OnlineReplayOverview = { id: string; startTime: string; @@ -98,7 +97,7 @@ async function fetchJson(url: string) { } export async function searchOnlineReplaysByPlayer(username: string, limit: number): Promise> { - const url = `${ONLINE_REPLAYS_API_URL}?${new URLSearchParams({ players: username, limit: limit.toString(), page: "1" })}`; + const url = `${configService.getConfig().onlineReplaysApiUrl}?${new URLSearchParams({ players: username, limit: limit.toString(), page: "1" })}`; try { const body = (await fetchJson(url)) as { data?: ApiReplay[] }; @@ -111,7 +110,7 @@ export async function searchOnlineReplaysByPlayer(username: string, limit: numbe export async function getOnlineReplay(replayId: string): Promise> { try { - const body = (await fetchJson(`${ONLINE_REPLAYS_API_URL}/${encodeURIComponent(replayId)}`)) as ApiReplay; + const body = (await fetchJson(`${configService.getConfig().onlineReplaysApiUrl}/${encodeURIComponent(replayId)}`)) as ApiReplay; return { status: "success", data: toDetails(body) }; } catch (error) { diff --git a/src/main/services/config.service.ts b/src/main/services/config.service.ts new file mode 100644 index 000000000..92e3c42af --- /dev/null +++ b/src/main/services/config.service.ts @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import fs from "node:fs/promises"; +import { CONFIG_PATH } from "@main/config/app"; +import { FileStore } from "@main/json/file-store"; +import { configSchema, updateConfigSchema, TUpdateConfigSchema } from "@main/json/model/config"; +import { Value } from "@sinclair/typebox/value"; +import path from "path"; +import { logger } from "@main/utils/logger"; +import { ipcMain } from "@main/typed-ipc"; +import { parseArgs } from "node:util"; + +const log = logger("config.service.ts"); + +const configStore = new FileStore(path.join(CONFIG_PATH, "config.json"), configSchema); + +async function init() { + await configStore.init(); + await fetchConfig(); + await checkForConfigOverride(); +} + +async function checkForConfigOverride() { + const parsedArgs = parseArgs({ + args: process.argv.slice(1), + options: { config: { type: "string" } }, + strict: false, + }); + if (parsedArgs.values.config) { + const configPath = path.resolve(process.cwd(), parsedArgs.values.config.toString()); + log.info(`Using config file: ${configPath}`); + const data = JSON.parse(await fs.readFile(configPath, "utf-8")); + if (!Value.Check(configSchema, data)) { + for (const err of Value.Errors(configSchema, data)) { + log.error(`Config error: ${err.path} ${err.message} : ${err.value}`); + } + throw new Error("Provided config file does not match schema"); + } + await configStore.update(data); + } +} + +/** + * Get the current config values. Note that in Vue arrays have to be wrapped in toRaw() or else access will fail. + * @returns The current configuration values as properties + */ +function getConfig() { + return configStore.model; +} + +async function updateConfig(data: TUpdateConfigSchema) { + return await configStore.update(data); +} + +/** + * Fetch the latest configuration from the remote URL and update the local config store. + * Note that env vars will be used to override the config values if they are set, including remote config values. + */ +async function fetchConfig() { + try { + const response = await fetch(getConfig().configUrl); + if (!response.ok) { + throw Error(`${response.status} ${response.statusText}`); + } + const data = await response.json(); + if (!Value.Check(updateConfigSchema, data)) { + for (const err of Value.Errors(updateConfigSchema, data)) { + log.error(`Config error: ${err.path} ${err.message} : ${err.value}`); + } + throw new Error("Fetched config does not match schema"); + } + log.info(`Fetched config successfully from ${getConfig().configUrl}`); + const mergedConfig = Value.Cast(configSchema, data); + await configStore.update(mergedConfig); + } catch (err) { + if (err instanceof Error) { + log.error(`Error fetching config: ${err.message}`); + } else { + log.error(err); + } + } +} + +function registerIpcHandlers() { + ipcMain.handle("config:get", () => getConfig()); + ipcMain.handle("config:fetch", () => fetchConfig()); +} + +export type Config = typeof configStore.model; +export const configService = { + init, + registerIpcHandlers, + getConfig, + updateConfig, + fetchConfig, +}; diff --git a/src/main/services/engine.service.ts b/src/main/services/engine.service.ts index e8e044505..0fff44435 100644 --- a/src/main/services/engine.service.ts +++ b/src/main/services/engine.service.ts @@ -4,12 +4,12 @@ import { contentAPI } from "@main/content/content-api"; import { EngineVersion } from "@main/content/engine/engine-version"; -import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; +import { configService } from "@main/services/config.service"; import { ipcMain } from "@main/typed-ipc"; function registerIpcHandlers() { ipcMain.handle("engine:listAvailableVersions", () => contentAPI.engineVersions()); - ipcMain.handle("engine:downloadEngine", (_, version?: string) => contentAPI.ensure([{ type: "engine", id: version ?? DEFAULT_ENGINE_VERSION }])); + ipcMain.handle("engine:downloadEngine", (_, version?: string) => contentAPI.ensure([{ type: "engine", id: version ?? configService.getConfig().defaultEngineVersion }])); ipcMain.handle("engine:isVersionInstalled", (_, id: string) => contentAPI.isPresent({ type: "engine", id })); ipcMain.handle("engine:uninstallVersion", (_, version: EngineVersion) => contentAPI.remove([{ type: "engine", id: version.id }])); } diff --git a/src/main/services/log.service.ts b/src/main/services/log.service.ts index 8308121a8..20b03e2e1 100644 --- a/src/main/services/log.service.ts +++ b/src/main/services/log.service.ts @@ -10,7 +10,7 @@ import { packSpecificFiles } from "@main/utils/pack-7z"; import { randomBytes } from "crypto"; import axios from "axios"; import { logger } from "@main/utils/logger"; -import { settingsService } from "@main/services/settings.service"; +import { configService } from "@main/services/config.service"; async function getSortedLogFiles() { type logData = { @@ -104,7 +104,7 @@ async function uploadLogFiles() { }); const archiveFile = path.basename(archivePath); - const uploadUrl = settingsService.getSettings().logUploadUrl + archiveFile; + const uploadUrl = configService.getConfig().logUploadUrl + archiveFile; const dataToUpload = await readFile(archivePath); await axios({ diff --git a/src/main/services/maps.service.ts b/src/main/services/maps.service.ts index 223a54e33..a1214ce10 100644 --- a/src/main/services/maps.service.ts +++ b/src/main/services/maps.service.ts @@ -7,11 +7,12 @@ 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"; +import { configService } from "@main/services/config.service"; const FETCH_MAPS_TIMEOUT_MS = 15_000; async function fetchAllMaps(): Promise<[MapData[], MapDownloadData[]]> { - const maps = await fetch("https://maps-metadata.beyondallreason.dev/latest/lobby_maps.validated.json", { + const maps = await fetch(configService.getConfig().mapsMetadataUrl, { signal: AbortSignal.timeout(FETCH_MAPS_TIMEOUT_MS), }); const mapsAsObject = await maps.json(); diff --git a/src/main/services/news.service.ts b/src/main/services/news.service.ts index 0331ba385..8e1a9104e 100644 --- a/src/main/services/news.service.ts +++ b/src/main/services/news.service.ts @@ -6,6 +6,7 @@ import { extract, FeedData, FeedEntry } from "@extractus/feed-extractor"; import { FetchDevlogRssFeed, FetchNewsRssFeed } from "@main/services/type"; import { logger } from "@main/utils/logger"; import { ipcMain } from "@main/typed-ipc"; +import { configService } from "@main/services/config.service"; export interface NewsFeedData extends FeedData { entries?: Array; @@ -18,9 +19,6 @@ export interface NewsFeedEntry extends FeedEntry { const log = logger("news.service.ts"); -const NEWS_RSS_URL = "https://www.beyondallreason.info/news/rss.xml"; -const DEVLOG_RSS_URL = "https://www.beyondallreason.info/microblogs/rss.xml"; - export async function fetchImageToBase64(url: string) { try { const response = await fetch(url); @@ -44,7 +42,7 @@ const fetchNewsRssFeed: FetchNewsRssFeed = async (numberOfNews) => { return newsFeed; } newsFeed = await extract( - NEWS_RSS_URL, + configService.getConfig().newsRssUrl, { getExtraEntryFields: (entry) => { const thumbnail = entry["media:thumbnail"]; @@ -83,7 +81,7 @@ const fetchDevlogRssFeed: FetchDevlogRssFeed = async () => { if (devlogFeed) { return devlogFeed; } - devlogFeed = (await extract(DEVLOG_RSS_URL, {}, {})) as NewsFeedData; + devlogFeed = (await extract(configService.getConfig().devlogRssUrl, {}, {})) as NewsFeedData; return devlogFeed; } catch (error) { log.error("Error fetching devlog feed:", error); diff --git a/src/main/services/settings.service.ts b/src/main/services/settings.service.ts index cd3d14145..8f1880fdf 100644 --- a/src/main/services/settings.service.ts +++ b/src/main/services/settings.service.ts @@ -5,6 +5,8 @@ import { CONFIG_PATH } from "@main/config/app"; import { FileStore } from "@main/json/file-store"; import { settingsSchema } from "@main/json/model/settings"; +import { configService } from "@main/services/config.service"; +import { toRaw } from "vue"; import { ipcMain } from "@main/typed-ipc"; import path from "path"; @@ -13,6 +15,9 @@ const settingsStore = new FileStore(path.join(CONFIG_PATH async function init() { await settingsStore.init(); + if (configService.getConfig().defaultServers && settingsStore.model.lobbyServer === "") { + settingsStore.update({ lobbyServer: toRaw(configService.getConfig().defaultServers)![0] }); + } } function getSettings() { diff --git a/src/main/services/shell.service.ts b/src/main/services/shell.service.ts index 6023839f8..b30b55d20 100644 --- a/src/main/services/shell.service.ts +++ b/src/main/services/shell.service.ts @@ -8,9 +8,7 @@ import { ipcMain, IpcResult } from "@main/typed-ipc"; import { logger } from "@main/utils/logger"; import fs from "fs"; import path from "path"; - -const REPLAY_SERVICE_URL = "https://bar-rts.com/replays"; -const NEWS_SERVICE_URL = "https://www.beyondallreason.info/news"; +import { configService } from "@main/services/config.service"; const log = logger("shell-service"); @@ -58,8 +56,7 @@ function isAllowedUrl(url: string): boolean { } if (!["https:", "http:"].includes(parsed.protocol)) return false; - // Additional checks to prevent opening arbitrary URLs - return [REPLAY_SERVICE_URL, NEWS_SERVICE_URL].some((serviceUrl) => url.startsWith(serviceUrl)); + return configService.getConfig().allowedUrlLinks.some((serviceUrl) => url.startsWith(serviceUrl)); } async function openInBrowser(url: string): Promise { @@ -75,7 +72,7 @@ function registerIpcHandlers() { ipcMain.handle("shell:openStartScript", () => openPath(path.join(WRITE_DATA_PATH, "script.txt"))); ipcMain.handle("shell:openReplaysDir", () => openPath(REPLAYS_PATH)); ipcMain.handle("shell:showReplayInFolder", (_event, fileName: string) => showInFolder(path.join(REPLAYS_PATH, fileName))); - + ipcMain.handle("shell:openConfigFile", () => openPath(path.join(CONFIG_PATH, "config.json"))); // External ipcMain.handle("shell:openInBrowser", (_event, url) => openInBrowser(url)); } diff --git a/src/main/typed-ipc.ts b/src/main/typed-ipc.ts index cae3e2111..9cedef5d5 100644 --- a/src/main/typed-ipc.ts +++ b/src/main/typed-ipc.ts @@ -23,6 +23,7 @@ import type { Scenario } from "@main/content/game/scenario"; import type { Settings } from "@main/services/settings.service"; import type { TachyonEvent, TachyonResponse } from "tachyon-protocol"; import { ipcRenderer as electronIpcRenderer, ipcMain as electronIpcMain } from "electron"; +import { Config } from "@main/services/config.service"; // Errors thrown in main arrive in the renderer stripped of everything but a message, so failures a // caller needs to act on have to travel as data instead. Mirrors the shape tachyon-protocol uses for @@ -113,6 +114,9 @@ export type IPCCommands = { "settings:get": () => Settings; "settings:toggleFullscreen": () => void; "settings:update": (settings: Partial) => Partial; + "config:get": () => Config; + "config:fetch": () => void; + "shell:openConfigFile": () => IpcResult; "shell:openStateDir": () => IpcResult; "shell:openAssetsDir": () => IpcResult; "shell:openInBrowser": (url: string) => IpcResult; diff --git a/src/preload/interface.d.ts b/src/preload/interface.d.ts index a73459dbe..ebbd2ee30 100644 --- a/src/preload/interface.d.ts +++ b/src/preload/interface.d.ts @@ -16,6 +16,7 @@ import type { MiscApi, ReplaysApi, SettingsApi, + ConfigApi, ShellApi, TachyonApi, PathsApi, @@ -30,6 +31,7 @@ declare global { replays: ReplaysApi; auth: AuthApi; settings: SettingsApi; + config: ConfigApi; content: ContentApi; engine: EngineApi; game: GameApi; diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 5473224d7..2fa4473c9 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -19,6 +19,7 @@ import { GetCommandData, GetCommandIds, GetCommands } from "tachyon-protocol"; import type { BattleStartRequestData } from "tachyon-protocol/types"; import { MultiplayerLaunchSettings } from "@main/game/game"; import { logLevels } from "@main/services/log.service"; +import { Config } from "@main/services/config.service"; import { AuthState } from "@main/services/auth.service"; import { StoredIdentity } from "@main/model/user"; @@ -55,7 +56,7 @@ const shellApi = { openStartScript: (): Promise => ipcRenderer.invoke("shell:openStartScript"), openReplaysDir: (): Promise => ipcRenderer.invoke("shell:openReplaysDir"), showReplayInFolder: (fileName: string): Promise => ipcRenderer.invoke("shell:showReplayInFolder", fileName), - + openConfigFile: (): Promise => ipcRenderer.invoke("shell:openConfigFile"), // External openInBrowser: (url: string): Promise => ipcRenderer.invoke("shell:openInBrowser", url), }; @@ -85,6 +86,13 @@ const settingsApi = { export type SettingsApi = typeof settingsApi; contextBridge.exposeInMainWorld("settings", settingsApi); +const configApi = { + getConfig: (): Promise => ipcRenderer.invoke("config:get"), + fetchConfig: (): Promise => ipcRenderer.invoke("config:fetch"), +}; +export type ConfigApi = typeof configApi; +contextBridge.exposeInMainWorld("config", configApi); + const authApi = { login: (interactive?: boolean): Promise => ipcRenderer.invoke("auth:login", interactive), logout: (): Promise => ipcRenderer.invoke("auth:logout"), diff --git a/src/renderer/api/shell.ts b/src/renderer/api/shell.ts index b34221d6d..7d259ab5c 100644 --- a/src/renderer/api/shell.ts +++ b/src/renderer/api/shell.ts @@ -30,4 +30,5 @@ export const shellApi = { openReplaysDir: () => openAndReport(window.shell.openReplaysDir), showReplayInFolder: (fileName: string) => openAndReport(() => window.shell.showReplayInFolder(fileName)), openInBrowser: (url: string) => openAndReport(() => window.shell.openInBrowser(url)), + openConfigFile: () => openAndReport(window.shell.openConfigFile), }; diff --git a/src/renderer/assets/languages/cs.json b/src/renderer/assets/languages/cs.json index 55228ad8e..d847f8005 100644 --- a/src/renderer/assets/languages/cs.json +++ b/src/renderer/assets/languages/cs.json @@ -2351,6 +2351,7 @@ "debugSidebar": { "debugSandbox": null, "openSettingsFile": null, + "openConfigFile": null, "openAssetsDir": null, "openStateDir": null, "openStartScript": null, diff --git a/src/renderer/assets/languages/de.json b/src/renderer/assets/languages/de.json index b92d6d637..ef377fd8f 100644 --- a/src/renderer/assets/languages/de.json +++ b/src/renderer/assets/languages/de.json @@ -2222,6 +2222,7 @@ "debugSidebar": { "debugSandbox": null, "openSettingsFile": null, + "openConfigFile": null, "openAssetsDir": null, "openStateDir": null, "openStartScript": null, diff --git a/src/renderer/assets/languages/dev.json b/src/renderer/assets/languages/dev.json index ed8affad4..ac270d559 100644 --- a/src/renderer/assets/languages/dev.json +++ b/src/renderer/assets/languages/dev.json @@ -368,6 +368,7 @@ "debugSidebar": { "debugSandbox": "Duebg Snbodax", "openSettingsFile": "Oepn Setngits Flie", + "openConfigFile": "Oepn Cionfg Flie", "openAssetsDir": "Oepn Aessts Dir", "openStateDir": "Oepn Satte Dir", "openStartScript": "Oepn Lasett Strat Sipcrt", diff --git a/src/renderer/assets/languages/en.json b/src/renderer/assets/languages/en.json index e7ce23d7d..f8ffd8734 100644 --- a/src/renderer/assets/languages/en.json +++ b/src/renderer/assets/languages/en.json @@ -2199,6 +2199,7 @@ "debugSidebar": { "debugSandbox": "Debug Sandbox", "openSettingsFile": "Open Settings File", + "openConfigFile": "Open Config File", "openAssetsDir": "Open Assets Dir", "openStateDir": "Open State Dir", "openStartScript": "Open Latest Start Script", diff --git a/src/renderer/assets/languages/fr.json b/src/renderer/assets/languages/fr.json index 1ee274bca..f5b150161 100644 --- a/src/renderer/assets/languages/fr.json +++ b/src/renderer/assets/languages/fr.json @@ -4204,6 +4204,7 @@ "debugSidebar": { "debugSandbox": null, "openSettingsFile": null, + "openConfigFile": null, "openAssetsDir": null, "openStateDir": null, "openStartScript": null, diff --git a/src/renderer/assets/languages/ru.json b/src/renderer/assets/languages/ru.json index 28d466776..cfb57d879 100644 --- a/src/renderer/assets/languages/ru.json +++ b/src/renderer/assets/languages/ru.json @@ -4173,6 +4173,7 @@ "debugSidebar": { "debugSandbox": null, "openSettingsFile": null, + "openConfigFile": null, "openAssetsDir": null, "openStateDir": null, "openStartScript": null, diff --git a/src/renderer/assets/languages/zh.json b/src/renderer/assets/languages/zh.json index 58fc92d55..2210de24a 100644 --- a/src/renderer/assets/languages/zh.json +++ b/src/renderer/assets/languages/zh.json @@ -4315,6 +4315,7 @@ "debugSidebar": { "debugSandbox": null, "openSettingsFile": null, + "openConfigFile": null, "openAssetsDir": null, "openStateDir": null, "openStartScript": null, diff --git a/src/renderer/components/misc/DebugSidebar.vue b/src/renderer/components/misc/DebugSidebar.vue index fb1e347c6..89d452f4c 100644 --- a/src/renderer/components/misc/DebugSidebar.vue +++ b/src/renderer/components/misc/DebugSidebar.vue @@ -24,6 +24,7 @@ SPDX-License-Identifier: MIT /> + @@ -99,6 +100,10 @@ function openSettings() { shellApi.openSettingsFile(); } +async function openConfigFile() { + shellApi.openConfigFile(); +} + function openAssetsDir() { shellApi.openAssetsDir(); } diff --git a/src/renderer/components/misc/InitialSetup.vue b/src/renderer/components/misc/InitialSetup.vue index 27307f887..5a473d93a 100644 --- a/src/renderer/components/misc/InitialSetup.vue +++ b/src/renderer/components/misc/InitialSetup.vue @@ -66,6 +66,7 @@ SPDX-License-Identifier: MIT import { delay } from "$/jaz-ts-utils/delay"; import { randomFromArray } from "$/jaz-ts-utils/object"; import { computed, onMounted, onUnmounted, ref, watch } from "vue"; +import { useTypedI18n } from "@renderer/i18n"; import Button from "@renderer/components/controls/Button.vue"; import Textbox from "@renderer/components/controls/Textbox.vue"; import Loader from "@renderer/components/common/Loader.vue"; @@ -75,8 +76,7 @@ import { backgroundImages, fontFiles } from "@renderer/assets/assetFiles"; import { fetchMissingMapImages, initMapsStore, syncMapsMetadata } from "@renderer/store/maps.store"; import { initReplaysStore } from "@renderer/store/replays.store"; import { db, initDb } from "@renderer/store/db"; -import { defaultMaps } from "@main/config/default-maps"; -import { LATEST_GAME_VERSION } from "@main/config/default-versions"; +import { configStore } from "@renderer/store/config.store"; import { initBattleStore } from "@renderer/store/battle.store"; import { defaultEngineInstalled, installedEngineVersions } from "@renderer/store/engine.store"; import { downloadGame, gameStore } from "@renderer/store/game.store"; @@ -88,7 +88,6 @@ import Select from "@renderer/components/controls/Select.vue"; import { Icon } from "@iconify/vue"; import language from "@iconify-icons/mdi/language"; -import { useTypedI18n } from "@renderer/i18n"; import type { Locale } from "@renderer/locales"; import { useLocaleOptions } from "@renderer/composables/useLocaleOptions"; @@ -428,7 +427,7 @@ onMounted(async () => { return; } await window.content.preloadPool(); - await downloadGame(LATEST_GAME_VERSION); + await downloadGame(configStore.latestGameVersion); if (gameStore.selectedGameVersion === undefined) { throw new Error("Game download did not complete successfully"); } @@ -447,7 +446,7 @@ onMounted(async () => { } const installedMaps = await db.maps.filter((m) => m.isInstalled === true).count(); if (installedMaps === 0) { - await window.maps.downloadMaps(defaultMaps); + await window.maps.downloadMaps([...configStore.defaultMaps]); } try { await fetchMissingMapImages(); diff --git a/src/renderer/components/navbar/ServerSettings.vue b/src/renderer/components/navbar/ServerSettings.vue index b8690aeaa..f398cf0e1 100644 --- a/src/renderer/components/navbar/ServerSettings.vue +++ b/src/renderer/components/navbar/ServerSettings.vue @@ -41,6 +41,7 @@ import Select from "@renderer/components/controls/Select.vue"; import Button from "@renderer/components/controls/Button.vue"; import OverlayPanel from "primevue/overlaypanel"; import { settingsStore } from "@renderer/store/settings.store"; +import { configStore } from "@renderer/store/config.store"; import Textbox from "@renderer/components/controls/Textbox.vue"; import { useTypedI18n } from "@renderer/i18n"; const { t } = useTypedI18n(); @@ -50,12 +51,7 @@ const serverInput = ref(""); const op = ref(); const tooltipMessage = ref(""); -const defaultServers: string[] = [ - "wss://server4.beyondallreason.info", - "wss://server5.beyondallreason.info", - "wss://lobby-server-dev.beyondallreason.dev", - "ws://localhost:4000", -]; +const defaultServers: string[] = [...configStore.defaultServers]; const disableRemoveButton = computed(() => { return defaultServers.includes(settingsStore.lobbyServer); diff --git a/src/renderer/components/user/ReportUserModal.vue b/src/renderer/components/user/ReportUserModal.vue index 27a64fedd..f2d1a1f88 100644 --- a/src/renderer/components/user/ReportUserModal.vue +++ b/src/renderer/components/user/ReportUserModal.vue @@ -170,11 +170,12 @@ import { users } from "@renderer/store/users.store"; import { getFriendlyDuration } from "@renderer/utils/misc"; import type { OnlineReplayDetails, OnlineReplayOverview } from "@main/replays/online-replays"; +import { configStore } from "@renderer/store/config.store"; + // The website report form stores a type and a sub type per report, and caps its description at 255 // characters. Tachyon only carries a single reason string, so the two are joined for the wire. const maxMessageLength = 255; const matchesToList = 10; -const REPLAY_SERVICE_URL = "https://bar-rts.com/replays"; const reportSections = [ { @@ -265,7 +266,7 @@ const reportedUserSpectated = computed(() => matchDetails.value?.spectators.some((spectator) => spectator.userId?.toString() === reportedUser.value?.userId) ); -const messageSuffix = computed(() => (selectedMatch.value ? `\nReplay: ${REPLAY_SERVICE_URL}/${selectedMatch.value.id}` : "")); +const messageSuffix = computed(() => (selectedMatch.value ? `\nReplay: ${configStore.replayServiceUrl}/${selectedMatch.value.id}` : "")); const maxDescriptionLength = computed(() => maxMessageLength - messageSuffix.value.length); diff --git a/src/renderer/store/config.store.ts b/src/renderer/store/config.store.ts new file mode 100644 index 000000000..791b1ae94 --- /dev/null +++ b/src/renderer/store/config.store.ts @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import type { Config } from "@main/services/config.service"; +import { reactive, readonly } from "vue"; + +const state = reactive({ + isInitialized: false, +} as { + isInitialized: boolean; +} & Config); + +// Config is static at runtime; expose a readonly view so the renderer cannot mutate it directly. +export const configStore = readonly(state); + +export async function initConfigStore() { + if (state.isInitialized) { + console.warn("Config store is already initialized, skipping initialization."); + return; + } + const currentConfig = await window.config.getConfig(); + Object.assign(state, currentConfig); + state.isInitialized = true; +} diff --git a/src/renderer/store/engine.store.ts b/src/renderer/store/engine.store.ts index 2b84d2abd..372704838 100644 --- a/src/renderer/store/engine.store.ts +++ b/src/renderer/store/engine.store.ts @@ -1,8 +1,7 @@ // SPDX-FileCopyrightText: 2025 The BAR Lobby Authors // // SPDX-License-Identifier: MIT - -import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; +import { configStore } from "@renderer/store/config.store"; import { EngineVersion } from "@main/content/engine/engine-version"; import { notificationsApi } from "@renderer/api/notifications"; import { onContentSettled } from "@renderer/store/contents.store"; @@ -22,7 +21,7 @@ export const installedEngineVersions = computed(() => enginesStore.availableEngi // The default is a version picked to work with the default game version rather than the newest one // published, so it stays the preferred choice whenever it is installed. -export const defaultEngineInstalled = computed(() => enginesStore.availableEngineVersions.some((e) => e.id === DEFAULT_ENGINE_VERSION && e.installed)); +export const defaultEngineInstalled = computed(() => enginesStore.availableEngineVersions.some((e) => e.id === configStore.defaultEngineVersion && e.installed)); let requestedEngineVersion: string | undefined; @@ -31,9 +30,9 @@ function reselectEngineVersion() { enginesStore.selectedEngineVersion = installed(requestedEngineVersion) ?? - installed(DEFAULT_ENGINE_VERSION) ?? + installed(configStore.defaultEngineVersion) ?? installedEngineVersions.value.at(-1) ?? - enginesStore.availableEngineVersions.find((e) => e.id === DEFAULT_ENGINE_VERSION); + enginesStore.availableEngineVersions.find((e) => e.id === configStore.defaultEngineVersion); } export function selectEngineVersion(version?: EngineVersion) { @@ -65,7 +64,7 @@ export async function initEnginesStore() { await refreshEnginesStore(); if (!defaultEngineInstalled.value) { - console.warn(`Default engine version ${DEFAULT_ENGINE_VERSION} is not installed — engine download required.`); + console.warn(`Default engine version ${configStore.defaultEngineVersion} is not installed — engine download required.`); } enginesStore.isInitialized = true; diff --git a/src/renderer/store/stores.ts b/src/renderer/store/stores.ts index 50b0aaca7..ca61101af 100644 --- a/src/renderer/store/stores.ts +++ b/src/renderer/store/stores.ts @@ -16,9 +16,11 @@ import { initTachyonStore } from "@renderer/store/tachyon.store"; import { initUsersStore } from "@renderer/store/users.store"; import { initLobbyStore } from "./lobby.store"; import { initChatStore } from "@renderer/store/chat.store"; +import { initConfigStore } from "@renderer/store/config.store"; export async function initPreMountStores() { await Promise.all([ + initConfigStore(), initSettingsStore(), initInfosStore(), initGameStore(), diff --git a/src/renderer/views/debug/script-launcher.vue b/src/renderer/views/debug/script-launcher.vue index 85ceb8c27..6c051c6a1 100644 --- a/src/renderer/views/debug/script-launcher.vue +++ b/src/renderer/views/debug/script-launcher.vue @@ -17,10 +17,9 @@ SPDX-License-Identifier: MIT diff --git a/src/renderer/views/play/scenarios.vue b/src/renderer/views/play/scenarios.vue index 9d3754f71..5a5f1fe0b 100644 --- a/src/renderer/views/play/scenarios.vue +++ b/src/renderer/views/play/scenarios.vue @@ -79,7 +79,7 @@ import Button from "@renderer/components/controls/Button.vue"; import Select from "@renderer/components/controls/Select.vue"; import ScenarioTile from "@renderer/components/misc/ScenarioTile.vue"; import { Scenario } from "@main/content/game/scenario"; -import { LATEST_GAME_VERSION } from "@main/config/default-versions"; + import Panel from "@renderer/components/common/Panel.vue"; import { db } from "@renderer/store/db"; import { MapDownloadData } from "@main/content/maps/map-data"; @@ -87,7 +87,7 @@ import { useDexieLiveQueryWithDeps } from "@renderer/composables/useDexieLiveQue import Markdown from "@renderer/components/misc/Markdown.vue"; import DownloadContentButton from "@renderer/components/controls/DownloadContentButton.vue"; import { GameStatus, gameStore } from "@renderer/store/game.store"; - +import { configStore } from "@renderer/store/config.store"; import { useTypedI18n } from "@renderer/i18n"; const { t } = useTypedI18n(); @@ -157,7 +157,7 @@ async function launch() { .replaceAll("__SCENARIOOPTIONS__", scenarioOptionsStr) //TODO replace with online name when implemented .replaceAll("__PLAYERNAME__", "Player") - .replaceAll("__BARVERSION__", LATEST_GAME_VERSION) + .replaceAll("__BARVERSION__", configStore.latestGameVersion) .replaceAll("__MAPNAME__", selectedScenario.value.mapfilename) .replaceAll("__PLAYERSIDE__", selectedFaction.value) .replaceAll("__ENEMYHANDICAP__", selectedDifficulty.value?.enemyhandicap?.toString() ?? "0") @@ -168,7 +168,7 @@ async function launch() { if (!enginesStore.selectedEngineVersion) { throw new Error("No engine version selected"); } - await window.game.launchScript(script, LATEST_GAME_VERSION, enginesStore.selectedEngineVersion.id); + await window.game.launchScript(script, configStore.latestGameVersion, enginesStore.selectedEngineVersion.id); } diff --git a/src/renderer/views/watch/replays.vue b/src/renderer/views/watch/replays.vue index a5475c5ae..38c0e6ca8 100644 --- a/src/renderer/views/watch/replays.vue +++ b/src/renderer/views/watch/replays.vue @@ -164,6 +164,7 @@ import { Icon } from "@iconify/vue"; import folder from "@iconify-icons/mdi/folder"; import SearchBox from "@renderer/components/controls/SearchBox.vue"; import { settingsStore } from "@renderer/store/settings.store"; +import { configStore } from "@renderer/store/config.store"; import { shellApi } from "@renderer/api/shell"; const { t } = useTypedI18n(); @@ -264,7 +265,7 @@ function onSort(event: DataTableSortEvent) { } function openBrowserToReplayService() { - shellApi.openInBrowser("https://bar-rts.com/replays"); + shellApi.openInBrowser(configStore.replayServiceUrl); } function openReplaysFolder() { diff --git a/tests/e2e/e2e.spec.ts b/tests/e2e/e2e.spec.ts index 3fe2212de..0f01f02b4 100644 --- a/tests/e2e/e2e.spec.ts +++ b/tests/e2e/e2e.spec.ts @@ -11,7 +11,7 @@ const { describe, beforeAll, afterAll } = test; describe("Electron App", async () => { let electronApp: ElectronApplication; - let firstWindow: Page; + let mainWindow: Page; beforeAll(async () => { const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -24,7 +24,7 @@ describe("Electron App", async () => { } electronApp = await electron.launch({ args }); - firstWindow = await electronApp.firstWindow(); + mainWindow = await electronApp.firstWindow(); }); afterAll(async () => { @@ -32,26 +32,35 @@ describe("Electron App", async () => { }); test("run app", async () => { - const title = await firstWindow.title(); + const title = await mainWindow.title(); expect(title).toBeTruthy(); }); test("Main window state", async () => { - const windowState: { isVisible: boolean; isDevToolsOpened: boolean; isCrashed: boolean } = await electronApp.evaluate(({ BrowserWindow }) => { - const mainWindow = BrowserWindow.getAllWindows()[0]; + const windowState: { isVisible: boolean; isDevToolsOpened: boolean; isCrashed: boolean } = await electronApp.evaluate(async ({ BrowserWindow }) => { + const windows = BrowserWindow.getAllWindows(); + const targetMainWindow = + windows.find((w) => { + const url = w.webContents.getURL(); + return url.includes("index.html") && !url.includes("splash"); + }) || windows[windows.length - 1]; + + if (!targetMainWindow) { + throw new Error("Could not find the main window process."); + } function getState() { return { - isVisible: mainWindow.isVisible(), - isDevToolsOpened: mainWindow.webContents.isDevToolsOpened(), - isCrashed: mainWindow.webContents.isCrashed(), + isVisible: targetMainWindow.isVisible(), + isDevToolsOpened: targetMainWindow.webContents.isDevToolsOpened(), + isCrashed: targetMainWindow.webContents.isCrashed(), }; } return new Promise((resolve) => { - if (mainWindow.isVisible()) { + if (targetMainWindow.isVisible()) { resolve(getState()); - } else mainWindow.once("ready-to-show", () => setTimeout(() => resolve(getState()), 0)); + } else targetMainWindow.once("ready-to-show", () => setTimeout(() => resolve(getState()), 0)); }); }); @@ -61,14 +70,13 @@ describe("Electron App", async () => { }); test("Main window web content", async () => { - const page = await electronApp.firstWindow(); - const element = await page.$("#app", { strict: true }); + const element = await mainWindow.$("#app", { strict: true }); expect(element, "Was unable to find the root element").not.toBeNull(); }); test("Survives a Tachyon disconnect and reconnect", async () => { const pageErrors: Error[] = []; - firstWindow.on("pageerror", (error) => pageErrors.push(error)); + mainWindow.on("pageerror", (error) => pageErrors.push(error)); // The stores react to these IPC events rather than to the socket itself, so driving them // from main exercises the real handlers without needing a reachable Tachyon server. @@ -78,9 +86,9 @@ describe("Electron App", async () => { }, channel); } - await firstWindow.waitForTimeout(500); + await mainWindow.waitForTimeout(500); expect(pageErrors, `Uncaught errors after disconnect: ${pageErrors.map((e) => e.message).join(", ")}`).toHaveLength(0); - expect(await firstWindow.$("#app", { strict: true }), "The app was torn down by a disconnect").not.toBeNull(); + expect(await mainWindow.$("#app", { strict: true }), "The app was torn down by a disconnect").not.toBeNull(); }); }); diff --git a/tests/unit/main/content/content-api.spec.ts b/tests/unit/main/content/content-api.spec.ts index b96a74f1f..f2d3250b1 100644 --- a/tests/unit/main/content/content-api.spec.ts +++ b/tests/unit/main/content/content-api.spec.ts @@ -174,6 +174,15 @@ vi.mock("@main/content/content-usage", () => ({ contentUsage: usage })); vi.mock("@main/utils/logger", () => ({ logger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); +vi.mock("@main/services/config.service", () => ({ + configService: { + getConfig: () => ({ + defaultEngineVersion: "2026.07.04", + rapidHost: "repos-cdn.beyondallreason.dev", + rapidGame: "byar", + }), + }, +})); import { contentAPI } from "@main/content/content-api"; import { ContentRef } from "@main/content/content-ref"; @@ -254,7 +263,7 @@ describe("contentAPI.ensure", () => { await contentAPI.ensure([{ type: "map", id: "Coast To Coast 1.3" }]); - expect(acquired).toEqual(["engine:2025.01.3", "map:Coast To Coast 1.3"]); + expect(acquired).toEqual(["engine:2026.07.04", "map:Coast To Coast 1.3"]); }); it("does not fetch an engine when one is already usable", async () => { diff --git a/tests/unit/main/content/game-version-lookup.spec.ts b/tests/unit/main/content/game-version-lookup.spec.ts index 6f8316731..cda248edf 100644 --- a/tests/unit/main/content/game-version-lookup.spec.ts +++ b/tests/unit/main/content/game-version-lookup.spec.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as path from "path"; import zlib from "zlib"; import { promisify } from "util"; @@ -25,6 +26,17 @@ vi.mock("@main/utils/checksums", () => ({ calcChecksum })); vi.mock("@main/content/engine/engine-provider", () => ({ engineProvider: { onDownloadComplete: { add: vi.fn() }, getDefaultEngine: () => defaultEngine.current }, })); +vi.mock("@main/services/config.service", () => { + return { + configService: { + getConfig: vi.fn(() => ({ + latestGameVersion: "byar:test", + rapidHost: "repos-cdn.beyondallreason.dev", + rapidGame: "byar", + })), + }, + }; +}); import { GameProvider } from "@main/content/game/game-provider"; import { GameVersion } from "@main/content/game/game-version"; @@ -55,6 +67,14 @@ describe("game version lookup", () => { readFile.mockReset(); }); + it("reads the index from the configured Rapid source", async () => { + await versionsIndex(`${TAG},${MD5},,${BUILD}`); + + await provider([]); + + expect(readFile).toHaveBeenCalledWith(path.join("/rapid", "repos-cdn.beyondallreason.dev", "byar", "versions.gz")); + }); + // A rolling tag is never a build name, so looking for it literally can only ever say "not installed" // and the acquisition that just succeeded gets reported as a failure. it("counts a tag as installed once the build it points at is", async () => { @@ -105,7 +125,7 @@ describe("batched game downloads", () => { beforeEach(() => { readFile.mockReset(); calcChecksum.mockReset(); - defaultEngine.current = { id: "2025.06.21", installed: true }; + defaultEngine.current = { id: "2026.07.04", installed: true }; }); it("registers every version the one invocation covered", async () => { diff --git a/tests/unit/main/main-process-lifecycle.spec.ts b/tests/unit/main/main-process-lifecycle.spec.ts index 323224862..cb2ae0d6e 100644 --- a/tests/unit/main/main-process-lifecycle.spec.ts +++ b/tests/unit/main/main-process-lifecycle.spec.ts @@ -17,6 +17,8 @@ describe("Main Process Lifecycle", () => { const mockServices = { engineService: { registerIpcHandlers: vi.fn() }, settingsService: { init: vi.fn().mockResolvedValue(undefined), registerIpcHandlers: vi.fn(), getSettings: vi.fn().mockReturnValue({ assetsPath: "" }) }, + configService: { init: vi.fn().mockResolvedValue(undefined), registerIpcHandlers: vi.fn(), getConfig: vi.fn().mockReturnValue({ configUrl: "" }) }, + accountService: { init: vi.fn().mockResolvedValue(undefined) }, replaysService: { init: vi.fn().mockResolvedValue(undefined), registerIpcHandlers: vi.fn() }, gameService: { registerIpcHandlers: vi.fn() }, mapsService: { registerIpcHandlers: vi.fn() }, @@ -33,10 +35,13 @@ describe("Main Process Lifecycle", () => { pathsService: { registerIpcHandlers: vi.fn() }, }; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); + vi.stubGlobal("SPLASH_WINDOW_VITE_DEV_SERVER_URL", "http://localhost:5173/splash.html"); + vi.stubGlobal("MAIN_WINDOW_VITE_DEV_SERVER_URL", "http://localhost:5173/index.html"); + mockApp = { requestSingleInstanceLock: vi.fn().mockReturnValue(true), exit: vi.fn(), @@ -79,6 +84,23 @@ describe("Main Process Lifecycle", () => { protocol: mockProtocol, session: mockSession, net: mockNet, + + BrowserWindow: vi.fn().mockImplementation(() => ({ + loadURL: vi.fn(), + show: vi.fn(), + on: vi.fn(), + destroy: vi.fn(), + webContents: { + on: vi.fn(), + send: vi.fn(), + }, + })), + nativeImage: { + createEmpty: vi.fn().mockReturnValue({}), + createFromPath: vi.fn().mockReturnValue({}), + createFromBuffer: vi.fn().mockReturnValue({}), + createFromDataURL: vi.fn().mockReturnValue({}), + }, })); vi.doMock("node:net", () => ({ @@ -87,9 +109,19 @@ describe("Main Process Lifecycle", () => { }, })); + vi.doMock("@main/splash-window", () => ({ + createSplashWindow: vi.fn().mockReturnValue({ + webContents: {}, + once: vi.fn(), + destroy: vi.fn(), + close: vi.fn(), + }), + })); + vi.doMock("@main/main-window", () => ({ createWindow: vi.fn().mockReturnValue({ webContents: {}, + on: vi.fn(), }), })); @@ -119,9 +151,9 @@ describe("Main Process Lifecycle", () => { vi.doMock("@main/services/maps.service", () => ({ default: mockServices.mapsService })); vi.doMock("@main/services/engine.service", () => ({ default: mockServices.engineService })); vi.doMock("@main/services/auto-updater.service", () => ({ default: mockServices.autoUpdaterService })); + vi.doMock("@main/services/config.service", () => ({ configService: mockServices.configService })); vi.doMock("@main/services/content.service", () => ({ default: mockServices.contentService })); vi.doMock("@main/content/content-api", () => ({ contentAPI: mockServices.contentAPI })); - vi.doMock("@main/services/settings.service", () => ({ settingsService: mockServices.settingsService })); vi.doMock("@main/services/info.service", () => ({ infoService: mockServices.infoService })); vi.doMock("@main/services/log.service", () => ({ logService: mockServices.logService })); @@ -209,6 +241,7 @@ describe("Main Process Lifecycle", () => { it("should initialize services when app is ready", async () => { await import("@main/main"); + expect(mockServices.configService.init).toHaveBeenCalled(); expect(mockServices.settingsService.init).toHaveBeenCalled(); expect(mockServices.contentAPI.init).toHaveBeenCalled(); expect(mockServices.authService.init).toHaveBeenCalled(); @@ -231,6 +264,7 @@ describe("Main Process Lifecycle", () => { it("should register IPC handlers when app is ready", async () => { await import("@main/main"); + expect(mockServices.configService.registerIpcHandlers).toHaveBeenCalled(); expect(mockServices.logService.registerIpcHandlers).toHaveBeenCalled(); expect(mockServices.infoService.registerIpcHandlers).toHaveBeenCalled(); expect(mockServices.settingsService.registerIpcHandlers).toHaveBeenCalled(); diff --git a/tests/unit/main/online-replays.spec.ts b/tests/unit/main/online-replays.spec.ts index 2ae43d679..5b33ffee6 100644 --- a/tests/unit/main/online-replays.spec.ts +++ b/tests/unit/main/online-replays.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getOnlineReplay, ONLINE_REPLAYS_API_URL, searchOnlineReplaysByPlayer } from "@main/replays/online-replays"; +const ONLINE_REPLAYS_API_URL = "https://api.bar-rts.com/replays"; +import { getOnlineReplay, searchOnlineReplaysByPlayer } from "@main/replays/online-replays"; const apiReplay = { id: "abcdef", @@ -30,6 +31,16 @@ const apiReplay = { Spectators: [{ name: "Watcher", userId: 9012 }], }; +vi.mock("@main/services/config.service", () => { + return { + configService: { + getConfig: vi.fn(() => ({ + onlineReplaysApiUrl: ONLINE_REPLAYS_API_URL, + })), + }, + }; +}); + function mockFetch(response: { ok: boolean; body?: unknown }) { const fetchMock = vi.fn().mockResolvedValue({ ok: response.ok, diff --git a/tests/unit/main/pool-cdn.spec.ts b/tests/unit/main/pool-cdn.spec.ts index be5417ad3..6e0285ddb 100644 --- a/tests/unit/main/pool-cdn.spec.ts +++ b/tests/unit/main/pool-cdn.spec.ts @@ -30,6 +30,13 @@ vi.mock("@main/config/app", () => ({ vi.mock("fs", () => ({ promises: { mkdir: mockMkdir, rm: mockRm } })); vi.mock("@main/utils/extract-7z", () => ({ extract7z: mockExtract7z })); vi.mock("@main/utils/file", () => ({ fileExists: vi.fn().mockResolvedValue(false) })); +vi.mock("@main/services/config.service", () => { + return { + configService: { + getConfig: vi.fn(() => ({ initialPoolDataUrl: "https://pool-init.beyondallreason.dev" })), + }, + }; +}); import { PoolCdnDownloader } from "@main/content/game/pool-cdn"; diff --git a/tests/unit/main/shell-service.spec.ts b/tests/unit/main/shell-service.spec.ts index 6beb9661f..2c88e506c 100644 --- a/tests/unit/main/shell-service.spec.ts +++ b/tests/unit/main/shell-service.spec.ts @@ -31,7 +31,13 @@ vi.mock("@main/config/app", () => ({ REPLAYS_PATH: "/state/replays", getAssetsPath: () => "/assets", })); - +vi.mock("@main/services/config.service", () => { + return { + configService: { + getConfig: vi.fn(() => ({ allowedUrlLinks: ["https://bar-rts.com/replays", "https://www.beyondallreason.info/news"] })), + }, + }; +}); const { shellService } = await import("@main/services/shell.service"); shellService.registerIpcHandlers(); diff --git a/tests/unit/renderer/engine-selection.spec.ts b/tests/unit/renderer/engine-selection.spec.ts index 62bd44ff9..5f89621e2 100644 --- a/tests/unit/renderer/engine-selection.spec.ts +++ b/tests/unit/renderer/engine-selection.spec.ts @@ -2,7 +2,6 @@ // // SPDX-License-Identifier: MIT -import { DEFAULT_ENGINE_VERSION } from "@main/config/default-versions"; import { EngineVersion } from "@main/content/engine/engine-version"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,12 +9,14 @@ const { listAvailableVersions } = vi.hoisted(() => ({ listAvailableVersions: vi. vi.mock("@renderer/api/notifications", () => ({ notificationsApi: { alert: vi.fn() } })); vi.mock("@renderer/store/contents.store", () => ({ onContentSettled: vi.fn() })); +vi.mock("@renderer/store/config.store", () => ({ configStore: { defaultEngineVersion: "2026.07.04" } })); vi.stubGlobal("window", Object.assign(window, { engine: { listAvailableVersions } })); import { defaultEngineInstalled, enginesStore, refreshEnginesStore, selectEngineVersion } from "@renderer/store/engine.store"; const OLDER = "2025.01.5"; const NEWER = "2026.10.1"; +const DEFAULT_ENGINE_VERSION = "2026.07.04"; function engine(id: string, installed: boolean): EngineVersion { return { id, ais: [], installed } as EngineVersion; diff --git a/tests/unit/renderer/report-user-modal.spec.ts b/tests/unit/renderer/report-user-modal.spec.ts index fd8d8538b..f28b8a922 100644 --- a/tests/unit/renderer/report-user-modal.spec.ts +++ b/tests/unit/renderer/report-user-modal.spec.ts @@ -30,6 +30,13 @@ vi.mock("@renderer/audio/audio", () => ({ vi.mock("vue-router", () => ({ useRouter: () => ({ currentRoute: { value: { path: "/" } }, push: vi.fn() }), })); +vi.mock("@renderer/store/config.store", () => { + return { + configStore: { + replayServiceUrl: "https://bar-rts.com/replays", + }, + }; +}); const searchOnlineByPlayer = vi.fn(); const getOnline = vi.fn(); diff --git a/tools/generate-default-config.ts b/tools/generate-default-config.ts new file mode 100644 index 000000000..06466f276 --- /dev/null +++ b/tools/generate-default-config.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT +// Run this script to generate a default config file. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "url"; +import { Value } from "@sinclair/typebox/value"; +import { configSchema } from "../src/main/json/model/config"; + +export function generateDefaultConfig(targetPath?: string) { + const filename = fileURLToPath(import.meta.url); + const dirname = path.dirname(filename); + const OUTPUT_PATH = targetPath ? targetPath : path.resolve(dirname, "../config.json"); + const targetDir = path.dirname(OUTPUT_PATH); + + if (!fs.existsSync(targetDir)) { + fs.mkdirSync(targetDir, { recursive: true }); + } + const DEFAULT_CONFIG = Value.Create(configSchema); + const json = JSON.stringify(DEFAULT_CONFIG, null, 2); + fs.writeFileSync(OUTPUT_PATH, json, "utf-8"); + console.log(`Default config written to ${OUTPUT_PATH}`); + return OUTPUT_PATH; +} + +generateDefaultConfig(process.env.BAR_CONFIG_OUTPUT_PATH); diff --git a/tools/launch-clients.ts b/tools/launch-clients.ts index 9da8882f0..f3e9f5771 100644 --- a/tools/launch-clients.ts +++ b/tools/launch-clients.ts @@ -11,7 +11,7 @@ import os from "os"; // 1. Read instance count from terminal flags (defaults to 2 if none provided) const args: string[] = process.argv.slice(2); -const passedCount: number = parseInt(args, 10); +const passedCount: number = parseInt(args[0], 10); const CLIENT_COUNT: number = !isNaN(passedCount) && passedCount > 0 ? passedCount : 2; console.log(`Preparing to compile and launch ${CLIENT_COUNT} instances...`); diff --git a/tools/validate-config.ts b/tools/validate-config.ts new file mode 100644 index 000000000..29d514207 --- /dev/null +++ b/tools/validate-config.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import fs from "node:fs/promises"; +import path from "node:path"; +import { Value } from "@sinclair/typebox/value"; +import { updateConfigSchema } from "../src/main/json/model/config"; + +const configPath = path.resolve(process.cwd(), "remoteConfig/config.json"); + +try { + const config = JSON.parse(await fs.readFile(configPath, "utf-8")); + + if (!Value.Check(updateConfigSchema, config)) { + console.error(`Config file does not match the schema: ${configPath}`); + for (const error of Value.Errors(updateConfigSchema, config)) { + console.error(`${error.path} ${error.message}: ${JSON.stringify(error.value)}`); + } + process.exitCode = 1; + } else { + console.log(`Config file matches the schema: ${configPath}`); + } +} catch (error) { + console.error(`Unable to validate config file: ${configPath}`); + console.error(error); + process.exitCode = 1; +} diff --git a/tsconfig.node.json b/tsconfig.node.json index c7a4df4dd..b2a56fcd2 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,8 +8,10 @@ "vendor/**/*", "tests/**/main/**/*.ts", "tests/**/shared/**/*.ts", + "tests/**/tools/**/*.ts", "tests/setup.main.mts", - "vitest.config.mts" + "vitest.config.mts", + "tools/*.ts" ], "compilerOptions": { "composite": true, @@ -19,7 +21,7 @@ "@main/*": ["src/main/*"], "@preload/*": ["src/preload/*"], "@renderer/*": ["src/renderer/*"], - "$/*": ["vendor/*"] + "$/*": ["vendor/*", "tools/*"] }, "strict": true, "strictNullChecks": true, diff --git a/vite.main.config.mts b/vite.main.config.mts index 167c864d7..e6f3bf951 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -18,6 +18,7 @@ export default defineConfig({ "parse-replay-worker": path.resolve(__dirname, "src/main/replays/parse-replay-worker.ts"), "map-image-worker": path.resolve(__dirname, "src/main/content/maps/map-image-worker.ts"), }, + external: ["fs/promises"], }, lib: { entry: path.resolve(__dirname, "src/main/main.ts"), diff --git a/vite.renderer.config.mts b/vite.renderer.config.mts index 9060e75f6..fa535297e 100644 --- a/vite.renderer.config.mts +++ b/vite.renderer.config.mts @@ -18,6 +18,7 @@ export default defineConfig({ sourcemap: true, }, optimizeDeps: { + force: true, entries: ["src/renderer/**/*.vue", "src/renderer/**/*.ts", "src/renderer/**/*.js"], esbuildOptions: { target: "esnext", diff --git a/vitest.config.mts b/vitest.config.mts index bc13a6040..b6eb3ee3a 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -52,6 +52,13 @@ export default defineConfig({ }, resolve: { alias }, }, + { + test: { + name: "tools", + environment: "node", + include: ["tests/unit/tools/**/*.{test,spec}.{ts,mts}"], + }, + }, ], }, resolve: { alias },