Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/domain/account/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown> {
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"
Expand Down
14 changes: 6 additions & 8 deletions src/domain/account/modPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -61,10 +62,6 @@ export interface RepointModPathsResult {
outcome: RepointModPathsOutcome
}

function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : 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)
Expand Down Expand Up @@ -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" }
Expand All @@ -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"
}
}
6 changes: 2 additions & 4 deletions src/domain/appUpdate/canAutoUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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<string> = 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.
*
Expand Down
6 changes: 2 additions & 4 deletions src/domain/backgrounds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -113,10 +115,6 @@ export function backgroundCacheFileName(id: string): string {
return `${id}.jpg`
}

function isRecord(value: unknown): value is Record<string, unknown> {
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
Expand Down
6 changes: 2 additions & 4 deletions src/domain/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -110,10 +112,6 @@ export interface ConfigMigrationOptions {
readonly targetSchema?: number
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

/**
* Reads the schema a stored document is at.
*
Expand Down
17 changes: 5 additions & 12 deletions src/domain/installations/create.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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 }
}
Expand Down Expand Up @@ -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.
*
Expand All @@ -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,
Expand Down
5 changes: 1 addition & 4 deletions src/domain/installations/delete.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FileSystem } from "../ports"
import { refuse } from "../refusal"
import type { BackupRecord } from "./backup"
import { deleteInstallationBackup } from "./backupDeletion"

Expand Down Expand Up @@ -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.
*
Expand Down
6 changes: 2 additions & 4 deletions src/domain/moddbVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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<string, unknown> {
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 : ""
}
Expand Down
5 changes: 1 addition & 4 deletions src/domain/mods/install.ts
Original file line number Diff line number Diff line change
@@ -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"

Expand Down Expand Up @@ -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.
*
Expand Down
6 changes: 2 additions & 4 deletions src/domain/mods/moddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -35,10 +37,6 @@ export type ModDbResponse<T> = { 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<string, unknown> {
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
Expand Down
5 changes: 1 addition & 4 deletions src/domain/mods/profiles.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isRecord } from "../records"
import { MAX_MOD_ARCHIVES, renameModArchiveTo } from "./scanInstalled"

/**
Expand Down Expand Up @@ -72,10 +73,6 @@ export function emptyModProfilesDocument(): ModProfilesDocument {
return { format: MOD_PROFILES_FORMAT, activeProfileId: null, profiles: [] }
}

function isRecord(value: unknown): value is Record<string, unknown> {
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.
Expand Down
6 changes: 2 additions & 4 deletions src/domain/optimum/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -78,10 +80,6 @@ export interface OptimumManifest {
files: OptimumFile[]
}

function isRecord(value: unknown): value is Record<string, unknown> {
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
Expand Down
6 changes: 2 additions & 4 deletions src/domain/optimum/ndjson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -75,10 +77,6 @@ export interface OptimumOutputReader {
finish(): OptimumRunResult
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}

/**
* Reads the reason off a failed result.
*
Expand Down
30 changes: 15 additions & 15 deletions src/domain/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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. */
Expand All @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/domain/records.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
13 changes: 13 additions & 0 deletions src/domain/refusal.ts
Original file line number Diff line number Diff line change
@@ -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<R>(reason: R): { ok: false; reason: R } {
return { ok: false, reason }
}
Loading
Loading