Fixed tweaks and mods, added localization, added antivirus walkthrough
Build check / build (push) Has been cancelled
Build check / build (push) Has been cancelled
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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() };
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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})`;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user