OctoLauncher 1.3.1

Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client:
launcher-owned realmlist, torrent-backed content sync with bundled aria2c,
antivirus and Defender exclusion handling, hardware-aware render distance,
optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
OctoWoW
2026-08-14 01:45:50 +00:00
commit 5dca94a3fc
124 changed files with 24276 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod';
import Addons from '~main/modules/addons';
import { AddonDataSchema } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const addonsRouter = createTRPCRouter({
verify: publicProcedure.mutation(() => {
Addons.verify();
}),
update: publicProcedure
.input(z.object({ toUpdate: z.array(z.string()).optional() }))
.mutation(({ input }) => Addons.update(input.toUpdate)),
install: publicProcedure
.input(AddonDataSchema)
.mutation(({ input }) => Addons.install(input)),
remove: publicProcedure
.input(z.object({ toDelete: z.array(z.string()) }))
.mutation(({ input }) => Addons.remove(input.toDelete)),
checkGitUrl: publicProcedure
.input(z.string())
.query(({ input }) => Addons.checkGitUrl(input)),
observe: publicProcedure.subscription(() => Addons.observe())
});
+47
View File
@@ -0,0 +1,47 @@
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import {
ForumAnnouncementSchema,
type ForumAnnouncement
} from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchLatestAnnouncement = async (): Promise<ForumAnnouncement | null> => {
const url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
}/forum/octonews.php?forum=35&mode=full`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const json = (await res.json()) as unknown;
if (!json || typeof json !== 'object' || !('id' in json)) return null;
const parsed = ForumAnnouncementSchema.safeParse(json);
if (!parsed.success) {
Logger.error(
'Forum announcement failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed forum announcement');
}
return parsed.data;
} finally {
clearTimeout(t);
}
};
export const forumRouter = createTRPCRouter({
latestAnnouncement: publicProcedure.query(async () => {
try {
return await fetchLatestAnnouncement();
} catch (e) {
Logger.error('Failed to fetch forum announcement', e);
throw e;
}
})
});
+88
View File
@@ -0,0 +1,88 @@
import path from 'node:path';
import { app, dialog, shell } from 'electron';
import Logger from 'electron-log/main';
import { z } from 'zod';
import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences';
import {
addDefenderExclusions,
detectAntivirusBlocks
} from '~main/modules/defender';
import { detectHardware, recommendFarClip } from '~main/modules/hardware';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const generalRouter = createTRPCRouter({
appVersion: publicProcedure.query(() => app.getVersion()),
hardware: publicProcedure.query(() => {
const hardware = Preferences.data.hardware ?? null;
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
redetectHardware: publicProcedure.mutation(async () => {
const hardware = await detectHardware();
Preferences.data = { hardware };
return { hardware, recommendedFarClip: recommendFarClip(hardware) };
}),
quit: publicProcedure.mutation(() => app.quit()),
minimize: publicProcedure.mutation(() => mainWindow?.minimize()),
openLink: publicProcedure
.input(z.string().url())
.mutation(({ input }) => shell.openExternal(input)),
openInstallFolder: publicProcedure.mutation(() => {
// Explorer needs native separators; a stored forward-slash path fails to open.
const dir = Preferences.data.clientDir;
if (dir) shell.openPath(path.normalize(dir));
}),
openLogFile: publicProcedure.mutation(() => {
const file = Logger.transports.file.getFile().path;
shell.openPath(path.normalize(file));
}),
addDefenderExclusion: publicProcedure.mutation(() => addDefenderExclusions()),
antivirusBlocks: publicProcedure.query(() => detectAntivirusBlocks()),
filePicker: publicProcedure
.input(
z.object({
title: z.string().optional(),
message: z.string().optional(),
filters: z
.array(
z.object({
name: z.string(),
extensions: z.array(z.string())
})
)
.optional(),
properties: z
.array(
z.enum([
'openDirectory',
'openFile',
'multiSelections',
'showHiddenFiles',
'createDirectory',
'promptToCreate',
'noResolveAliases',
'treatPackageAsDirectory',
'dontAddToRecent'
])
)
.optional()
})
)
.mutation(async ({ input }) => {
if (!mainWindow) return { canceled: true } as const;
const { canceled, filePaths } = await dialog.showOpenDialog(
mainWindow,
input
);
return canceled
? ({ canceled: true } as const)
: ({
canceled: false,
path: filePaths as [string, ...string[]]
} as const);
})
});
+172
View File
@@ -0,0 +1,172 @@
import path from 'path';
import { spawn } from 'child_process';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import Preferences from '~main/modules/preferences';
import Mods from '~main/modules/mods';
import { mainWindow } from '~main/index';
import Updater, { isGameRunning } from '~main/modules/updater';
import {
patchConfig,
patchExecutable,
ensureDxvkConf
} from '~main/modules/patcher';
import { removeLegacyLocalePatches } from '~main/modules/localePatch';
import { syncVanillaFixesCache } from '~main/modules/dllsTxt';
import { stopSeeding } from '~main/modules/aria2';
import { minimizeToTray, restoreFromTray } from '~main/modules/tray';
import { getMod } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
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 dllsPath = path.join(clientDir, 'dlls.txt');
if (await fs.pathExists(dllsPath)) {
const raw = await fs.readFile(dllsPath, 'utf8');
return raw.split(/\r?\n/).some(l => l.trim() && !l.trim().startsWith('#'));
}
return false;
};
type StartResult = { ok: boolean; error?: string };
const delay = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
let starting = false;
export const launcherRouter = createTRPCRouter({
start: publicProcedure.mutation(async (): Promise<StartResult> => {
if (starting) return { ok: false, error: 'The game is already launching.' };
starting = true;
try {
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 (Mods.status.dirty)
return {
ok: false,
error: 'You have unapplied mod changes. Click Apply first.'
};
stopSeeding();
if (cleanWdb) {
Logger.log('Cleaning up WDB...');
await fs.remove(path.join(clientDir, 'WDB'));
}
Logger.log('Syncing preferred monitor...');
await Mods.verify();
Logger.log('Checking Config.wtf...');
await patchConfig();
await ensureDxvkConf(clientDir);
await removeLegacyLocalePatches(clientDir);
if (Preferences.data.patchedLocale !== Preferences.data.locale) {
Logger.log(
`Applying the client language (${Preferences.data.locale})...`
);
try {
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
if (!cleanWdb)
await fs.remove(path.join(clientDir, 'WDB')).catch(() => {});
} catch (e) {
Logger.error(
'Could not apply the client language; launching with the previous one',
e
);
}
}
const loaderPath = path.join(clientDir, 'VanillaFixes.exe');
const needsLoader = await chainloaderNeeded(clientDir);
const useLoader = needsLoader && (await fs.pathExists(loaderPath));
if (useLoader) await syncVanillaFixesCache(clientDir);
if (needsLoader && !useLoader)
Logger.warn(
'VanillaFixes.exe is missing but mods/dlls.txt expect a chainloader; ' +
'launching WoW.exe directly (mods will not load).'
);
Logger.log(
useLoader ? 'Launching via VanillaFixes...' : `Launching ${exePath}...`
);
const child = useLoader
? spawn(loaderPath, ['WoW.exe'], {
cwd: clientDir,
detached: !minimizeToTrayOnPlay
})
: spawn(exePath, {
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 { ok: true };
}
minimizeToTray();
if (useLoader) {
void (async () => {
try {
const started = Date.now();
while (
Date.now() - started < 30_000 &&
!(await isGameRunning(exePath))
)
await delay(1000);
while (await isGameRunning(exePath)) await delay(3000);
} finally {
Logger.log('WoW stopped');
restoreFromTray();
}
})();
} else {
child.on('exit', () => {
Logger.log('WoW stopped');
restoreFromTray();
});
}
return { ok: true };
} catch (e) {
Logger.error('Failed to start the game', e);
return { ok: false, error: e instanceof Error ? e.message : String(e) };
} finally {
starting = false;
}
})
});
+38
View File
@@ -0,0 +1,38 @@
import path from 'path';
import { z } from 'zod';
import Mods from '~main/modules/mods';
import Preferences from '~main/modules/preferences';
import { isGameRunning } from '~main/modules/updater';
import { ModIdSchema } from '~common/mods';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const modsRouter = createTRPCRouter({
list: publicProcedure.query(() => Mods.status),
verify: publicProcedure.mutation(() => Mods.verify()),
toggle: publicProcedure
.input(z.object({ id: ModIdSchema, enabled: z.boolean() }))
.mutation(({ input }) => Mods.toggle(input.id, input.enabled)),
toggleCustom: publicProcedure
.input(z.object({ name: z.string(), enabled: z.boolean() }))
.mutation(({ input }) => Mods.toggleCustom(input.name, input.enabled)),
addCustomDll: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(({ input }) => Mods.addCustomDll(input.path)),
setIgnoreUpdates: publicProcedure
.input(z.object({ id: ModIdSchema, ignore: z.boolean() }))
.mutation(({ input }) => Mods.setIgnoreUpdates(input.id, input.ignore)),
applyAll: publicProcedure.mutation(() => Mods.applyAll()),
repair: publicProcedure.mutation(async () => {
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
const exePath = path.join(clientDir, 'WoW.exe');
if (await isGameRunning(exePath))
throw new Error('Please close WoW first before verifying files.');
}
return Mods.applyAll({ repairOnly: true });
}),
observe: publicProcedure.subscription(() => Mods.observe())
});
+49
View File
@@ -0,0 +1,49 @@
import { z } from 'zod';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { NewsFeedSchema, type NewsItem } from '~common/schemas';
import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
// Boards octonews.php exposes as a list: 2 = Announcements, 4 = Patch Notes.
const FEED_FORUMS = [2, 4];
const fetchNews = async (forum: number): Promise<NewsItem[]> => {
const f = FEED_FORUMS.includes(forum) ? forum : 2;
const url = `${
import.meta.env.MAIN_VITE_FORUM_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=${f}&limit=5`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw Error(`HTTP ${res.status}`);
const parsed = NewsFeedSchema.safeParse(await res.json());
if (!parsed.success) {
Logger.error(
'News feed failed schema validation',
parsed.error.flatten()
);
throw Error('Malformed news feed');
}
return parsed.data.items;
} finally {
clearTimeout(t);
}
};
export const newsRouter = createTRPCRouter({
list: publicProcedure
.input(z.object({ forum: z.number() }).optional())
.query(async ({ input }) => {
try {
return await fetchNews(input?.forum ?? 2);
} catch (e) {
Logger.error('Failed to fetch news', e);
throw e;
}
})
});
+15
View File
@@ -0,0 +1,15 @@
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';
export const patcherRouter = createTRPCRouter({
apply: publicProcedure.mutation(async () => {
await patchExecutable();
await patchConfig(true);
await Updater.recordPatchedWow();
Preferences.data = { version: await getClientVersion() };
})
});
+23
View File
@@ -0,0 +1,23 @@
import { z } from 'zod';
import { PreferencesSchema } from '~common/schemas';
import Preferences from '~main/modules/preferences';
import Updater from '~main/modules/updater';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const preferencesRouter = createTRPCRouter({
get: publicProcedure.output(PreferencesSchema).query(() => Preferences.data),
set: publicProcedure
.input(PreferencesSchema.partial())
.mutation(async ({ input }) => {
// Language change no longer touches the game folder; the exe is re-patched on
// the next Play (launcher router), so this stays network-free and can't fail.
Preferences.data = input;
if (input.shareDownloads !== undefined) void Updater.refreshSeeding();
return Preferences.data;
}),
isValidClientDir: publicProcedure
.input(z.string().optional())
.query(({ input }) => Preferences.isValidClientDir(input))
});
+8
View File
@@ -0,0 +1,8 @@
import SelfUpdater from '~main/modules/selfUpdater';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const selfUpdaterRouter = createTRPCRouter({
observe: publicProcedure.subscription(() => SelfUpdater.observe()),
install: publicProcedure.mutation(() => SelfUpdater.triggerInstall())
});
+14
View File
@@ -0,0 +1,14 @@
import { z } from 'zod';
import Updater from '~main/modules/updater';
import { createTRPCRouter, publicProcedure } from '../trpc';
export const updaterRouter = createTRPCRouter({
verify: publicProcedure.mutation(() => Updater.verify()),
syncRaidVisuals: publicProcedure.mutation(() => Updater.syncRaidVisuals()),
update: publicProcedure
.input(z.boolean().optional())
.mutation(async ({ input }) => Updater.update(input)),
observe: publicProcedure.subscription(() => Updater.observe())
});