Initial commit

This commit is contained in:
2026-05-08 00:00:00 +00:00
commit 530ec7a144
110 changed files with 18537 additions and 0 deletions
+400
View File
@@ -0,0 +1,400 @@
import path from 'node:path';
import git, { type ProgressCallback } from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { isNotUndef } from '~common/utils';
import { type AddonData, type TocData } from '~common/schemas';
import { runWorker } from '~main/utils';
import gitPull from '~main/workers/gitPull?nodeWorker';
import gitClone from '~main/workers/gitClone?nodeWorker';
import Preferences from './preferences';
import Observable from './observable';
export type AddonsStatus = {
state: 'verifying' | 'done';
addons: { [name: string]: AddonData };
available: AddonData[];
};
type AddonsList = {
name: string;
owner: string;
branch?: string;
ref?: string;
git: string;
toc?: TocData;
description?: string;
lastUpdated?: string;
stars?: number;
dependencies?: string[];
}[];
const readTocData = (content: string) =>
content
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const [key, value] = l.split(':');
return [key.trim(), value.trim()];
})
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as TocData);
const fetchAddons = async () => {
try {
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);
return [];
}
};
class AddonsClass extends Observable<AddonsStatus> {
protected _value: AddonsStatus = {
state: 'done',
addons: {},
available: []
};
get status() {
return this._value;
}
private set status(v: AddonsStatus) {
this._value = v;
this._notifyObservers(v);
}
#onProgress =
(folder: string, data: AddonData): ProgressCallback =>
progress => {
const getPhase = (step: string) => {
switch (step) {
case 'Counting objects':
return 1;
case 'Compressing objects':
return 2;
case 'Receiving objects':
return 3;
case 'Resolving deltas':
return 4;
case 'Analyzing workdir':
return 5;
case 'Updating workdir':
return 6;
default:
return 0;
}
};
this.#setAddon(folder, {
...data,
progress: `${Math.round(
(progress.loaded / (progress.total ?? progress.loaded)) * 100
)}% (${getPhase(progress.phase)}/6)`
});
};
async checkGitUrl(url: string) {
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
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))) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
// preview stays undefined
}
return {
status: 'available',
folder: gitUrl.slice(0, -4).split('/').at(-1),
git: gitUrl,
preview
} as AddonData;
} catch (e) {
return undefined;
}
}
async verify() {
if (this.status.state !== 'done') return;
this.status = {
...this.status,
state: 'verifying'
};
const remoteAddons = await fetchAddons();
const available: AddonData[] = remoteAddons.map(a => ({
status: 'available',
git: a.git,
toc: a.toc,
description: a.description,
folder: a.name,
branch: a.branch,
ref: a.ref
}));
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'done', addons: {}, available };
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = await fs.pathExists(addonsPath)
? await fs.readdir(addonsPath)
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs
.filter(d => !d.startsWith('Blizzard_'))
.map(name => [name, { status: 'fetching' as const, folder: name }])
);
this.status = { state: 'verifying', addons, available };
const verifyOne = async (folder: string) => {
const dir = path.join(addonsPath, folder);
if (!fs.existsSync(path.join(dir, `${folder}.toc`))) {
this.#setAddon(folder, {
status: 'invalid',
error: 'Missing .toc file',
folder
});
return;
}
const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r[0])
.catch(() => null);
const avail = remoteAddons.find(a => a.name === folder);
if (!remote) {
Logger.log(`Addon "${folder}" is not a git repository`);
this.#setAddon(
folder,
avail
? {
status: 'outOfDate',
git: avail.git,
toc,
description: avail.description,
folder
}
: { status: 'unknown', toc, folder }
);
return;
}
try {
await git.fetch({ fs, dir, http, tags: true });
const branch = await git.currentBranch({ fs, dir });
const localCommit = await git
.log({ fs, dir, ref: 'HEAD', depth: 1 })
.then(r => r[0].oid)
.catch(() => null);
const remoteCommit = avail?.ref
? 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)
.catch(() => null);
const status = await git.statusMatrix({ fs, dir });
const hasChanges = status.some(
([_, HEAD, index, workdir]) => HEAD !== index || index !== workdir
);
const isUpToDate =
!hasChanges && remoteCommit && localCommit === remoteCommit;
this.#setAddon(folder, {
git: remote.url,
status: isUpToDate ? 'upToDate' : 'outOfDate',
toc,
description: avail?.description,
ref: avail?.ref,
folder
});
Logger.log(
isUpToDate
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
: `Addon "${folder}" has an update available`
);
} catch (e) {
this.#setAddon(folder, {
git: remote.url,
status: 'invalid',
error: 'Failed to verify',
toc,
folder
});
Logger.error(`Addon "${folder}" failed to verify`, e);
}
};
const folders = Object.keys(addons);
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]);
}
})
);
this.status = { ...this.status, state: 'done' };
}
async update(
toUpdate = Object.values(this.status.addons)
.filter(e => e.status === 'outOfDate')
.map(e => e.folder)
.filter(isNotUndef)
) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
for (const folder of toUpdate) {
if (this.status.addons[folder]?.status === 'downloading') continue;
const dir = path.join(addonsPath, folder);
const avail = this.status.available.find(a => a.folder === folder);
const data: AddonData = {
...avail,
...this.status.addons[folder],
status: 'downloading'
};
this.#setAddon(folder, data);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r?.[0])
.catch(() => null);
try {
if (!remote) {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(folder, data) }
);
} else {
const branch =
(await git.currentBranch({ fs, dir })) ?? avail?.branch ?? 'master';
await runWorker(
gitPull,
{
dir,
remote: remote.remote,
branch,
ref: avail?.ref
},
{ onProgress: this.#onProgress(folder, data) }
);
}
const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
this.#setAddon(folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Updated addon "${folder}"`);
} catch (e) {
this.#setAddon(folder, {
...data,
status: 'invalid',
error: 'Failed to update'
});
Logger.error(`Addon "${folder}" failed to update`, e);
}
}
}
async remove(toRemove: string[]) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
for (const folder of toRemove) {
const dir = path.join(clientPath, 'Interface', 'Addons', folder);
if (fs.existsSync(dir)) await fs.remove(dir);
this.#setAddon(folder);
Logger.log(`Removed addon "${folder}"`);
}
}
async install(data: AddonData) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder);
try {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(data.folder, data) }
);
const toc = await readTocData(
await fs.readFile(path.join(dir, `${data.folder}.toc`), 'utf-8')
);
this.#setAddon(data.folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Installed addon "${data.folder}"`);
} catch (e) {
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Failed to install'
});
Logger.error(`Addon "${data.folder}" failed to install`, e);
}
}
#setAddon(folder: string, data?: AddonData) {
const { [folder]: _, ...addons } = this.status.addons;
this.status = {
...this.status,
addons: data ? { ...addons, [folder]: data } : addons
};
}
}
const Addons = new AddonsClass();
export default Addons;
+55
View File
@@ -0,0 +1,55 @@
import path from 'path';
import fs from 'fs-extra';
let queue: Promise<unknown> = Promise.resolve();
const serial = <T>(fn: () => Promise<T>): Promise<T> => {
const next = queue.then(fn, fn);
queue = next.catch(() => {});
return next;
};
const dllsPath = (clientDir: string) => path.join(clientDir, 'dlls.txt');
const readLines = async (clientDir: string): Promise<string[]> => {
const file = dllsPath(clientDir);
if (!(await fs.pathExists(file))) return [];
const text = await fs.readFile(file, 'utf8');
return text.split(/\r?\n/);
};
const writeLines = async (clientDir: string, lines: string[]) => {
const file = dllsPath(clientDir);
const trimmed = lines.join('\n').replace(/\n+$/, '');
if (!trimmed.trim()) {
if (await fs.pathExists(file)) await fs.remove(file);
return;
}
await fs.writeFile(file, trimmed + '\n', 'utf8');
};
const matches = (line: string, name: string) =>
line.trim().toLowerCase() === name.toLowerCase();
export const addDll = (clientDir: string, name: string) =>
serial(async () => {
const lines = await readLines(clientDir);
if (lines.some(l => matches(l, name))) return;
lines.push(name);
await writeLines(clientDir, lines);
});
export const removeDll = (clientDir: string, name: string) =>
serial(async () => {
const lines = await readLines(clientDir);
const next = lines.filter(l => !matches(l, name));
if (next.length === lines.length) return;
await writeLines(clientDir, next);
});
export const hasDll = (clientDir: string, name: string) =>
serial(async () => {
const lines = await readLines(clientDir);
return lines.some(l => matches(l, name));
});
+389
View File
@@ -0,0 +1,389 @@
import path from 'path';
import os from 'os';
import fs from 'fs-extra';
import fetch from 'node-fetch';
import AdmZip from 'adm-zip';
import * as tar from 'tar';
import Logger from 'electron-log/main';
import { MODS, type ModEntry, type ModId, getMod } from '~common/mods';
import { type ModState } from '~common/schemas';
import Preferences from './preferences';
import Observable from './observable';
import { addDll, removeDll } from './dllsTxt';
export type ModRowStatus = {
id: ModId;
name: string;
description: string;
repoUrl: string;
recommended: boolean;
requires: ModId[];
enabled: boolean;
ignoreUpdates: boolean;
installedVersion?: string;
latestVersion: string;
state: 'idle' | 'downloading' | 'installing' | 'uninstalling' | 'error';
progress?: number;
error?: string;
};
export type ModsStatus = {
state: 'verifying' | 'idle' | 'busy';
dirty: boolean;
mods: ModRowStatus[];
};
const VERSION_CACHE_MS = 10 * 60 * 1000;
class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = {
state: 'verifying',
dirty: false,
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;
}
#initialRow(m: ModEntry): ModRowStatus {
const state = Preferences.data?.mods?.[m.id];
return {
id: m.id,
name: m.name,
description: m.description,
repoUrl: m.repoUrl,
recommended: !!m.recommended,
requires: m.requires ?? [],
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates,
installedVersion: state?.installedVersion,
latestVersion: m.version,
state: 'idle'
};
}
#patchRow(id: ModId, patch: Partial<ModRowStatus>) {
this._value = {
...this._value,
mods: this._value.mods.map(r => (r.id === id ? { ...r, ...patch } : r))
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
}
#computeDirty(): boolean {
return this._value.mods.some(r => {
const wantInstalled = r.enabled;
const isInstalled = !!r.installedVersion;
if (wantInstalled !== isInstalled) return true;
if (
r.installedVersion &&
r.installedVersion !== r.latestVersion &&
!r.ignoreUpdates
)
return true;
return false;
});
}
load() {
this._value = {
state: 'verifying',
dirty: false,
mods: MODS.map(m => this.#initialRow(m))
};
}
async verify() {
this.load();
this._notifyObservers();
const clientDir = Preferences.data?.clientDir;
for (const m of MODS) {
const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion;
if (clientDir && installedVersion) {
const filesPresent = await Promise.all(
(state?.installedFiles ?? []).map(rel =>
fs.pathExists(path.join(clientDir, rel))
)
);
if (state?.installedFiles?.length && !filesPresent.every(Boolean)) {
installedVersion = undefined;
await this.#savePref(m.id, {
enabled: state?.enabled ?? false,
installedVersion: undefined,
installedFiles: [],
ignoreUpdates: state?.ignoreUpdates ?? false
});
}
}
const latest = await this.#fetchLatestVersion(m).catch(() => m.version);
this.#patchRow(m.id, {
installedVersion,
latestVersion: latest,
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates
});
}
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, {
enabled,
installedVersion: cur?.installedVersion,
installedFiles: cur?.installedFiles ?? [],
ignoreUpdates: cur?.ignoreUpdates ?? false
});
this.#patchRow(id, { enabled });
}
async setIgnoreUpdates(id: ModId, ignore: boolean) {
const cur = Preferences.data?.mods?.[id];
await this.#savePref(id, {
enabled: cur?.enabled ?? false,
installedVersion: cur?.installedVersion,
installedFiles: cur?.installedFiles ?? [],
ignoreUpdates: ignore
});
this.#patchRow(id, { ignoreUpdates: ignore });
}
async applyAll() {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.');
return;
}
this._value = { ...this._value, state: 'busy' };
this._notifyObservers();
const queue = [...this._value.mods];
queue.sort((a, b) => {
if (a.id === 'vanillaFixes') return -1;
if (b.id === 'vanillaFixes') return 1;
return 0;
});
for (const row of queue) {
const m = getMod(row.id);
if (!m) continue;
const wantInstalled = row.enabled;
const isInstalled = !!row.installedVersion;
const updateAvailable =
isInstalled &&
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates;
try {
if (wantInstalled && !isInstalled) {
await this.#install(m);
} else if (!wantInstalled && isInstalled) {
await this.#uninstall(m);
} else if (wantInstalled && updateAvailable) {
await this.#uninstall(m);
await this.#install(m);
}
} catch (e) {
Logger.error(`Failed to apply ${m.id}:`, e);
this.#patchRow(m.id, {
state: 'error',
error: e instanceof Error ? e.message : String(e)
});
}
}
this._value = { ...this._value, state: 'idle' };
await this.verify();
}
async #install(m: ModEntry) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return;
Logger.info(`Installing mod ${m.id}...`);
this.#patchRow(m.id, { state: 'downloading', progress: 0, error: undefined });
const written: 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 tmp = path.join(
os.tmpdir(),
`octolauncher-${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp);
this.#patchRow(m.id, { state: 'installing' });
const map = m.source.extractMap;
if (m.source.format === 'zip') {
const zip = new AdmZip(tmp);
const entries = zip.getEntries();
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.`);
continue;
}
const target = path.join(clientDir, dst);
await fs.ensureDir(path.dirname(target));
await fs.writeFile(target, entry.getData());
written.push(dst);
}
} else {
const stagingDir = path.join(
os.tmpdir(),
`octolauncher-${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.`);
continue;
}
const target = path.join(clientDir, dst);
await fs.ensureDir(path.dirname(target));
await fs.copy(srcPath, target);
written.push(dst);
}
await fs.remove(stagingDir).catch(() => {});
}
await fs.remove(tmp).catch(() => {});
}
if (m.registerInDllsTxt) {
await addDll(clientDir, m.registerInDllsTxt);
}
await this.#savePref(m.id, {
enabled: true,
installedVersion: m.version,
installedFiles: written,
ignoreUpdates: Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
});
this.#patchRow(m.id, {
state: 'idle',
installedVersion: m.version,
progress: 1
});
}
async #uninstall(m: ModEntry) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) throw new Error('No client dir');
if (m.source.kind === 'managed') return;
Logger.info(`Uninstalling mod ${m.id}...`);
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
const cur = Preferences.data?.mods?.[m.id];
const files = cur?.installedFiles ?? [];
for (const rel of files) {
const fullPath = path.join(clientDir, rel);
await fs
.remove(fullPath)
.catch(err => Logger.warn(`Couldn't remove ${fullPath}:`, err));
}
if (m.registerInDllsTxt) {
await removeDll(clientDir, m.registerInDllsTxt);
}
await this.#savePref(m.id, {
enabled: cur?.enabled ?? false,
installedVersion: undefined,
installedFiles: [],
ignoreUpdates: cur?.ignoreUpdates ?? false
});
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
}
async #downloadTo(url: string, dest: string) {
const res = await fetch(url, {
headers: { 'User-Agent': 'OctoLauncher' }
});
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));
}
async #savePref(id: ModId, state: ModState) {
const allMods = { ...(Preferences.data?.mods ?? {}), [id]: state };
Preferences.data = { mods: allMods };
}
}
const Mods = new ModsClass();
export default Mods;
+36
View File
@@ -0,0 +1,36 @@
import { observable } from '@trpc/server/observable';
type Func<T> = (arg: T) => void;
abstract class Observable<T> {
private _listeners: Func<T>[] = [];
protected abstract _value: T;
protected _notifyObservers(v = this._value) {
this._listeners = this._listeners.filter(l => {
try {
l(v);
return true;
} catch {
return false;
}
});
}
observe() {
return observable<T>(e => {
e.next(this._value);
this._listeners.push(e.next);
return () => {
this._listeners = this._listeners.filter(v => v !== e.next);
};
});
}
clearObservers() {
this._listeners = [];
}
}
export default Observable;
+229
View File
@@ -0,0 +1,229 @@
import path from 'path';
import { screen } from 'electron';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
import { isNotUndef } from '~common/utils';
import { fetchFile } from '~main/modules/updater';
const Servers = {
live: {
realmList: 'octowow.st',
patchList: 'octowow.st',
realmName: 'OctoWoW'
},
ptr: {
realmList: 'octowow.st',
patchList: 'octowow.st',
realmName: 'OctoWoW PTR'
}
} 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;
}
);
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
const { clientDir, config } = Preferences.data;
if (!clientDir) return;
const exePath = path.join(clientDir, 'WoW.exe');
try {
Logger.log('Fetching clean WoW.exe...');
const file = await fetchFile('WoW.exe');
const buffer = Buffer.from(file);
const Tweaks = [
{
key: 'largeAddress',
type: 'uint16',
offset: 0x126,
value: buffer.readUint16LE(0x126) | 0x20,
default: false
},
{ key: 'farClip', type: 'float', offset: 0x40fed8 },
{
key: 'fieldOfView',
type: 'float',
offset: 0x4089b4,
value: (config.fieldOfView ?? 1) * (Math.PI / 180),
default: 90
},
{ key: 'frillDistance', type: 'float', offset: 0x467958 },
{
key: 'soundInBackground',
type: 'int8',
offset: 0x3a4869,
value: config.soundInBackground ? 0x27 : 0x14,
default: false
},
{
key: 'alwaysAutoLoot',
type: 'bytes',
tweaks: [
[0x0c1ecf, [0x75]],
[0x0c2b25, [0x75]]
]
},
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
{ key: 'cameraDistance', type: 'float', offset: 0x4089a4 },
{
key: 'crossFactionResurrect' as never,
type: 'bytes',
default: true,
tweaks: [
[0x006e5fb8, [0x006e5fb9]],
[0x006e62a8, [0x006e62a9]]
]
},
{
key: 'skillUiGateHijack' as never,
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
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
]
]
]
}
] satisfies Tweak[];
// Apply patches
Tweaks.forEach(t => {
const val =
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);
} else if (t.type === 'int8') {
buffer.writeInt8(t.value ?? (val as never), t.offset);
} else if (t.type === 'uint16') {
if (!t.forced && !val) return;
buffer.writeUInt16LE(t.value ?? (val as never), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) return;
t.tweaks.forEach(([offset, bytes]) =>
Buffer.from(bytes).copy(buffer, offset)
);
}
});
await fs.writeFile(exePath, buffer);
Logger.log('WoW.exe successfully patched');
} catch (e) {
Logger.error('Failed to patch WoW.exe', e);
}
};
export const patchConfig = async () => {
const { clientDir, server, config } = Preferences.data;
if (!clientDir) return;
const configPath = path.join(clientDir, 'WTF', 'Config.wtf');
await fs.ensureDir(path.dirname(configPath));
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')
.map(l => {
const [_, k, v] = l.match(/SET (\w+) "(.+)"/) ?? [];
return !k || !v ? undefined : [k, v];
})
.filter(isNotUndef)
);
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.bounds;
const parsed = {
scriptMemory: 512000,
gxResolution: `${width}x${height}`,
gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60,
gxMultisample: 8,
gxMultisampleQuality: 0,
gxTripleBuffer: 1,
anisotropic: 16,
frillDensity: 48,
fullAlpha: 1,
SmallCull: 0.01,
DistCull: 888.8,
shadowLevel: 0,
trilinear: 1,
specular: 1,
pixelShaders: 1,
M2UsePixelShaders: 1,
particleDensity: 1,
unitDrawDist: 300,
weatherDensity: 3,
movieSubtitle: 1,
minimapZoom: 0,
minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1,
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
gxWindow: configWtf['gxWindow'] ?? 1,
gxMaximize: configWtf['gxMaximize'] ?? 1,
gxCursor: configWtf['gxCursor'] ?? 1,
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
...configWtf,
CameraDistanceMax: config.cameraDistance,
farClip: config.farClip,
realmList: Servers[server].realmList,
hwDetect: 0,
M2UseShaders: 1
};
await fs.writeFile(
configPath,
Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.map(l => `SET ${l[0]} "${l[1]}"`)
.join('\n')
);
Logger.log('Config.wtf successfully patched');
};
+59
View File
@@ -0,0 +1,59 @@
import path from 'path';
import fs from 'fs-extra';
import { type z } from 'zod';
import { app } from 'electron';
import { PreferencesSchema } from '~common/schemas';
import { omit } from '~common/utils';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData');
static async load() {
await fs.ensureDir(this.userDataDir);
const userDataPath = path.join(this.userDataDir, 'settings.json');
try {
const json = await fs.readJSON(userDataPath);
return PreferencesSchema.parse({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
} catch (e) {
return PreferencesSchema.parse({
isPortable: !!portableDir,
clientDir: portableDir
});
}
}
static get data(): PreferencesSchema {
return this.#data;
}
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 }
);
}
static async isValidClientDir(clientDir?: string) {
return !!clientDir && (await fs.exists(path.join(clientDir, 'WoW.exe')));
}
}
export default Preferences;
+115
View File
@@ -0,0 +1,115 @@
import { app } from 'electron';
import { autoUpdater } from 'electron-updater';
import Logger from 'electron-log/main';
import { is } from '@electron-toolkit/utils';
import Observable from './observable';
export type SelfUpdaterStatus =
| { state: 'idle'; currentVersion: string }
| { state: 'checking'; currentVersion: string }
| { state: 'unavailable'; currentVersion: string }
| { state: 'available'; currentVersion: string; nextVersion: string }
| { state: 'downloading'; currentVersion: string; nextVersion: string; progress: number }
| { state: 'ready'; currentVersion: string; nextVersion: string }
| { state: 'error'; currentVersion: string; message: string };
class SelfUpdaterClass extends Observable<SelfUpdaterStatus> {
protected _value: SelfUpdaterStatus = {
state: 'idle',
currentVersion: app.getVersion()
};
#initialized = false;
#nextVersion: string | undefined;
get status(): SelfUpdaterStatus {
return this._value;
}
private set status(v: SelfUpdaterStatus) {
this._value = v;
this._notifyObservers();
}
init() {
if (this.#initialized) return;
this.#initialized = true;
if (is.dev) {
Logger.info('[selfUpdater] dev mode — skipping');
return;
}
const currentVersion = app.getVersion();
autoUpdater.logger = Logger;
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = false;
autoUpdater.on('checking-for-update', () => {
Logger.info('[selfUpdater] checking');
this.status = { state: 'checking', currentVersion };
});
autoUpdater.on('update-available', info => {
Logger.info(`[selfUpdater] update available: ${info.version}`);
this.#nextVersion = info.version;
this.status = {
state: 'available',
currentVersion,
nextVersion: info.version
};
});
autoUpdater.on('update-not-available', info => {
Logger.info(`[selfUpdater] up to date (current: ${info.version})`);
this.status = { state: 'unavailable', currentVersion };
});
autoUpdater.on('error', err => {
Logger.error('[selfUpdater] error', err);
this.status = {
state: 'error',
currentVersion,
message: err?.message ?? String(err)
};
});
autoUpdater.on('download-progress', p => {
Logger.info(`[selfUpdater] downloading ${Math.round(p.percent)}%`);
this.status = {
state: 'downloading',
currentVersion,
nextVersion: this.#nextVersion ?? '',
progress: Math.max(0, Math.min(1, p.percent / 100))
};
});
autoUpdater.on('update-downloaded', info => {
Logger.info(
`[selfUpdater] downloaded ${info.version} — awaiting user click`
);
this.status = {
state: 'ready',
currentVersion,
nextVersion: info.version
};
});
autoUpdater.checkForUpdates().catch(err => {
Logger.error('[selfUpdater] checkForUpdates failed', err);
});
}
triggerInstall() {
if (this._value.state !== 'ready') {
Logger.warn(
`[selfUpdater] triggerInstall called in state ${this._value.state} — ignoring`
);
return;
}
Logger.info('[selfUpdater] user clicked install — quitting + running installer');
autoUpdater.quitAndInstall(false, true);
}
}
const SelfUpdater = new SelfUpdaterClass();
export default SelfUpdater;
export const initSelfUpdater = () => SelfUpdater.init();
+53
View File
@@ -0,0 +1,53 @@
import { Tray, Menu, nativeImage, app } from 'electron';
import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset';
import { mainWindow } from '~main/index';
let tray: Tray | null = null;
let isMinimizedToTray = false;
const restoreWindow = () => {
if (!mainWindow) return;
mainWindow.show();
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
isMinimizedToTray = false;
};
const ensureTray = () => {
if (tray) return tray;
const trayIcon = nativeImage.createFromPath(icon).resize({ width: 16, height: 16 });
tray = new Tray(trayIcon);
tray.setToolTip('OctoLauncher');
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Show launcher', click: restoreWindow },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() }
])
);
tray.on('click', restoreWindow);
return tray;
};
export const minimizeToTray = () => {
if (!mainWindow) return;
ensureTray();
mainWindow.hide();
isMinimizedToTray = true;
Logger.info('Minimized to tray');
};
export const restoreFromTray = () => {
if (!isMinimizedToTray) return;
restoreWindow();
};
export const isInTray = () => isMinimizedToTray;
export const destroyTray = () => {
tray?.destroy();
tray = null;
};
+973
View File
@@ -0,0 +1,973 @@
import path from 'node:path';
import crypto from 'node:crypto';
import { exec } from 'node:child_process';
import os from 'node:os';
import { app } from 'electron';
import fetch from 'node-fetch';
import fs from 'fs-extra';
import {
SFileOpenArchive,
type HANDLE,
SFileHasFile,
SFileCloseArchive,
SFileOpenFileEx,
SFileReadFile,
SFileGetFileSize,
SFileCloseFile,
SFileCreateFile,
SFileWriteFile,
SFileFinishFile,
SFileFlushArchive,
SFileRemoveFile,
SFileCompactArchive
} from 'stormlib-node';
import {
MPQ_COMPRESSION,
MPQ_FILE,
STREAM_FLAG
} from 'stormlib-node/dist/enums';
import Logger from 'electron-log/main';
import {
asyncMap,
formatFileSize,
isNotUndef,
nestedGet,
nestedSet
} from '~common/utils';
import { mainWindow } from '~main/index';
import { patchExecutable } from '~main/modules/patcher';
import { getClientVersion } from '~main/utils';
import Preferences from './preferences';
import Observable from './observable';
const getAvailableDiskSpace = async (probePath?: string): Promise<number> => {
const target =
probePath ||
Preferences.data?.clientDir ||
os.homedir() ||
(os.platform() === 'win32' ? 'C:\\' : '/');
try {
const s = await fs.promises.statfs(target);
return Number(s.bsize) * Number(s.bavail);
} catch (e) {
Logger.warn(
`fs.statfs("${target}") failed; treating disk-space check as ` +
`unavailable. Error: ${e instanceof Error ? e.message : String(e)}`
);
return Number.POSITIVE_INFINITY;
}
};
const isReadOnly = async (filePath: string) => {
try {
const { mode } = await fs.stat(filePath);
return !(mode & fs.constants.S_IWUSR);
} catch (e) {
return false;
}
};
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes';
type FileManifest = { name: string } & (
| { type: 'del' }
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
| {
type: 'file';
hash: string;
version?: number;
size: number;
tags?: FileTags[];
}
);
type CacheEntry = [hash: string, mtime: number];
type CacheTree = { [key: string]: CacheTree & CacheEntry };
const getManifestSize = (m?: FileManifest): number =>
(m?.type === 'del'
? 0
: m?.type === 'file'
? m?.size
: m?.files?.reduce((acc, v) => acc + getManifestSize(v), 0)) ?? 0;
const getManifestFiles = (m?: FileManifest, p = ''): string[] =>
(m?.type === 'del'
? [`-- ${path.join(p, m?.name)}`]
: m?.type === 'file'
? [`++ ${path.join(p, m?.name)}`]
: m?.files?.flatMap(v => getManifestFiles(v, path.join(p, m?.name)))) ?? [];
const getManifestItem = (
m?: FileManifest,
p?: string[]
): FileManifest | undefined => {
if (!p?.length) return m;
if (m?.type === 'file' || m?.type === 'del')
throw Error(`Can't access ${p.join('.')} from file ${m.name}`);
const [next, ...rest] = p;
return getManifestItem(
m?.files.find(f => f.name === next),
rest
);
};
export const isGameRunning = (executablePath: string) =>
os.platform() === 'win32'
? new Promise<boolean>(resolve => {
const exeName = path.basename(executablePath);
exec(
`tasklist /FI "IMAGENAME eq ${exeName}" /FO CSV /NH`,
(error, stdout) => {
if (error) {
Logger.warn(
`tasklist probe for "${exeName}" failed; assuming game ` +
`is not running. Error: ${error.message}`
);
resolve(false);
return;
}
resolve(
stdout.toLowerCase().includes(`"${exeName.toLowerCase()}"`)
);
}
);
})
: false;
const toUrlPath = (p: string) => p.split(path.sep).map(encodeURIComponent).join('/');
const CDN_VERSION = import.meta.env.MAIN_VITE_CLIENT_VERSION || 'latest';
const fetchManifest = async () => {
try {
const r = await fetch(
`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/file/${CDN_VERSION}/manifest.json`
);
const j = await r.json();
await fs.writeJSON(path.join(Preferences.userDataDir, 'manifest.json'), j);
return j.root as FileManifest;
} catch (e) {
Logger.error('Failed to reach update server', e);
return null;
}
};
const buildClientUrl = (filePath: string) =>
`${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));
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);
chunks.push(buf);
onChunk(buf.byteLength);
}
const total = chunks.reduce((acc, c) => acc + c.byteLength, 0);
const out = Buffer.concat(chunks, total);
return out.buffer.slice(out.byteOffset, out.byteOffset + out.byteLength);
} catch (e) {
Logger.error(`Failed to download ${path.normalize(filePath)}`, e);
throw Error(`Failed to download ${path.normalize(filePath)}`);
}
};
export const downloadFileToDisk = async (
filePath: string,
fullPath: string,
expectedSize: number,
onChunk: (deltaBytes: number) => void
) => {
const partPath = `${fullPath}.part`;
await fs.ensureFile(partPath);
let resumeFrom = 0;
try {
const stats = await fs.stat(partPath);
if (stats.size > 0 && stats.size < expectedSize) resumeFrom = stats.size;
else if (stats.size >= expectedSize) {
await fs.truncate(partPath, 0);
}
} catch {
}
if (resumeFrom > 0) onChunk(resumeFrom);
const url = buildClientUrl(filePath);
const headers: Record<string, string> = {};
if (resumeFrom > 0) headers.Range = `bytes=${resumeFrom}-`;
let response;
try {
response = await fetch(url, { headers });
} 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}`);
}
// 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);
resumeFrom = 0;
}
const writeStream = fs.createWriteStream(partPath, {
flags: resumeFrom > 0 ? 'a' : 'w'
});
try {
await new Promise<void>((resolve, reject) => {
if (!response.body) {
reject(Error('No response body'));
return;
}
const body = response.body as NodeJS.ReadableStream;
body.on('data', (chunk: Buffer | Uint8Array) => {
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);
});
} catch (e) {
writeStream.destroy();
Logger.error(`Download interrupted for ${filePath}`, e);
throw Error(`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.`
);
}
await fs.move(partPath, fullPath, { overwrite: true });
};
type UpdaterState =
| 'verifying'
| 'serverUnreachable'
| 'noClient'
| 'updateAvailable'
| 'updating'
| 'upToDate'
| 'failed';
export type UpdaterStatus = {
state: UpdaterState;
progress?: number;
message?: string;
bytesDone?: number;
bytesTotal?: number;
bytesPerSecond?: number;
etaSeconds?: number;
};
const RATE_WINDOW_MS = 5_000;
const ETA_WARMUP_MS = 10_000;
const ETA_PADDING = 1.15;
class ProgressTracker {
#startedAt = Date.now();
#samples: { t: number; bytesDone: number }[] = [];
bytesDone: number;
#baseline: number;
constructor(baseline = 0) {
this.bytesDone = baseline;
this.#baseline = baseline;
}
add(delta: number) {
this.bytesDone = Math.max(this.#baseline, this.bytesDone + delta);
const now = Date.now();
this.#samples.push({ t: now, bytesDone: this.bytesDone });
const cutoff = now - RATE_WINDOW_MS;
while (this.#samples.length > 2 && this.#samples[0].t < cutoff)
this.#samples.shift();
}
bytesPerSecond() {
if (this.#samples.length < 2) return 0;
const first = this.#samples[0];
const last = this.#samples[this.#samples.length - 1];
const dt = (last.t - first.t) / 1000;
if (dt <= 0) return 0;
return Math.max(0, (last.bytesDone - first.bytesDone) / dt);
}
etaSeconds(bytesTotal: number) {
if (Date.now() - this.#startedAt < ETA_WARMUP_MS) return undefined;
const rate = this.bytesPerSecond();
if (rate <= 0) return undefined;
const remaining = bytesTotal - this.bytesDone;
if (remaining <= 0) return 0;
return (remaining / rate) * ETA_PADDING;
}
}
class UpdaterClass extends Observable<UpdaterStatus> {
#manifest?: FileManifest;
#clientTotalBytes = 0;
#bytesAlreadyOnDisk = 0;
#cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = fs.existsSync(this.#cachePath)
? fs.readJSONSync(this.#cachePath)
: {};
async #saveCache() {
await fs.writeJSON(this.#cachePath, this.#cache);
}
async #getHash(
{
clientPath,
...m
}: { clientPath: string } & (
| { hMpq: HANDLE; mpqPath: string[] }
| { hMpq?: never }
),
...filePath: string[]
) {
if (m.hMpq) {
if (!SFileHasFile(m.hMpq, path.join(...filePath))) {
nestedSet(this.#cache, filePath, undefined);
return undefined;
}
const c = nestedGet<CacheEntry>(this.#cache, [...m.mpqPath, ...filePath]);
if (c?.[0]) return c[0];
const hFile = SFileOpenFileEx(m.hMpq, path.join(...filePath), 0);
try {
const fileSize = Number(SFileGetFileSize(hFile).toString());
const buffer = new ArrayBuffer(fileSize);
if (fileSize > 0) SFileReadFile(hFile, buffer);
const newHash = crypto
.createHash('sha1')
.update(new Uint8Array(buffer))
.digest('hex')
.toLocaleUpperCase();
nestedSet(this.#cache, [...m.mpqPath, ...filePath], { [0]: newHash });
return newHash;
} finally {
SFileCloseFile(hFile);
}
}
if (!(await fs.exists(path.join(clientPath, ...filePath)))) {
nestedSet(this.#cache, filePath, undefined);
return undefined;
}
const stats = await fs.stat(path.join(clientPath, ...filePath));
if (stats.isDirectory())
throw Error(`Tried to get hash of directory ${path.join(...filePath)}`);
const c = nestedGet<CacheEntry>(this.#cache, filePath);
if (c?.[0] && c[1] === stats.mtimeMs) return c[0];
const newHash = crypto
.createHash('sha1')
.update(await fs.readFile(path.join(clientPath, ...filePath)))
.digest('hex')
.toLocaleUpperCase();
nestedSet(this.#cache, filePath, {
...c,
[0]: newHash,
[1]: stats.mtimeMs
});
return newHash;
}
protected _value: UpdaterStatus = { state: 'failed' };
get status() {
return this._value;
}
private set status(v: UpdaterStatus) {
this._value = v;
this._notifyObservers(v);
if (this.status.state === 'failed') {
mainWindow?.setProgressBar(1, { mode: 'error' });
} else if (this.status.progress === 1) {
mainWindow?.setProgressBar(0);
} else {
mainWindow?.setProgressBar(this.status.progress ?? 0, {
mode: this.status.progress === -1 ? 'indeterminate' : 'normal'
});
}
}
async verify() {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'noClient' };
return;
}
if (os.platform() === 'win32' && clientPath.length > 220) {
this.status = {
state: 'failed',
message:
'Path to current install location is too long and may cause issues.'
};
return;
}
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
Logger.log(`Verifying client files at ${path.join(clientPath)}...`);
this.status = {
state: 'verifying',
progress: -1,
message: 'Looking for updates...'
};
try {
const vanillaFixes = Preferences.data.config.vanillaFixes;
const hashTree = await fetchManifest();
if (!hashTree) {
this.status = { state: 'serverUnreachable' };
return;
}
this.#manifest = { type: 'dir', name: 'root', files: [] };
const totalSize = getManifestSize(hashTree);
let i = 0;
const buildMpqTree = async (
hMpq: HANDLE,
mpqPath: string[],
...filePath: string[]
): Promise<FileManifest | undefined> => {
const item = getManifestItem(hashTree, [...mpqPath, ...filePath]);
if (!item) return undefined;
if (item.type === 'del') return item;
if (item.type === 'dir') {
const files = (
await asyncMap(item.files, f =>
buildMpqTree(hMpq, mpqPath, ...filePath, f.name)
)
).filter(isNotUndef);
return !files.length ? undefined : { ...item, files };
}
if (item.type === 'mpq')
throw Error(
`There can't be an mpq archive inside mpq at path ${path.join(
...mpqPath,
...filePath
)}`
);
this.status = {
state: 'verifying',
progress: i / totalSize,
message: `Verifying: [${mpqPath.at(-1)}] "${path.join(
...filePath
)}"...`
};
i += item.size;
if (
(await this.#getHash({ clientPath, hMpq, mpqPath }, ...filePath)) ===
item.hash
)
return undefined;
return item;
};
const buildTree = async (
...filePath: string[]
): Promise<FileManifest | undefined> => {
const item = getManifestItem(hashTree, filePath);
if (!item) return undefined;
if (item.type === 'del') return item;
if (item.type === 'dir') {
const files = (
await asyncMap(item.files, f => buildTree(...filePath, f.name))
).filter(isNotUndef);
return !files.length ? undefined : { ...item, files };
}
if (item.type === 'mpq') {
const patchPath = [
...filePath.slice(0, -1),
`${filePath.at(-1)}.mpq`
];
this.status = {
state: 'verifying',
progress: i / totalSize,
message: `Verifying: "${path.join(...patchPath)}"...`
};
if (!(await fs.exists(path.join(clientPath, ...patchPath)))) {
i += item.size;
return {
type: 'file',
name: `${item.name}.mpq`,
hash: item.hash,
size: item.size
};
}
if (
(await this.#getHash({ clientPath }, ...patchPath)) === item.hash
) {
i += item.size;
return undefined;
}
try {
const hMpq = SFileOpenArchive(
path.join(clientPath, ...patchPath),
STREAM_FLAG.READ_ONLY
);
try {
const files = (
await asyncMap(item.files, f =>
buildMpqTree(hMpq, filePath, f.name)
)
).filter(isNotUndef);
return !files.length ? undefined : { ...item, files };
} finally {
SFileCloseArchive(hMpq);
}
} catch (e) {
Logger.log(
`Failed to verify ${path.join(
...patchPath
)}, will be downloaded fresh`,
'warning',
e
);
return {
type: 'file',
name: `${item.name}.mpq`,
hash: item.hash,
size: item.size
};
}
}
if (item.tags?.includes('vanillaFixes') && !vanillaFixes) {
if (await fs.exists(path.join(clientPath, ...filePath))) {
return {
type: 'del',
name: item.name
};
} else {
return undefined;
}
}
this.status = {
state: 'verifying',
progress: i / totalSize,
message: `Verifying: "${path.join(...filePath)}"...`
};
i += item.size;
const hash = await this.#getHash({ clientPath }, ...filePath);
if (hash === item.hash) return undefined;
if (
filePath.length === 1 &&
filePath[0] === 'WoW.exe' &&
hash &&
hash === Preferences.data.expectedPatchedWowHash
)
return undefined;
if (hash && item.version) {
const stats = await fs.stat(path.join(clientPath, ...filePath));
if (item.version <= stats.mtimeMs) return undefined;
}
return item;
};
this.#manifest = await buildTree();
await this.#saveCache();
const toDownload = getManifestSize(this.#manifest);
this.#clientTotalBytes = getManifestSize(hashTree);
this.#bytesAlreadyOnDisk = Math.max(
0,
this.#clientTotalBytes - toDownload
);
const availableSpace = await getAvailableDiskSpace();
if (toDownload > availableSpace) {
this.status = {
state: 'failed',
message: `Not enough disk space. Required: ${formatFileSize(
toDownload
)}, Available: ${formatFileSize(availableSpace)}`
};
return;
}
this.status = this.#manifest
? {
state: 'updateAvailable',
message: formatFileSize(toDownload),
progress: this.#bytesAlreadyOnDisk / this.#clientTotalBytes,
bytesDone: this.#bytesAlreadyOnDisk,
bytesTotal: this.#clientTotalBytes
}
: { state: 'upToDate', progress: 1 };
this.#manifest &&
Logger.log(
`Detected changes:\n\t${getManifestFiles(this.#manifest).join(
',\n\t'
)}`
);
const currentLauncherVersion = app.getVersion();
if (
this.status.state === 'upToDate' &&
Preferences.data.lastPatchedLauncherVersion !==
currentLauncherVersion
) {
Logger.log(
`Launcher version changed (${
Preferences.data.lastPatchedLauncherVersion ?? 'unset'
} -> ${currentLauncherVersion}); silently re-applying tweaks via patchExecutable`
);
void (async () => {
try {
await patchExecutable();
const cd = Preferences.data.clientDir;
if (cd) {
const patchedHash = await this.#getHash(
{ clientPath: cd },
'WoW.exe'
);
await this.#saveCache();
Preferences.data = {
lastPatchedLauncherVersion: currentLauncherVersion,
expectedPatchedWowHash: patchedHash
};
}
} catch (e) {
Logger.error(
'Auto-rerun patchExecutable after launcher version bump failed',
e
);
}
})();
}
} catch (e) {
const message =
e instanceof Error ? e.message : 'Unexpected error occurred';
Logger.error(`Verification failed: ${message}`, e);
this.status = { state: 'failed', message };
}
}
async update(clean?: boolean) {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'noClient' };
return;
}
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
Logger.log(`Updating client files at ${path.join(clientPath)}...`);
this.status = {
state: 'updating',
progress: -1,
message: 'Preparing files...'
};
try {
if (clean) {
this.status = {
state: 'updating',
progress: -1,
message: 'Cleaning up old files...'
};
const files = await fs.readdir(clientPath);
for (const file of files) {
if (file === 'OctoLauncher.exe') continue;
await fs.rm(path.join(clientPath, file), {
recursive: true,
force: true
});
}
this.#bytesAlreadyOnDisk = 0;
}
const hashTree =
(clean ? undefined : this.#manifest) ?? (await fetchManifest());
if (!hashTree) {
this.status = { state: 'serverUnreachable' };
return;
}
const fullClientTotal =
this.#clientTotalBytes > 0
? this.#clientTotalBytes
: getManifestSize(hashTree);
this.#clientTotalBytes = fullClientTotal;
const baseline = this.#bytesAlreadyOnDisk;
const tracker = new ProgressTracker(baseline);
let executableUpdate = false;
let lastEmit = 0;
const STATUS_EMIT_INTERVAL_MS = 250;
const emitProgress = (message: string, force = false) => {
const now = Date.now();
if (!force && now - lastEmit < STATUS_EMIT_INTERVAL_MS) return;
lastEmit = now;
this.status = {
state: 'updating',
progress: tracker.bytesDone / fullClientTotal,
message,
bytesDone: tracker.bytesDone,
bytesTotal: fullClientTotal,
bytesPerSecond: tracker.bytesPerSecond(),
etaSeconds: tracker.etaSeconds(fullClientTotal)
};
};
const iterateMpqTree = async (
hMpq: HANDLE,
mpqPath: string[],
...filePath: string[]
) => {
const item = getManifestItem(hashTree, [...mpqPath, ...filePath]);
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 (item.type === 'dir') {
for (const f of item.files)
await iterateMpqTree(hMpq, mpqPath, ...filePath, f.name);
return;
}
if (item.type === 'mpq')
throw Error(
`There can't be an mpq archive inside mpq at path ${path.join(
...mpqPath,
...filePath
)}`
);
const label = `Patching: [${mpqPath.at(-1)}] "${path.join(...filePath)}"`;
emitProgress(label, true);
const data = await fetchFile(
path.join(...mpqPath, ...filePath),
delta => {
tracker.add(delta);
emitProgress(label);
}
);
if (SFileHasFile(hMpq, path.join(...filePath)))
SFileRemoveFile(hMpq, path.join(...filePath));
const hFile = SFileCreateFile(
hMpq,
path.join(...filePath),
0,
data.byteLength,
0,
MPQ_FILE.COMPRESS
);
try {
SFileWriteFile(hFile, data, MPQ_COMPRESSION.ZLIB);
} finally {
SFileFinishFile(hFile);
}
};
const iterateTree = async (...filePath: string[]) => {
const item = getManifestItem(hashTree, filePath);
if (!item) return undefined;
if (item.type === 'del') {
const fullPath = path.join(clientPath, ...filePath);
if (await isReadOnly(fullPath))
throw Error(
`Failed to delete "${fullPath}" because it's read-only.`
);
await fs.remove(fullPath);
await this.#getHash({ clientPath }, ...filePath);
return;
}
if (item.type === 'dir') {
for (const i of item.files) await iterateTree(...filePath, i.name);
return;
}
if (item.type === 'mpq') {
const patchPath = [
...filePath.slice(0, -1),
`${filePath.at(-1)}.mpq`
];
const patchFile = path.join(clientPath, ...patchPath);
const label = `Downloading: "${path.join(...patchPath)}"`;
emitProgress(label, true);
if (!(await fs.exists(patchFile))) {
await downloadFileToDisk(
path.join(...patchPath),
patchFile,
item.size,
delta => {
tracker.add(delta);
emitProgress(label);
}
);
return;
}
if (await isReadOnly(patchFile))
throw Error(
`Failed to update "${patchFile}" because it's read-only.`
);
const hMpq = SFileOpenArchive(path.join(clientPath, ...patchPath), 0);
try {
for (const f of item.files)
await iterateMpqTree(hMpq, filePath, f.name);
SFileFlushArchive(hMpq);
SFileCompactArchive(hMpq);
} finally {
SFileCloseArchive(hMpq);
}
return;
}
const label = `Downloading: "${path.join(...filePath)}"`;
emitProgress(label, true);
if (item.name === 'WoW.exe') executableUpdate = true;
const fullPath = path.join(clientPath, ...filePath);
if (await fs.exists(fullPath) && (await isReadOnly(fullPath)))
throw Error(`Failed to update "${fullPath}" because it's read-only.`);
await downloadFileToDisk(
path.join(...filePath),
fullPath,
item.size,
delta => {
tracker.add(delta);
emitProgress(label);
}
);
await this.#getHash({ clientPath }, ...filePath);
};
await iterateTree();
await this.#saveCache();
const currentLauncherVersion = app.getVersion();
const launcherVersionChanged =
Preferences.data.lastPatchedLauncherVersion !== currentLauncherVersion;
if (executableUpdate || launcherVersionChanged) {
await patchExecutable();
await this.#getHash({ clientPath }, 'WoW.exe');
const patchedWowHash = await this.#getHash({ clientPath }, 'WoW.exe');
await this.#saveCache();
Preferences.data = {
version: await getClientVersion(),
lastPatchedLauncherVersion: currentLauncherVersion,
expectedPatchedWowHash: patchedWowHash
};
}
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'
};
}
}
}
const Updater = new UpdaterClass();
export default Updater;