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:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "octo-launcher",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"description": "An Electron application for launching and updating the OctoWoW client",
|
||||
"author": "OctoWoW",
|
||||
"copyright": "Copyright © 2026 OctoWoW",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const FILL = 0x78;
|
||||
|
||||
function pathVariants(p) {
|
||||
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
|
||||
return [...set].filter(Boolean);
|
||||
}
|
||||
|
||||
const root = process.cwd();
|
||||
const home = os.homedir();
|
||||
let username = '';
|
||||
try {
|
||||
username = os.userInfo().username;
|
||||
} catch {
|
||||
username = '';
|
||||
}
|
||||
|
||||
const secrets = [];
|
||||
if (root.length > 2) secrets.push(...pathVariants(root));
|
||||
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
|
||||
if (username.length >= 4) secrets.push(username);
|
||||
|
||||
const needles = [...new Set(secrets)]
|
||||
.filter(s => s.length > 0)
|
||||
.map(s => s.toLowerCase())
|
||||
.sort((a, b) => b.length - a.length);
|
||||
|
||||
function scanAndFill(buf, s, stride) {
|
||||
const n = s.length;
|
||||
const span = n * stride;
|
||||
if (span === 0 || span > buf.length) return 0;
|
||||
let hits = 0;
|
||||
outer: for (let i = 0; i + span <= buf.length; i++) {
|
||||
for (let j = 0; j < n; j++) {
|
||||
const at = i + j * stride;
|
||||
let b = buf[at];
|
||||
if (b >= 0x41 && b <= 0x5a) b += 0x20;
|
||||
if (b !== s.charCodeAt(j)) continue outer;
|
||||
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
|
||||
}
|
||||
buf.fill(FILL, i, i + span);
|
||||
hits++;
|
||||
i += span - 1;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function redact(buf) {
|
||||
let hits = 0;
|
||||
for (const s of needles) {
|
||||
hits += scanAndFill(buf, s, 1);
|
||||
hits += scanAndFill(buf, s, 2);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
function collect(dir, out) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) collect(p, out);
|
||||
else if (e.isFile() && p.endsWith('.node')) out.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const addons = [];
|
||||
collect(path.join(root, 'node_modules'), addons);
|
||||
|
||||
let files = 0;
|
||||
let total = 0;
|
||||
const redacted = [];
|
||||
for (const file of addons) {
|
||||
try {
|
||||
const buf = fs.readFileSync(file);
|
||||
const hits = redact(buf);
|
||||
if (hits > 0) {
|
||||
fs.writeFileSync(file, buf);
|
||||
files++;
|
||||
total += hits;
|
||||
redacted.push(`${path.relative(root, file)} (${hits})`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
|
||||
for (const r of redacted) console.log(` ${r}`);
|
||||
+9
-1
@@ -15,6 +15,7 @@ const allowedExtra = [
|
||||
];
|
||||
|
||||
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
|
||||
const raidVisuals = ['patch-O.mpq'];
|
||||
|
||||
const skipFiles = new Set([
|
||||
'manifest.json',
|
||||
@@ -45,7 +46,7 @@ const isSkipDir = (...filePath: string[]) =>
|
||||
skipDirsPosix.has(filePath.join('/'));
|
||||
|
||||
type FolderTags = 'allowExtra';
|
||||
type FileTags = 'vanillaFixes';
|
||||
type FileTags = 'vanillaFixes' | 'raidVisuals';
|
||||
|
||||
type FileManifest = { name: string } & (
|
||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||
@@ -191,6 +192,12 @@ export const buildCache = async (
|
||||
if (stats.isDirectory()) {
|
||||
if (isSkipDir(...filePath, file)) continue;
|
||||
if (file.match(/patch-./)) {
|
||||
if (raidVisuals.includes(`${file}.mpq`))
|
||||
throw new Error(
|
||||
`${file}/ exists beside ${file}.mpq. Opt-in archives must stay ` +
|
||||
'whole-file: an mpq node carries no tags, so this would ' +
|
||||
'ship the patch to every player regardless of preference.'
|
||||
);
|
||||
patches.push(file);
|
||||
const mpqRelPath = path
|
||||
.join(...filePath, `${file}.mpq`)
|
||||
@@ -243,6 +250,7 @@ export const buildCache = async (
|
||||
|
||||
const tags: FileTags[] = [];
|
||||
vanillaFixes.includes(file) && tags.push('vanillaFixes');
|
||||
raidVisuals.includes(file) && tags.push('raidVisuals');
|
||||
|
||||
tree.push({
|
||||
type: 'file',
|
||||
|
||||
@@ -4,6 +4,7 @@ export const ModIdSchema = z.enum([
|
||||
'dxvk',
|
||||
'nampower',
|
||||
'multiMonitorFix',
|
||||
'superWow',
|
||||
'transmogFix',
|
||||
'unitXp',
|
||||
'vanillaFixes',
|
||||
@@ -19,6 +20,7 @@ export type ModSource =
|
||||
apiUrl?: string;
|
||||
pinnedTag?: string;
|
||||
assetName: string;
|
||||
sha256?: string;
|
||||
}
|
||||
| {
|
||||
kind: 'archive';
|
||||
@@ -28,6 +30,7 @@ export type ModSource =
|
||||
pinnedTag?: string;
|
||||
format: 'zip' | 'tar.gz';
|
||||
extractMap: Record<string, string>;
|
||||
sha256?: string;
|
||||
}
|
||||
| { kind: 'managed' };
|
||||
|
||||
@@ -98,6 +101,28 @@ export const MODS: ModEntry[] = [
|
||||
},
|
||||
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
|
||||
},
|
||||
{
|
||||
id: 'superWow',
|
||||
name: 'SuperWoW',
|
||||
version: '2.2',
|
||||
description:
|
||||
'Extends the client Lua API with unit GUIDs and other data many addons rely on.',
|
||||
repoUrl: 'https://github.com/balakethelock/SuperWoW',
|
||||
requires: ['vanillaFixes'],
|
||||
source: {
|
||||
kind: 'archive',
|
||||
url: 'https://github.com/balakethelock/SuperWoW/releases/download/Release/SuperWoW.release.2.2.zip',
|
||||
apiUrl:
|
||||
'https://api.github.com/repos/balakethelock/SuperWoW/releases/latest',
|
||||
parseLatest: 'githubRelease',
|
||||
pinnedTag: '2.2',
|
||||
format: 'zip',
|
||||
extractMap: {
|
||||
'SuperWoWhook.dll': 'SuperWoWhook.dll'
|
||||
}
|
||||
},
|
||||
registerInDllsTxt: 'SuperWoWhook.dll'
|
||||
},
|
||||
{
|
||||
id: 'transmogFix',
|
||||
name: 'transmogFix',
|
||||
@@ -177,3 +202,9 @@ export const MODS: ModEntry[] = [
|
||||
|
||||
export const getMod = (id: ModId): ModEntry | undefined =>
|
||||
MODS.find(m => m.id === id);
|
||||
|
||||
const NOT_DEFAULT_ENABLED: ModId[] = [];
|
||||
|
||||
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
|
||||
m => !NOT_DEFAULT_ENABLED.includes(m.id)
|
||||
).map(m => m.id);
|
||||
|
||||
@@ -17,6 +17,7 @@ const f = {
|
||||
|
||||
export const ConfigWtfSchema = z.object({
|
||||
vanillaFixes: f.boolean(),
|
||||
raidVisuals: f.boolean(),
|
||||
largeAddress: f.boolean(true),
|
||||
nameplateRange: f.number(41),
|
||||
alwaysAutoLoot: f.boolean(),
|
||||
@@ -62,6 +63,8 @@ export const PreferencesSchema = z.object({
|
||||
.default('enUS'),
|
||||
localePatchLetter: z.string().optional(),
|
||||
localePatchLocale: z.string().optional(),
|
||||
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
|
||||
lastWrittenResolution: z.string().optional(),
|
||||
rememberPosition: f.boolean(),
|
||||
windowPosition: z
|
||||
.object({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { PreferencesSchema } from '~common/schemas';
|
||||
import zodResolver from '~renderer/utils/zodResolver';
|
||||
@@ -8,6 +8,7 @@ import { useT } from '~renderer/i18n';
|
||||
|
||||
import TextButton from './styled/TextButton';
|
||||
import FilePickerInput from './form/FilePickerInput';
|
||||
import CheckboxInput from './form/CheckboxInput';
|
||||
import CloseButton from './styled/CloseButton';
|
||||
|
||||
type Props = { close: () => void };
|
||||
@@ -36,6 +37,19 @@ const ClientDirDialog = ({ close }: Props) => {
|
||||
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
|
||||
});
|
||||
|
||||
const chosen = watch('clientDir');
|
||||
const [acceptEmpty, setAcceptEmpty] = useState(false);
|
||||
|
||||
const chosenIsClient = api.preferences.isValidClientDir.useQuery(chosen, {
|
||||
enabled: !!chosen && !pref?.isPortable
|
||||
});
|
||||
const needsEmptyConfirm =
|
||||
!!chosen && chosenIsClient.isFetched && chosenIsClient.data === false;
|
||||
|
||||
useEffect(() => {
|
||||
setAcceptEmpty(false);
|
||||
}, [chosen]);
|
||||
|
||||
useEffect(() => {
|
||||
pref && reset(pref);
|
||||
}, [reset, pref]);
|
||||
@@ -62,6 +76,7 @@ const ClientDirDialog = ({ close }: Props) => {
|
||||
<form
|
||||
className="tw-dialog"
|
||||
onSubmit={handleSubmit(async ({ clientDir }) => {
|
||||
if (needsEmptyConfirm && !acceptEmpty) return;
|
||||
try {
|
||||
await setPref.mutateAsync({ clientDir });
|
||||
verify.mutate();
|
||||
@@ -105,9 +120,23 @@ const ClientDirDialog = ({ close }: Props) => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{needsEmptyConfirm && (
|
||||
<>
|
||||
<p className="text-secondary text-sm">
|
||||
{t('prefs.noClientHere', { exe: 'WoW.exe' })}
|
||||
</p>
|
||||
<CheckboxInput
|
||||
value={acceptEmpty}
|
||||
setValue={setAcceptEmpty}
|
||||
label={t('prefs.noClientHereConfirm')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<TextButton
|
||||
type="submit"
|
||||
loading={formState.isSubmitting}
|
||||
disabled={needsEmptyConfirm && !acceptEmpty}
|
||||
className="self-end text-green"
|
||||
>
|
||||
{t('prefs.confirm')}
|
||||
|
||||
@@ -50,7 +50,6 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
|
||||
);
|
||||
};
|
||||
|
||||
// The "Announcements" list — most-recent forum topics as short previews + links.
|
||||
const AnnouncementsBox = () => {
|
||||
const t = useT();
|
||||
const query = api.news.list.useQuery(undefined, {
|
||||
@@ -108,9 +107,6 @@ const AnnouncementsBox = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// The News tab holds both boxes side by side: the parchment "newsletter" (the
|
||||
// featured Nautilus News Network post, biggest) and the "Announcements" list.
|
||||
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
|
||||
const NewsTab = () => (
|
||||
<div className="flex min-h-0 flex-grow gap-3">
|
||||
<ForumAnnouncementPanel />
|
||||
|
||||
@@ -117,6 +117,12 @@ const TweaksTab = () => {
|
||||
label={t('tweaks.alwaysAutoLoot.label')}
|
||||
text={t('tweaks.alwaysAutoLoot.text')}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="raidVisuals"
|
||||
label={t('tweaks.raidVisuals.label')}
|
||||
text={t('tweaks.raidVisuals.text')}
|
||||
/>
|
||||
<Item
|
||||
form={form}
|
||||
id="largeAddress"
|
||||
@@ -214,8 +220,15 @@ const TweaksTab = () => {
|
||||
onClick={async () => {
|
||||
const config =
|
||||
recommendedFarClip != null
|
||||
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
|
||||
: ConfigWtfSchema.parse({});
|
||||
? {
|
||||
...ConfigWtfSchema.parse({}),
|
||||
farClip: recommendedFarClip,
|
||||
raidVisuals: form.getValues('raidVisuals')
|
||||
}
|
||||
: {
|
||||
...ConfigWtfSchema.parse({}),
|
||||
raidVisuals: form.getValues('raidVisuals')
|
||||
};
|
||||
await setPref.mutateAsync({ config, farClipUserSet: false });
|
||||
reset(config);
|
||||
}}
|
||||
|
||||
@@ -34,6 +34,9 @@ const enUS: Dict = {
|
||||
'launch.remaining': 'remaining',
|
||||
'launch.calculating': 'calculating…',
|
||||
'launch.onDisk': 'on disk',
|
||||
'tweaks.raidVisuals.label': 'Updated Raid Visuals',
|
||||
'tweaks.raidVisuals.text':
|
||||
'Optional ~9 MB download. Adds clearer ground markers and sounds for raid boss abilities, kept in sync with the server automatically. (AKA Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': 'Always auto-loot',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
|
||||
@@ -224,6 +227,10 @@ const enUS: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.',
|
||||
'prefs.installDirectory': 'Install directory:',
|
||||
'prefs.noClientHere':
|
||||
'No {exe} in this folder. The launcher will download a fresh client here, and it will not contain your existing addons or settings. If you already have an install, pick that folder instead.',
|
||||
'prefs.noClientHereConfirm':
|
||||
'I understand — download a fresh client into this folder',
|
||||
'prefs.confirm': 'Confirm'
|
||||
};
|
||||
|
||||
@@ -260,6 +267,9 @@ const deDE: Dict = {
|
||||
'launch.remaining': 'verbleibend',
|
||||
'launch.calculating': 'wird berechnet…',
|
||||
'launch.onDisk': 'auf der Festplatte',
|
||||
'tweaks.raidVisuals.label': 'Aktualisierte Raid-Effekte',
|
||||
'tweaks.raidVisuals.text':
|
||||
'Optionaler Download (~9 MB). Fügt deutlichere Bodenmarkierungen und Sounds für Raidboss-Fähigkeiten hinzu, automatisch mit dem Server synchron gehalten. (auch bekannt als Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.',
|
||||
@@ -429,6 +439,10 @@ const deDE: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.',
|
||||
'prefs.installDirectory': 'Installationsverzeichnis:',
|
||||
'prefs.noClientHere':
|
||||
'In diesem Ordner ist keine {exe}. Der Launcher lädt hier einen neuen Client herunter, der deine vorhandenen Addons und Einstellungen nicht enthält. Wenn du bereits eine Installation hast, wähle stattdessen deren Ordner.',
|
||||
'prefs.noClientHereConfirm':
|
||||
'Verstanden — einen neuen Client in diesen Ordner herunterladen',
|
||||
'prefs.confirm': 'Bestätigen',
|
||||
'misc.newsTitle': 'Neuigkeiten',
|
||||
'misc.newsByAuthor': 'von {author}',
|
||||
@@ -485,6 +499,9 @@ const zhCN: Dict = {
|
||||
'launch.remaining': '剩余',
|
||||
'launch.calculating': '计算中…',
|
||||
'launch.onDisk': '在磁盘上',
|
||||
'tweaks.raidVisuals.label': '团队副本视觉增强',
|
||||
'tweaks.raidVisuals.text':
|
||||
'可选下载(约 9 MB)。为团队首领技能添加更清晰的地面标记和音效,并自动与服务器保持同步。(又称 Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': '始终自动拾取',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
|
||||
@@ -638,6 +655,9 @@ const zhCN: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
|
||||
'prefs.installDirectory': '安装目录:',
|
||||
'prefs.noClientHere':
|
||||
'此文件夹中没有 {exe}。启动器将在此处下载全新的客户端,其中不会包含你现有的插件和设置。如果你已经安装过,请改为选择原有的安装目录。',
|
||||
'prefs.noClientHereConfirm': '我已了解——在此文件夹下载全新客户端',
|
||||
'prefs.confirm': '确认',
|
||||
'misc.newsTitle': '新闻',
|
||||
'misc.newsByAuthor': '作者:{author}',
|
||||
@@ -693,6 +713,9 @@ const esES: Dict = {
|
||||
'launch.remaining': 'restante',
|
||||
'launch.calculating': 'calculando…',
|
||||
'launch.onDisk': 'en disco',
|
||||
'tweaks.raidVisuals.label': 'Efectos de banda mejorados',
|
||||
'tweaks.raidVisuals.text':
|
||||
'Descarga opcional de ~9 MB. Añade marcadores de suelo y sonidos más claros para las habilidades de los jefes de banda, sincronizados automáticamente con el servidor. (también conocido como Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.',
|
||||
@@ -861,6 +884,10 @@ const esES: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.',
|
||||
'prefs.installDirectory': 'Directorio de instalación:',
|
||||
'prefs.noClientHere':
|
||||
'No hay ningún {exe} en esta carpeta. El launcher descargará aquí un cliente nuevo, que no incluirá tus addons ni tu configuración actuales. Si ya tienes una instalación, elige esa carpeta.',
|
||||
'prefs.noClientHereConfirm':
|
||||
'Lo entiendo: descargar un cliente nuevo en esta carpeta',
|
||||
'prefs.confirm': 'Confirmar',
|
||||
'misc.newsTitle': 'Noticias',
|
||||
'misc.newsByAuthor': 'por {author}',
|
||||
@@ -919,6 +946,9 @@ const ptBR: Dict = {
|
||||
'launch.remaining': 'restante',
|
||||
'launch.calculating': 'calculando…',
|
||||
'launch.onDisk': 'no disco',
|
||||
'tweaks.raidVisuals.label': 'Efeitos de raide atualizados',
|
||||
'tweaks.raidVisuals.text':
|
||||
'Download opcional de ~9 MB. Adiciona marcações de chão e sons mais claros para as habilidades dos chefes de raide, mantidos em sincronia com o servidor automaticamente. (também conhecido como Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.',
|
||||
@@ -1086,6 +1116,9 @@ const ptBR: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.',
|
||||
'prefs.installDirectory': 'Diretório de instalação:',
|
||||
'prefs.noClientHere':
|
||||
'Não há {exe} nesta pasta. O launcher vai baixar um cliente novo aqui, sem os seus addons e configurações atuais. Se você já tem uma instalação, escolha a pasta dela.',
|
||||
'prefs.noClientHereConfirm': 'Entendi — baixar um cliente novo nesta pasta',
|
||||
'prefs.confirm': 'Confirmar',
|
||||
'misc.newsTitle': 'Notícias',
|
||||
'misc.newsByAuthor': 'por {author}',
|
||||
@@ -1142,6 +1175,9 @@ const ruRU: Dict = {
|
||||
'launch.remaining': 'осталось',
|
||||
'launch.calculating': 'вычисление…',
|
||||
'launch.onDisk': 'на диске',
|
||||
'tweaks.raidVisuals.label': 'Улучшенные эффекты рейдов',
|
||||
'tweaks.raidVisuals.text':
|
||||
'Дополнительная загрузка (~9 МБ). Добавляет более заметные отметки на земле и звуки для способностей рейдовых боссов, автоматически синхронизируется с сервером. (также известен как Patch-O)',
|
||||
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
|
||||
'tweaks.alwaysAutoLoot.text':
|
||||
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
|
||||
@@ -1305,6 +1341,9 @@ const ruRU: Dict = {
|
||||
'prefs.upgradeExisting':
|
||||
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
|
||||
'prefs.installDirectory': 'Папка установки:',
|
||||
'prefs.noClientHere':
|
||||
'В этой папке нет {exe}. Лаунчер скачает сюда новый клиент, в котором не будет ваших текущих аддонов и настроек. Если игра уже установлена, выберите её папку.',
|
||||
'prefs.noClientHereConfirm': 'Понятно — скачать новый клиент в эту папку',
|
||||
'prefs.confirm': 'Подтвердить',
|
||||
'misc.newsTitle': 'Новости',
|
||||
'misc.newsByAuthor': 'от {author}',
|
||||
|
||||
Reference in New Issue
Block a user