Sync launcher 1.2.2: monitor-aware resolution ownership, SuperWoW mod and mods-on-by-default, Patch-O raid visuals toggle, background-sound CVar, launch/quit stability fixes
Build check / build (push) Has been cancelled
Build check / build (push) Has been cancelled
This commit is contained in:
@@ -47,6 +47,9 @@ export const launcherRouter = createTRPCRouter({
|
||||
await fs.remove(path.join(clientDir, 'WDB'));
|
||||
}
|
||||
|
||||
Logger.log('Syncing preferred monitor...');
|
||||
await Mods.verify();
|
||||
|
||||
Logger.log('Checking Config.wtf...');
|
||||
await patchConfig();
|
||||
|
||||
@@ -62,21 +65,17 @@ export const launcherRouter = createTRPCRouter({
|
||||
'launching WoW.exe directly (mods will not load).'
|
||||
);
|
||||
|
||||
const octoLocale = Preferences.data.locale || 'enUS';
|
||||
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
|
||||
Logger.log(
|
||||
useLoader
|
||||
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
|
||||
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
|
||||
? 'Launching via VanillaFixes...'
|
||||
: `Launching ${exePath}...`
|
||||
);
|
||||
const child = useLoader
|
||||
? spawn(loaderPath, ['WoW.exe'], {
|
||||
env: gameEnv,
|
||||
cwd: clientDir,
|
||||
detached: !minimizeToTrayOnPlay
|
||||
})
|
||||
: spawn(exePath, {
|
||||
env: gameEnv,
|
||||
cwd: clientDir,
|
||||
detached: !minimizeToTrayOnPlay
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
|
||||
Logger.initialize();
|
||||
Logger.errorHandler.startCatching();
|
||||
Logger.transports.ipc.level = false;
|
||||
Logger.info('Launcher starting...');
|
||||
|
||||
app.disableHardwareAcceleration();
|
||||
@@ -191,6 +192,16 @@ if (!gotSingleInstanceLock) {
|
||||
await createWindow();
|
||||
});
|
||||
|
||||
let settingsFlushed = false;
|
||||
app.on('before-quit', event => {
|
||||
if (settingsFlushed) return;
|
||||
settingsFlushed = true;
|
||||
event.preventDefault();
|
||||
Promise.race([Preferences.save(), new Promise(r => setTimeout(r, 3000))])
|
||||
.catch(e => Logger.error('Failed to flush settings before quit', e))
|
||||
.finally(() => app.quit());
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ class AddonsClass extends Observable<AddonsStatus> {
|
||||
: [];
|
||||
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
||||
dirs
|
||||
.filter(d => !d.startsWith('Blizzard_'))
|
||||
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
|
||||
.map(name => [name, { status: 'fetching' as const, folder: name }])
|
||||
);
|
||||
|
||||
|
||||
@@ -3,8 +3,21 @@ import { spawn } from 'node:child_process';
|
||||
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
export type DisplayDevice = {
|
||||
index: number;
|
||||
deviceName: string;
|
||||
deviceString: string;
|
||||
attached: boolean;
|
||||
primary: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
refresh: number;
|
||||
modes: string[];
|
||||
};
|
||||
|
||||
const SCRIPT = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
"$ProgressPreference = 'SilentlyContinue'",
|
||||
"Add-Type -TypeDefinition @'",
|
||||
'using System;',
|
||||
'using System.Runtime.InteropServices;',
|
||||
@@ -18,30 +31,79 @@ const SCRIPT = [
|
||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
|
||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;',
|
||||
' }',
|
||||
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
|
||||
' public struct DEVMODE {',
|
||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmDeviceName;',
|
||||
' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
|
||||
' public int dmFields; public int dmPositionX; public int dmPositionY;',
|
||||
' public int dmDisplayOrientation; public int dmDisplayFixedOutput;',
|
||||
' public short dmColor; public short dmDuplex; public short dmYResolution; public short dmTTOption; public short dmCollate;',
|
||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmFormName;',
|
||||
' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
|
||||
' public int dmDisplayFlags; public int dmDisplayFrequency;',
|
||||
' public int dmICMMethod; public int dmICMIntent; public int dmMediaType; public int dmDitherType;',
|
||||
' public int dmReserved1; public int dmReserved2; public int dmPanningWidth; public int dmPanningHeight;',
|
||||
' }',
|
||||
' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
|
||||
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);',
|
||||
' [DllImport("user32.dll", EntryPoint="EnumDisplaySettingsA", CharSet=CharSet.Ansi)]',
|
||||
' public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);',
|
||||
'}',
|
||||
"'@",
|
||||
'$dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
|
||||
'$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
|
||||
'for ($i = 0; [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0); $i++) {',
|
||||
' if ($dd.StateFlags -band 4) { Write-Output $i; exit 0 }',
|
||||
'}',
|
||||
'exit 1'
|
||||
'for ($i = 0; ; $i++) {',
|
||||
' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
|
||||
' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
|
||||
' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
|
||||
' $dm = New-Object VmmfDisplays+DEVMODE',
|
||||
' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
|
||||
' $cur = ""',
|
||||
' if ([VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, -1, [ref]$dm)) {',
|
||||
' $cur = "$($dm.dmPelsWidth)|$($dm.dmPelsHeight)|$($dm.dmDisplayFrequency)"',
|
||||
' }',
|
||||
' $modes = New-Object System.Collections.Generic.HashSet[string]',
|
||||
' for ($m = 0; ; $m++) {',
|
||||
' $d2 = New-Object VmmfDisplays+DEVMODE',
|
||||
' $d2.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($d2)',
|
||||
' if (-not [VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, $m, [ref]$d2)) { break }',
|
||||
' [void]$modes.Add("$($d2.dmPelsWidth)x$($d2.dmPelsHeight)")',
|
||||
' }',
|
||||
' Write-Output ("{0}`t{1}`t{2}`t{3}`t{4}`t{5}" -f $i, $dd.DeviceName, $dd.DeviceString, $dd.StateFlags, $cur, ($modes -join ","))',
|
||||
'}'
|
||||
].join('\n');
|
||||
|
||||
export const detectPrimaryDisplayIndex = (): Promise<number> => {
|
||||
if (os.platform() !== 'win32') return Promise.resolve(0);
|
||||
const parseRow = (line: string): DisplayDevice | undefined => {
|
||||
const f = line.split('\t');
|
||||
if (f.length < 6) return undefined;
|
||||
const index = Number(f[0]);
|
||||
const stateFlags = Number(f[3]);
|
||||
if (!Number.isInteger(index) || index < 0 || !Number.isInteger(stateFlags))
|
||||
return undefined;
|
||||
const [w, h, hz] = (f[4] || '').split('|').map(Number);
|
||||
return {
|
||||
index,
|
||||
deviceName: f[1],
|
||||
deviceString: f[2],
|
||||
attached: (stateFlags & 1) !== 0,
|
||||
primary: (stateFlags & 4) !== 0,
|
||||
width: Number.isFinite(w) ? w : 0,
|
||||
height: Number.isFinite(h) ? h : 0,
|
||||
refresh: Number.isFinite(hz) ? hz : 0,
|
||||
modes: (f[5] || '').split(',').filter(Boolean)
|
||||
};
|
||||
};
|
||||
|
||||
export const enumerateDisplays = (): Promise<DisplayDevice[] | null> => {
|
||||
if (os.platform() !== 'win32') return Promise.resolve(null);
|
||||
|
||||
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
|
||||
|
||||
return new Promise(resolve => {
|
||||
let settled = false;
|
||||
const finish = (index: number) => {
|
||||
const finish = (v: DisplayDevice[] | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve(index);
|
||||
resolve(v);
|
||||
};
|
||||
|
||||
const child = spawn(
|
||||
@@ -52,25 +114,33 @@ export const detectPrimaryDisplayIndex = (): Promise<number> => {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
child.kill();
|
||||
Logger.warn('Primary display detection timed out');
|
||||
finish(0);
|
||||
}, 8000);
|
||||
Logger.warn('Display enumeration timed out');
|
||||
finish(null);
|
||||
}, 10000);
|
||||
|
||||
let stdout = '';
|
||||
child.stdout.on('data', d => (stdout += String(d)));
|
||||
child.on('error', e => {
|
||||
Logger.warn('Primary display detection failed to launch PowerShell', e);
|
||||
finish(0);
|
||||
Logger.warn('Display enumeration failed to launch PowerShell', e);
|
||||
finish(null);
|
||||
});
|
||||
child.on('exit', code => {
|
||||
const index = Number(stdout.trim());
|
||||
if (code === 0 && Number.isInteger(index) && index >= 0) {
|
||||
Logger.info(`Detected primary display at device index ${index}`);
|
||||
finish(index);
|
||||
const devices = stdout
|
||||
.split(/\r?\n/)
|
||||
.map(parseRow)
|
||||
.filter((d): d is DisplayDevice => d !== undefined);
|
||||
if (code === 0 && devices.length) {
|
||||
Logger.info(`Enumerated ${devices.length} display device(s)`);
|
||||
finish(devices);
|
||||
} else {
|
||||
Logger.warn('Primary display detection failed, defaulting to 0');
|
||||
finish(0);
|
||||
Logger.warn('Display enumeration returned nothing usable');
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
|
||||
const devices = await enumerateDisplays();
|
||||
return devices?.find(d => d.primary && d.attached)?.index ?? null;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,9 @@ import Logger from 'electron-log/main';
|
||||
import Preferences from './preferences';
|
||||
|
||||
const PREFERRED = 'L';
|
||||
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const ALL_LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const RAID_LETTER = 'O';
|
||||
const ALLOC_LETTERS = ALL_LETTERS.filter(l => l !== RAID_LETTER);
|
||||
const MARKER = 'octolocale.marker';
|
||||
|
||||
const patchFile = (dataDir: string, letter: string) =>
|
||||
@@ -42,7 +44,7 @@ const usableSlot = (dataDir: string, letter: string): boolean => {
|
||||
};
|
||||
|
||||
const removeOurPatch = async (dataDir: string) => {
|
||||
for (const l of LETTERS) {
|
||||
for (const l of ALL_LETTERS) {
|
||||
const f = patchFile(dataDir, l);
|
||||
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
||||
}
|
||||
@@ -79,10 +81,12 @@ export const applyLocalePatch = async (
|
||||
|
||||
const tracked = Preferences.data.localePatchLetter;
|
||||
const letter =
|
||||
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
|
||||
(tracked && tracked !== RAID_LETTER && usableSlot(dataDir, tracked)
|
||||
? tracked
|
||||
: undefined) ??
|
||||
(usableSlot(dataDir, PREFERRED)
|
||||
? PREFERRED
|
||||
: LETTERS.find(l => usableSlot(dataDir, l)));
|
||||
: ALLOC_LETTERS.find(l => usableSlot(dataDir, l)));
|
||||
if (!letter) {
|
||||
Logger.warn('Locale patch: no usable patch slot');
|
||||
return;
|
||||
@@ -100,7 +104,7 @@ export const applyLocalePatch = async (
|
||||
}
|
||||
|
||||
try {
|
||||
for (const l of LETTERS) {
|
||||
for (const l of ALL_LETTERS) {
|
||||
if (l === letter) continue;
|
||||
const f = patchFile(dataDir, l);
|
||||
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
||||
|
||||
+78
-10
@@ -1,4 +1,5 @@
|
||||
import path from 'path';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import fetch from 'node-fetch';
|
||||
@@ -13,7 +14,7 @@ import Preferences from './preferences';
|
||||
import Observable from './observable';
|
||||
import Updater from './updater';
|
||||
import { addDll, removeDll } from './dllsTxt';
|
||||
import { detectPrimaryDisplayIndex } from './displays';
|
||||
import { enumerateDisplays } from './displays';
|
||||
|
||||
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||
|
||||
@@ -105,6 +106,69 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
};
|
||||
}
|
||||
|
||||
async #syncPreferredMonitor(clientDir: string) {
|
||||
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
|
||||
if (!(await fs.pathExists(vmmfDll))) return;
|
||||
|
||||
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
|
||||
const existsCfg = await fs.pathExists(vmmfCfg);
|
||||
const current = existsCfg
|
||||
? Number(
|
||||
await fs
|
||||
.readFile(vmmfCfg, 'utf8')
|
||||
.then(s => s.trim())
|
||||
.catch(() => '')
|
||||
)
|
||||
: NaN;
|
||||
const hasCurrent = Number.isInteger(current);
|
||||
const ours = Preferences.data?.vmmfWrittenIndex;
|
||||
|
||||
const devices = await enumerateDisplays();
|
||||
const usable = devices?.filter(d => d.attached && d.width > 0);
|
||||
if (!devices || !usable?.length) {
|
||||
Logger.warn('Could not enumerate displays; preferred monitor unchanged');
|
||||
return;
|
||||
}
|
||||
const primary = usable.find(d => d.primary) ?? usable[0];
|
||||
|
||||
if (hasCurrent && ours === undefined) {
|
||||
const pinned = devices.find(d => d.index === current);
|
||||
const broken = !pinned || !pinned.attached || !pinned.primary;
|
||||
if (!broken) {
|
||||
Preferences.data = { vmmfWrittenIndex: current };
|
||||
Logger.info(`Adopting existing preferred monitor ${current} as chosen`);
|
||||
return;
|
||||
}
|
||||
Logger.warn(
|
||||
`Preferred monitor ${current} (${
|
||||
pinned ? pinned.deviceName : 'missing'
|
||||
}) is ${
|
||||
!pinned || !pinned.attached
|
||||
? 'not attached'
|
||||
: 'not the primary display'
|
||||
}; healing to ${primary.index}`
|
||||
);
|
||||
} else if (hasCurrent && current !== ours) {
|
||||
Logger.info(
|
||||
`Preferred monitor ${current} was set manually; leaving it alone`
|
||||
);
|
||||
Preferences.data = { vmmfWrittenIndex: current };
|
||||
return;
|
||||
} else if (hasCurrent && current === primary.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
await fs
|
||||
.writeFile(vmmfCfg, `${primary.index}\n`, 'utf8')
|
||||
.then(() => {
|
||||
Preferences.data = { vmmfWrittenIndex: primary.index };
|
||||
Logger.info(
|
||||
`Preferred monitor set to ${primary.index} (${primary.deviceName} ${primary.width}x${primary.height})`
|
||||
);
|
||||
})
|
||||
.catch(e => Logger.warn('Failed to write preferred monitor', e));
|
||||
}
|
||||
|
||||
async verify() {
|
||||
this.load();
|
||||
this._notifyObservers();
|
||||
@@ -112,12 +176,7 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
const clientDir = Preferences.data?.clientDir;
|
||||
|
||||
if (clientDir) {
|
||||
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
|
||||
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
|
||||
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) {
|
||||
const index = await detectPrimaryDisplayIndex();
|
||||
await fs.writeFile(vmmfCfg, `${index}\n`, 'utf8').catch(() => {});
|
||||
}
|
||||
await this.#syncPreferredMonitor(clientDir);
|
||||
}
|
||||
|
||||
for (const m of MODS) {
|
||||
@@ -258,7 +317,7 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
|
||||
if (m.source.kind === 'directFile') {
|
||||
const dest = path.join(clientDir, m.source.assetName);
|
||||
await this.#downloadTo(m.source.url, dest);
|
||||
await this.#downloadTo(m.source.url, dest, m.source.sha256);
|
||||
written.push(m.source.assetName);
|
||||
} else if (m.source.kind === 'archive') {
|
||||
const scratch = path.join(clientDir, '.octolauncher-tmp');
|
||||
@@ -267,7 +326,7 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
scratch,
|
||||
`${m.id}-${Date.now()}.${m.source.format}`
|
||||
);
|
||||
await this.#downloadTo(m.source.url, tmp);
|
||||
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
|
||||
this.#patchRow(m.id, { state: 'installing' });
|
||||
|
||||
const map = m.source.extractMap;
|
||||
@@ -360,7 +419,7 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
|
||||
}
|
||||
|
||||
async #downloadTo(url: string, dest: string) {
|
||||
async #downloadTo(url: string, dest: string, sha256?: string) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'OctoLauncher' },
|
||||
timeout: MOD_DOWNLOAD_TIMEOUT_MS
|
||||
@@ -368,6 +427,15 @@ class ModsClass extends Observable<ModsStatus> {
|
||||
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
||||
await fs.ensureDir(path.dirname(dest));
|
||||
const buf = await res.arrayBuffer();
|
||||
|
||||
if (sha256) {
|
||||
const got = createHash('sha256').update(Buffer.from(buf)).digest('hex');
|
||||
if (got !== sha256.toLowerCase())
|
||||
throw new Error(
|
||||
`Checksum mismatch for ${path.basename(dest)}: expected ${sha256}, got ${got}. Refusing to install.`
|
||||
);
|
||||
}
|
||||
|
||||
await fs.writeFile(dest, Buffer.from(buf));
|
||||
if (!(await fs.pathExists(dest)))
|
||||
throw new Error(
|
||||
|
||||
+147
-42
@@ -8,6 +8,7 @@ import Preferences from '~main/modules/preferences';
|
||||
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
|
||||
import { isNotUndef } from '~common/utils';
|
||||
import { fetchFile } from '~main/modules/updater';
|
||||
import { enumerateDisplays } from '~main/modules/displays';
|
||||
|
||||
const Servers = {
|
||||
live: {
|
||||
@@ -32,7 +33,7 @@ type Tweak = TweakKey & {
|
||||
} & (
|
||||
| {
|
||||
type: 'bytes';
|
||||
tweaks: [number, number[]][];
|
||||
tweaks: [number, number[], number[]?][];
|
||||
}
|
||||
| {
|
||||
type: 'int8' | 'uint16' | 'float';
|
||||
@@ -41,6 +42,9 @@ type Tweak = TweakKey & {
|
||||
}
|
||||
);
|
||||
|
||||
const hex = (bytes: number[]) =>
|
||||
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||
|
||||
export const patchExecutable = async () => {
|
||||
Logger.log('Patching WoW.exe...');
|
||||
|
||||
@@ -127,6 +131,20 @@ export const patchExecutable = async () => {
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
synthetic: true,
|
||||
key: 'levitateAnimRangeBounds',
|
||||
type: 'bytes',
|
||||
default: true,
|
||||
forced: true,
|
||||
tweaks: [
|
||||
[
|
||||
0x313d5f,
|
||||
[0x57, 0x8b, 0x55, 0x0c, 0x3b, 0xd0, 0x73, 0x0e],
|
||||
[0x85, 0xc0, 0x57, 0x74, 0x11, 0x8b, 0x55, 0x0c]
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
synthetic: true,
|
||||
key: 'octowowUrlAllowlist',
|
||||
@@ -137,8 +155,8 @@ export const patchExecutable = async () => {
|
||||
[
|
||||
0x45ccd8,
|
||||
[
|
||||
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -160,9 +178,24 @@ export const patchExecutable = async () => {
|
||||
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
|
||||
} else if (t.type === 'bytes') {
|
||||
if (!t.forced && !val) return;
|
||||
t.tweaks.forEach(([offset, bytes]) =>
|
||||
Buffer.from(bytes).copy(buffer, offset)
|
||||
);
|
||||
t.tweaks.forEach(([offset, bytes, expect]) => {
|
||||
if (expect) {
|
||||
const current = buffer.subarray(offset, offset + expect.length);
|
||||
if (current.equals(Buffer.from(bytes))) return;
|
||||
if (!current.equals(Buffer.from(expect)))
|
||||
throw new Error(
|
||||
`"${t.key}" expected [${hex(expect)}] at 0x${offset.toString(16)} ` +
|
||||
`but found [${hex([...current])}] - refusing to patch WoW.exe`
|
||||
);
|
||||
}
|
||||
const written = Buffer.from(bytes).copy(buffer, offset);
|
||||
if (written !== bytes.length)
|
||||
Logger.error(
|
||||
`"${t.key}" wrote ${written}/${bytes.length} bytes at ` +
|
||||
`0x${offset.toString(16)} - past end of file (${buffer.length} bytes). ` +
|
||||
'This tweak is a no-op; the offset is probably a virtual address.'
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -174,6 +207,48 @@ export const patchExecutable = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const repairResolution = async (
|
||||
clientDir: string,
|
||||
current: string | undefined,
|
||||
lastWritten: string | undefined
|
||||
): Promise<{ gxResolution?: string }> => {
|
||||
const devices = await enumerateDisplays();
|
||||
if (!devices?.length) return {};
|
||||
|
||||
const pinnedRaw = await fs
|
||||
.readFile(path.join(clientDir, 'VMMFix_preferred_monitor.txt'), 'utf8')
|
||||
.then(s => s.trim())
|
||||
.catch(() => '');
|
||||
const pinned = pinnedRaw ? Number(pinnedRaw) : NaN;
|
||||
const target =
|
||||
devices.find(d => d.index === pinned && d.attached) ??
|
||||
devices.find(d => d.primary && d.attached);
|
||||
if (!target?.modes.length) return {};
|
||||
|
||||
const native = `${target.width}x${target.height}`;
|
||||
if (!target.modes.includes(native)) return {};
|
||||
|
||||
let owned = !current || current === lastWritten;
|
||||
|
||||
if (!owned && lastWritten === undefined && current) {
|
||||
const width = Number(current.split('x')[0]);
|
||||
if (Number.isFinite(width) && width * 2 < target.width) {
|
||||
Logger.warn(
|
||||
`gxResolution ${current} is far below ${target.deviceName}'s ${native} and predates resolution tracking; treating it as a client fallback`
|
||||
);
|
||||
owned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!owned) return {};
|
||||
if (current === native) return {};
|
||||
|
||||
Logger.warn(
|
||||
`gxResolution ${current ?? '<unset>'} is launcher-owned; correcting to ${target.deviceName}'s ${native}`
|
||||
);
|
||||
return { gxResolution: native };
|
||||
};
|
||||
|
||||
export const patchConfig = async (forceTweaks = false) => {
|
||||
const { clientDir, server, config, locale } = Preferences.data;
|
||||
if (!clientDir) return;
|
||||
@@ -194,50 +269,74 @@ export const patchConfig = async (forceTweaks = false) => {
|
||||
.filter(isNotUndef)
|
||||
);
|
||||
|
||||
const isFirstRun = Object.keys(configWtf).length === 0;
|
||||
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const scale = primaryDisplay.scaleFactor || 1;
|
||||
const width = Math.round(primaryDisplay.bounds.width * scale);
|
||||
const height = Math.round(primaryDisplay.bounds.height * scale);
|
||||
|
||||
const parsed = {
|
||||
scriptMemory: 512000,
|
||||
gxResolution: `${width}x${height}`,
|
||||
gxColorBits: primaryDisplay.colorDepth,
|
||||
gxDepthBits: primaryDisplay.colorDepth,
|
||||
gxRefresh: 60,
|
||||
gxMultisample: 8,
|
||||
gxMultisampleQuality: 0,
|
||||
gxTripleBuffer: 1,
|
||||
anisotropic: 16,
|
||||
frillDensity: 48,
|
||||
fullAlpha: 1,
|
||||
SmallCull: 0.01,
|
||||
DistCull: 888.8,
|
||||
shadowLevel: 0,
|
||||
trilinear: 1,
|
||||
specular: 1,
|
||||
pixelShaders: 1,
|
||||
M2UsePixelShaders: 1,
|
||||
particleDensity: 1,
|
||||
unitDrawDist: 300,
|
||||
weatherDensity: 3,
|
||||
movieSubtitle: 1,
|
||||
minimapZoom: 0,
|
||||
minimapInsideZoom: 0,
|
||||
SoundZoneMusicNoDelay: 1,
|
||||
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
||||
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
||||
gxWindow: configWtf['gxWindow'] ?? 1,
|
||||
gxMaximize: configWtf['gxMaximize'] ?? 1,
|
||||
gxCursor: configWtf['gxCursor'] ?? 1,
|
||||
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
|
||||
farClip: configWtf['farClip'] ?? config.farClip,
|
||||
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
|
||||
...configWtf,
|
||||
const seededResolution = `${width}x${height}`;
|
||||
|
||||
const seed = isFirstRun
|
||||
? {
|
||||
scriptMemory: 512000,
|
||||
gxResolution: seededResolution,
|
||||
gxColorBits: primaryDisplay.colorDepth,
|
||||
gxDepthBits: primaryDisplay.colorDepth,
|
||||
gxRefresh: 60,
|
||||
gxMultisample: 8,
|
||||
gxMultisampleQuality: 0,
|
||||
gxTripleBuffer: 1,
|
||||
anisotropic: 16,
|
||||
frillDensity: 48,
|
||||
fullAlpha: 1,
|
||||
SmallCull: 0.01,
|
||||
DistCull: 888.8,
|
||||
shadowLevel: 0,
|
||||
trilinear: 1,
|
||||
specular: 1,
|
||||
pixelShaders: 1,
|
||||
M2UsePixelShaders: 1,
|
||||
M2UseShaders: 1,
|
||||
particleDensity: 1,
|
||||
unitDrawDist: 300,
|
||||
weatherDensity: 3,
|
||||
movieSubtitle: 1,
|
||||
minimapZoom: 0,
|
||||
minimapInsideZoom: 0,
|
||||
SoundZoneMusicNoDelay: 1,
|
||||
gxWindow: 1,
|
||||
gxMaximize: 1,
|
||||
gxCursor: 1,
|
||||
checkAddonVersion: 0,
|
||||
farClip: config.farClip,
|
||||
CameraDistanceMax: config.cameraDistance,
|
||||
patchList: Servers[server].patchList,
|
||||
realmName: Servers[server].realmName
|
||||
}
|
||||
: {};
|
||||
|
||||
const owned = {
|
||||
locale,
|
||||
realmList: Servers[server].realmList,
|
||||
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
||||
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
||||
hwDetect: 0,
|
||||
M2UseShaders: 1,
|
||||
BackgroundSound: config.soundInBackground ? 1 : 0
|
||||
};
|
||||
|
||||
const repaired = await repairResolution(
|
||||
clientDir,
|
||||
configWtf['gxResolution'],
|
||||
Preferences.data.lastWrittenResolution
|
||||
);
|
||||
|
||||
const parsed = {
|
||||
...seed,
|
||||
...configWtf,
|
||||
...repaired,
|
||||
...owned,
|
||||
...(forceTweaks
|
||||
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
|
||||
: {})
|
||||
@@ -250,5 +349,11 @@ export const patchConfig = async (forceTweaks = false) => {
|
||||
const tmpPath = `${configPath}.tmp`;
|
||||
await fs.writeFile(tmpPath, body);
|
||||
await fs.move(tmpPath, configPath, { overwrite: true });
|
||||
|
||||
const chosen =
|
||||
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
|
||||
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
|
||||
Preferences.data = { lastWrittenResolution: chosen };
|
||||
|
||||
Logger.log('Config.wtf successfully patched');
|
||||
};
|
||||
|
||||
+179
-34
@@ -6,40 +6,143 @@ 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';
|
||||
|
||||
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 async load() {
|
||||
await fs.ensureDir(this.userDataDir);
|
||||
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
||||
static readonly #settingsPath = path.join(
|
||||
Preferences.userDataDir,
|
||||
'settings.json'
|
||||
);
|
||||
|
||||
let json: Record<string, unknown>;
|
||||
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 fs.readJSON(settingsPath);
|
||||
} catch {
|
||||
return PreferencesSchema.parse({
|
||||
isPortable: !!portableDir,
|
||||
clientDir: portableDir
|
||||
});
|
||||
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 = {
|
||||
const merged = dropUndefined({
|
||||
...json,
|
||||
isPortable: !!portableDir,
|
||||
clientDir: portableDir ?? json.clientDir
|
||||
};
|
||||
});
|
||||
|
||||
const parsed = PreferencesSchema.safeParse(merged);
|
||||
if (parsed.success) return parsed.data;
|
||||
if (parsed.success)
|
||||
return this.#withKnownClientDir(
|
||||
this.#withFreshInstallDefaults(parsed.data)
|
||||
);
|
||||
|
||||
Logger.warn(
|
||||
'settings.json failed validation; salvaging valid fields',
|
||||
@@ -47,17 +150,40 @@ abstract class Preferences {
|
||||
);
|
||||
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
|
||||
|
||||
const salvaged: Record<string, unknown> = {
|
||||
const salvaged: Record<string, unknown> = dropUndefined({
|
||||
isPortable: !!portableDir,
|
||||
clientDir: portableDir ?? json.clientDir
|
||||
};
|
||||
});
|
||||
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;
|
||||
}
|
||||
return PreferencesSchema.parse(salvaged);
|
||||
return this.#withKnownClientDir(PreferencesSchema.parse(salvaged));
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -67,31 +193,50 @@ abstract class Preferences {
|
||||
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
||||
this.#data = { ...this.#data, ...newData };
|
||||
|
||||
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
||||
const delta = omit(
|
||||
newData,
|
||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
||||
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 = omit(
|
||||
this.#data,
|
||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
||||
const snapshot = dropUndefined(
|
||||
omit(this.#data, dropped as (keyof PreferencesSchema)[])
|
||||
);
|
||||
this.#writeChain = this.#writeChain
|
||||
.then(async () => {
|
||||
let onDisk: unknown = null;
|
||||
let base: Record<string, unknown> | null = null;
|
||||
try {
|
||||
onDisk = await fs.readJSON(settingsPath);
|
||||
} catch {
|
||||
onDisk = null;
|
||||
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 base =
|
||||
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
|
||||
? (onDisk as Record<string, unknown>)
|
||||
: null;
|
||||
const merged = base ? { ...base, ...delta } : snapshot;
|
||||
const tmp = `${settingsPath}.tmp`;
|
||||
await fs.writeJSON(tmp, merged, { spaces: 2 });
|
||||
await fs.move(tmp, settingsPath, { overwrite: true });
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ const friendlyError = (e: unknown): string => {
|
||||
};
|
||||
|
||||
type FolderTags = 'allowExtra';
|
||||
type FileTags = 'vanillaFixes';
|
||||
type FileTags = 'vanillaFixes' | 'raidVisuals';
|
||||
type FileManifest = { name: string } & (
|
||||
| { type: 'del' }
|
||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||
@@ -558,6 +558,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
||||
|
||||
try {
|
||||
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
||||
const raidVisuals = Preferences.data.config.raidVisuals;
|
||||
const modOwnedFiles = new Set<string>();
|
||||
for (const state of Object.values(Preferences.data.mods ?? {}))
|
||||
for (const rel of state?.installedFiles ?? [])
|
||||
@@ -709,6 +710,12 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
||||
}
|
||||
}
|
||||
|
||||
if (item.tags?.includes('raidVisuals') && !raidVisuals) {
|
||||
if (await fs.exists(path.join(clientPath, ...filePath)))
|
||||
return { type: 'del', name: item.name };
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.status = {
|
||||
state: 'verifying',
|
||||
progress: i / totalSize,
|
||||
|
||||
@@ -10,6 +10,7 @@ if (!port) throw new Error('IllegalState');
|
||||
const { dir, url, ref } = workerData;
|
||||
|
||||
const tmpDir = `${dir}.tmp`;
|
||||
const bakDir = `${dir}.bak`;
|
||||
|
||||
const run = async () => {
|
||||
await fs.remove(tmpDir);
|
||||
@@ -23,8 +24,18 @@ const run = async () => {
|
||||
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
||||
});
|
||||
|
||||
await fs.remove(dir);
|
||||
await fs.move(tmpDir, dir);
|
||||
await fs.remove(bakDir);
|
||||
const hadExisting = await fs.pathExists(dir);
|
||||
if (hadExisting) await fs.move(dir, bakDir);
|
||||
|
||||
try {
|
||||
await fs.move(tmpDir, dir);
|
||||
} catch (e) {
|
||||
if (hadExisting) await fs.move(bakDir, dir).catch(() => undefined);
|
||||
throw e;
|
||||
}
|
||||
|
||||
await fs.remove(bakDir).catch(() => undefined);
|
||||
};
|
||||
|
||||
run()
|
||||
|
||||
Reference in New Issue
Block a user