forked from OctoWoW/OctoLauncher
OctoLauncher 1.3.1
Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client: launcher-owned realmlist, torrent-backed content sync with bundled aria2c, antivirus and Defender exclusion handling, hardware-aware render distance, optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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 = <T extends object>(obj: T): Partial<T> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(obj).filter(([, v]) => v !== undefined)
|
||||
) as Partial<T>;
|
||||
|
||||
abstract class Preferences {
|
||||
static #data: z.infer<typeof PreferencesSchema>;
|
||||
static #writeChain: Promise<void> = 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<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<string, unknown> = 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<string, unknown>)[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<Omit<PreferencesSchema, 'portableDir'>>) {
|
||||
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<string, unknown> | null = null;
|
||||
try {
|
||||
const onDisk = await readJsonRetrying(settingsPath);
|
||||
base =
|
||||
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
|
||||
? (onDisk as Record<string, unknown>)
|
||||
: 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;
|
||||
Reference in New Issue
Block a user