From 7995c00cd3f861e734412e5e1662dbd1e57e0c48 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:32:59 +0200 Subject: [PATCH 1/5] Fold the twelve isRecord copies into one domain guard Eleven modules and src/ipc/validation.ts each carried a byte-identical private copy of the object guard. They now share src/domain/records.ts, which validation.ts re-exports so every host caller and test keeps its import path. modPaths.ts drops its asRecord variant for the same guard. --- src/domain/account/credentials.ts | 6 ++---- src/domain/account/modPaths.ts | 14 ++++++-------- src/domain/backgrounds.ts | 6 ++---- src/domain/config/migrations.ts | 6 ++---- src/domain/moddbVisibility.ts | 6 ++---- src/domain/mods/moddb.ts | 6 ++---- src/domain/mods/profiles.ts | 5 +---- src/domain/optimum/manifest.ts | 6 ++---- src/domain/optimum/ndjson.ts | 6 ++---- src/domain/records.ts | 11 +++++++++++ src/domain/sessions/sampling.ts | 6 ++---- src/domain/versions/gameVersionCatalog.ts | 6 ++---- src/ipc/optimumOverlay.ts | 5 +---- src/ipc/validation.ts | 8 ++++---- 14 files changed, 41 insertions(+), 56 deletions(-) create mode 100644 src/domain/records.ts diff --git a/src/domain/account/credentials.ts b/src/domain/account/credentials.ts index ba5a351f..a1ab189e 100644 --- a/src/domain/account/credentials.ts +++ b/src/domain/account/credentials.ts @@ -17,6 +17,8 @@ * NAME of the field they refused and never its value. */ +import { isRecord } from "../records" + /** Session credentials. Main process only: these never cross IPC. */ export type AccountSecrets = { sessionKey: string @@ -66,10 +68,6 @@ export function accountFieldDiagnosis(error: unknown): string | undefined { return error instanceof AccountFieldError ? error.diagnosis : undefined } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** A type-level description of a value: never the value, never its length. */ function describeShape(value: unknown): string { if (value === undefined) return "undefined" diff --git a/src/domain/account/modPaths.ts b/src/domain/account/modPaths.ts index 01011070..0d4dedd2 100644 --- a/src/domain/account/modPaths.ts +++ b/src/domain/account/modPaths.ts @@ -30,6 +30,7 @@ import { MODS_FOLDER_NAME } from "../mods/folder" import { normalizeFolderForComparison } from "../paths" +import { isRecord } from "../records" /** Key of the section the mod folder list lives in. */ const STRING_LIST_SETTINGS_SECTION = "stringListSettings" @@ -61,10 +62,6 @@ export interface RepointModPathsResult { outcome: RepointModPathsOutcome } -function asRecord(value: unknown): Record | null { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null -} - /** True for a path the game wrote as an absolute one, on either platform. */ function isAbsoluteEntry(entry: string): boolean { return /^([a-zA-Z]:[\\/]|[\\/])/.test(entry) @@ -109,9 +106,10 @@ function defaultShapeAbsoluteEntry(value: unknown): string | null { * @param target The installation the game is about to be launched on. */ export function repointModPaths(existingDocument: unknown, target: ModPathsTarget): RepointModPathsResult { - const document = asRecord(existingDocument) - const section = document && asRecord(document[STRING_LIST_SETTINGS_SECTION]) - if (!document || !section || !(MOD_PATHS_KEY in section)) return { document: existingDocument, outcome: "unchanged" } + if (!isRecord(existingDocument)) return { document: existingDocument, outcome: "unchanged" } + + const section = existingDocument[STRING_LIST_SETTINGS_SECTION] + if (!isRecord(section) || !(MOD_PATHS_KEY in section)) return { document: existingDocument, outcome: "unchanged" } const absolute = defaultShapeAbsoluteEntry(section[MOD_PATHS_KEY]) if (absolute === null) return { document: existingDocument, outcome: "left-as-found" } @@ -120,7 +118,7 @@ export function repointModPaths(existingDocument: unknown, target: ModPathsTarge const repointed = (section[MOD_PATHS_KEY] as string[]).map((entry) => (entry === absolute ? target.modsPath : entry)) return { - document: { ...document, [STRING_LIST_SETTINGS_SECTION]: { ...section, [MOD_PATHS_KEY]: repointed } }, + document: { ...existingDocument, [STRING_LIST_SETTINGS_SECTION]: { ...section, [MOD_PATHS_KEY]: repointed } }, outcome: "repointed" } } diff --git a/src/domain/backgrounds.ts b/src/domain/backgrounds.ts index 25c15664..8f065cc6 100644 --- a/src/domain/backgrounds.ts +++ b/src/domain/backgrounds.ts @@ -16,6 +16,8 @@ * the picker; unlike the full-size scene, it is rendered directly and never cached by the app. */ +import { isRecord } from "./records" + /** The scene shipped inside the app. Selected when nothing else is, and the offline answer. */ export const DEFAULT_BACKGROUND_ID = "default" @@ -113,10 +115,6 @@ export function backgroundCacheFileName(id: string): string { return `${id}.jpg` } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - function parseEntry(value: unknown): BackgroundType | null { if (!isRecord(value)) return null const { id, name, file, thumbnail, sha256 } = value diff --git a/src/domain/config/migrations.ts b/src/domain/config/migrations.ts index 46f9d097..6f85cc99 100644 --- a/src/domain/config/migrations.ts +++ b/src/domain/config/migrations.ts @@ -17,6 +17,8 @@ * strange as it likes and the launcher still starts. */ +import { isRecord } from "../records" + /** Schema every config the launcher writes today carries. */ export const CURRENT_CONFIG_SCHEMA = 5 @@ -110,10 +112,6 @@ export interface ConfigMigrationOptions { readonly targetSchema?: number } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** * Reads the schema a stored document is at. * diff --git a/src/domain/moddbVisibility.ts b/src/domain/moddbVisibility.ts index 87de2ca2..4460d178 100644 --- a/src/domain/moddbVisibility.ts +++ b/src/domain/moddbVisibility.ts @@ -28,6 +28,8 @@ * the first launches of a beta routinely land before it. */ +import { isRecord } from "./records" + /** The launcher's own entry on the ModDB. */ export const MODDB_LISTING_MOD_ID = 11016 @@ -150,10 +152,6 @@ export function moddbListingVersion(version: string): string { const LEGACY_ANSWERED = new Set(["accepted", "declined", "already-done"]) const LEGACY_ACCEPTED = "accepted" -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - function readVersion(value: unknown): string { return typeof value === "string" && value.length > 0 && value.length <= MAX_VERSION_LENGTH ? value : "" } diff --git a/src/domain/mods/moddb.ts b/src/domain/mods/moddb.ts index ed23893e..f713ad49 100644 --- a/src/domain/mods/moddb.ts +++ b/src/domain/mods/moddb.ts @@ -21,6 +21,8 @@ * both shapes for the same field, not just an object. */ +import { isRecord } from "../records" + /** Why a v1 response could not be trusted. */ export type ModDbApiFailure = "api-error" | "malformed-response" @@ -35,10 +37,6 @@ export type ModDbResponse = { ok: true; payload: T } | { ok: false; reason: M /** The string v1 uses to mean success. Every other value, including a missing field, is a failure. */ const SUCCESS_STATUS_CODE = "200" -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** True for a value the ModDB would consider filled in: a non-blank string, or anything else defined. */ function present(value: unknown): boolean { return typeof value === "string" ? value.trim().length > 0 : value !== undefined && value !== null diff --git a/src/domain/mods/profiles.ts b/src/domain/mods/profiles.ts index dcd48f47..90e00349 100644 --- a/src/domain/mods/profiles.ts +++ b/src/domain/mods/profiles.ts @@ -1,3 +1,4 @@ +import { isRecord } from "../records" import { MAX_MOD_ARCHIVES, renameModArchiveTo } from "./scanInstalled" /** @@ -72,10 +73,6 @@ export function emptyModProfilesDocument(): ModProfilesDocument { return { format: MOD_PROFILES_FORMAT, activeProfileId: null, profiles: [] } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** * The last segment of a path, split on either separator: the renderer never has Node's path module, * and a Windows path reaches it with backslashes. diff --git a/src/domain/optimum/manifest.ts b/src/domain/optimum/manifest.ts index 4f07fce3..3d8d63dc 100644 --- a/src/domain/optimum/manifest.ts +++ b/src/domain/optimum/manifest.ts @@ -22,6 +22,8 @@ import semver from "semver" +import { isRecord } from "../records" + /** The platforms an overlay is published for. A label on the wire, a closed set here. */ export const OPTIMUM_RIDS = ["linux-x64", "win-x64"] as const @@ -78,10 +80,6 @@ export interface OptimumManifest { files: OptimumFile[] } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** The bare hex of a `sha256:`-prefixed field, or undefined when it is not one. */ function readHash(value: unknown): string | undefined { if (typeof value !== "string") return undefined diff --git a/src/domain/optimum/ndjson.ts b/src/domain/optimum/ndjson.ts index 9e74f2a1..6ecb6fa1 100644 --- a/src/domain/optimum/ndjson.ts +++ b/src/domain/optimum/ndjson.ts @@ -13,6 +13,8 @@ * out of a closed set and a number between 0 and 99. */ +import { isRecord } from "../records" + /** * Why a run did not succeed. * @@ -75,10 +77,6 @@ export interface OptimumOutputReader { finish(): OptimumRunResult } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** * Reads the reason off a failed result. * diff --git a/src/domain/records.ts b/src/domain/records.ts new file mode 100644 index 00000000..8c35745c --- /dev/null +++ b/src/domain/records.ts @@ -0,0 +1,11 @@ +/** + * The object guard every parser in the launcher narrows unknown JSON with. + * + * It lived as a byte-identical private copy in eleven modules before this file existed (#484). + * `src/domain/redaction.ts` sets the precedent and its header says why: a predicate duplicated so + * a pure module can keep its own copy is how two copies drift apart. `src/ipc/validation.ts` + * re-exports it unchanged, so the host and the tests that import it from there keep doing so. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/src/domain/sessions/sampling.ts b/src/domain/sessions/sampling.ts index 7f3a924f..a07538b1 100644 --- a/src/domain/sessions/sampling.ts +++ b/src/domain/sessions/sampling.ts @@ -13,6 +13,8 @@ * src/global.d.ts, because they cross the IPC boundary and both sides have to name them. */ +import { isRecord } from "../records" + /** How often the game process is read while it runs. */ export const SAMPLE_INTERVAL_MS = 5_000 @@ -44,10 +46,6 @@ export function emptyPlaySessionsDocument(): PlaySessionsDocument { return { format: PLAY_SESSIONS_FORMAT, sessions: [] } } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** The mean of the readings that carry a CPU figure, or nothing when neither does. */ function meanCpu(first: PlaySample, second: PlaySample): number | undefined { const readings = [first.cpuPercent, second.cpuPercent].filter((value): value is number => value !== undefined) diff --git a/src/domain/versions/gameVersionCatalog.ts b/src/domain/versions/gameVersionCatalog.ts index 67212c04..c19194e8 100644 --- a/src/domain/versions/gameVersionCatalog.ts +++ b/src/domain/versions/gameVersionCatalog.ts @@ -5,6 +5,8 @@ * Shape: { [version]: { [platform]: { filename, filesize, md5, urls: { cdn, local }, ... } } } */ +import { isRecord } from "../records" + /** Where one build is downloaded from, tolerant of every field beyond the one the installer reads. */ export interface RawPlatformUrls extends Record { cdn: string @@ -29,10 +31,6 @@ export type RawVersions = Record /** The four keys the install picker reads. Any other key on a row is carried through unchecked. */ const PLATFORM_KEYS = new Set(["windows", "linux", "mac-arm64", "mac-x64"]) -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** * Reads one platform's build. * diff --git a/src/ipc/optimumOverlay.ts b/src/ipc/optimumOverlay.ts index 3046a2e0..a529df90 100644 --- a/src/ipc/optimumOverlay.ts +++ b/src/ipc/optimumOverlay.ts @@ -26,6 +26,7 @@ import { join, relative, sep } from "node:path" import { type OptimumManifest } from "@domain/optimum/manifest" import { OPTIMUM_STATE_FOLDER } from "@domain/optimum/plan" +import { isRecord } from "@domain/records" /** The one file the archive carries that `files[]` never names: the walk that built the list ran before it was written. */ const UNLISTED_ARCHIVE_FILE = "optimum-manifest.json" @@ -35,10 +36,6 @@ const OPTIMUM_STATE_MANIFEST = join(OPTIMUM_STATE_FOLDER, "manifest.json") /** The file the patch leaves at the game root, which the launcher's own rollback has to take back out. */ export const OPTIMUM_CONTRACTS_ASSEMBLY = "Optimum.Api.Contracts.dll" -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - /** Streams a file through SHA-256, so a 200 MB assembly is never held in memory to be hashed. */ export function sha256File(path: string): Promise { return new Promise((resolvePromise, rejectPromise) => { diff --git a/src/ipc/validation.ts b/src/ipc/validation.ts index fde6582b..7c6c0f4f 100644 --- a/src/ipc/validation.ts +++ b/src/ipc/validation.ts @@ -4,6 +4,10 @@ import { fileURLToPath } from "node:url" import semver from "semver" import { RESTORE_REPLACED_SUFFIX, RESTORE_STAGING_SUFFIX } from "../domain/installations/restore" +import { isRecord } from "../domain/records" + +// Re-exported unchanged so the host code and the tests that reach for it here keep doing so. +export { isRecord } export const MAX_IPC_STRING_LENGTH = 8_192 export const MAX_PATH_LENGTH = 4_096 @@ -120,10 +124,6 @@ export const BROWSER_URL_RULES: readonly UrlRule[] = [ { hostname: "www.youtube.com", pathPrefixes: ["/watch"] } ] -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) -} - export function assertString(value: unknown, name: string, maxLength = MAX_IPC_STRING_LENGTH): string { if (typeof value !== "string" || value.length === 0 || value.length > maxLength || value.includes("\0")) { throw new TypeError(`Invalid ${name}`) From 6fca2a2a83f8b2aaf50ac1c53c105dd88f6a2192 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:33:52 +0200 Subject: [PATCH 2/5] Fold the seven refuse helpers into one generic Six modules restated the same three-line helper once per failure union, create.ts twice. src/domain/refusal.ts carries the union as a type parameter instead and returns the same { ok: false, reason } shape. The richer refusals in backup.ts and restore.ts keep their own helpers. --- src/domain/appUpdate/canAutoUpdate.ts | 6 ++---- src/domain/installations/create.ts | 17 +++++------------ src/domain/installations/delete.ts | 5 +---- src/domain/mods/install.ts | 5 +---- src/domain/refusal.ts | 13 +++++++++++++ src/domain/versions/install.ts | 5 +---- src/domain/versions/uninstall.ts | 5 +---- 7 files changed, 24 insertions(+), 32 deletions(-) create mode 100644 src/domain/refusal.ts diff --git a/src/domain/appUpdate/canAutoUpdate.ts b/src/domain/appUpdate/canAutoUpdate.ts index 6720fc22..dcad4833 100644 --- a/src/domain/appUpdate/canAutoUpdate.ts +++ b/src/domain/appUpdate/canAutoUpdate.ts @@ -11,6 +11,8 @@ * marker stays refused. Nothing is published for macOS at all. */ +import { refuse } from "../refusal" + /** Why the updater stays off. */ export type CanAutoUpdateFailure = "updates-disabled" | "linux-unsupported-package" | "unsupported-platform" @@ -26,10 +28,6 @@ export interface CanAutoUpdateInput { /** The package-type marker values electron-updater has a Linux updater for. */ const SUPPORTED_LINUX_PACKAGE_TYPES: ReadonlySet = new Set(["deb", "rpm", "pacman"]) -function refuse(reason: CanAutoUpdateFailure): CanAutoUpdateResult { - return { ok: false, reason } -} - /** * Says whether this run can check for, download and apply updates. * diff --git a/src/domain/installations/create.ts b/src/domain/installations/create.ts index 609ebb29..857d0947 100644 --- a/src/domain/installations/create.ts +++ b/src/domain/installations/create.ts @@ -1,5 +1,6 @@ import type { IdGenerator } from "../ports" import { folderIsInUse } from "../paths" +import { refuse } from "../refusal" /** Bounds an installation name must fall within. */ export const INSTALLATION_NAME_MIN_LENGTH = 5 @@ -18,10 +19,6 @@ export interface InstallationFieldsInput { startParams: string } -function refuseFields(reason: InstallationFieldsFailure): InstallationFieldsResult { - return { ok: false, reason } -} - /** * Checks the two rules that apply to an installation's data whether it is * being created or edited: the name has to fit inside the launcher's bounds, @@ -33,8 +30,8 @@ function refuseFields(reason: InstallationFieldsFailure): InstallationFieldsResu export function validateInstallationFields(input: InstallationFieldsInput): InstallationFieldsResult { const { name, startParams } = input - if (name.length < INSTALLATION_NAME_MIN_LENGTH || name.length > INSTALLATION_NAME_MAX_LENGTH) return refuseFields("name-length") - if (startParams.includes(RESERVED_START_PARAM)) return refuseFields("reserved-start-param") + if (name.length < INSTALLATION_NAME_MIN_LENGTH || name.length > INSTALLATION_NAME_MAX_LENGTH) return refuse("name-length") + if (startParams.includes(RESERVED_START_PARAM)) return refuse("reserved-start-param") return { ok: true } } @@ -87,10 +84,6 @@ export interface CreatedInstallation { totalTimePlayed: number } -function refuseCreate(reason: CreateInstallationFailure): CreateInstallationResult { - return { ok: false, reason } -} - /** * Validates a new installation's fields and builds the record for it. * @@ -103,9 +96,9 @@ function refuseCreate(reason: CreateInstallationFailure): CreateInstallationResu */ export function createInstallation(ports: CreateInstallationPorts, input: CreateInstallationInput): CreateInstallationResult { const fields = validateInstallationFields(input) - if (!fields.ok) return refuseCreate(fields.reason) + if (!fields.ok) return refuse(fields.reason) - if (folderIsInUse(input.path, input.foldersInUse, input.platform)) return refuseCreate("folder-in-use") + if (folderIsInUse(input.path, input.foldersInUse, input.platform)) return refuse("folder-in-use") return { ok: true, diff --git a/src/domain/installations/delete.ts b/src/domain/installations/delete.ts index f048615f..a23b0abf 100644 --- a/src/domain/installations/delete.ts +++ b/src/domain/installations/delete.ts @@ -1,4 +1,5 @@ import type { FileSystem } from "../ports" +import { refuse } from "../refusal" import type { BackupRecord } from "./backup" import { deleteInstallationBackup } from "./backupDeletion" @@ -45,10 +46,6 @@ export interface DeleteInstallationEvents { onBackupDeleteFailed?(path: string): void } -function refuse(reason: DeleteInstallationFailure): DeleteInstallationResult { - return { ok: false, reason } -} - /** * Drops an installation's data off disk, optionally. * diff --git a/src/domain/mods/install.ts b/src/domain/mods/install.ts index ab1918e6..df784ae7 100644 --- a/src/domain/mods/install.ts +++ b/src/domain/mods/install.ts @@ -1,4 +1,5 @@ import type { DownloadOutcome, Downloader, FileSystem, PathBuilder } from "../ports" +import { refuse } from "../refusal" import { modsFolder } from "./folder" import { MOD_DISABLED_SUFFIX } from "./scanInstalled" @@ -101,10 +102,6 @@ export function modArchiveFileName(release: ModReleaseToInstall, disabled = fals return `${release.modidstr}-${release.modversion}${MOD_ARCHIVE_EXTENSION}${disabled ? MOD_DISABLED_SUFFIX : ""}` } -function refuse(reason: InstallModFailure): InstallModResult { - return { ok: false, reason } -} - /** * Puts one mod release in an installation's Mods folder, replacing the copy already there. * diff --git a/src/domain/refusal.ts b/src/domain/refusal.ts new file mode 100644 index 00000000..004e3cd9 --- /dev/null +++ b/src/domain/refusal.ts @@ -0,0 +1,13 @@ +/** + * The one way a domain flow says no. + * + * Seven modules held their own three-line copy before this file existed (#484), differing only in + * the failure union, which the type parameter carries instead. The shape on the wire is unchanged: + * `{ ok: false, reason }`, with `ok` still the literal the result unions discriminate on. + * + * A refusal that carries more than a reason keeps its own helper, because the extra fields are part + * of what the caller has to act on: see `installations/backup.ts` and `installations/restore.ts`. + */ +export function refuse(reason: R): { ok: false; reason: R } { + return { ok: false, reason } +} diff --git a/src/domain/versions/install.ts b/src/domain/versions/install.ts index b857debc..5340b77f 100644 --- a/src/domain/versions/install.ts +++ b/src/domain/versions/install.ts @@ -1,5 +1,6 @@ import type { DownloadOutcome, Downloader, FileSystem, PathBuilder, UnpackOutcome, Unpacker } from "../ports" import { folderIsInUse } from "../paths" +import { refuse } from "../refusal" import { expectedGameExecutables, toGameOs } from "./gameExecutable" import type { GameOs } from "./gameExecutable" @@ -80,10 +81,6 @@ export interface InstallGameVersionEvents { onDiscarded?(reason: InstallGameVersionFailure): void } -function refuse(reason: InstallGameVersionFailure): InstallGameVersionResult { - return { ok: false, reason } -} - /** * Picks the download for a platform. * diff --git a/src/domain/versions/uninstall.ts b/src/domain/versions/uninstall.ts index c23e87f1..296c37fd 100644 --- a/src/domain/versions/uninstall.ts +++ b/src/domain/versions/uninstall.ts @@ -1,4 +1,5 @@ import type { FileSystem } from "../ports" +import { refuse } from "../refusal" /** The game version state an uninstall decision needs, copied out of wherever it lives. */ export interface GameVersionSnapshot { @@ -39,10 +40,6 @@ export interface UninstallGameVersionEvents { onFinished?(): void } -function refuse(reason: UninstallGameVersionFailure): UninstallGameVersionResult { - return { ok: false, reason } -} - /** * Deletes one installed version's folder off disk, and drops it from the * launcher's list either way (the caller's job, and only once this says the From f8e32b28ac6bdf112e1663f852d7560f7e2dc22b Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:34:39 +0200 Subject: [PATCH 3/5] Give the four host outcomes one shape CompressOutcome, ExtractOutcome and UnpackOutcome were byte-identical interfaces; DownloadOutcome was the same plus filePath. They now alias or extend HostOutcome, so the shape is written once. The four ports and every name they answer under are unchanged. --- src/domain/ports.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 8c2c3181..f0431d6f 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -6,6 +6,17 @@ * this folder may reach for Electron, Node, React or the DOM. */ +/** + * How one attempt on a host port ended. + * + * Four ports answer with this shape and each restated it once (#484). They stay four ports: only + * the shape is shared, and every one of them keeps the name it already answered under. + */ +export interface HostOutcome { + ok: boolean + error?: string +} + /** Storage the host exposes to the domain. */ export interface FileSystem { /** Resolves true when `path` points at something that exists. */ @@ -32,10 +43,7 @@ export interface CompressRequest { } /** How a compression attempt ended. */ -export interface CompressOutcome { - ok: boolean - error?: string -} +export type CompressOutcome = HostOutcome /** Produces archives. Progress reporting and task UI stay on the host side. */ export interface Archiver { @@ -57,10 +65,7 @@ export interface ExtractRequest { } /** How an extraction attempt ended. */ -export interface ExtractOutcome { - ok: boolean - error?: string -} +export type ExtractOutcome = HostOutcome /** * Unpacks archives. Kept apart from {@link Archiver} because no service does @@ -86,11 +91,9 @@ export interface DownloadRequest { } /** How a download attempt ended. */ -export interface DownloadOutcome { - ok: boolean +export interface DownloadOutcome extends HostOutcome { /** Where the file landed. Only meaningful when `ok` is true. */ filePath?: string - error?: string } /** Fetches files. Progress reporting and task UI stay on the host side. */ @@ -112,10 +115,7 @@ export interface UnpackRequest { } /** How an unpacking attempt ended. */ -export interface UnpackOutcome { - ok: boolean - error?: string -} +export type UnpackOutcome = HostOutcome /** * The two ways a downloaded game build becomes an installed folder. From e79421f77d838219dd0c6b4a2a1c8e7d7c78c999 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:35:22 +0200 Subject: [PATCH 4/5] Alias the ambient build variant to the domain declaration GameBuildVariant was spelled out twice, once in src/domain/versions/detect.ts and once as the ambient GameBuildVariantType, doc comment included. The ambient name now imports the domain type, so the Optimum token lives in one place and cannot drift. Resolves under all three tsconfigs. --- src/global.d.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/global.d.ts b/src/global.d.ts index 7bdb1c67..78b1bfdd 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -177,14 +177,12 @@ declare global { /** * A fork of Vintage Story that named itself when the launcher probed it. * - * Declared here so the domain, the preload bridge and the renderer spell it - * once. `name` is a fixed token the launcher chose, never text echoed from a - * binary it did not build: see toWireBuildVariant in src/ipc/validation.ts. + * The ambient name the preload bridge and the renderer reach the domain's own + * declaration under, so the token and the rule behind it are written once: see + * GameBuildVariant in src/domain/versions/detect.ts, and toWireBuildVariant in + * src/ipc/validation.ts for what holds the wire value to it. */ - type GameBuildVariantType = { - name: "Optimum" - version: string - } + type GameBuildVariantType = import("./domain/versions/detect").GameBuildVariant type GameVersionType = { /** Stable technical identity; independent from the displayed label and version number. */ From f15f08ab71579792dfffb66b2757c7b0657495d4 Mon Sep 17 00:00:00 2001 From: Pixnop <77785313+Pixnop@users.noreply.github.com> Date: Sun, 20 Sep 2026 12:36:27 +0200 Subject: [PATCH 5/5] Say what the two new domain modules replaced, accurately --- src/domain/records.ts | 11 ++++++----- src/domain/refusal.ts | 10 +++++----- src/ipc/validation.ts | 3 ++- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/domain/records.ts b/src/domain/records.ts index 8c35745c..2045d899 100644 --- a/src/domain/records.ts +++ b/src/domain/records.ts @@ -1,10 +1,11 @@ /** - * The object guard every parser in the launcher narrows unknown JSON with. + * The object guard the launcher narrows an unknown value with. * - * It lived as a byte-identical private copy in eleven modules before this file existed (#484). - * `src/domain/redaction.ts` sets the precedent and its header says why: a predicate duplicated so - * a pure module can keep its own copy is how two copies drift apart. `src/ipc/validation.ts` - * re-exports it unchanged, so the host and the tests that import it from there keep doing so. + * Twelve modules each held a byte-identical private copy of it, and `account/modPaths.ts` a + * variant returning the record or null (#484). `src/domain/redaction.ts` sets the precedent and + * its header says why: a predicate duplicated so a pure module can keep its own copy is how two + * copies drift apart. `src/ipc/validation.ts` re-exports this one, so the host modules and the + * tests that import it from there keep doing so. */ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value) diff --git a/src/domain/refusal.ts b/src/domain/refusal.ts index 004e3cd9..57487b67 100644 --- a/src/domain/refusal.ts +++ b/src/domain/refusal.ts @@ -1,12 +1,12 @@ /** * The one way a domain flow says no. * - * Seven modules held their own three-line copy before this file existed (#484), differing only in - * the failure union, which the type parameter carries instead. The shape on the wire is unchanged: - * `{ ok: false, reason }`, with `ok` still the literal the result unions discriminate on. + * Six modules held their own three-line copy of it and `installations/create.ts` two (#484), each + * differing only in the failure union, which the type parameter carries instead. What comes back is + * the same `{ ok: false, reason }`, `ok` still the literal every result union discriminates on. * - * A refusal that carries more than a reason keeps its own helper, because the extra fields are part - * of what the caller has to act on: see `installations/backup.ts` and `installations/restore.ts`. + * A refusal that carries more than a reason keeps its own helper, because those extra fields are + * part of what the caller has to act on: see `installations/backup.ts` and `installations/restore.ts`. */ export function refuse(reason: R): { ok: false; reason: R } { return { ok: false, reason } diff --git a/src/ipc/validation.ts b/src/ipc/validation.ts index 7c6c0f4f..b1739481 100644 --- a/src/ipc/validation.ts +++ b/src/ipc/validation.ts @@ -6,7 +6,8 @@ import semver from "semver" import { RESTORE_REPLACED_SUFFIX, RESTORE_STAGING_SUFFIX } from "../domain/installations/restore" import { isRecord } from "../domain/records" -// Re-exported unchanged so the host code and the tests that reach for it here keep doing so. +// The guard moved to the domain (#484). Re-exported unchanged so every caller that reaches for it +// here, tests included, keeps its import path. export { isRecord } export const MAX_IPC_STRING_LENGTH = 8_192