Fixed tweaks and mods, added localization, added antivirus walkthrough

This commit is contained in:
OctoWoW
2026-06-28 18:47:47 +00:00
parent c2f7b7d6e4
commit 1047a90704
51 changed files with 3426 additions and 938 deletions
+2
View File
@@ -4,6 +4,7 @@ import { z } from 'zod';
import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences';
import { addDefenderExclusions } from '~main/modules/defender';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -22,6 +23,7 @@ export const generalRouter = createTRPCRouter({
const file = Logger.transports.file.getFile().path;
shell.openPath(file);
}),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
filePicker: publicProcedure
.input(
z.object({
+63 -58
View File
@@ -2,7 +2,6 @@ import path from 'path';
import { spawn } from 'child_process';
import fs from 'fs-extra';
import { inject } from 'dll-inject';
import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
@@ -10,95 +9,101 @@ import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index';
import { isGameRunning } from '~main/modules/updater';
import { patchConfig } from '~main/modules/patcher';
import { applyLocalePatch } from '~main/modules/localePatch';
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
const ensureChainloaderTweak = async (clientDir: string): Promise<boolean> => {
if (Preferences.data.config.vanillaFixes) return true;
const chainloaderNeeded = async (clientDir: string): Promise<boolean> => {
const installed = Mods.status.mods.filter(r => r.installedVersion);
if (installed.some(r => r.id === 'vanillaFixes')) return true;
if (installed.some(r => getMod(r.id)?.requires?.includes('vanillaFixes')))
return true;
const installedMods = Mods.status.mods.filter(r => r.installedVersion);
const anyDependsOnVf = installedMods.some(r =>
getMod(r.id)?.requires?.includes('vanillaFixes')
);
let dllsTxtHasEntries = false;
const dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8');
dllsTxtHasEntries = raw
.split(/\r?\n/)
.some(l => l.trim() && !l.trim().startsWith('#'));
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
}
if (!anyDependsOnVf && !dllsTxtHasEntries) return false;
Logger.info(
`Auto-enabling vanillaFixes Tweak (chainloader required): ${
anyDependsOnVf ? 'a dependent mod is installed' : ''
}${anyDependsOnVf && dllsTxtHasEntries ? ' + ' : ''}${
dllsTxtHasEntries ? 'dlls.txt has user entries' : ''
}.`
);
Preferences.data = {
config: { ...Preferences.data.config, vanillaFixes: true }
};
return true;
return false;
};
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async () => {
const { cleanWdb, minimizeToTrayOnPlay, config, clientDir } =
Preferences.data;
if (!clientDir) return false;
type StartResult = { ok: boolean; error?: string };
const clientPath = path.join(clientDir, 'WoW.exe');
Logger.log(`Launching ${clientPath}...`);
if (await isGameRunning(clientPath)) return false;
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => {
const { cleanWdb, minimizeToTrayOnPlay, clientDir } = Preferences.data;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const exePath = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exePath)))
return { ok: false, error: 'WoW.exe was not found in the game folder.' };
if (await isGameRunning(exePath))
return { ok: false, error: 'WoW is already running.' };
if (cleanWdb) {
Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientPath, 'WDB'));
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Checking Config.wtf...');
await patchConfig();
Logger.log('Launching WoW...');
const process = spawn(clientPath, { detached: !minimizeToTrayOnPlay });
Logger.log('Applying UI language...');
await applyLocalePatch(clientDir, Preferences.data.locale);
const wantChainloader = await ensureChainloaderTweak(clientDir);
if (wantChainloader) {
Logger.log('Injecting VanillaFixes...');
const vfPath = path.join(clientDir, 'VfPatcher.dll');
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
if (!(await fs.pathExists(vfPath))) {
Logger.warn(
`VfPatcher.dll missing at ${vfPath} — chainloader needed but ` +
'the vanillaFixes mod is not installed. Skipping inject; ' +
'dlls.txt entries and dependent mods will not load. Install ' +
"vanillaFixes from the Mods tab to fix."
);
} else {
const status = inject('WoW.exe', vfPath);
if (status) {
Logger.error(`Injecting failed with error code ${status}...`);
return true;
}
}
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})...`
);
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
})
: spawn(exePath, {
env: gameEnv,
cwd: clientDir,
detached: !minimizeToTrayOnPlay
});
try {
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
});
} catch (e) {
Logger.error('Failed to launch the game', e);
const message = e instanceof Error ? e.message : String(e);
return { ok: false, error: `Failed to launch the game: ${message}` };
}
child.on('error', e => Logger.error('Game process error', e));
if (!minimizeToTrayOnPlay) {
mainWindow?.close();
return true;
return { ok: true };
}
minimizeToTray();
process.on('exit', () => {
child.on('exit', () => {
Logger.log('WoW stopped');
restoreFromTray();
});
return true;
return { ok: true };
})
});
+3 -1
View File
@@ -1,5 +1,6 @@
import { patchConfig, patchExecutable } from '~main/modules/patcher';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { getClientVersion } from '~main/utils';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -7,7 +8,8 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => {
await patchExecutable();
await patchConfig();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
})
});
+3
View File
@@ -2,6 +2,7 @@ import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences';
import { applyLocalePatch } from '~main/modules/localePatch';
import { createTRPCRouter, publicProcedure } from '../trpc';
@@ -11,6 +12,8 @@ export const preferencesRouter = createTRPCRouter({
.input(PreferencesSchema.partial())
.mutation(async ({ input }) => {
Preferences.data = input;
if (input.locale !== undefined)
await applyLocalePatch(Preferences.data.clientDir, input.locale);
return Preferences.data;
}),
isValidClientDir: publicProcedure
+67 -25
View File
@@ -1,6 +1,6 @@
import { join } from 'path';
import { app, shell, BrowserWindow } from 'electron';
import { app, shell, BrowserWindow, screen } from 'electron';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main';
@@ -22,10 +22,33 @@ app.disableHardwareAcceleration();
export let mainWindow: BrowserWindow | null = null;
const isOnScreen = (
pos?: {
x: number;
y: number;
width: number;
height: number;
} | null
) => {
if (!pos) return false;
return screen.getAllDisplays().some(d => {
const a = d.workArea;
return (
pos.x < a.x + a.width &&
pos.x + pos.width > a.x &&
pos.y < a.y + a.height &&
pos.y + pos.height > a.y
);
});
};
const createWindow = async () => {
const position = Preferences.data.rememberPosition
? Preferences.data.windowPosition
: { width: 1000, height: 700 };
const saved =
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({
...position,
@@ -50,16 +73,22 @@ const createWindow = async () => {
Logger.error('Renderer unresponsive');
});
mainWindow.webContents.on('console-message', (_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
});
mainWindow.webContents.on(
'console-message',
(_e, level, message, line, sourceId) => {
const lvl = level === 3 ? 'error' : level === 2 ? 'warn' : 'info';
Logger[lvl](`[renderer:${lvl}] ${message} (${sourceId}:${line})`);
}
);
mainWindow.webContents.on('before-input-event', (_e, input) => {
if (input.type !== 'keyDown') return;
if (input.key === 'F12') {
mainWindow?.webContents.toggleDevTools();
return;
}
if ((input.control || input.meta) && input.key.toLowerCase() === 'c')
mainWindow?.webContents.copy();
});
createIPCHandler({ router: appRouter, windows: [mainWindow] });
@@ -77,7 +106,7 @@ const createWindow = async () => {
const [width = 0, height = 0] = mainWindow.getSize();
Preferences.data = { windowPosition: { x, y, width, height } };
});
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
} else {
@@ -85,23 +114,36 @@ const createWindow = async () => {
}
};
app.whenReady().then(async () => {
Preferences.data = await Preferences.load();
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
Addons.verify();
Updater.verify();
Mods.verify();
initSelfUpdater();
electronApp.setAppUserModelId('com.electron');
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
await createWindow();
});
app.whenReady().then(async () => {
Preferences.data = await Preferences.load();
app.on('window-all-closed', async () => {
app.quit();
});
Addons.verify();
Updater.verify();
Mods.verify();
initSelfUpdater();
electronApp.setAppUserModelId('st.octowow.launcher');
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
await createWindow();
});
app.on('window-all-closed', () => {
app.quit();
});
}
+76 -24
View File
@@ -35,22 +35,51 @@ type AddonsList = {
}[];
const readTocData = (content: string) =>
content
(content.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const [key, value] = l.split(':');
return [key.trim(), value.trim()];
const idx = l.indexOf(':');
if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
})
.filter((e): e is readonly [string, string] => !!e)
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as TocData);
const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name);
// only allow known git hosts over https
const ALLOWED_GIT_HOSTS = [
'github.com',
'gitlab.com',
'gitea.com',
'codeberg.org',
'octowow.st'
];
const isAllowedGitUrl = (url: string) => {
try {
const parsed = new URL(url);
if (parsed.protocol !== 'https:') return false;
const host = parsed.hostname.toLowerCase();
return ALLOWED_GIT_HOSTS.some(h => host === h || host.endsWith('.' + h));
} catch {
return false;
}
};
const fetchAddons = async () => {
try {
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
const response = await fetch(
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/addons.json`
);
return (await response.json()) as AddonsList;
} catch (e) {
Logger.error('Failed to reach update server', e);
@@ -103,35 +132,36 @@ class AddonsClass extends Observable<AddonsStatus> {
};
async checkGitUrl(url: string) {
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
const clean = url.trim().replace(/\/+$/, '');
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try {
await git.getRemoteInfo({
http,
url: gitUrl
});
// Only fetch preview from known public git hosts to prevent SSRF.
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
let preview: string | undefined;
try {
const host = new URL(url).hostname.toLowerCase();
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
if (isAllowedGitUrl(url)) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
// preview stays undefined
/* ignore */
}
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return {
status: 'available',
folder: gitUrl.slice(0, -4).split('/').at(-1),
folder,
git: gitUrl,
preview
} as AddonData;
} catch (e) {
} catch {
return undefined;
}
}
@@ -162,7 +192,7 @@ class AddonsClass extends Observable<AddonsStatus> {
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = await fs.pathExists(addonsPath)
const dirs = (await fs.pathExists(addonsPath))
? await fs.readdir(addonsPath)
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
@@ -223,9 +253,7 @@ class AddonsClass extends Observable<AddonsStatus> {
.catch(() => null);
const remoteCommit = avail?.ref
? await git
.resolveRef({ fs, dir, ref: avail.ref })
.catch(() => null)
? await git.resolveRef({ fs, dir, ref: avail.ref }).catch(() => null)
: await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid)
@@ -249,7 +277,9 @@ class AddonsClass extends Observable<AddonsStatus> {
Logger.log(
isUpToDate
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
? `Addon "${folder}" is up to date${
avail?.ref ? ` (pinned ${avail.ref})` : ''
}`
: `Addon "${folder}" has an update available`
);
} catch (e) {
@@ -268,13 +298,16 @@ class AddonsClass extends Observable<AddonsStatus> {
const VERIFY_CONCURRENCY = 6;
let idx = 0;
await Promise.all(
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
Array.from(
{ length: Math.min(VERIFY_CONCURRENCY, folders.length) },
async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
}
}
})
)
);
this.status = { ...this.status, state: 'done' };
@@ -330,7 +363,7 @@ class AddonsClass extends Observable<AddonsStatus> {
{ onProgress: this.#onProgress(folder, data) }
);
}
const toc = await readTocData(
const toc = readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
@@ -363,6 +396,25 @@ class AddonsClass extends Observable<AddonsStatus> {
async install(data: AddonData) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (isUnsafeFolder(data.folder)) {
Logger.error(`Refusing addon with unsafe folder name: "${data.folder}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Invalid addon name'
});
return;
}
if (!data.git || !isAllowedGitUrl(data.git)) {
Logger.error(`Refusing addon from disallowed git host: "${data.git}"`);
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Addon URL is not from an allowed git host'
});
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder);
+80
View File
@@ -0,0 +1,80 @@
import { spawn } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { app } from 'electron';
import Logger from 'electron-log/main';
import Preferences from './preferences';
export type ExclusionResult = { ok: boolean; error?: string; paths?: string[] };
const psSingleQuote = (s: string) => `'${s.replace(/'/g, "''")}'`;
export const addDefenderExclusions = async (): Promise<ExclusionResult> => {
if (os.platform() !== 'win32')
return {
ok: false,
error: 'Antivirus exclusions are only needed on Windows.'
};
const clientDir = Preferences.data.clientDir;
if (!clientDir)
return {
ok: false,
error: 'Set your game folder first, then add the exclusion.'
};
const launcherDir =
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const paths = [...new Set([clientDir, launcherDir])];
const inner = [
'$ErrorActionPreference = "Stop"',
'try {',
...paths.map(p => `Add-MpPreference -ExclusionPath ${psSingleQuote(p)}`),
'Add-MpPreference -ExclusionProcess "WoW.exe"',
'Add-MpPreference -ExclusionProcess "VanillaFixes.exe"',
'exit 0',
'} catch { exit 2 }'
].join('\n');
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
const outer =
'try { $p = Start-Process powershell -Verb RunAs -WindowStyle Hidden ' +
"-Wait -PassThru -ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}'; exit $p.ExitCode } catch { exit 1 }`;
return new Promise<ExclusionResult>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', outer],
{ windowsHide: true }
);
let stderr = '';
child.stderr.on('data', d => (stderr += String(d)));
child.on('error', e => {
Logger.error('Failed to launch PowerShell for Defender exclusion', e);
resolve({ ok: false, error: 'Could not run Windows PowerShell.' });
});
child.on('exit', code => {
if (code === 0) {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
} else if (code === 1) {
resolve({
ok: false,
error:
'No permission granted. Click Yes on the Windows prompt to add the exclusion.'
});
} else {
Logger.error(`Defender exclusion failed (code ${code}): ${stderr}`);
resolve({
ok: false,
error:
'Could not add the exclusion automatically. You may need to add it in Windows Security manually.'
});
}
});
});
};
+117
View File
@@ -0,0 +1,117 @@
import path from 'node:path';
import fs from 'fs-extra';
import {
SFileOpenArchive,
SFileCloseArchive,
SFileHasFile
} from 'stormlib-node';
import { STREAM_FLAG } from 'stormlib-node/dist/enums';
import Logger from 'electron-log/main';
import Preferences from './preferences';
const PREFERRED = 'L';
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.mpq`);
const prebuiltFor = (dataDir: string, locale: string) =>
path.join(dataDir, locale, 'patch-L.mpq');
const isOurPatch = (mpqPath: string): boolean => {
if (!fs.existsSync(mpqPath)) return false;
try {
const h = SFileOpenArchive(mpqPath, STREAM_FLAG.READ_ONLY);
try {
return SFileHasFile(h, MARKER);
} finally {
SFileCloseArchive(h);
}
} catch {
return false;
}
};
const usableSlot = (dataDir: string, letter: string): boolean => {
if (fs.existsSync(path.join(dataDir, `patch-${letter}.MPQ`))) return false;
const f = patchFile(dataDir, letter);
return !fs.existsSync(f) || isOurPatch(f);
};
const removeOurPatch = async (dataDir: string) => {
for (const l of LETTERS) {
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
export const applyLocalePatch = async (
clientDir: string | undefined,
locale: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
const nextLocale = !locale || locale === 'enUS' ? undefined : locale;
if (Preferences.data.localePatchLocale !== nextLocale)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
if (!locale || locale === 'enUS') {
await removeOurPatch(dataDir);
return;
}
const source = prebuiltFor(dataDir, locale);
if (!(await fs.pathExists(source))) {
Logger.warn(
`Locale patch: no prebuilt patch-L for ${locale}; leaving UI as-is`
);
return;
}
const tracked = Preferences.data.localePatchLetter;
const letter =
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
(usableSlot(dataDir, PREFERRED)
? PREFERRED
: LETTERS.find(l => usableSlot(dataDir, l)));
if (!letter) {
Logger.warn('Locale patch: no usable patch slot');
return;
}
const target = patchFile(dataDir, letter);
try {
if (
Preferences.data.localePatchLocale === locale &&
isOurPatch(target) &&
fs.statSync(target).mtimeMs >= fs.statSync(source).mtimeMs
)
return;
} catch {
/* ignore */
}
try {
for (const l of LETTERS) {
if (l === letter) continue;
const f = patchFile(dataDir, l);
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
}
await fs.copy(source, target, { overwrite: true });
Preferences.data = { localePatchLetter: letter, localePatchLocale: locale };
Logger.log(
`Locale patch: swapped prebuilt ${locale} -> patch-${letter}.mpq`
);
} catch (e) {
Logger.error('Locale patch: failed to swap in prebuilt patch', e);
}
};
+62 -69
View File
@@ -1,5 +1,4 @@
import path from 'path';
import os from 'os';
import fs from 'fs-extra';
import fetch from 'node-fetch';
@@ -12,8 +11,17 @@ import { type ModState } from '~common/schemas';
import Preferences from './preferences';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll } from './dllsTxt';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
const AV_ERROR =
'Windows Defender blocked this download. Use "Allow through antivirus" and apply again.';
const looksLikeAvBlock = (msg: string) =>
/windows defender|virus|potentially unwanted/i.test(msg);
export type ModRowStatus = {
id: ModId;
name: string;
@@ -36,8 +44,6 @@ export type ModsStatus = {
mods: ModRowStatus[];
};
const VERSION_CACHE_MS = 10 * 60 * 1000;
class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = {
state: 'verifying',
@@ -45,21 +51,6 @@ class ModsClass extends Observable<ModsStatus> {
mods: []
};
#latestCache = new Map<ModId, { v: string; ts: number }>();
installedFilePaths(): Set<string> {
const set = new Set<string>();
const mods = Preferences.data?.mods ?? {};
for (const id of Object.keys(mods) as ModId[]) {
const state = mods[id];
if (!state?.installedFiles?.length) continue;
for (const rel of state.installedFiles) {
set.add(rel.replace(/\\/g, '/').toLowerCase());
}
}
return set;
}
get status(): ModsStatus {
return this._value;
}
@@ -119,6 +110,13 @@ class ModsClass extends Observable<ModsStatus> {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg)))
await fs.writeFile(vmmfCfg, '1\n', 'utf8').catch(() => {});
}
for (const m of MODS) {
const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion;
@@ -140,54 +138,28 @@ class ModsClass extends Observable<ModsStatus> {
}
}
const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
if (clientDir && m.registerInDllsTxt)
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, {
installedVersion,
latestVersion: latest,
latestVersion: m.version,
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates
});
}
this._value = { ...this._value, state: 'idle', dirty: this.#computeDirty() };
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty()
};
this._notifyObservers();
}
async #fetchLatestVersion(m: ModEntry): Promise<string> {
if (m.source.kind === 'managed') return m.version;
const cached = this.#latestCache.get(m.id);
if (cached && Date.now() - cached.ts < VERSION_CACHE_MS) return cached.v;
const apiUrl =
'apiUrl' in m.source && m.source.apiUrl ? m.source.apiUrl : undefined;
const parser =
'parseLatest' in m.source && m.source.parseLatest
? m.source.parseLatest
: undefined;
if (!apiUrl || !parser) {
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
this.#latestCache.set(m.id, { v, ts: Date.now() });
return v;
}
try {
const res = await fetch(apiUrl, {
headers: { 'User-Agent': 'OctoLauncher' }
});
if (!res.ok) throw new Error(`${apiUrl}${res.status}`);
const json = (await res.json()) as { tag_name?: string };
const tag = json.tag_name ?? m.version;
this.#latestCache.set(m.id, { v: tag, ts: Date.now() });
return tag;
} catch (e) {
Logger.warn(`Could not check latest version for ${m.id}:`, e);
const v = ('pinnedTag' in m.source && m.source.pinnedTag) || m.version;
return v;
}
}
async toggle(id: ModId, enabled: boolean) {
const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, {
@@ -216,6 +188,10 @@ class ModsClass extends Observable<ModsStatus> {
Logger.warn('No clientDir set; cannot apply mods.');
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
this._value = { ...this._value, state: 'busy' };
this._notifyObservers();
@@ -226,6 +202,7 @@ class ModsClass extends Observable<ModsStatus> {
return 0;
});
const failures = new Map<ModId, string>();
for (const row of queue) {
const m = getMod(row.id);
if (!m) continue;
@@ -248,15 +225,16 @@ class ModsClass extends Observable<ModsStatus> {
}
} catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e);
this.#patchRow(m.id, {
state: 'error',
error: e instanceof Error ? e.message : String(e)
});
const msg = e instanceof Error ? e.message : String(e);
failures.set(m.id, looksLikeAvBlock(msg) ? AV_ERROR : msg);
}
}
this._value = { ...this._value, state: 'idle' };
await this.verify();
for (const [id, error] of failures)
this.#patchRow(id, { state: 'error', error });
await Updater.verify();
}
async #install(m: ModEntry) {
@@ -265,18 +243,25 @@ class ModsClass extends Observable<ModsStatus> {
if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
this.#patchRow(m.id, {
state: 'downloading',
progress: 0,
error: undefined
});
const written: string[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}.${m.source.format}`
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' });
@@ -288,7 +273,7 @@ class ModsClass extends Observable<ModsStatus> {
for (const [src, dst] of Object.entries(map)) {
const entry = entries.find(e => e.entryName === src);
if (!entry) {
Logger.warn(`Mod ${m.id}: zip entry ${src} not found.`);
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
@@ -297,16 +282,13 @@ class ModsClass extends Observable<ModsStatus> {
written.push(dst);
}
} else {
const stagingDir = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}-extract`
);
const stagingDir = path.join(scratch, `${m.id}-${Date.now()}-extract`);
await fs.ensureDir(stagingDir);
await tar.x({ file: tmp, cwd: stagingDir });
for (const [src, dst] of Object.entries(map)) {
const srcPath = path.join(stagingDir, src);
if (!(await fs.pathExists(srcPath))) {
Logger.warn(`Mod ${m.id}: tar entry ${src} not found.`);
missing.push(src);
continue;
}
const target = path.join(clientDir, dst);
@@ -319,6 +301,11 @@ class ModsClass extends Observable<ModsStatus> {
await fs.remove(tmp).catch(() => {});
}
if (missing.length)
throw new Error(
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
);
if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt);
}
@@ -371,12 +358,18 @@ class ModsClass extends Observable<ModsStatus> {
async #downloadTo(url: string, dest: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' }
headers: { 'User-Agent': 'OctoLauncher' },
timeout: MOD_DOWNLOAD_TIMEOUT_MS
});
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
await fs.ensureDir(path.dirname(dest));
const buf = await res.arrayBuffer();
await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
`Downloaded file disappeared after writing: ${path.basename(dest)}. ` +
'This is often Windows Defender quarantine; if so, use "Allow through antivirus" and apply again.'
);
}
async #savePref(id: ModId, state: ModState) {
+2 -1
View File
@@ -12,7 +12,8 @@ abstract class Observable<T> {
try {
l(v);
return true;
} catch {
} catch (err) {
console.error('Observer threw, removing listener', err);
return false;
}
});
+65 -57
View File
@@ -22,17 +22,24 @@ const Servers = {
}
} as const;
type Tweak = { key: keyof PreferencesSchema['config']; default?: unknown; forced?: boolean } & (
| {
type: 'bytes';
tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
type TweakKey =
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
| { synthetic: true; key: string };
type Tweak = TweakKey & {
default?: unknown;
forced?: boolean;
} & (
| {
type: 'bytes';
tweaks: [number, number[]][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
@@ -81,7 +88,8 @@ export const patchExecutable = async () => {
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{
key: 'crossFactionResurrect' as never,
synthetic: true,
key: 'crossFactionResurrect',
type: 'bytes',
default: true,
tweaks: [
@@ -89,8 +97,10 @@ export const patchExecutable = async () => {
[0x006e62a8, [0x006e62a9]]
]
},
// version-pinned in-place patch of the WoW.exe routine at this offset
{
key: 'skillUiGateHijack' as never,
synthetic: true,
key: 'skillUiGateHijack',
type: 'bytes',
default: true,
forced: true,
@@ -98,47 +108,41 @@ export const patchExecutable = async () => {
[
0x002ddf90,
[
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56,
0x57, 0x8b, 0x3d, 0x60, 0xab, 0xce, 0x00, 0x83,
0xff, 0xff, 0x89, 0x55, 0xfc, 0x89, 0x4d, 0xf8,
0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58,
0xab, 0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d,
0x04, 0x40, 0x8b, 0x4c, 0x82, 0x08, 0xf6, 0xc1,
0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04, 0x85,
0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00,
0xf6, 0xc1, 0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74,
0x4a, 0x39, 0x31, 0x74, 0x13, 0x8b, 0xc7, 0x23,
0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0,
0x8b, 0x59, 0x1c, 0x8b, 0x71, 0x18, 0x33, 0xff,
0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64, 0x24, 0x00,
0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00,
0x6a, 0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b,
0x55, 0xfc, 0xe8, 0xb9, 0xfd, 0xff, 0xff, 0x84,
0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20, 0x3b,
0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b,
0x8b, 0xe5, 0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b,
0xc6, 0x5e, 0x5b, 0x8b, 0xe5, 0x5d, 0xc2, 0x04,
0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
0x55, 0x8b, 0xec, 0x83, 0xec, 0x08, 0x53, 0x56, 0x57, 0x8b, 0x3d,
0x60, 0xab, 0xce, 0x00, 0x83, 0xff, 0xff, 0x89, 0x55, 0xfc, 0x89,
0x4d, 0xf8, 0x74, 0x79, 0x8b, 0x75, 0x08, 0x8b, 0x15, 0x58, 0xab,
0xce, 0x00, 0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8b, 0x4c,
0x82, 0x08, 0xf6, 0xc1, 0x01, 0x8d, 0x44, 0x82, 0x04, 0x75, 0x04,
0x85, 0xc9, 0x75, 0x05, 0x33, 0xc9, 0x8d, 0x49, 0x00, 0xf6, 0xc1,
0x01, 0x75, 0x4e, 0x85, 0xc9, 0x74, 0x4a, 0x39, 0x31, 0x74, 0x13,
0x8b, 0xc7, 0x23, 0xc6, 0x8d, 0x04, 0x40, 0x8d, 0x04, 0x82, 0x8b,
0x00, 0x03, 0xc1, 0x8b, 0x48, 0x04, 0xeb, 0xe0, 0x8b, 0x59, 0x1c,
0x8b, 0x71, 0x18, 0x33, 0xff, 0x85, 0xdb, 0x7e, 0x27, 0x8d, 0x64,
0x24, 0x00, 0x8b, 0x4e, 0x0c, 0x8b, 0x56, 0x08, 0x6a, 0x00, 0x6a,
0x00, 0x51, 0x8b, 0x4d, 0xf8, 0x52, 0x8b, 0x55, 0xfc, 0xe8, 0xb9,
0xfd, 0xff, 0xff, 0x84, 0xc0, 0x75, 0x13, 0x47, 0x83, 0xc6, 0x20,
0x3b, 0xfb, 0x7c, 0xdd, 0x5f, 0x5e, 0x33, 0xc0, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x5f, 0x8b, 0xc6, 0x5e, 0x5b, 0x8b, 0xe5,
0x5d, 0xc2, 0x04, 0x00, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90
]
]
]
}
] satisfies Tweak[];
// Apply patches
Tweaks.forEach(t => {
const val =
config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
const val = t.synthetic
? t.default
: config[t.key] ?? t.default ?? ConfigWtfSchema.parse({})[t.key];
Logger.log(`Applying "${t.key}" patch with value: ${val}`);
if (t.type === 'float') {
buffer.writeFloatLE(t.value ?? (val as never), t.offset);
buffer.writeFloatLE(t.value ?? (val as number), t.offset);
} else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as never), t.offset);
buffer.writeInt8(t.value ?? (val as number), t.offset);
} else if (t.type === 'uint16') {
if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) =>
@@ -151,11 +155,12 @@ export const patchExecutable = async () => {
Logger.log('WoW.exe successfully patched');
} catch (e) {
Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
}
};
export const patchConfig = async () => {
const { clientDir, server, config } = Preferences.data;
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, server, config, locale } = Preferences.data;
if (!clientDir) return;
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
@@ -163,14 +168,13 @@ export const patchConfig = async () => {
const raw = (await fs.pathExists(configPath))
? await fs.readFile(configPath, { encoding: 'utf-8' })
: '';
if (raw) await fs.remove(configPath);
const configWtf = Object.fromEntries(
raw
.split('\n')
.split(/\r?\n/)
.map(l => {
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
return !k || !v ? undefined : [k, v];
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
return !k || v === undefined ? undefined : [k, v];
})
.filter(isNotUndef)
);
@@ -210,20 +214,24 @@ export const patchConfig = async () => {
gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
farClip: configWtf['farClip'] ?? config.farClip,
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
...configWtf,
CameraDistanceMax: config.cameraDistance,
farClip: config.farClip,
locale,
realmList: Servers[server].realmList,
hwDetect: 0,
M2UseShaders: 1
M2UseShaders: 1,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
};
await fs.writeFile(
configPath,
Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n')
);
const body = Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n');
const tmpPath = `${configPath}.tmp`;
await fs.writeFile(tmpPath, body);
await fs.move(tmpPath, configPath, { overwrite: true });
Logger.log('Config.wtf successfully patched');
};
+64 -15
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs-extra';
import { type z } from 'zod';
import { app } from 'electron';
import Logger from 'electron-log/main';
import { PreferencesSchema } from '~common/schemas';
import { omit } from '~common/utils';
@@ -11,6 +12,7 @@ const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
@@ -18,21 +20,44 @@ abstract class Preferences {
static async load() {
await fs.ensureDir(this.userDataDir);
const settingsPath = path.join(this.userDataDir, 'settings.json');
const userDataPath = path.join(this.userDataDir, 'settings.json');
let json: Record<string, unknown>;
try {
const json = await fs.readJSON(userDataPath);
return PreferencesSchema.parse({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
} catch (e) {
json = await fs.readJSON(settingsPath);
} catch {
return PreferencesSchema.parse({
isPortable: !!portableDir,
clientDir: portableDir
});
}
const merged = {
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success) return parsed.data;
Logger.warn(
'settings.json failed validation; salvaging valid fields',
parsed.error
);
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
const salvaged: Record<string, unknown> = {
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
};
const shape = PreferencesSchema.shape;
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
if (!(key in merged)) continue;
const value = (merged as Record<string, unknown>)[key];
if (shape[key].safeParse(value).success) salvaged[key] = value;
}
return PreferencesSchema.parse(salvaged);
}
static get data(): PreferencesSchema {
@@ -41,14 +66,38 @@ abstract class Preferences {
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData };
fs.writeJSON(
path.join(this.userDataDir, 'settings.json'),
omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
),
{ spaces: 2 }
const settingsPath = path.join(this.userDataDir, 'settings.json');
const delta = omit(
newData,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
const snapshot = omit(
this.#data,
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
);
this.#writeChain = this.#writeChain
.then(async () => {
let onDisk: unknown = null;
try {
onDisk = await fs.readJSON(settingsPath);
} catch {
onDisk = null;
}
const base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
const merged = base ? { ...base, ...delta } : snapshot;
const tmp = `${settingsPath}.tmp`;
await fs.writeJSON(tmp, merged, { spaces: 2 });
await fs.move(tmp, settingsPath, { overwrite: true });
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
static save() {
return this.#writeChain;
}
static async isValidClientDir(clientDir?: string) {
+12 -5
View File
@@ -10,7 +10,12 @@ export type SelfUpdaterStatus =
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
| {
state: 'downloading';
currentVersion: string;
nextVersion: string;
progress: number;
}
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
@@ -37,7 +42,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
this.#initialized = true;
if (is.dev) {
Logger.info('[selfUpdater] dev mode skipping');
Logger.info('[selfUpdater] dev mode, skipping');
return;
}
@@ -83,7 +88,7 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
});
autoUpdater.on('update-downloaded', info => {
Logger.info(
`[selfUpdater] downloaded ${info.version} awaiting user click`
`[selfUpdater] downloaded ${info.version}, awaiting user click`
);
this.status = {
state: 'ready',
@@ -100,11 +105,13 @@ class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
triggerInstall() {
if (this._value.state !== 'ready') {
Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state} ignoring`
`[selfUpdater] triggerInstall called in state ${this._value.state}, ignoring`
);
return;
}
Logger.info('[selfUpdater] user clicked install — quitting + running installer');
Logger.info(
'[selfUpdater] user clicked install, quitting + running installer'
);
autoUpdater.quitAndInstall(false, true);
}
}
+158 -46
View File
@@ -50,6 +50,7 @@ const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
os.homedir() ||
(os.platform() === 'win32' ? 'C:\\' : '/');
try {
// @ts-expect-error statfs exists at runtime (Node 18) but not @types/node 16
const s = await fs.promises.statfs(target);
return Number(s.bsize) * Number(s.bavail);
} catch (e) {
@@ -65,11 +66,23 @@ const isReadOnly = async (filePath: string) => {
try {
const { mode } = await fs.stat(filePath);
return !(mode & fs.constants.S_IWUSR);
} catch (e) {
} catch {
return false;
}
};
const friendlyError = (e: unknown): string => {
const msg = e instanceof Error ? e.message : String(e);
if (/EPERM|EACCES|EROFS|read-only/i.test(msg))
return (
'Cannot write to the game folder. Move your OctoWoW install out of ' +
'Program Files (or any protected folder), or run the launcher as ' +
'administrator, then try again.'
);
if (/ENOSPC/i.test(msg)) return 'Not enough disk space to finish the update.';
return msg;
};
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & (
@@ -141,18 +154,54 @@ export const isGameRunning = (executablePath: string) =>
})
: false;
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/');
const toUrlPath = (p: string) =>
p.split(path.sep).map(encodeURIComponent).join('/');
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
const CONNECT_TIMEOUT_MS = 30_000;
const STALL_TIMEOUT_MS = 60_000;
const isUnsafeName = (name: string) =>
!name ||
name === '.' ||
name === '..' ||
/[/\\]/.test(name) ||
path.isAbsolute(name);
const manifestPathsSafe = (m: FileManifest, isRoot = false): boolean => {
if (!isRoot && isUnsafeName(m.name)) return false;
if (m.type === 'dir' || m.type === 'mpq')
return m.files.every(f => manifestPathsSafe(f));
return true;
};
const fetchManifest = async () => {
try {
const r = await fetch(
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json`
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/api/file/${CDN_VERSION}/manifest.json`,
{ timeout: CONNECT_TIMEOUT_MS }
);
if (!r.ok) {
Logger.error(`Update server returned HTTP ${r.status}`);
return null;
}
const j = await r.json();
if (!j || typeof j !== 'object' || !('root' in j) || !j.root) {
Logger.error('Update server returned a malformed manifest');
return null;
}
const root = j.root as FileManifest;
if (!manifestPathsSafe(root, true)) {
Logger.error(
'Update server manifest has unsafe path names; refusing it.'
);
return null;
}
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
return j.root as FileManifest;
return root;
} catch (e) {
Logger.error('Failed to reach update server', e);
return null;
@@ -160,22 +209,26 @@ const fetchManifest = async () => {
};
const buildClientUrl = (filePath: string) =>
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/client/${CDN_VERSION}/${toUrlPath(
path.normalize(filePath)
)}`;
`${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/client/${CDN_VERSION}/${toUrlPath(path.normalize(filePath))}`;
export const fetchFile = async (
filePath: string,
onChunk?: (deltaBytes: number) => void
) => {
try {
const response = await fetch(buildClientUrl(filePath));
const response = await fetch(buildClientUrl(filePath), {
timeout: CONNECT_TIMEOUT_MS
});
if (!response.ok) throw Error(`HTTP ${response.status}`);
if (!onChunk || !response.body) return await response.arrayBuffer();
const chunks: Buffer[] = [];
for await (const chunk of response.body as NodeJS.ReadableStream) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
const buf = Buffer.isBuffer(chunk)
? chunk
: Buffer.from(chunk);
chunks.push(buf);
onChunk(buf.byteLength);
}
@@ -203,7 +256,8 @@ export const downloadFileToDisk = async (
else if (stats.size >= expectedSize) {
await fs.truncate(partPath, 0);
}
} catch {
} catch (e) {
Logger.warn(`Failed to resume from "${partPath}".`, e);
}
if (resumeFrom > 0) onChunk(resumeFrom);
@@ -212,20 +266,25 @@ export const downloadFileToDisk = async (
const headers: Record<string, string> = {};
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
const controller = new AbortController();
let response;
try {
response = await fetch(url, { headers });
response = await fetch(url, {
headers,
signal: controller.signal,
timeout: CONNECT_TIMEOUT_MS
});
} catch (e) {
Logger.error(`Network error downloading ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`);
}
if (!response.ok && response.status !== 206) {
throw Error(`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`);
throw Error(
`Failed to download ${path.normalize(filePath)}: HTTP ${response.status}`
);
}
// If we got 200, the server gave us the whole file
// roll back and truncate
if (resumeFrom > 0 && response.status === 200) {
onChunk(-resumeFrom);
await fs.truncate(partPath, 0);
@@ -236,6 +295,7 @@ export const downloadFileToDisk = async (
flags: resumeFrom > 0 ? 'a' : 'w'
});
let stalled = false;
try {
await new Promise<void>((resolve, reject) => {
if (!response.body) {
@@ -243,26 +303,53 @@ export const downloadFileToDisk = async (
return;
}
const body = response.body as NodeJS.ReadableStream;
let stallTimer: NodeJS.Timeout;
const armStall = () => {
clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
stalled = true;
controller.abort();
}, STALL_TIMEOUT_MS);
};
armStall();
body.on('data', (chunk: Buffer | Uint8Array) => {
armStall();
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
if (!writeStream.write(buf)) body.pause();
onChunk(buf.byteLength);
});
writeStream.on('drain', () => body.resume());
body.on('end', () => writeStream.end(resolve));
body.on('error', reject);
writeStream.on('error', reject);
body.on('end', () => {
clearTimeout(stallTimer);
writeStream.end(resolve);
});
body.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
writeStream.on('error', err => {
clearTimeout(stallTimer);
reject(err);
});
});
} catch (e) {
writeStream.destroy();
Logger.error(`Download interrupted for ${filePath}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`);
throw Error(
stalled
? `Download stalled for ${path.normalize(
filePath
)}; no data received in ${STALL_TIMEOUT_MS / 1000}s.`
: `Failed to download ${path.normalize(filePath)}`
);
}
const finalStats = await fs.stat(partPath);
if (finalStats.size !== expectedSize) {
throw Error(
`Size mismatch for ${path.normalize(filePath)}: got ${finalStats.size}, expected ${expectedSize}. Will retry on next run.`
`Size mismatch for ${path.normalize(filePath)}: got ${
finalStats.size
}, expected ${expectedSize}. Will retry on next run.`
);
}
@@ -465,6 +552,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
const vanillaFixes = Preferences.data.config.vanillaFixes;
const modOwnedFiles = new Set<string>();
for (const state of Object.values(Preferences.data.mods ?? {}))
for (const rel of state?.installedFiles ?? [])
modOwnedFiles.add(rel.replace(/\\/g, '/').toLowerCase());
const hashTree = await fetchManifest();
if (!hashTree) {
@@ -582,11 +673,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
SFileCloseArchive(hMpq);
}
} catch (e) {
Logger.log(
Logger.warn(
`Failed to verify ${path.join(
...patchPath
)}, will be downloaded fresh`,
'warning',
e
);
return {
@@ -598,13 +688,13 @@ class UpdaterClass extends Observable<UpdaterStatus> {
}
}
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) {
if (await fs.exists(path.join(clientPath, ...filePath))) {
return {
type: 'del',
name: item.name
};
} else {
if (item.tags?.includes('vanillaFixes')) {
if (modOwnedFiles.has(filePath.join('/').toLowerCase()))
return undefined;
if (!vanillaFixes) {
if (await fs.exists(path.join(clientPath, ...filePath)))
return { type: 'del', name: item.name };
return undefined;
}
}
@@ -678,8 +768,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const currentLauncherVersion = app.getVersion();
if (
this.status.state === 'upToDate' &&
Preferences.data.lastPatchedLauncherVersion !==
currentLauncherVersion
Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion
) {
Logger.log(
`Launcher version changed (${
@@ -710,8 +799,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
})();
}
} catch (e) {
const message =
e instanceof Error ? e.message : 'Unexpected error occurred';
const message = friendlyError(e);
Logger.error(`Verification failed: ${message}`, e);
this.status = { state: 'failed', message };
}
@@ -744,6 +832,22 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
if (clean) {
// never wipe a drive root or a non-client dir
const resolvedClientPath = path.resolve(clientPath);
const isFilesystemRoot =
path.parse(resolvedClientPath).root === resolvedClientPath;
if (
isFilesystemRoot ||
!fs.existsSync(path.join(resolvedClientPath, 'WoW.exe'))
) {
this.status = {
state: 'failed',
message:
'Refusing to clean: the client folder does not look like a valid WoW install.'
};
return;
}
this.status = {
state: 'updating',
progress: -1,
@@ -804,12 +908,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (!item) return undefined;
if (item.type === 'del') {
throw Error(
`TODO: Deleting of files from MPQ not implemented at path ${path.join(
...mpqPath,
...filePath
)}`
);
if (SFileHasFile(hMpq, path.join(...filePath)))
SFileRemoveFile(hMpq, path.join(...filePath));
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
return;
}
if (item.type === 'dir') {
@@ -826,7 +928,9 @@ class UpdaterClass extends Observable<UpdaterStatus> {
)}`
);
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`;
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(
...filePath
)}"`;
emitProgress(label, true);
const data = await fetchFile(
@@ -853,6 +957,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
} finally {
SFileFinishFile(hFile);
}
nestedSet(this.#cache, [...mpqPath, ...filePath], undefined);
};
const iterateTree = async (...filePath: string[]) => {
@@ -922,7 +1027,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (item.name === 'WoW.exe') executableUpdate = true;
const fullPath = path.join(clientPath, ...filePath);
if (await fs.exists(fullPath) && (await isReadOnly(fullPath)))
if ((await fs.exists(fullPath)) && (await isReadOnly(fullPath)))
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
await downloadFileToDisk(
@@ -947,7 +1052,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
if (executableUpdate || launcherVersionChanged) {
await patchExecutable();
await this.#getHash({ clientPath }, 'WoW.exe');
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
@@ -960,13 +1064,21 @@ class UpdaterClass extends Observable<UpdaterStatus> {
this.#bytesAlreadyOnDisk = fullClientTotal;
this.status = { state: 'upToDate', progress: 1 };
} catch (e) {
console.error(e);
this.status = {
state: 'failed',
message: e instanceof Error ? e.message : 'Unexpected error occurred'
};
Logger.error('Update failed', e);
this.status = { state: 'failed', message: friendlyError(e) };
}
}
async recordPatchedWow() {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
lastPatchedLauncherVersion: app.getVersion(),
expectedPatchedWowHash: patchedWowHash
};
}
}
const Updater = new UpdaterClass();
+34 -7
View File
@@ -6,8 +6,15 @@ import fs from 'fs-extra';
import Preferences from './modules/preferences';
const isCallbackResponse = (data: any): data is { cb: string; args: any[] } =>
data && typeof data === 'object' && 'cb' in data && 'args' in data;
const isCallbackResponse = (
data: unknown
): data is { cb: string; args: unknown[] } =>
typeof data === 'object' &&
data !== null &&
'cb' in data &&
typeof (data as { cb: unknown }).cb === 'string' &&
'args' in data &&
Array.isArray((data as { args: unknown }).args);
export const runWorker = <T>(
worker: (o: WorkerOptions) => Worker,
@@ -16,10 +23,16 @@ export const runWorker = <T>(
) =>
new Promise<T>((resolve, reject) =>
worker({ workerData })
.on('message', m =>
isCallbackResponse(m) ? callbacks?.[m.cb](...m.args) : resolve(m)
)
.on('message', (m: unknown) => {
if (!isCallbackResponse(m)) return resolve(m as T);
const callback = callbacks?.[m.cb];
if (callback) callback(...m.args);
else Logger.warn('Unknown worker callback', m.cb);
})
.on('error', reject)
.on('exit', code =>
reject(new Error(`Worker exited (code ${code}) without finishing`))
)
);
export const getClientVersion = async () => {
@@ -35,8 +48,22 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath);
const buffer = Buffer.from(file);
const version = buffer.toString('utf-8', 0x00437c04, 0x00437c04 + 6);
const build = buffer.toString('utf-8', 0x00437bfc, 0x00437bfc + 4);
// Fixed addresses in the 1.12.1 client binary.
const VERSION_OFFSET = 0x00437c04;
const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc;
const BUILD_LEN = 4;
const version = buffer.toString(
'utf-8',
VERSION_OFFSET,
VERSION_OFFSET + VERSION_LEN
);
const build = buffer.toString(
'utf-8',
BUILD_OFFSET,
BUILD_OFFSET + BUILD_LEN
);
Logger.log(`Client version is: ${version} (${build})`);
return `${version} (${build})`;
+18 -6
View File
@@ -9,15 +9,27 @@ if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData;
fs.removeSync(dir);
git
.clone({
dir,
const tmpDir = `${dir}.tmp`;
const run = async () => {
await fs.remove(tmpDir);
await git.clone({
dir: tmpDir,
fs,
http,
url,
ref,
singleBranch: !ref || ref === 'master' || ref === 'main',
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
})
.then(() => port.postMessage(true));
});
await fs.remove(dir);
await fs.move(tmpDir, dir);
};
run()
.then(() => port.postMessage(true))
.catch(async err => {
await fs.remove(tmpDir).catch(() => undefined);
throw err;
});
+14 -14
View File
@@ -5,7 +5,7 @@ import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
if (!port) throw new Error('IllegalState');
if (!port) throw new Error('gitPull worker has no parentPort');
const { dir, remote, branch, ref } = workerData as {
dir: string;
@@ -17,20 +17,17 @@ const { dir, remote, branch, ref } = workerData as {
const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args });
const removeUntrackedFiles = async () => {
const status = await git.statusMatrix({ fs, dir });
await Promise.all(
status
.filter(([, HEAD]) => HEAD === 0)
.map(([filepath]) => fs.remove(`${dir}/${filepath}`))
);
};
const run = async () => {
if (ref) {
await git.fetch({ fs, http, dir, tags: true, singleBranch: false, onProgress });
await git.fetch({
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress });
await removeUntrackedFiles();
return;
}
@@ -41,7 +38,6 @@ const run = async () => {
ref: `${remote}/${branch}`,
onProgress
});
await removeUntrackedFiles();
await git.pull({
fs,
http,
@@ -53,4 +49,8 @@ const run = async () => {
});
};
run().then(() => port.postMessage(true));
run()
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});