diff --git a/package.json b/package.json
index d264763..737745f 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/scripts/scrub-native-paths.cjs b/scripts/scrub-native-paths.cjs
new file mode 100644
index 0000000..13aad03
--- /dev/null
+++ b/scripts/scrub-native-paths.cjs
@@ -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}`);
diff --git a/server/src/cache.ts b/server/src/cache.ts
index f78c01c..64cbb1e 100644
--- a/server/src/cache.ts
+++ b/server/src/cache.ts
@@ -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',
diff --git a/src/common/mods.ts b/src/common/mods.ts
index d003629..b8aca4a 100644
--- a/src/common/mods.ts
+++ b/src/common/mods.ts
@@ -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;
+ 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);
diff --git a/src/common/schemas.ts b/src/common/schemas.ts
index c49b897..2354536 100644
--- a/src/common/schemas.ts
+++ b/src/common/schemas.ts
@@ -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({
diff --git a/src/main/api/routers/launcher.ts b/src/main/api/routers/launcher.ts
index e1c875d..48b99aa 100644
--- a/src/main/api/routers/launcher.ts
+++ b/src/main/api/routers/launcher.ts
@@ -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
});
diff --git a/src/main/index.ts b/src/main/index.ts
index 98507ae..a1c0716 100644
--- a/src/main/index.ts
+++ b/src/main/index.ts
@@ -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();
});
diff --git a/src/main/modules/addons.ts b/src/main/modules/addons.ts
index 12472a0..266121c 100644
--- a/src/main/modules/addons.ts
+++ b/src/main/modules/addons.ts
@@ -195,7 +195,7 @@ class AddonsClass extends Observable {
: [];
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 }])
);
diff --git a/src/main/modules/displays.ts b/src/main/modules/displays.ts
index a1ee0eb..b43f074 100644
--- a/src/main/modules/displays.ts
+++ b/src/main/modules/displays.ts
@@ -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 => {
- 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 => {
+ 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 => {
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 => {
+ const devices = await enumerateDisplays();
+ return devices?.find(d => d.primary && d.attached)?.index ?? null;
+};
diff --git a/src/main/modules/localePatch.ts b/src/main/modules/localePatch.ts
index 7f9e166..2e801c6 100644
--- a/src/main/modules/localePatch.ts
+++ b/src/main/modules/localePatch.ts
@@ -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(() => {});
diff --git a/src/main/modules/mods.ts b/src/main/modules/mods.ts
index d8eaf59..1e1ba04 100644
--- a/src/main/modules/mods.ts
+++ b/src/main/modules/mods.ts
@@ -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 {
};
}
+ 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 {
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 {
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 {
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 {
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 {
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(
diff --git a/src/main/modules/patcher.ts b/src/main/modules/patcher.ts
index afd7aa6..2596433 100644
--- a/src/main/modules/patcher.ts
+++ b/src/main/modules/patcher.ts
@@ -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 ?? ''} 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');
};
diff --git a/src/main/modules/preferences.ts b/src/main/modules/preferences.ts
index 52dd534..8ccd2c2 100644
--- a/src/main/modules/preferences.ts
+++ b/src/main/modules/preferences.ts
@@ -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 = (obj: T): Partial =>
+ Object.fromEntries(
+ Object.entries(obj).filter(([, v]) => v !== undefined)
+ ) as Partial;
+
abstract class Preferences {
static #data: z.infer;
static #writeChain: Promise = Promise.resolve();
+ static #readOnly = false;
+ static #rememberedClientDir?: string;
+ static #freshInstall = false;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData');
- static 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;
+ static readonly #installPath = path.join(
+ Preferences.userDataDir,
+ 'install.json'
+ );
+
+ static get isFreshInstall() {
+ return this.#freshInstall;
+ }
+
+ static async #detectFreshInstall() {
+ const [settings, install, pending] = await Promise.all([
+ fs.pathExists(this.#settingsPath),
+ fs.pathExists(this.#installPath),
+ fs.pathExists(`${this.#settingsPath}.tmp`)
+ ]);
+ return !settings && !install && !pending;
+ }
+
+ static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
+ if (!this.#freshInstall || Object.keys(data.mods).length) return data;
+
+ const mods = { ...data.mods };
+ for (const id of DEFAULT_ENABLED_MODS)
+ mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
+
+ Logger.info(
+ `Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
+ );
+ return { ...data, mods };
+ }
+
+ static async load() {
+ this.#freshInstall = await this.#detectFreshInstall();
+ await fs.ensureDir(this.userDataDir);
+ const settingsPath = this.#settingsPath;
+
+ let json: Record = {};
try {
- json = await 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;
+ }
+ }
}
- 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 = {
+ const salvaged: Record = 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)[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>) {
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 | 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)
+ : 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)
- : 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));
}
diff --git a/src/main/modules/updater.ts b/src/main/modules/updater.ts
index 08cc171..ae6abdb 100644
--- a/src/main/modules/updater.ts
+++ b/src/main/modules/updater.ts
@@ -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 {
try {
const vanillaFixes = Preferences.data.config.vanillaFixes;
+ const raidVisuals = Preferences.data.config.raidVisuals;
const modOwnedFiles = new Set();
for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? [])
@@ -709,6 +710,12 @@ class UpdaterClass extends Observable {
}
}
+ 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,
diff --git a/src/main/workers/gitClone.ts b/src/main/workers/gitClone.ts
index 31593e8..42ae74f 100644
--- a/src/main/workers/gitClone.ts
+++ b/src/main/workers/gitClone.ts
@@ -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()
diff --git a/src/renderer/components/ClientDirDialog.tsx b/src/renderer/components/ClientDirDialog.tsx
index b0173d4..9462a9a 100644
--- a/src/renderer/components/ClientDirDialog.tsx
+++ b/src/renderer/components/ClientDirDialog.tsx
@@ -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) => {
)}
+ {needsEmptyConfirm && (
+ <>
+
+ {t('prefs.noClientHere', { exe: 'WoW.exe' })}
+
+
+ >
+ )}
+
{t('prefs.confirm')}
diff --git a/src/renderer/components/tabs/NewsTab.tsx b/src/renderer/components/tabs/NewsTab.tsx
index b35949e..f1f9160 100644
--- a/src/renderer/components/tabs/NewsTab.tsx
+++ b/src/renderer/components/tabs/NewsTab.tsx
@@ -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 = () => (
diff --git a/src/renderer/components/tabs/TweaksTab.tsx b/src/renderer/components/tabs/TweaksTab.tsx
index 65c287d..67b56b2 100644
--- a/src/renderer/components/tabs/TweaksTab.tsx
+++ b/src/renderer/components/tabs/TweaksTab.tsx
@@ -117,6 +117,12 @@ const TweaksTab = () => {
label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
+
- {
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);
}}
diff --git a/src/renderer/i18n/translations.ts b/src/renderer/i18n/translations.ts
index 803d9c9..f6e639c 100644
--- a/src/renderer/i18n/translations.ts
+++ b/src/renderer/i18n/translations.ts
@@ -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}',