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
109 changes: 105 additions & 4 deletions src/config/configManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { join } from "node:path"
import { writeJsonAtomic } from "@src/ipc/atomicJsonFile"
import { logMessage } from "@src/utils/logManager"
import { parseLegacyAccount, toPublicAccount } from "@domain/account/credentials"
import { saveAccountSecrets } from "@src/ipc/accountStore"
import { adoptLegacySingleAccountSecrets, saveAccountSecrets } from "@src/ipc/accountStore"
import { isRecord } from "@src/ipc/validation"
import { clampConfigSchema, CURRENT_CONFIG_SCHEMA, migrateConfigDocument } from "@domain/config/migrations"
import { normalizeBackgroundId } from "@domain/backgrounds"
Expand Down Expand Up @@ -106,8 +106,13 @@ export async function getConfig(): Promise<ConfigType> {
const migration = migrateConfigDocument(config)
logConfigMigration(migration)
const ensuredConfig = normalizeConfig(migration.doc)
const reKeyedAccountStore = await migrateAccountStore(config, ensuredConfig)
// Every path that overwrites config.json gets the same backup, not just the schema pipeline:
// a re-key or a legacy-secrets migration writes just as real a document as a schema bump does.
const mustSave = hadLegacyAccountSecrets || reKeyedAccountStore || migration.applied.length > 0
if (mustSave) await backupConfigBeforeMigration()
configCache = ensuredConfig
if (hadLegacyAccountSecrets || migration.applied.length > 0) await saveConfig(ensuredConfig)
if (mustSave) await saveConfig(ensuredConfig)
return ensuredConfig
} catch (err) {
logMessage("error", `[back] [config] [config/configManager.ts] [getConfig] Error getting config at ${configPath}. Using default config.`)
Expand Down Expand Up @@ -176,7 +181,7 @@ async function migrateLegacyAccount(config: unknown): Promise<boolean> {

if (legacyAccount) {
try {
await saveAccountSecrets(legacyAccount.secrets)
await saveAccountSecrets(legacyAccount.publicAccount.playerUid, legacyAccount.secrets)
} catch {
logMessage("warn", "[back] [config] [configManager.ts] Legacy account credentials were not migrated to secure storage.")
}
Expand All @@ -187,6 +192,76 @@ async function migrateLegacyAccount(config: unknown): Promise<boolean> {
return true
}

/**
* Re-keys a single-account secret store under the account it belongs to.
*
* The v1 store held one `AccountSecrets` with no account attached to it; the
* v2 store (see `src/ipc/accountStore.ts`) holds entries keyed by
* `playerUid`. The only place that missing key exists pre-migration is
* `config.account.playerUid`, which is why this reads the raw pre-pipeline
* document first, exactly as {@link migrateLegacyAccount} does, and not
* inside `accountStore.ts` itself: the store cannot name its own contents.
* Kept out of the schema pipeline for the same reason `migrateLegacyAccount`
* is: a side effect the pure domain layer is not allowed to have.
*
* Falls back to the already-migrated config's `activeAccountId` when the raw
* document has no legacy `account` field, which is what makes this retry
* automatically on every later launch rather than only the one that first
* saw the legacy field. `adoptLegacySingleAccountSecrets` is a no-op once the
* v1 file is gone or already re-keyed, so calling it again costs one failed
* read and nothing else; there is no separate "already migrated" flag to
* gate this on. Leaving the schema-4 commit itself ungated matters just as
* much as the retry: `normalizeConfig` drops any field it does not know,
* `account` included, so holding the document back at schema 3 would lose
* the account record outright on the very next save, which is worse than
* the stranded session this retry is fixing.
*
* A config with no readable account (raw or migrated), or a v1 store that
* has already been re-keyed (or never existed), makes this a safe no-op:
* `false` either way, same as `migrateLegacyAccount` when there is nothing
* to do.
*/
async function migrateAccountStore(legacyDocument: unknown, config: ConfigType): Promise<boolean> {
const legacyUid = isRecord(legacyDocument) && isRecord(legacyDocument.account) ? legacyDocument.account.playerUid : null
const uid = typeof legacyUid === "string" && legacyUid.length > 0 ? legacyUid : config.activeAccountId
if (uid === null || uid.length === 0) return false

try {
return await adoptLegacySingleAccountSecrets(uid)
} catch {
logMessage("warn", "[back] [config] [configManager.ts] The stored account session was not carried into the multi-account store. Retrying on the next launch.")
return false
}
}

function getConfigBackupPath(): string {
return join(app.getPath("userData"), "config.pre-migration.bak.json")
}

/**
* Copies `config.json` exactly as it sits on disk, before a schema migration
* overwrites it with the reshaped document.
*
* One rolling backup, not one per migration: this is a local single-writer
* desktop file, not a fleet of servers, so "the config as it was right before
* the most recent migration" is the useful recovery point, not a full
* history. `overwrite: false` with `errorOnExist: false` makes the first
* migration's backup the one that survives; a later migration on an already-
* backed-up config leaves that earlier, more original snapshot alone. Best
* effort: a failed backup logs and does not stop the migration, since the
* live config is still correct either way and refusing to proceed over a
* backup that could not be written would trade a small safety net for a
* launcher that will not start.
*/
async function backupConfigBeforeMigration(): Promise<void> {
try {
await fse.copy(configPath, getConfigBackupPath(), { overwrite: false, errorOnExist: false })
} catch (err) {
logMessage("warn", "[back] [config] [configManager.ts] Could not back up config.json before migrating it.")
logMessage("debug", `[back] [config] [configManager.ts] ${err}`)
}
}

function asString(value: unknown, fallback: string, maxLength = 4_096): string {
return typeof value === "string" && value.length <= maxLength && !value.includes("\0") ? value : fallback
}
Expand Down Expand Up @@ -262,6 +337,23 @@ function normalizeIcon(value: unknown): IconType | null {
return icon.id && icon.name && icon.icon.toLowerCase().endsWith(".png") ? icon : null
}

/** Ceiling on saved accounts, the same shape as the 1,000-entry caps above: generous for the real use case, not a promise to scale past it. */
const MAX_STORED_ACCOUNTS = 50

/** Reads the accounts list, dropping anything unreadable and deduplicating by `playerUid`. */
function normalizeAccounts(value: unknown): AccountPublicType[] {
const seen = new Set<string>()
return (Array.isArray(value) ? value : [])
.map(toPublicAccount)
.filter((account): account is AccountPublicType => account !== null)
.filter((account) => {
if (seen.has(account.playerUid)) return false
seen.add(account.playerUid)
return true
})
.slice(0, MAX_STORED_ACCOUNTS)
}

export function normalizeConfig(config: unknown): ConfigType {
const rawConfig = (isRecord(config) ? config : {}) as Partial<ConfigType>
const rawWindow = (isRecord(rawConfig.window) ? rawConfig.window : {}) as Partial<WindowType>
Expand All @@ -280,6 +372,8 @@ export function normalizeConfig(config: unknown): ConfigType {
.filter((icon): icon is IconType => icon !== null)
.slice(0, 1_000)

const accounts = normalizeAccounts(rawConfig.accounts)

const fixedConfig: ConfigType = {
schemaVersion: clampConfigSchema(rawConfig.schemaVersion),
lastUsedInstallation: rawConfig.lastUsedInstallation === null ? null : asString(rawConfig.lastUsedInstallation, defaultConfig.lastUsedInstallation ?? "", 128) || null,
Expand All @@ -293,7 +387,14 @@ export function normalizeConfig(config: unknown): ConfigType {
y: Math.trunc(asNumber(rawWindow.y, defaultConfig.window.y, -100_000, 100_000)),
maximized: asBoolean(rawWindow.maximized, defaultConfig.window.maximized)
},
account: toPublicAccount(rawConfig.account),
accounts,
// An id naming nobody falls back to the first saved account rather than to null: a
// household that lost its choice (a dangling id, or a config hand-edited down to one
// fewer account) should land on someone, not on "no account selected". Every reader
// downstream (EXECUTE_GAME, the renderer contexts) can then do a plain lookup with no
// fallback branch of its own, because this is the one place the invariant is enforced:
// activeAccountId either names an entry in accounts, or is null when accounts is empty.
activeAccountId: accounts.some((account) => account.playerUid === rawConfig.activeAccountId) ? (rawConfig.activeAccountId as string) : (accounts[0]?.playerUid ?? null),
installations,
gameVersions,
favMods: Array.isArray(rawConfig.favMods) ? rawConfig.favMods.filter((modId): modId is number => typeof modId === "number" && Number.isSafeInteger(modId)).slice(0, 10_000) : defaultConfig.favMods,
Expand Down
59 changes: 59 additions & 0 deletions src/domain/account/clientSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,62 @@ export async function writeClientSettingsSession(ports: WriteClientSettingsSessi

return written.ok ? { outcome: "written" } : { outcome: "write-failed" }
}

/**
* The document with the eight session keys removed and everything else intact.
*
* The mirror image of {@link mergeSessionIntoClientSettings}'s read-modify-write
* discipline: every other setting the file carries survives untouched.
*/
export function removeSessionFromClientSettings(existingDocument: unknown): Record<string, unknown> {
const document = existingDocument && typeof existingDocument === "object" && !Array.isArray(existingDocument) ? (existingDocument as Record<string, unknown>) : {}
const stringSettings = { ...existingStringSettings(existingDocument) }

for (const key of ["mptoken", "sessionkey", "sessionsignature", "useremail", "entitlements", "playeruid", "playername", "hostgameserver"]) delete stringSettings[key]

return { ...document, [STRING_SETTINGS_SECTION]: stringSettings }
}

export type ClearClientSettingsSessionResult =
| { outcome: "cleared" }
// Covers both a file with no session in it, and one that already holds our own: neither can
// launch anyone into the wrong identity, so neither is touched.
| { outcome: "not-foreign" }
| { outcome: "unreadable-settings" }
| { outcome: "write-failed" }

export interface ClearForeignClientSettingsSessionInput {
/** Full path of the settings file. The caller resolves it, so path policy stays on the host side. */
settingsPath: string
/** `playerUid` of the account the launcher is about to launch as. */
playerUid: string
}

/**
* Removes a session belonging to a DIFFERENT player from the settings file,
* and only that. Never writes our own account's session: that is
* {@link writeClientSettingsSession}'s job, and this function only runs when
* the caller has no secrets to write with (see gameHandlers.ts's EXECUTE_GAME).
*
* ## Why this exists
*
* With one saved account, a settings file the launcher could not update was
* harmless: whatever session it already held was that same account's own.
* With more than one, the file can hold a HOUSEMATE's session, since the game
* writes it there directly on its own successful login. Launching without
* clearing it would start the game already signed in as somebody else. This
* is narrow on purpose: it only fires when the file demonstrably holds a
* different player's `playeruid`, so it can never touch a file that already
* shows nobody, or shows us.
*/
export async function clearForeignClientSettingsSession(ports: WriteClientSettingsSessionPorts, input: ClearForeignClientSettingsSessionInput): Promise<ClearClientSettingsSessionResult> {
const existing = await ports.jsonFile.read(input.settingsPath)
if (!existing.ok) return { outcome: "unreadable-settings" }

const stringSettings = existingStringSettings(existing.document)
const foreignUid = typeof stringSettings.playeruid === "string" ? stringSettings.playeruid : null
if (foreignUid === null || foreignUid === input.playerUid) return { outcome: "not-foreign" }

const written = await ports.jsonFile.write(input.settingsPath, removeSessionFromClientSettings(existing.document))
return written.ok ? { outcome: "cleared" } : { outcome: "write-failed" }
}
37 changes: 37 additions & 0 deletions src/domain/account/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,43 @@ export function parseStoredSecrets(value: unknown): AccountSecrets | null {
}
}

/** One id-keyed entry as the multi-account encrypted store holds it. */
export type StoredAccountSecretsEntry = { id: string; secrets: AccountSecrets }

/**
* Reads the multi-account payload out of the decrypted store: `{ accounts: [{
* id, secrets }, ...] }`.
*
* An array of records rather than an object keyed by id on purpose: an object
* keyed by a service-supplied string invites a `__proto__`/`constructor` key,
* and an array has no such surface. An entry that does not parse is dropped
* rather than failing the whole read, the same reasoning `parseStoredSecrets`
* already gives for a single account: an unreadable entry logs that one
* account out, it does not cost every other saved account its session. On a
* duplicate id, the first entry wins.
*/
export function parseStoredSecretsById(value: unknown): Map<string, AccountSecrets> {
const result = new Map<string, AccountSecrets>()
if (!isRecord(value) || !Array.isArray(value.accounts)) return result

for (const entry of value.accounts) {
if (!isRecord(entry)) continue

let id: string
try {
id = accountString(entry.id, "account id", 256)
} catch {
continue
}
if (result.has(id)) continue

const secrets = parseStoredSecrets(entry.secrets)
if (secrets) result.set(id, secrets)
}

return result
}

/**
* Reads the renderer-visible half out of stored config, dropping anything else
* the object carried. A config file that still holds legacy session fields
Expand Down
3 changes: 2 additions & 1 deletion src/domain/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export const DEFAULT_CONFIG_BASE: Omit<ConfigType, "schemaVersion" | "defaultIns
y: 0,
maximized: false
},
account: null,
accounts: [],
activeAccountId: null,
installations: [],
gameVersions: [],
favMods: [],
Expand Down
34 changes: 32 additions & 2 deletions src/domain/config/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

/** Schema every config the launcher writes today carries. */
export const CURRENT_CONFIG_SCHEMA = 3
export const CURRENT_CONFIG_SCHEMA = 4

/**
* First schema expressed as an integer.
Expand Down Expand Up @@ -217,8 +217,38 @@ export const stampLinkedOnExternalVersions: ConfigMigration = {
}
}

/**
* One account becomes a list of accounts and a choice of which is active.
*
* The account's own `playerUid` is the key, because the launcher already
* treats it as the account's identity (see `sessionToAdopt` in
* `domain/account/clientSettings.ts`). A document with no readable account
* arrives at an empty list and no active choice, which is what a launcher
* nobody has logged into has always looked like. This step does not touch the
* account's session: that lives in the encrypted secret store, re-keyed
* separately by `migrateAccountStore` in `config/configManager.ts`, kept out
* of this pure pipeline for the same reason `migrateLegacyAccount` already
* is.
*/
export const singleAccountToAccountList: ConfigMigration = {
fromSchema: 3,
toSchema: 4,
migrate(doc: unknown): unknown {
if (!isRecord(doc)) return doc

const migrated = { ...doc }
const account = migrated.account
delete migrated.account

const uid = isRecord(account) && typeof account.playerUid === "string" && account.playerUid.length > 0 ? account.playerUid : null
migrated.accounts = uid ? [account] : []
migrated.activeAccountId = uid
return migrated
}
}

/** Every migration the launcher knows, lowest schema first. */
export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions]
export const CONFIG_MIGRATIONS: readonly ConfigMigration[] = [floatMarkerToIntegerSchema, stampLinkedOnExternalVersions, singleAccountToAccountList]

function byFromSchema(migrations: readonly ConfigMigration[]): Map<number, ConfigMigration> {
return new Map(migrations.map((migration) => [migration.fromSchema, migration]))
Expand Down
16 changes: 15 additions & 1 deletion src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,21 @@ declare global {

type ConfigType = BasicConfigType & {
window: WindowType
account: AccountPublicType | null
/**
* Every saved account, public half only. Keyed by `playerUid` (no separate
* `id` field: two identifiers that must always agree is a bug waiting to
* happen, and `playerUid` is already what clientSettings.ts trusts as an
* account's identity). The encrypted store holds the matching secrets
* under the same key. Deduplicated and capped by normalizeConfig.
*/
accounts: AccountPublicType[]
/**
* `playerUid` of the account the next game launch writes into
* `clientsettings.json`, or null when none is saved. normalizeConfig
* guarantees this either names an entry in `accounts` or is null, so
* every reader can look it up with a plain `find` and no fallback branch.
*/
activeAccountId: string | null
installations: InstallationType[]
gameVersions: GameVersionType[]
customIcons: IconType[]
Expand Down
Loading
Loading