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/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/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/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/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/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/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/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. diff --git a/src/domain/records.ts b/src/domain/records.ts new file mode 100644 index 00000000..2045d899 --- /dev/null +++ b/src/domain/records.ts @@ -0,0 +1,12 @@ +/** + * The object guard the launcher narrows an unknown value with. + * + * 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 new file mode 100644 index 00000000..57487b67 --- /dev/null +++ b/src/domain/refusal.ts @@ -0,0 +1,13 @@ +/** + * The one way a domain flow says no. + * + * 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 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/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/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 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. */ 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..b1739481 100644 --- a/src/ipc/validation.ts +++ b/src/ipc/validation.ts @@ -4,6 +4,11 @@ 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" + +// 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 export const MAX_PATH_LENGTH = 4_096 @@ -120,10 +125,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}`)