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

This commit is contained in:
OctoWoW
2026-08-05 17:20:48 -07:00
parent 5812065b56
commit 1255e40d01
19 changed files with 767 additions and 131 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "octo-launcher", "name": "octo-launcher",
"version": "1.2.1", "version": "1.2.2",
"description": "An Electron application for launching and updating the OctoWoW client", "description": "An Electron application for launching and updating the OctoWoW client",
"author": "OctoWoW", "author": "OctoWoW",
"copyright": "Copyright © 2026 OctoWoW", "copyright": "Copyright © 2026 OctoWoW",
+97
View File
@@ -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
View File
@@ -15,6 +15,7 @@ const allowedExtra = [
]; ];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf']; const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([ const skipFiles = new Set([
'manifest.json', 'manifest.json',
@@ -45,7 +46,7 @@ const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/')); skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra'; type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes'; type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & ( type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] } | { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -191,6 +192,12 @@ export const buildCache = async (
if (stats.isDirectory()) { if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue; if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) { 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); patches.push(file);
const mpqRelPath = path const mpqRelPath = path
.join(...filePath, `${file}.mpq`) .join(...filePath, `${file}.mpq`)
@@ -243,6 +250,7 @@ export const buildCache = async (
const tags: FileTags[] = []; const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes'); vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({ tree.push({
type: 'file', type: 'file',
+31
View File
@@ -4,6 +4,7 @@ export const ModIdSchema = z.enum([
'dxvk', 'dxvk',
'nampower', 'nampower',
'multiMonitorFix', 'multiMonitorFix',
'superWow',
'transmogFix', 'transmogFix',
'unitXp', 'unitXp',
'vanillaFixes', 'vanillaFixes',
@@ -19,6 +20,7 @@ export type ModSource =
apiUrl?: string; apiUrl?: string;
pinnedTag?: string; pinnedTag?: string;
assetName: string; assetName: string;
sha256?: string;
} }
| { | {
kind: 'archive'; kind: 'archive';
@@ -28,6 +30,7 @@ export type ModSource =
pinnedTag?: string; pinnedTag?: string;
format: 'zip' | 'tar.gz'; format: 'zip' | 'tar.gz';
extractMap: Record<string, string>; extractMap: Record<string, string>;
sha256?: string;
} }
| { kind: 'managed' }; | { kind: 'managed' };
@@ -98,6 +101,28 @@ export const MODS: ModEntry[] = [
}, },
registerInDllsTxt: 'VanillaMultiMonitorFix.dll' 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', id: 'transmogFix',
name: 'transmogFix', name: 'transmogFix',
@@ -177,3 +202,9 @@ export const MODS: ModEntry[] = [
export const getMod = (id: ModId): ModEntry | undefined => export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id); 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);
+3
View File
@@ -17,6 +17,7 @@ const f = {
export const ConfigWtfSchema = z.object({ export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(), vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true), largeAddress: f.boolean(true),
nameplateRange: f.number(41), nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(), alwaysAutoLoot: f.boolean(),
@@ -62,6 +63,8 @@ export const PreferencesSchema = z.object({
.default('enUS'), .default('enUS'),
localePatchLetter: z.string().optional(), localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(), localePatchLocale: z.string().optional(),
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
lastWrittenResolution: z.string().optional(),
rememberPosition: f.boolean(), rememberPosition: f.boolean(),
windowPosition: z windowPosition: z
.object({ .object({
+5 -6
View File
@@ -47,6 +47,9 @@ export const launcherRouter = createTRPCRouter({
await fs.remove(path.join(clientDir, 'WDB')); await fs.remove(path.join(clientDir, 'WDB'));
} }
Logger.log('Syncing preferred monitor...');
await Mods.verify();
Logger.log('Checking Config.wtf...'); Logger.log('Checking Config.wtf...');
await patchConfig(); await patchConfig();
@@ -62,21 +65,17 @@ export const launcherRouter = createTRPCRouter({
'launching WoW.exe directly (mods will not load).' 'launching WoW.exe directly (mods will not load).'
); );
const octoLocale = Preferences.data.locale || 'enUS';
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
Logger.log( Logger.log(
useLoader useLoader
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...` ? 'Launching via VanillaFixes...'
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...` : `Launching ${exePath}...`
); );
const child = useLoader const child = useLoader
? spawn(loaderPath, ['WoW.exe'], { ? spawn(loaderPath, ['WoW.exe'], {
env: gameEnv,
cwd: clientDir, cwd: clientDir,
detached: !minimizeToTrayOnPlay detached: !minimizeToTrayOnPlay
}) })
: spawn(exePath, { : spawn(exePath, {
env: gameEnv,
cwd: clientDir, cwd: clientDir,
detached: !minimizeToTrayOnPlay detached: !minimizeToTrayOnPlay
}); });
+11
View File
@@ -21,6 +21,7 @@ import {
Logger.initialize(); Logger.initialize();
Logger.errorHandler.startCatching(); Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...'); Logger.info('Launcher starting...');
app.disableHardwareAcceleration(); app.disableHardwareAcceleration();
@@ -191,6 +192,16 @@ if (!gotSingleInstanceLock) {
await createWindow(); 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.on('window-all-closed', () => {
app.quit(); app.quit();
}); });
+1 -1
View File
@@ -195,7 +195,7 @@ class AddonsClass extends Observable<AddonsStatus> {
: []; : [];
const addons: AddonsStatus['addons'] = Object.fromEntries( const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs dirs
.filter(d => !d.startsWith('Blizzard_')) .filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
.map(name => [name, { status: 'fetching' as const, folder: name }]) .map(name => [name, { status: 'fetching' as const, folder: name }])
); );
+91 -21
View File
@@ -3,8 +3,21 @@ import { spawn } from 'node:child_process';
import Logger from 'electron-log/main'; 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 = [ const SCRIPT = [
'$ErrorActionPreference = "Stop"', '$ErrorActionPreference = "Stop"',
"$ProgressPreference = 'SilentlyContinue'",
"Add-Type -TypeDefinition @'", "Add-Type -TypeDefinition @'",
'using System;', 'using System;',
'using System.Runtime.InteropServices;', '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 DeviceID;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;', ' [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)]', ' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);', ' 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', 'for ($i = 0; ; $i++) {',
'$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)', ' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
'for ($i = 0; [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0); $i++) {', ' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
' if ($dd.StateFlags -band 4) { Write-Output $i; exit 0 }', ' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
'}', ' $dm = New-Object VmmfDisplays+DEVMODE',
'exit 1' ' $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'); ].join('\n');
export const detectPrimaryDisplayIndex = (): Promise<number> => { const parseRow = (line: string): DisplayDevice | undefined => {
if (os.platform() !== 'win32') return Promise.resolve(0); 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'); const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
return new Promise(resolve => { return new Promise(resolve => {
let settled = false; let settled = false;
const finish = (index: number) => { const finish = (v: DisplayDevice[] | null) => {
if (settled) return; if (settled) return;
settled = true; settled = true;
clearTimeout(timer); clearTimeout(timer);
resolve(index); resolve(v);
}; };
const child = spawn( const child = spawn(
@@ -52,25 +114,33 @@ export const detectPrimaryDisplayIndex = (): Promise<number> => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
child.kill(); child.kill();
Logger.warn('Primary display detection timed out'); Logger.warn('Display enumeration timed out');
finish(0); finish(null);
}, 8000); }, 10000);
let stdout = ''; let stdout = '';
child.stdout.on('data', d => (stdout += String(d))); child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => { child.on('error', e => {
Logger.warn('Primary display detection failed to launch PowerShell', e); Logger.warn('Display enumeration failed to launch PowerShell', e);
finish(0); finish(null);
}); });
child.on('exit', code => { child.on('exit', code => {
const index = Number(stdout.trim()); const devices = stdout
if (code === 0 && Number.isInteger(index) && index >= 0) { .split(/\r?\n/)
Logger.info(`Detected primary display at device index ${index}`); .map(parseRow)
finish(index); .filter((d): d is DisplayDevice => d !== undefined);
if (code === 0 && devices.length) {
Logger.info(`Enumerated ${devices.length} display device(s)`);
finish(devices);
} else { } else {
Logger.warn('Primary display detection failed, defaulting to 0'); Logger.warn('Display enumeration returned nothing usable');
finish(0); finish(null);
} }
}); });
}); });
}; };
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
const devices = await enumerateDisplays();
return devices?.find(d => d.primary && d.attached)?.index ?? null;
};
+9 -5
View File
@@ -12,7 +12,9 @@ import Logger from 'electron-log/main';
import Preferences from './preferences'; import Preferences from './preferences';
const PREFERRED = 'L'; 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 MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) => const patchFile = (dataDir: string, letter: string) =>
@@ -42,7 +44,7 @@ const usableSlot = (dataDir: string, letter: string): boolean => {
}; };
const removeOurPatch = async (dataDir: string) => { const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) { for (const l of ALL_LETTERS) {
const f = patchFile(dataDir, l); const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {}); if (isOurPatch(f)) await fs.remove(f).catch(() => {});
} }
@@ -79,10 +81,12 @@ export const applyLocalePatch = async (
const tracked = Preferences.data.localePatchLetter; const tracked = Preferences.data.localePatchLetter;
const letter = const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ?? (tracked && tracked !== RAID_LETTER && usableSlot(dataDir, tracked)
? tracked
: undefined) ??
(usableSlot(dataDir, PREFERRED) (usableSlot(dataDir, PREFERRED)
? PREFERRED ? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l))); : ALLOC_LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) { if (!letter) {
Logger.warn('Locale patch: no usable patch slot'); Logger.warn('Locale patch: no usable patch slot');
return; return;
@@ -100,7 +104,7 @@ export const applyLocalePatch = async (
} }
try { try {
for (const l of LETTERS) { for (const l of ALL_LETTERS) {
if (l === letter) continue; if (l === letter) continue;
const f = patchFile(dataDir, l); const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {}); if (isOurPatch(f)) await fs.remove(f).catch(() => {});
+78 -10
View File
@@ -1,4 +1,5 @@
import path from 'path'; import path from 'path';
import { createHash } from 'crypto';
import fs from 'fs-extra'; import fs from 'fs-extra';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
@@ -13,7 +14,7 @@ import Preferences from './preferences';
import Observable from './observable'; import Observable from './observable';
import Updater from './updater'; import Updater from './updater';
import { addDll, removeDll } from './dllsTxt'; import { addDll, removeDll } from './dllsTxt';
import { detectPrimaryDisplayIndex } from './displays'; import { enumerateDisplays } from './displays';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000; 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() { async verify() {
this.load(); this.load();
this._notifyObservers(); this._notifyObservers();
@@ -112,12 +176,7 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir; const clientDir = Preferences.data?.clientDir;
if (clientDir) { if (clientDir) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll'); await this.#syncPreferredMonitor(clientDir);
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(() => {});
}
} }
for (const m of MODS) { for (const m of MODS) {
@@ -258,7 +317,7 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'directFile') { if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName); 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); written.push(m.source.assetName);
} else if (m.source.kind === 'archive') { } else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp'); const scratch = path.join(clientDir, '.octolauncher-tmp');
@@ -267,7 +326,7 @@ class ModsClass extends Observable<ModsStatus> {
scratch, scratch,
`${m.id}-${Date.now()}.${m.source.format}` `${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' }); this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap; const map = m.source.extractMap;
@@ -360,7 +419,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined }); 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, { const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' }, headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS 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}`); if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest)); await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer(); 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)); await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest))) if (!(await fs.pathExists(dest)))
throw new Error( throw new Error(
+122 -17
View File
@@ -8,6 +8,7 @@ import Preferences from '~main/modules/preferences';
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas'; import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
import { isNotUndef } from '~common/utils'; import { isNotUndef } from '~common/utils';
import { fetchFile } from '~main/modules/updater'; import { fetchFile } from '~main/modules/updater';
import { enumerateDisplays } from '~main/modules/displays';
const Servers = { const Servers = {
live: { live: {
@@ -32,7 +33,7 @@ type Tweak = TweakKey & {
} & ( } & (
| { | {
type: 'bytes'; type: 'bytes';
tweaks: [number, number[]][]; tweaks: [number, number[], number[]?][];
} }
| { | {
type: 'int8' | 'uint16' | 'float'; 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 () => { export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...'); 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, synthetic: true,
key: 'octowowUrlAllowlist', key: 'octowowUrlAllowlist',
@@ -137,8 +155,8 @@ export const patchExecutable = async () => {
[ [
0x45ccd8, 0x45ccd8,
[ [
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00 0x00, 0x00, 0x00, 0x00, 0x00
] ]
] ]
] ]
@@ -160,10 +178,25 @@ export const patchExecutable = async () => {
buffer.writeUInt16LE(t.value ?? (val as number), t.offset); buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') { } else if (t.type === 'bytes') {
if (!t.forced && !val) return; if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) => t.tweaks.forEach(([offset, bytes, expect]) => {
Buffer.from(bytes).copy(buffer, offset) 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.'
);
});
}
}); });
await fs.writeFile(exePath, buffer); await fs.writeFile(exePath, buffer);
@@ -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) => { export const patchConfig = async (forceTweaks = false) => {
const { clientDir, server, config, locale } = Preferences.data; const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return; if (!clientDir) return;
@@ -194,14 +269,19 @@ export const patchConfig = async (forceTweaks = false) => {
.filter(isNotUndef) .filter(isNotUndef)
); );
const isFirstRun = Object.keys(configWtf).length === 0;
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
const scale = primaryDisplay.scaleFactor || 1; const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale); const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale); const height = Math.round(primaryDisplay.bounds.height * scale);
const parsed = { const seededResolution = `${width}x${height}`;
const seed = isFirstRun
? {
scriptMemory: 512000, scriptMemory: 512000,
gxResolution: `${width}x${height}`, gxResolution: seededResolution,
gxColorBits: primaryDisplay.colorDepth, gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth, gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60, gxRefresh: 60,
@@ -218,6 +298,7 @@ export const patchConfig = async (forceTweaks = false) => {
specular: 1, specular: 1,
pixelShaders: 1, pixelShaders: 1,
M2UsePixelShaders: 1, M2UsePixelShaders: 1,
M2UseShaders: 1,
particleDensity: 1, particleDensity: 1,
unitDrawDist: 300, unitDrawDist: 300,
weatherDensity: 3, weatherDensity: 3,
@@ -225,19 +306,37 @@ export const patchConfig = async (forceTweaks = false) => {
minimapZoom: 0, minimapZoom: 0,
minimapInsideZoom: 0, minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1, SoundZoneMusicNoDelay: 1,
patchList: configWtf['patchList'] ?? Servers[server].patchList, gxWindow: 1,
realmName: configWtf['realmName'] ?? Servers[server].realmName, gxMaximize: 1,
gxWindow: configWtf['gxWindow'] ?? 1, gxCursor: 1,
gxMaximize: configWtf['gxMaximize'] ?? 1, checkAddonVersion: 0,
gxCursor: configWtf['gxCursor'] ?? 1, farClip: config.farClip,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0, CameraDistanceMax: config.cameraDistance,
farClip: configWtf['farClip'] ?? config.farClip, patchList: Servers[server].patchList,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance, realmName: Servers[server].realmName
...configWtf, }
: {};
const owned = {
locale, locale,
realmList: Servers[server].realmList, realmList: Servers[server].realmList,
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
hwDetect: 0, 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 ...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance } ? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {}) : {})
@@ -250,5 +349,11 @@ export const patchConfig = async (forceTweaks = false) => {
const tmpPath = `${configPath}.tmp`; const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, body); await fs.writeFile(tmpPath, body);
await fs.move(tmpPath, configPath, { overwrite: true }); 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'); Logger.log('Config.wtf successfully patched');
}; };
+178 -33
View File
@@ -6,40 +6,143 @@ import { app } from 'electron';
import Logger from 'electron-log/main'; import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import { DEFAULT_ENABLED_MODS } from '~common/mods';
import { omit } from '~common/utils'; import { omit } from '~common/utils';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR; 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 { abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>; static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve(); static #writeChain: Promise<void> = Promise.resolve();
static #readOnly = false;
static #rememberedClientDir?: string;
static #freshInstall = false;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher') ? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData'); : app.getPath('userData');
static async load() { static readonly #settingsPath = path.join(
await fs.ensureDir(this.userDataDir); Preferences.userDataDir,
const settingsPath = path.join(this.userDataDir, 'settings.json'); 'settings.json'
);
let json: Record<string, unknown>; static readonly #installPath = path.join(
try { Preferences.userDataDir,
json = await fs.readJSON(settingsPath); 'install.json'
} catch { );
return PreferencesSchema.parse({
isPortable: !!portableDir, static get isFreshInstall() {
clientDir: portableDir return this.#freshInstall;
});
} }
const merged = { static async #detectFreshInstall() {
const [settings, install, pending] = await Promise.all([
fs.pathExists(this.#settingsPath),
fs.pathExists(this.#installPath),
fs.pathExists(`${this.#settingsPath}.tmp`)
]);
return !settings && !install && !pending;
}
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
Logger.info(
`Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
);
return { ...data, mods };
}
static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
let json: Record<string, unknown> = {};
try {
json = await readJsonRetrying(settingsPath);
} catch (e) {
if (isLocked(e)) {
this.#readOnly = true;
Logger.error(
`Could not read ${settingsPath} (${errCode(e)}); running on ` +
'defaults and leaving settings untouched for this session.',
e
);
} else {
if (errCode(e) !== 'ENOENT') {
Logger.warn(`${settingsPath} is unreadable; keeping a copy`, e);
await fs
.copy(settingsPath, `${settingsPath}.corrupt`)
.catch(() => {});
}
const recovered = await fs
.readJSON(`${settingsPath}.tmp`)
.catch(() => null);
if (recovered && typeof recovered === 'object') {
Logger.warn(`Recovered settings from ${settingsPath}.tmp`);
json = recovered as Record<string, unknown>;
}
}
}
const merged = dropUndefined({
...json, ...json,
isPortable: !!portableDir, isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir clientDir: portableDir ?? json.clientDir
}; });
const parsed = PreferencesSchema.safeParse(merged); const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success) return parsed.data; if (parsed.success)
return this.#withKnownClientDir(
this.#withFreshInstallDefaults(parsed.data)
);
Logger.warn( Logger.warn(
'settings.json failed validation; salvaging valid fields', 'settings.json failed validation; salvaging valid fields',
@@ -47,17 +150,40 @@ abstract class Preferences {
); );
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {}); await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = { const salvaged: Record<string, unknown> = dropUndefined({
isPortable: !!portableDir, isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir clientDir: portableDir ?? json.clientDir
}; });
const shape = PreferencesSchema.shape; const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) { for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue; if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key]; const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value; 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 { static get data(): PreferencesSchema {
@@ -67,31 +193,50 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) { static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData }; this.#data = { ...this.#data, ...newData };
const settingsPath = path.join(this.userDataDir, 'settings.json'); if (this.#readOnly) return;
const delta = omit(
newData, const settingsPath = this.#settingsPath;
portableDir ? ['isPortable', 'clientDir'] : ['isPortable'] const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable'];
const delta = dropUndefined(
omit(newData, dropped as (keyof typeof newData)[])
); );
const snapshot = omit( const snapshot = dropUndefined(
this.#data, omit(this.#data, dropped as (keyof PreferencesSchema)[])
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
); );
this.#writeChain = this.#writeChain this.#writeChain = this.#writeChain
.then(async () => { .then(async () => {
let onDisk: unknown = null; let base: Record<string, unknown> | null = null;
try { try {
onDisk = await fs.readJSON(settingsPath); const onDisk = await readJsonRetrying(settingsPath);
} catch { base =
onDisk = null;
}
const base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk) !!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>) ? (onDisk as Record<string, unknown>)
: null; : null;
} catch (e) {
if (isLocked(e)) {
Logger.error(
`Skipping settings write; ${settingsPath} is locked (${errCode(
e
)})`,
e
);
return;
}
}
const merged = base ? { ...base, ...delta } : snapshot; const merged = base ? { ...base, ...delta } : snapshot;
const tmp = `${settingsPath}.tmp`; await writeJsonAtomic(settingsPath, merged);
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true }); 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)); .catch(e => Logger.error('Failed to persist settings.json', e));
} }
+8 -1
View File
@@ -84,7 +84,7 @@ const friendlyError = (e: unknown): string => {
}; };
type FolderTags = 'allowExtra'; type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes'; type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & ( type FileManifest = { name: string } & (
| { type: 'del' } | { type: 'del' }
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] } | { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
@@ -558,6 +558,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try { try {
const vanillaFixes = Preferences.data.config.vanillaFixes; const vanillaFixes = Preferences.data.config.vanillaFixes;
const raidVisuals = Preferences.data.config.raidVisuals;
const modOwnedFiles = new Set<string>(); const modOwnedFiles = new Set<string>();
for (const state of Object.values(Preferences.data.mods ?? {})) for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? []) 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 = { this.status = {
state: 'verifying', state: 'verifying',
progress: i / totalSize, progress: i / totalSize,
+12 -1
View File
@@ -10,6 +10,7 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData; const { dir, url, ref } = workerData;
const tmpDir = `${dir}.tmp`; const tmpDir = `${dir}.tmp`;
const bakDir = `${dir}.bak`;
const run = async () => { const run = async () => {
await fs.remove(tmpDir); await fs.remove(tmpDir);
@@ -23,8 +24,18 @@ const run = async () => {
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args }) onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
}); });
await fs.remove(dir); await fs.remove(bakDir);
const hadExisting = await fs.pathExists(dir);
if (hadExisting) await fs.move(dir, bakDir);
try {
await fs.move(tmpDir, dir); 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() run()
+30 -1
View File
@@ -1,5 +1,5 @@
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { PreferencesSchema } from '~common/schemas'; import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver'; import zodResolver from '~renderer/utils/zodResolver';
@@ -8,6 +8,7 @@ import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton'; import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput'; import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton'; import CloseButton from './styled/CloseButton';
type Props = { close: () => void }; type Props = { close: () => void };
@@ -36,6 +37,19 @@ const ClientDirDialog = ({ close }: Props) => {
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true })) 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(() => { useEffect(() => {
pref && reset(pref); pref && reset(pref);
}, [reset, pref]); }, [reset, pref]);
@@ -62,6 +76,7 @@ const ClientDirDialog = ({ close }: Props) => {
<form <form
className="tw-dialog" className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => { onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try { try {
await setPref.mutateAsync({ clientDir }); await setPref.mutateAsync({ clientDir });
verify.mutate(); verify.mutate();
@@ -105,9 +120,23 @@ const ClientDirDialog = ({ close }: Props) => {
</p> </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 <TextButton
type="submit" type="submit"
loading={formState.isSubmitting} loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green" className="self-end text-green"
> >
{t('prefs.confirm')} {t('prefs.confirm')}
-4
View File
@@ -50,7 +50,6 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
); );
}; };
// The "Announcements" list — most-recent forum topics as short previews + links.
const AnnouncementsBox = () => { const AnnouncementsBox = () => {
const t = useT(); const t = useT();
const query = api.news.list.useQuery(undefined, { 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 = () => ( const NewsTab = () => (
<div className="flex min-h-0 flex-grow gap-3"> <div className="flex min-h-0 flex-grow gap-3">
<ForumAnnouncementPanel /> <ForumAnnouncementPanel />
+15 -2
View File
@@ -117,6 +117,12 @@ const TweaksTab = () => {
label={t('tweaks.alwaysAutoLoot.label')} label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')} text={t('tweaks.alwaysAutoLoot.text')}
/> />
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/>
<Item <Item
form={form} form={form}
id="largeAddress" id="largeAddress"
@@ -214,8 +220,15 @@ const TweaksTab = () => {
onClick={async () => { onClick={async () => {
const config = const config =
recommendedFarClip != null 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 }); await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config); reset(config);
}} }}
+39
View File
@@ -34,6 +34,9 @@ const enUS: Dict = {
'launch.remaining': 'remaining', 'launch.remaining': 'remaining',
'launch.calculating': 'calculating…', 'launch.calculating': 'calculating…',
'launch.onDisk': 'on disk', '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.label': 'Always auto-loot',
'tweaks.alwaysAutoLoot.text': 'tweaks.alwaysAutoLoot.text':
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.', 'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
@@ -224,6 +227,10 @@ const enUS: Dict = {
'prefs.upgradeExisting': 'prefs.upgradeExisting':
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.', '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.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' 'prefs.confirm': 'Confirm'
}; };
@@ -260,6 +267,9 @@ const deDE: Dict = {
'launch.remaining': 'verbleibend', 'launch.remaining': 'verbleibend',
'launch.calculating': 'wird berechnet…', 'launch.calculating': 'wird berechnet…',
'launch.onDisk': 'auf der Festplatte', '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.label': 'Immer automatisch plündern',
'tweaks.alwaysAutoLoot.text': '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.', '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': 'prefs.upgradeExisting':
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.', '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.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', 'prefs.confirm': 'Bestätigen',
'misc.newsTitle': 'Neuigkeiten', 'misc.newsTitle': 'Neuigkeiten',
'misc.newsByAuthor': 'von {author}', 'misc.newsByAuthor': 'von {author}',
@@ -485,6 +499,9 @@ const zhCN: Dict = {
'launch.remaining': '剩余', 'launch.remaining': '剩余',
'launch.calculating': '计算中…', 'launch.calculating': '计算中…',
'launch.onDisk': '在磁盘上', 'launch.onDisk': '在磁盘上',
'tweaks.raidVisuals.label': '团队副本视觉增强',
'tweaks.raidVisuals.text':
'可选下载(约 9 MB)。为团队首领技能添加更清晰的地面标记和音效,并自动与服务器保持同步。(又称 Patch-O)',
'tweaks.alwaysAutoLoot.label': '始终自动拾取', 'tweaks.alwaysAutoLoot.label': '始终自动拾取',
'tweaks.alwaysAutoLoot.text': 'tweaks.alwaysAutoLoot.text':
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。', '反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
@@ -638,6 +655,9 @@ const zhCN: Dict = {
'prefs.upgradeExisting': 'prefs.upgradeExisting':
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。', '你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
'prefs.installDirectory': '安装目录:', 'prefs.installDirectory': '安装目录:',
'prefs.noClientHere':
'此文件夹中没有 {exe}。启动器将在此处下载全新的客户端,其中不会包含你现有的插件和设置。如果你已经安装过,请改为选择原有的安装目录。',
'prefs.noClientHereConfirm': '我已了解——在此文件夹下载全新客户端',
'prefs.confirm': '确认', 'prefs.confirm': '确认',
'misc.newsTitle': '新闻', 'misc.newsTitle': '新闻',
'misc.newsByAuthor': '作者:{author}', 'misc.newsByAuthor': '作者:{author}',
@@ -693,6 +713,9 @@ const esES: Dict = {
'launch.remaining': 'restante', 'launch.remaining': 'restante',
'launch.calculating': 'calculando…', 'launch.calculating': 'calculando…',
'launch.onDisk': 'en disco', '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.label': 'Saqueo automático siempre',
'tweaks.alwaysAutoLoot.text': '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.', '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': 'prefs.upgradeExisting':
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.', '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.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', 'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Noticias', 'misc.newsTitle': 'Noticias',
'misc.newsByAuthor': 'por {author}', 'misc.newsByAuthor': 'por {author}',
@@ -919,6 +946,9 @@ const ptBR: Dict = {
'launch.remaining': 'restante', 'launch.remaining': 'restante',
'launch.calculating': 'calculando…', 'launch.calculating': 'calculando…',
'launch.onDisk': 'no disco', '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.label': 'Saque automático sempre ativo',
'tweaks.alwaysAutoLoot.text': '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.', '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': '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.', '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.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', 'prefs.confirm': 'Confirmar',
'misc.newsTitle': 'Notícias', 'misc.newsTitle': 'Notícias',
'misc.newsByAuthor': 'por {author}', 'misc.newsByAuthor': 'por {author}',
@@ -1142,6 +1175,9 @@ const ruRU: Dict = {
'launch.remaining': 'осталось', 'launch.remaining': 'осталось',
'launch.calculating': 'вычисление…', 'launch.calculating': 'вычисление…',
'launch.onDisk': 'на диске', 'launch.onDisk': 'на диске',
'tweaks.raidVisuals.label': 'Улучшенные эффекты рейдов',
'tweaks.raidVisuals.text':
'Дополнительная загрузка (~9 МБ). Добавляет более заметные отметки на земле и звуки для способностей рейдовых боссов, автоматически синхронизируется с сервером. (также известен как Patch-O)',
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор', 'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
'tweaks.alwaysAutoLoot.text': 'tweaks.alwaysAutoLoot.text':
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.', 'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
@@ -1305,6 +1341,9 @@ const ruRU: Dict = {
'prefs.upgradeExisting': 'prefs.upgradeExisting':
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.', 'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
'prefs.installDirectory': 'Папка установки:', 'prefs.installDirectory': 'Папка установки:',
'prefs.noClientHere':
'В этой папке нет {exe}. Лаунчер скачает сюда новый клиент, в котором не будет ваших текущих аддонов и настроек. Если игра уже установлена, выберите её папку.',
'prefs.noClientHereConfirm': 'Понятно — скачать новый клиент в эту папку',
'prefs.confirm': 'Подтвердить', 'prefs.confirm': 'Подтвердить',
'misc.newsTitle': 'Новости', 'misc.newsTitle': 'Новости',
'misc.newsByAuthor': 'от {author}', 'misc.newsByAuthor': 'от {author}',