import path from 'path'; import fs from 'fs-extra'; import { type z } from 'zod'; import { app } from 'electron'; import Logger from 'electron-log/main'; import { PreferencesSchema } from '~common/schemas'; import { DEFAULT_ENABLED_MODS } from '~common/mods'; import { omit } from '~common/utils'; import { isTorrentMode } from '~main/modules/aria2'; const portableDir = process.env.PORTABLE_EXECUTABLE_DIR; const errCode = (e: unknown) => e && typeof e === 'object' ? (e as NodeJS.ErrnoException).code : undefined; const LOCK_CODES = ['EPERM', 'EACCES', 'EBUSY', 'EMFILE', 'ENFILE']; const isLocked = (e: unknown) => LOCK_CODES.includes(errCode(e) ?? ''); const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const readJsonRetrying = async (file: string, attempts = 5) => { for (let i = 0; ; i++) { try { return await fs.readJSON(file); } catch (e) { if (i >= attempts - 1 || !isLocked(e)) throw e; await delay(60 * (i + 1)); } } }; const renameRetrying = async (from: string, to: string, attempts = 5) => { for (let i = 0; ; i++) { try { return await fs.rename(from, to); } catch (e) { if (i >= attempts - 1 || !isLocked(e)) throw e; await delay(60 * (i + 1)); } } }; const writeJsonAtomic = async (file: string, data: unknown) => { const tmp = `${file}.tmp`; await fs.writeJSON(tmp, data, { spaces: 2 }); await renameRetrying(tmp, file); }; const dropUndefined = (obj: T): Partial => Object.fromEntries( Object.entries(obj).filter(([, v]) => v !== undefined) ) as Partial; abstract class Preferences { static #data: z.infer; static #writeChain: Promise = Promise.resolve(); static #readOnly = false; static #rememberedClientDir?: string; static #freshInstall = false; static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR ? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher') : app.getPath('userData'); static readonly #settingsPath = path.join( Preferences.userDataDir, 'settings.json' ); static readonly #installPath = path.join( Preferences.userDataDir, 'install.json' ); static get isFreshInstall() { return this.#freshInstall; } static async #detectFreshInstall() { const [settings, install, pending] = await Promise.all([ fs.pathExists(this.#settingsPath), fs.pathExists(this.#installPath), fs.pathExists(`${this.#settingsPath}.tmp`) ]); return !settings && !install && !pending; } static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema { if (!this.#freshInstall || Object.keys(data.mods).length) return data; const mods = { ...data.mods }; for (const id of DEFAULT_ENABLED_MODS) mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false }; Logger.info( `Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default` ); return { ...data, mods }; } static async load() { this.#freshInstall = await this.#detectFreshInstall(); await fs.ensureDir(this.userDataDir); const settingsPath = this.#settingsPath; let json: Record = {}; try { json = await readJsonRetrying(settingsPath); } catch (e) { if (isLocked(e)) { this.#readOnly = true; Logger.error( `Could not read ${settingsPath} (${errCode(e)}); running on ` + 'defaults and leaving settings untouched for this session.', e ); } else { if (errCode(e) !== 'ENOENT') { Logger.warn(`${settingsPath} is unreadable; keeping a copy`, e); await fs .copy(settingsPath, `${settingsPath}.corrupt`) .catch(() => {}); } const recovered = await fs .readJSON(`${settingsPath}.tmp`) .catch(() => null); if (recovered && typeof recovered === 'object') { Logger.warn(`Recovered settings from ${settingsPath}.tmp`); json = recovered as Record; } } } const merged = dropUndefined({ ...json, isPortable: !!portableDir, clientDir: portableDir ?? json.clientDir }); const parsed = PreferencesSchema.safeParse(merged); if (parsed.success) return this.#withKnownClientDir( this.#withFreshInstallDefaults(parsed.data) ); Logger.warn( 'settings.json failed validation; salvaging valid fields', parsed.error ); await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {}); const salvaged: Record = dropUndefined({ isPortable: !!portableDir, // coerce to string/undefined; the shape loop never clears a set key, so a // non-string would survive and throw at the final parse clientDir: portableDir ?? (typeof json.clientDir === 'string' ? json.clientDir : undefined) }); const shape = PreferencesSchema.shape; for (const key of Object.keys(shape) as (keyof typeof shape)[]) { if (!(key in merged)) continue; const value = (merged as Record)[key]; if (shape[key].safeParse(value).success) salvaged[key] = value; } // defaults if even the salvaged set is invalid; never throw out of load() const salvagedParsed = PreferencesSchema.safeParse(salvaged); return this.#withKnownClientDir( salvagedParsed.success ? salvagedParsed.data : PreferencesSchema.parse({}) ); } static async #withKnownClientDir(data: PreferencesSchema) { if (portableDir) return data; const remembered = await fs .readJSON(this.#installPath) .then(j => { const dir = (j as { clientDir?: unknown })?.clientDir; return typeof dir === 'string' && dir ? dir : undefined; }) .catch(() => undefined); this.#rememberedClientDir = remembered; if (await this.isValidClientDir(data.clientDir)) return data; if (!remembered || remembered === data.clientDir) return data; if (!(await this.isValidClientDir(remembered))) return data; Logger.warn( `No usable clientDir in settings.json; restored "${remembered}" from ` + this.#installPath ); return { ...data, clientDir: remembered }; } static get data(): PreferencesSchema { return this.#data; } static set data(newData: Partial>) { this.#data = { ...this.#data, ...newData }; if (this.#readOnly) return; const settingsPath = this.#settingsPath; const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable']; const delta = dropUndefined( omit(newData, dropped as (keyof typeof newData)[]) ); const snapshot = dropUndefined( omit(this.#data, dropped as (keyof PreferencesSchema)[]) ); this.#writeChain = this.#writeChain .then(async () => { let base: Record | null = null; try { const onDisk = await readJsonRetrying(settingsPath); base = !!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk) ? (onDisk as Record) : null; } catch (e) { if (isLocked(e)) { Logger.error( `Skipping settings write; ${settingsPath} is locked (${errCode( e )})`, e ); return; } } const merged = base ? { ...base, ...delta } : snapshot; await writeJsonAtomic(settingsPath, merged); const clientDir = (merged as { clientDir?: unknown }).clientDir; if ( typeof clientDir === 'string' && clientDir && clientDir !== this.#rememberedClientDir ) { this.#rememberedClientDir = clientDir; await writeJsonAtomic(this.#installPath, { clientDir }).catch(e => Logger.warn(`Failed to write ${this.#installPath}`, e) ); } }) .catch(e => Logger.error('Failed to persist settings.json', e)); } static save() { return this.#writeChain; } static async isValidClientDir(clientDir?: string) { if (!clientDir) return false; if (await fs.exists(path.join(clientDir, 'WoW.exe'))) return true; // torrent mode: no WoW.exe yet, accept a dir the download can populate if (isTorrentMode()) return ( (await fs.exists(clientDir)) || (await fs.exists(path.dirname(clientDir))) ); return false; } } export default Preferences;