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
+214
View File
@@ -0,0 +1,214 @@
import { z } from 'zod';
export const ModIdSchema = z.enum([
'dxvk',
'nampower',
'multiMonitorFix',
'superWow',
'transmogFix',
'unitXp',
'vanillaFixes',
'vanillaHelpers'
]);
export type ModId = z.infer<typeof ModIdSchema>;
export type ModSource =
| {
kind: 'directFile';
url: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
apiUrl?: string;
pinnedTag?: string;
assetName: string;
sha256?: string;
}
| {
kind: 'archive';
url: string;
apiUrl?: string;
parseLatest?: 'githubRelease' | 'gitlabRelease' | 'codebergRelease';
pinnedTag?: string;
format: 'zip' | 'tar.gz';
extractMap: Record<string, string>;
sha256?: string;
}
| { kind: 'managed' };
export type ModEntry = {
id: ModId;
name: string;
version: string;
description: string;
recommended?: boolean;
requires?: ModId[];
repoUrl: string;
source: ModSource;
registerInDllsTxt?: string;
// hidden from the Mods tab, never enabled on fresh installs; existing installs keep it
disabled?: boolean;
};
export const MODS: ModEntry[] = [
{
id: 'dxvk',
name: 'dxvk',
version: 'v2.7.1-1',
description: 'Enables Vulkan based rendering mode for better performance.',
recommended: true,
repoUrl: 'https://gitlab.com/Ph42oN/dxvk-gplasync',
source: {
kind: 'archive',
url: 'https://gitlab.com/Ph42oN/dxvk-gplasync/-/raw/main/releases/dxvk-gplasync-v2.7.1-1.tar.gz?ref_type=heads',
pinnedTag: 'v2.7.1-1',
format: 'tar.gz',
extractMap: {
'dxvk-gplasync-v2.7.1-1/x32/d3d9.dll': 'd3d9.dll'
}
}
},
{
id: 'nampower',
name: 'nampower',
version: 'v4.6.2',
description:
'A client modification that minimizes your input lag if you have higher latency.',
repoUrl: 'https://github.com/Emyrk/nampower',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://github.com/Emyrk/nampower/releases/download/v4.6.2/nampower.dll',
pinnedTag: 'v4.6.2',
assetName: 'nampower.dll'
},
registerInDllsTxt: 'nampower.dll'
},
{
id: 'multiMonitorFix',
name: 'no1600x1200',
version: '0.2',
description: 'Fix for larger resolutions or multi monitor setups.',
repoUrl: 'https://github.com/Mates1500/VanillaMultiMonitorFix',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://github.com/Mates1500/VanillaMultiMonitorFix/releases/download/0.2/release.zip',
apiUrl:
'https://api.github.com/repos/Mates1500/VanillaMultiMonitorFix/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: '0.2',
format: 'zip',
extractMap: {
'VanillaMultiMonitorFix.dll': 'VanillaMultiMonitorFix.dll'
}
},
registerInDllsTxt: 'VanillaMultiMonitorFix.dll'
},
{
id: 'superWow',
name: 'SuperWoW',
version: '2.2',
description:
'Extends the client Lua API with unit GUIDs and other data many addons rely on.',
repoUrl: 'https://github.com/balakethelock/SuperWoW',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://github.com/balakethelock/SuperWoW/releases/download/Release/SuperWoW.release.2.2.zip',
apiUrl:
'https://api.github.com/repos/balakethelock/SuperWoW/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: '2.2',
format: 'zip',
extractMap: {
'SuperWoWhook.dll': 'SuperWoWhook.dll'
}
},
registerInDllsTxt: 'SuperWoWhook.dll',
// disabled 2026-08-08 pending distribution permission; delete this line to re-enable
disabled: true
},
{
id: 'transmogFix',
name: 'transmogFix',
version: 'v0.7.0',
description:
"A client-side fix that eliminates frame drops caused by the server's transmogrification durability workaround.",
repoUrl: 'https://codeberg.org/MarcelineVQ/WeirdUtils',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://codeberg.org/MarcelineVQ/WeirdUtils/releases/download/v0.7.0/transmogfix.dll',
pinnedTag: 'v0.7.0',
assetName: 'transmogfix.dll'
},
registerInDllsTxt: 'transmogfix.dll'
},
{
id: 'unitXp',
name: 'unitXp',
version: 'v89',
description: 'An attempt to make Vanilla 1.12 modern.',
repoUrl: 'https://codeberg.org/konaka/UnitXP_SP3',
requires: ['vanillaFixes'],
source: {
kind: 'archive',
url: 'https://codeberg.org/konaka/UnitXP_SP3/releases/download/v89/UnitXP_SP3%20v89.zip',
pinnedTag: 'v89',
format: 'zip',
extractMap: {
'UnitXP_SP3.dll': 'UnitXP_SP3.dll'
}
},
registerInDllsTxt: 'UnitXP_SP3.dll'
},
{
id: 'vanillaFixes',
name: 'vanillaFixes',
version: 'v1.5.3',
description:
'A client modification that eliminates stutter and animation lag.',
recommended: true,
repoUrl: 'https://github.com/hannesmann/vanillafixes',
source: {
kind: 'archive',
url: 'https://github.com/hannesmann/vanillafixes/releases/download/v1.5.3/vanillafixes-1.5.3.zip',
apiUrl:
'https://api.github.com/repos/hannesmann/vanillafixes/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: 'v1.5.3',
format: 'zip',
extractMap: {
'VfPatcher.dll': 'VfPatcher.dll',
'VanillaFixes.exe': 'VanillaFixes.exe'
}
}
},
{
id: 'vanillaHelpers',
name: 'vanillaHelpers',
version: 'v1.1.2',
description:
'Utility library that might be required by other patches and addons.',
repoUrl: 'https://github.com/isfir/VanillaHelpers',
requires: ['vanillaFixes'],
source: {
kind: 'directFile',
url: 'https://github.com/isfir/VanillaHelpers/releases/download/v1.1.2/VanillaHelpers.dll',
apiUrl:
'https://api.github.com/repos/isfir/VanillaHelpers/releases/latest',
parseLatest: 'githubRelease',
pinnedTag: 'v1.1.2',
assetName: 'VanillaHelpers.dll'
},
registerInDllsTxt: 'VanillaHelpers.dll'
}
];
export const getMod = (id: ModId): ModEntry | undefined =>
MODS.find(m => m.id === id);
const NOT_DEFAULT_ENABLED: ModId[] = [];
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
m => !m.disabled && !NOT_DEFAULT_ENABLED.includes(m.id)
).map(m => m.id);
+149
View File
@@ -0,0 +1,149 @@
import { z } from 'zod';
const f = {
boolean: (defaultValue?: boolean) =>
z.boolean().nullish().default(!!defaultValue),
number: (defaultValue?: number, val?: (v: z.ZodNumber) => z.ZodNumber) =>
z.preprocess(
v =>
v === '' || v === undefined
? defaultValue ?? null
: typeof v === 'string'
? Number(v)
: v,
(val?.(z.number()) ?? z.number()).nullish()
)
};
export const ConfigWtfSchema = z.object({
vanillaFixes: f.boolean(),
raidVisuals: f.boolean(),
largeAddress: f.boolean(true),
nameplateRange: f.number(41),
alwaysAutoLoot: f.boolean(),
fieldOfView: f.number(110),
farClip: f.number(777),
frillDistance: f.number(70),
cameraDistance: f.number(50),
soundInBackground: f.boolean(true)
});
export type ConfigWtfSchema = z.infer<typeof ConfigWtfSchema>;
export const ModStateSchema = z.object({
enabled: z.boolean().default(false),
installedVersion: z.string().optional(),
installedFiles: z.array(z.string()).default([]),
ignoreUpdates: z.boolean().default(false)
});
export type ModState = z.infer<typeof ModStateSchema>;
export const HardwareInfoSchema = z.object({
totalRamMb: z.number(),
cpuCores: z.number(),
cpuModel: z.string(),
gpuModel: z.string(),
vramMb: z.number().nullable(),
vramSource: z.enum(['registry', 'wmi', 'none']),
detectedAt: z.string(),
schemaVersion: z.number()
});
export type HardwareInfo = z.infer<typeof HardwareInfoSchema>;
export const PreferencesSchema = z.object({
isPortable: z.boolean().optional(),
server: z.enum(['live', 'ptr']).default('live'),
clientDir: z.string().optional(),
version: z.string().optional(),
lastPatchedLauncherVersion: z.string().optional(),
expectedPatchedWowHash: z.string().optional(),
minimizeToTrayOnPlay: f.boolean(true),
cleanWdb: f.boolean(true),
shareDownloads: f.boolean(true),
locale: z
.enum(['enUS', 'deDE', 'zhCN', 'esES', 'ptBR', 'ruRU'])
.default('enUS'),
localePatchLetter: z.string().optional(),
localePatchLocale: z.string().optional(),
patchedLocale: z.string().optional(),
syncedTorrentHash: z.string().optional(),
activeTorrentHash: z.string().optional(),
activeClientDir: z.string().optional(),
raidVisualsHash: z.string().optional(),
clientPatchHash: z.string().optional(),
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
lastWrittenResolution: z.string().optional(),
rememberPosition: f.boolean(),
windowPosition: z
.object({
x: z.number(),
y: z.number(),
width: z.number(),
height: z.number()
})
.nullish(),
config: ConfigWtfSchema.default({}),
mods: z.record(ModStateSchema).default({}),
hardware: HardwareInfoSchema.optional(),
farClipUserSet: z.boolean().optional()
});
export type PreferencesSchema = z.infer<typeof PreferencesSchema>;
export const TocDataSchema = z.object({
Interface: z.string(),
Title: z.string(),
Author: z.string(),
Notes: z.string(),
Version: z.string(),
Dependencies: z.string().optional(),
OptionalDeps: z.string().optional()
});
export type TocData = z.infer<typeof TocDataSchema>;
export const AddonDataSchema = z.object({
status: z.enum([
'available',
'fetching',
'unknown',
'upToDate',
'outOfDate',
'downloading',
'invalid'
]),
git: z.string().optional(),
toc: TocDataSchema.optional(),
description: z.string().optional(),
error: z.string().optional(),
branch: z.string().optional(),
ref: z.string().optional(),
folder: z.string(),
progress: z.string().optional(),
preview: z.string().optional()
});
export type AddonData = z.infer<typeof AddonDataSchema>;
export const NewsItemSchema = z.object({
id: z.string(),
title: z.string(),
date: z.string(),
body: z.string(),
url: z.string().url().optional(),
author: z.string().nullish()
});
export type NewsItem = z.infer<typeof NewsItemSchema>;
export const NewsFeedSchema = z.object({
items: z.array(NewsItemSchema)
});
export type NewsFeed = z.infer<typeof NewsFeedSchema>;
export const ForumAnnouncementSchema = z.object({
id: z.string(),
title: z.string(),
author: z.string().nullish(),
date: z.string(),
url: z.string().url(),
html: z.string()
});
export type ForumAnnouncement = z.infer<typeof ForumAnnouncementSchema>;
+82
View File
@@ -0,0 +1,82 @@
type Path = readonly (string | number)[];
const isUnsafeKey = (key: string | number) =>
key === '__proto__' || key === 'constructor' || key === 'prototype';
export const nestedGet = <T>(object: unknown, path: Path) =>
path.reduce(
(obj, key) => (isUnsafeKey(key) ? undefined : obj?.[key]),
object
) as T;
export const nestedSet = (obj: any, path: Path, value: unknown) => {
const [key, ...rest] = path;
if (isUnsafeKey(key)) return;
if (path.length === 1) {
obj[key] = value;
return;
}
if (obj[key] === undefined) {
obj[key] = typeof rest[0] === 'number' ? [] : {};
}
nestedSet(obj[key], rest as never, value);
};
export const asyncReduce = async <T, U>(
arr: T[],
reducer: (acc: U, cur: T) => Promise<U>,
init: U
): Promise<U> => {
let acc: U = init;
for (const i of arr) acc = await reducer(acc, i);
return acc;
};
export const asyncMap = async <T, U>(
arr: T[],
map: (cur: T) => Promise<U>
): Promise<U[]> => {
const acc: U[] = [];
for (const i of arr) acc.push(await map(i));
return acc;
};
export const isNotUndef = <T>(obj: T): obj is Exclude<T, undefined> =>
obj !== undefined;
export const formatFileSize = (bytes: number, decimals = 2) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${parseFloat(size.toFixed(decimals))} ${units[unitIndex]}`;
};
export const formatDuration = (remaining: number) => {
const hours = Math.floor(remaining / 3600);
const minutes = Math.floor((remaining % 3600) / 60);
const seconds = Math.floor(remaining % 60);
return `${hours ? `${hours}h ` : ''}${
minutes ? `${minutes}m ` : ''
}${seconds}s`;
};
export const omit = <T extends object, const K extends keyof T>(
obj: T,
keys: K[]
): Omit<T, K> => {
const result = { ...obj };
keys.forEach(key => {
delete result[key];
});
return result;
};
+26
View File
@@ -0,0 +1,26 @@
import { createTRPCRouter } from './trpc';
import { addonsRouter } from './routers/addonts';
import { launcherRouter } from './routers/launcher';
import { updaterRouter } from './routers/updater';
import { patcherRouter } from './routers/patcher';
import { generalRouter } from './routers/general';
import { preferencesRouter } from './routers/preferences';
import { newsRouter } from './routers/news';
import { forumRouter } from './routers/forum';
import { modsRouter } from './routers/mods';
import { selfUpdaterRouter } from './routers/selfUpdater';
export const appRouter = createTRPCRouter({
addons: addonsRouter,
general: generalRouter,
preferences: preferencesRouter,
launcher: launcherRouter,
patcher: patcherRouter,
updater: updaterRouter,
news: newsRouter,
forum: forumRouter,
mods: modsRouter,
selfUpdater: selfUpdaterRouter
});
export type AppRouter = typeof appRouter;
+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())
});
+18
View File
@@ -0,0 +1,18 @@
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
import { ZodError } from 'zod';
const t = initTRPC.create({
transformer: superjson,
errorFormatter: ({ shape, error }) => ({
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null
}
})
});
export const createTRPCRouter = t.router;
export const publicProcedure = t.procedure;
+215
View File
@@ -0,0 +1,215 @@
import { join } from 'path';
import { app, shell, session, BrowserWindow, screen } from 'electron';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { createIPCHandler } from 'electron-trpc/main';
import Logger from 'electron-log/main';
import icon from '~build/icon.png?asset';
import { PreferencesSchema } from '~common/schemas';
import { appRouter } from './api/root';
import Preferences from './modules/preferences';
import Updater from './modules/updater';
import Addons from './modules/addons';
import Mods from './modules/mods';
import { initSelfUpdater } from './modules/selfUpdater';
import {
detectHardware,
recommendFarClip,
HARDWARE_SCHEMA_VERSION
} from './modules/hardware';
Logger.initialize();
Logger.errorHandler.startCatching();
Logger.transports.ipc.level = false;
Logger.info('Launcher starting...');
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 saved =
Preferences.data.rememberPosition &&
isOnScreen(Preferences.data.windowPosition)
? Preferences.data.windowPosition
: undefined;
const position = saved ?? { width: 1000, height: 700 };
mainWindow = new BrowserWindow({
...position,
minWidth: 1000,
minHeight: 700,
icon,
frame: false,
maximizable: false,
fullscreenable: false,
webPreferences: {
preload: join(__dirname, '../preload/index.js'),
contextIsolation: true,
sandbox: false,
devTools: true
}
});
mainWindow.webContents.on('render-process-gone', (_e, details) => {
Logger.error('Renderer process gone:', details);
});
mainWindow.webContents.on('unresponsive', () => {
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('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] });
mainWindow.on('ready-to-show', () => {
mainWindow?.show();
});
mainWindow.webContents.setWindowOpenHandler(details => {
shell.openExternal(details.url);
return { action: 'deny' };
});
mainWindow.on('close', () => {
if (!mainWindow) return;
const [x = 0, y = 0] = mainWindow.getPosition();
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 {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}
};
const gotSingleInstanceLock = is.dev || app.requestSingleInstanceLock();
if (!gotSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
});
app.whenReady().then(async () => {
// defaults on failure so createWindow() below still runs
try {
Preferences.data = await Preferences.load();
} catch (e) {
Logger.error('Preferences.load() failed; starting on defaults', e);
Preferences.data = PreferencesSchema.parse({});
}
Addons.verify();
Updater.verify();
Mods.verify();
initSelfUpdater();
void (async () => {
try {
let hardware = Preferences.data.hardware;
if (!hardware || hardware.schemaVersion < HARDWARE_SCHEMA_VERSION) {
hardware = await detectHardware();
Preferences.data = { hardware };
}
const rec = recommendFarClip(hardware ?? null);
if (
Preferences.data.farClipUserSet !== true &&
Preferences.data.config.farClip !== rec
)
Preferences.data = {
config: { ...Preferences.data.config, farClip: rec }
};
} catch (e) {
Logger.error('Hardware detection / farClip recommendation failed', e);
}
})();
electronApp.setAppUserModelId('st.octowow.launcher');
if (app.isPackaged) {
const serverOrigin = new URL(
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
).origin;
session.defaultSession.webRequest.onHeadersReceived((details, cb) => {
cb({
responseHeaders: {
...details.responseHeaders,
'Content-Security-Policy': [
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
`img-src 'self' data: https://octowow.st https://forum.octowow.st ${serverOrigin}`,
"font-src 'self' data:",
"connect-src 'self'"
].join('; ')
]
}
});
});
}
app.on('browser-window-created', (_, window) => {
optimizer.watchWindowShortcuts(window);
});
await createWindow();
});
let settingsFlushed = false;
app.on('before-quit', event => {
if (settingsFlushed) return;
settingsFlushed = true;
event.preventDefault();
Promise.race([Preferences.save(), new Promise(r => setTimeout(r, 3000))])
.catch(e => Logger.error('Failed to flush settings before quit', e))
.finally(() => app.quit());
});
app.on('window-all-closed', () => {
app.quit();
});
}
+450
View File
@@ -0,0 +1,450 @@
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.charCodeAt(0) === 0xfeff ? content.slice(1) : content)
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
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);
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`
);
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 clean = url.trim().replace(/\/+$/, '');
const gitUrl = clean.endsWith('.git') ? clean : `${clean}.git`;
if (!isAllowedGitUrl(gitUrl)) return undefined;
try {
await git.getRemoteInfo({
http,
url: gitUrl
});
let preview: string | undefined;
try {
if (isAllowedGitUrl(url)) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
}
const folder = gitUrl.slice(0, -4).split('/').at(-1);
if (isUnsafeFolder(folder)) return undefined;
return {
status: 'available',
folder,
git: gitUrl,
preview
} as AddonData;
} catch {
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_') && !/\.(tmp|bak)$/.test(d))
.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 = 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;
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);
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;
+476
View File
@@ -0,0 +1,476 @@
import crypto from 'crypto';
import path from 'path';
import { spawn, type ChildProcess } from 'child_process';
import { app } from 'electron';
import fs from 'fs-extra';
import Logger from 'electron-log/main';
import { mapPort, type PortMapping } from './upnp';
const TORRENT_NAME = 'client';
const bin = () =>
app.isPackaged
? path.join(process.resourcesPath, 'aria2c.exe')
: path.join(app.getAppPath(), 'resources', 'aria2c.exe');
type SyncOpts = {
torrentUrl: string;
clientDir: string;
totalBytes?: number;
checkIntegrity?: boolean;
seedTime?: number;
selectFiles?: number[];
onProgress?: (p: SyncProgress) => void;
signal?: AbortSignal;
};
export type SyncProgress = {
progress: number;
bytesDone: number;
bytesTotal: number;
bytesPerSecond: number;
};
const ensureJunction = async (clientDir: string): Promise<string> => {
await fs.ensureDir(clientDir);
const staging = path.join(app.getPath('userData'), 'torrent-root');
await fs.ensureDir(staging);
const link = path.join(staging, TORRENT_NAME);
const target = path.resolve(clientDir);
try {
const cur = await fs.lstat(link);
if (cur.isSymbolicLink() || cur.isDirectory()) {
const resolved = await fs.realpath(link).catch(() => '');
if (path.resolve(resolved) === target) return staging;
}
await fs.remove(link);
} catch {}
await fs.symlink(target, link, 'junction');
return staging;
};
const SIZE_UNITS: Record<string, number> = {
B: 1,
KiB: 1024,
MiB: 1024 ** 2,
GiB: 1024 ** 3,
TiB: 1024 ** 4
};
const toBytes = (s: string): number => {
const m = /^([\d.]+)(B|KiB|MiB|GiB|TiB)$/.exec(s.trim());
if (!m) return 0;
return parseFloat(m[1]) * (SIZE_UNITS[m[2]] ?? 1);
};
const parseProgress = (
line: string,
totalHint: number
): SyncProgress | undefined => {
const frac =
/([\d.]+(?:B|KiB|MiB|GiB|TiB))\/([\d.]+(?:B|KiB|MiB|GiB|TiB))\((\d+)%\)/.exec(
line
);
if (!frac) return undefined;
const dl = /DL:([\d.]+(?:B|KiB|MiB|GiB|TiB))/.exec(line);
return {
progress: parseInt(frac[3], 10) / 100,
bytesDone: toBytes(frac[1]),
bytesTotal: toBytes(frac[2]) || totalHint,
bytesPerSecond: dl ? toBytes(dl[1]) : 0
};
};
export const syncClient = (opts: SyncOpts): Promise<void> =>
new Promise<void>((resolve, reject) => {
let child: ChildProcess | undefined;
ensureJunction(opts.clientDir)
.then(dir => {
const args = [
`--dir=${dir}`,
`--seed-time=${opts.seedTime ?? 0}`,
`--check-integrity=${opts.checkIntegrity ? 'true' : 'false'}`,
'--bt-save-metadata=true',
'--bt-remove-unselected-file=false',
'--continue=true',
'--allow-overwrite=true',
'--auto-file-renaming=false',
'--file-allocation=none',
'--disk-cache=128M',
'--stream-piece-selector=inorder',
'--max-tries=0',
'--retry-wait=5',
'--bt-stop-timeout=120',
'--auto-save-interval=15',
'--summary-interval=1',
'--console-log-level=warn',
'--enable-dht=true',
'--bt-enable-lpd=true',
'--max-connection-per-server=8',
'--split=16',
'--min-split-size=1M',
...(opts.selectFiles?.length
? [`--select-file=${opts.selectFiles.join(',')}`]
: []),
'--stop-with-process=' + process.pid,
opts.torrentUrl
];
Logger.log(`aria2c ${args.join(' ')}`);
child = spawn(bin(), args, { windowsHide: true });
const onLine = (buf: Buffer) => {
for (const line of buf.toString().split(/\r?\n/)) {
if (!line.trim()) continue;
const p = parseProgress(line, opts.totalBytes ?? 0);
if (p) opts.onProgress?.(p);
else Logger.log(`[aria2] ${line}`);
}
};
child.stdout?.on('data', onLine);
child.stderr?.on('data', onLine);
opts.signal?.addEventListener('abort', () => child?.kill());
child.on('error', reject);
child.on('close', code => {
if (code === 0) resolve();
else reject(new Error(`aria2c exited with code ${code}`));
});
})
.catch(reject);
});
export const aria2Available = () => fs.pathExists(bin());
export const downloadIsComplete = async (): Promise<boolean> => {
const control = path.join(
app.getPath('userData'),
'torrent-root',
`${TORRENT_NAME}.aria2`
);
return !(await fs.pathExists(control));
};
export const clearTorrentResumeState = async (): Promise<void> => {
const dir = path.join(app.getPath('userData'), 'torrent-root');
await Promise.all([
fs.remove(path.join(dir, `${TORRENT_NAME}.aria2`)),
fs.remove(path.join(dir, `${TORRENT_NAME}.torrent`))
]);
};
export const torrentUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_TORRENT_URL || undefined;
export const isTorrentMode = (): boolean => !!torrentUrl();
export const raidVisualsUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_RAID_VISUALS_URL || undefined;
export const clientPatchUrl = (): string | undefined =>
import.meta.env.MAIN_VITE_CLIENT_PATCH_URL || undefined;
export const fetchTorrentSha = async (url: string): Promise<string> => {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
return crypto.createHash('sha1').update(buf).digest('hex');
};
const bdecode = (buf: Buffer, pos = 0): [unknown, number] => {
if (pos >= buf.length) throw new Error('bencode: unexpected end of data');
const ch = buf[pos];
if (ch === 0x69) {
const end = buf.indexOf(0x65, pos);
if (end === -1) throw new Error('bencode: unterminated integer');
return [parseInt(buf.toString('latin1', pos + 1, end), 10), end + 1];
}
if (ch === 0x6c) {
const list: unknown[] = [];
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated list');
const [v, np] = bdecode(buf, p);
list.push(v);
p = np;
}
return [list, p + 1];
}
if (ch === 0x64) {
const dict: Record<string, unknown> = {};
let p = pos + 1;
while (buf[p] !== 0x65) {
if (p >= buf.length) throw new Error('bencode: unterminated dict');
const [k, kp] = bdecode(buf, p);
const [v, vp] = bdecode(buf, kp);
dict[k as string] = v;
p = vp;
}
return [dict, p + 1];
}
const colon = buf.indexOf(0x3a, pos);
if (colon === -1) throw new Error('bencode: unterminated string length');
const len = parseInt(buf.toString('latin1', pos, colon), 10);
if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length)
throw new Error('bencode: invalid string length');
const start = colon + 1;
return [buf.toString('latin1', start, start + len), start + len];
};
const torrentDataArchives = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(
f =>
f.path?.length === 2 &&
f.path[0] === 'Data' &&
/\.mpq$/i.test(f.path[1])
)
.map(f => f.path![1].toLowerCase())
);
};
const LOCALE_DIRS = new Set([
'enus',
'engb',
'encn',
'entw',
'kokr',
'frfr',
'dede',
'zhcn',
'zhtw',
'eses',
'esmx',
'ruru',
'ptbr',
'ptpt',
'itit'
]);
const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
const [torrent] = bdecode(torrentBytes) as [
{ info?: { files?: { path?: string[] }[] } },
number
];
const files = torrent?.info?.files ?? [];
return new Set(
files
.filter(f => (f.path?.length ?? 0) >= 3 && f.path![0] === 'Data')
.map(f => f.path![1].toLowerCase())
);
};
export const pruneStaleArchives = async (
clientDir: string,
url: string,
_owned: Set<string>
): Promise<string[]> => {
try {
const r = await fetch(url);
if (!r.ok) return [];
const bytes = Buffer.from(await r.arrayBuffer());
const expected = torrentDataArchives(bytes);
if (!expected.size) return [];
for (const u of [clientPatchUrl(), raidVisualsUrl()])
if (u) expected.add(path.basename(u).toLowerCase());
const usedDirs = torrentDataDirs(bytes);
const dataDir = path.join(clientDir, 'Data');
const onDisk = await fs.readdir(dataDir).catch(() => []);
const removed: string[] = [];
for (const name of onDisk) {
const lc = name.toLowerCase();
const full = path.join(dataDir, name);
const st = await fs.stat(full).catch(() => null);
if (!st) continue;
if (st.isDirectory()) {
if (LOCALE_DIRS.has(lc) && !usedDirs.has(lc)) {
await fs.remove(full);
removed.push(name + '/');
}
continue;
}
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
await fs.remove(full);
removed.push(name);
}
return removed;
} catch (e) {
Logger.warn('Prune of stale archives failed', e);
return [];
}
};
export const torrentDownloadSelection = async (
clientDir: string,
url: string,
dropMismatched = false
): Promise<number[] | null> => {
try {
const r = await fetch(url);
if (!r.ok) return null;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return null;
const need: number[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i];
if (!f.path?.length || typeof f.length !== 'number') return null;
const dest = path.join(clientDir, ...f.path);
const st = await fs.stat(dest).catch(() => null);
if (!st) {
need.push(i + 1);
continue;
}
if (st.size !== f.length) {
if (dropMismatched || st.size > f.length)
await fs.remove(dest).catch(() => {});
need.push(i + 1);
}
}
return need;
} catch (e) {
Logger.warn('Torrent selection computation failed', e);
return null;
}
};
export const torrentTreeIntact = async (
clientDir: string,
url: string
): Promise<boolean> => {
try {
const r = await fetch(url);
if (!r.ok) return false;
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
{ info?: { files?: { path?: string[]; length?: number }[] } },
number
];
const files = torrent?.info?.files ?? [];
if (!files.length) return false;
for (const f of files) {
if (!f.path?.length || typeof f.length !== 'number') return false;
const st = await fs
.stat(path.join(clientDir, ...f.path))
.catch(() => null);
if (!st || st.size !== f.length) return false;
}
return true;
} catch (e) {
Logger.warn('Torrent tree check failed', e);
return false;
}
};
const LOCALE_ASSERT_OFFSET = 0x1b2115;
const pristineWowPath = () =>
path.join(app.getPath('userData'), 'base-WoW.exe');
export const refreshPristineWow = async (clientDir: string): Promise<void> => {
const exe = path.join(clientDir, 'WoW.exe');
if (!(await fs.pathExists(exe))) return;
const fd = await fs.open(exe, 'r');
try {
const b = Buffer.alloc(1);
await fs.read(fd, b, 0, 1, LOCALE_ASSERT_OFFSET);
if (b[0] === 0xa1) {
await fs.copy(exe, pristineWowPath(), { overwrite: true });
Logger.log('Cached pristine WoW.exe base');
}
} finally {
await fs.close(fd);
}
};
export const readPristineWow = async (clientDir: string): Promise<Buffer> => {
const cache = pristineWowPath();
if (await fs.pathExists(cache)) return fs.readFile(cache);
return fs.readFile(path.join(clientDir, 'WoW.exe'));
};
let seeder: ChildProcess | undefined;
let mapping: Promise<PortMapping> | undefined;
let wantSeeding = false;
let starting = false;
const SEED_PORT = 6881;
const SEED_TIME_MINUTES = 525600;
export const isSeeding = (): boolean => !!seeder;
const releaseMapping = (): void => {
const m = mapping;
mapping = undefined;
if (m) void m.then(x => x.stop()).catch(() => {});
};
export const stopSeeding = (): void => {
wantSeeding = false;
seeder?.kill();
seeder = undefined;
releaseMapping();
};
export const startSeeding = async (
clientDir: string,
uploadLimit = '2M'
): Promise<void> => {
wantSeeding = true;
if (seeder || starting) return;
starting = true;
try {
const url = torrentUrl();
if (!url) return;
const dir = await ensureJunction(clientDir);
if (!wantSeeding || seeder) return;
const args = [
`--dir=${dir}`,
'--bt-seed-unverified=true',
`--seed-time=${SEED_TIME_MINUTES}`,
'--check-integrity=false',
'--continue=true',
'--bt-save-metadata=true',
'--enable-dht=true',
'--bt-enable-lpd=true',
`--listen-port=${SEED_PORT}`,
`--dht-listen-port=${SEED_PORT}`,
`--max-overall-upload-limit=${uploadLimit}`,
'--summary-interval=0',
'--console-log-level=warn',
'--stop-with-process=' + process.pid,
url
];
Logger.log('aria2c (seed) ' + args.join(' '));
const child = spawn(bin(), args, { windowsHide: true });
seeder = child;
const onExit = () => {
if (seeder === child) {
seeder = undefined;
releaseMapping();
}
};
child.on('close', onExit);
child.on('error', e => {
Logger.warn('Seeder failed', e);
onExit();
});
mapping = mapPort(SEED_PORT, { description: 'OctoWoW' });
void mapping.catch(() => undefined);
} catch (e) {
Logger.warn('startSeeding failed', e);
} finally {
starting = false;
}
};
+192
View File
@@ -0,0 +1,192 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
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 resultFile = path.join(
os.tmpdir(),
`octo-defender-${process.pid}-${Date.now()}.txt`
);
const write = (v: string) =>
`Set-Content -LiteralPath ${psSingleQuote(
resultFile
)} -Value "${v}" -Encoding ASCII`;
const inner = [
'try {',
...paths.map(
p =>
` Add-MpPreference -ExclusionPath ${psSingleQuote(
p
)} -ErrorAction Stop`
),
' Add-MpPreference -ExclusionProcess "WoW.exe" -ErrorAction Stop',
' Add-MpPreference -ExclusionProcess "VanillaFixes.exe" -ErrorAction Stop',
` ${write('OK')}`,
'} catch {',
' $t = $false',
' try { $t = (Get-MpComputerStatus).IsTamperProtected } catch {}',
` if ($t) { ${write('TAMPER')} } else { ${write('FAIL')} }`,
'}'
].join('\n');
const encoded = Buffer.from(inner, 'utf16le').toString('base64');
const outer =
'try { Start-Process powershell -Verb RunAs -WindowStyle Hidden -Wait ' +
"-ArgumentList '-NoProfile','-NonInteractive'," +
`'-EncodedCommand','${encoded}' } 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 => {
let result: string | null = null;
try {
result = fs.readFileSync(resultFile, 'utf8').trim();
} catch {}
try {
fs.rmSync(resultFile, { force: true });
} catch {}
if (result === 'OK') {
Logger.info(`Added Defender exclusions: ${paths.join(', ')}`);
resolve({ ok: true, paths });
return;
}
if (result === 'TAMPER') {
Logger.error('Defender exclusion blocked by Tamper Protection');
resolve({
ok: false,
error:
'Windows Security Tamper Protection is blocking this. Turn it off in Windows Security, or add your game folder by hand under Exclusions.'
});
return;
}
if (result === 'FAIL') {
Logger.error(`Defender exclusion failed: ${stderr}`.trim());
resolve({
ok: false,
error:
'Windows would not add the exclusion. You can add your game folder by hand in Windows Security, under Exclusions.'
});
return;
}
Logger.warn(
`Defender exclusion: no result (exit ${code}) ${stderr}`.trim()
);
resolve({
ok: false,
error:
'Windows did not grant permission. Click Yes on the User Account Control prompt to add the exclusion.'
});
});
});
};
const SENSITIVE_FILES = [
'WoW.exe',
'VanillaFixes.exe',
'd3d9.dll',
'UnitXP_SP3.dll',
'nampower.dll',
'VfPatcher.dll',
'VanillaHelpers.dll',
'VanillaMultiMonitorFix.dll',
'transmogfix.dll'
];
export const detectAntivirusBlocks = async (): Promise<string[]> => {
if (os.platform() !== 'win32') return [];
const clientDir = Preferences.data.clientDir;
const launcherDir =
process.env.PORTABLE_EXECUTABLE_DIR ?? path.dirname(app.getPath('exe'));
const roots = [clientDir, launcherDir]
.filter((p): p is string => !!p)
.map(p => p.toLowerCase());
if (!roots.length) return [];
const blocked = new Set<string>();
if (clientDir && Preferences.data.syncedTorrentHash)
for (const name of SENSITIVE_FILES)
if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name);
const script =
'Get-MpThreatDetection | Where-Object ' +
'{ $_.InitialDetectionTime -gt (Get-Date).AddHours(-12) } | ' +
'Select-Object -ExpandProperty Resources';
await new Promise<void>(resolve => {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-Command', script],
{ windowsHide: true }
);
let out = '';
let settled = false;
const timer = setTimeout(() => {
try {
child.kill();
} catch {}
}, 15_000);
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve();
};
child.stdout.on('data', d => (out += String(d)));
child.on('error', finish);
child.on('exit', () => {
for (const line of out.split(/\r?\n/)) {
const m = /^file:_?(.+)$/.exec(line.trim());
if (!m) continue;
const full = m[1];
if (
roots.some(r => full.toLowerCase().startsWith(r)) &&
!fs.existsSync(full)
)
blocked.add(path.basename(full));
}
finish();
});
});
return [...blocked];
};
+146
View File
@@ -0,0 +1,146 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import Logger from 'electron-log/main';
export type DisplayDevice = {
index: number;
deviceName: string;
deviceString: string;
attached: boolean;
primary: boolean;
width: number;
height: number;
refresh: number;
modes: string[];
};
const SCRIPT = [
'$ErrorActionPreference = "Stop"',
"$ProgressPreference = 'SilentlyContinue'",
"Add-Type -TypeDefinition @'",
'using System;',
'using System.Runtime.InteropServices;',
'public static class VmmfDisplays {',
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
' public struct DISPLAY_DEVICE {',
' public int cb;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string DeviceName;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceString;',
' public int StateFlags;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;',
' }',
' [StructLayout(LayoutKind.Sequential, CharSet=CharSet.Ansi)]',
' public struct DEVMODE {',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmDeviceName;',
' public short dmSpecVersion; public short dmDriverVersion; public short dmSize; public short dmDriverExtra;',
' public int dmFields; public int dmPositionX; public int dmPositionY;',
' public int dmDisplayOrientation; public int dmDisplayFixedOutput;',
' public short dmColor; public short dmDuplex; public short dmYResolution; public short dmTTOption; public short dmCollate;',
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=32)] public string dmFormName;',
' public short dmLogPixels; public int dmBitsPerPel; public int dmPelsWidth; public int dmPelsHeight;',
' public int dmDisplayFlags; public int dmDisplayFrequency;',
' public int dmICMMethod; public int dmICMIntent; public int dmMediaType; public int dmDitherType;',
' public int dmReserved1; public int dmReserved2; public int dmPanningWidth; public int dmPanningHeight;',
' }',
' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);',
' [DllImport("user32.dll", EntryPoint="EnumDisplaySettingsA", CharSet=CharSet.Ansi)]',
' public static extern bool EnumDisplaySettings(string lpszDeviceName, int iModeNum, ref DEVMODE lpDevMode);',
'}',
"'@",
'for ($i = 0; ; $i++) {',
' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
' $dm = New-Object VmmfDisplays+DEVMODE',
' $dm.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($dm)',
' $cur = ""',
' if ([VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, -1, [ref]$dm)) {',
' $cur = "$($dm.dmPelsWidth)|$($dm.dmPelsHeight)|$($dm.dmDisplayFrequency)"',
' }',
' $modes = New-Object System.Collections.Generic.HashSet[string]',
' for ($m = 0; ; $m++) {',
' $d2 = New-Object VmmfDisplays+DEVMODE',
' $d2.dmSize = [System.Runtime.InteropServices.Marshal]::SizeOf($d2)',
' if (-not [VmmfDisplays]::EnumDisplaySettings($dd.DeviceName, $m, [ref]$d2)) { break }',
' [void]$modes.Add("$($d2.dmPelsWidth)x$($d2.dmPelsHeight)")',
' }',
' Write-Output ("{0}`t{1}`t{2}`t{3}`t{4}`t{5}" -f $i, $dd.DeviceName, $dd.DeviceString, $dd.StateFlags, $cur, ($modes -join ","))',
'}'
].join('\n');
const parseRow = (line: string): DisplayDevice | undefined => {
const f = line.split('\t');
if (f.length < 6) return undefined;
const index = Number(f[0]);
const stateFlags = Number(f[3]);
if (!Number.isInteger(index) || index < 0 || !Number.isInteger(stateFlags))
return undefined;
const [w, h, hz] = (f[4] || '').split('|').map(Number);
return {
index,
deviceName: f[1],
deviceString: f[2],
attached: (stateFlags & 1) !== 0,
primary: (stateFlags & 4) !== 0,
width: Number.isFinite(w) ? w : 0,
height: Number.isFinite(h) ? h : 0,
refresh: Number.isFinite(hz) ? hz : 0,
modes: (f[5] || '').split(',').filter(Boolean)
};
};
export const enumerateDisplays = (): Promise<DisplayDevice[] | null> => {
if (os.platform() !== 'win32') return Promise.resolve(null);
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (v: DisplayDevice[] | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(v);
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('Display enumeration timed out');
finish(null);
}, 10000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('Display enumeration failed to launch PowerShell', e);
finish(null);
});
child.on('exit', code => {
const devices = stdout
.split(/\r?\n/)
.map(parseRow)
.filter((d): d is DisplayDevice => d !== undefined);
if (code === 0 && devices.length) {
Logger.info(`Enumerated ${devices.length} display device(s)`);
finish(devices);
} else {
Logger.warn('Display enumeration returned nothing usable');
finish(null);
}
});
});
};
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
const devices = await enumerateDisplays();
return devices?.find(d => d.primary && d.attached)?.index ?? null;
};
+79
View File
@@ -0,0 +1,79 @@
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 dllNames = (lines: string[]) =>
lines.map(l => l.trim()).filter(l => l && !l.startsWith('#'));
// keep VanillaFixes' consent cache in step with dlls.txt so it won't re-prompt
const writeCache = async (clientDir: string, names: string[]) => {
const cache = path.join(clientDir, 'dlls.txt.cache');
if (!names.length) {
await fs.remove(cache).catch(() => {});
return;
}
const body = names.map(n => path.win32.join(clientDir, n)).join('\r\n');
await fs.writeFile(cache, body, 'utf8').catch(() => {});
};
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);
await writeCache(clientDir, []);
return;
}
await fs.writeFile(file, trimmed + '\n', 'utf8');
await writeCache(clientDir, dllNames(lines));
};
export const syncVanillaFixesCache = (clientDir: string) =>
serial(async () =>
writeCache(clientDir, dllNames(await readLines(clientDir)))
);
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));
});
export const listDlls = (clientDir: string): Promise<string[]> =>
serial(async () => dllNames(await readLines(clientDir)));
+132
View File
@@ -0,0 +1,132 @@
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app } from 'electron';
import Logger from 'electron-log/main';
import type { HardwareInfo } from '~common/schemas';
export const HARDWARE_SCHEMA_VERSION = 1;
export const FARCLIP_FLOOR = 777;
export const FARCLIP_CEILING = 3000;
const VIDEO_CLASS_GUID = '{4d36e968-e325-11ce-bfc1-08002be10318}';
const getVramMb = (): Promise<{
mb: number | null;
source: HardwareInfo['vramSource'];
}> => {
if (os.platform() !== 'win32')
return Promise.resolve({ mb: null, source: 'none' });
const script = [
'$ErrorActionPreference = "Stop"',
`$base = 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\${VIDEO_CLASS_GUID}'`,
'$max = [int64]0',
'Get-ChildItem $base -ErrorAction SilentlyContinue | ForEach-Object {',
' $p = Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue',
' $v = [int64]0',
" if ($p.'HardwareInformation.qwMemorySize') { $v = [int64]$p.'HardwareInformation.qwMemorySize' }",
" elseif ($p.'HardwareInformation.MemorySize') {",
" $m = $p.'HardwareInformation.MemorySize'",
' if ($m -is [byte[]]) { $v = [int64][System.BitConverter]::ToUInt32($m, 0) } else { $v = [int64]$m }',
' }',
' if ($v -gt $max) { $max = $v }',
'}',
'Write-Output $max'
].join('\n');
const encoded = Buffer.from(script, 'utf16le').toString('base64');
return new Promise(resolve => {
let settled = false;
const finish = (mb: number | null, source: HardwareInfo['vramSource']) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve({ mb, source });
};
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-EncodedCommand', encoded],
{ windowsHide: true }
);
const timer = setTimeout(() => {
child.kill();
Logger.warn('VRAM detection timed out');
finish(null, 'none');
}, 8000);
let stdout = '';
child.stdout.on('data', d => (stdout += String(d)));
child.on('error', e => {
Logger.warn('VRAM detection failed to launch PowerShell', e);
finish(null, 'none');
});
child.on('exit', code => {
const bytes = Number(stdout.trim());
if (code === 0 && Number.isFinite(bytes) && bytes > 0)
finish(Math.round(bytes / 1024 / 1024), 'registry');
else finish(null, 'none');
});
});
};
const getGpuModel = async (): Promise<string> => {
try {
const info = (await app.getGPUInfo('complete')) as {
auxAttributes?: { glRenderer?: string };
gpuDevice?: { active?: boolean; vendorId?: number; deviceId?: number }[];
};
const renderer = info?.auxAttributes?.glRenderer?.trim();
if (renderer) return renderer;
const active = info?.gpuDevice?.find(d => d.active) ?? info?.gpuDevice?.[0];
if (active) return `vendor ${active.vendorId} device ${active.deviceId}`;
} catch (e) {
Logger.warn('GPU info detection failed', e);
}
return 'unknown';
};
export const detectHardware = async (): Promise<HardwareInfo> => {
const cpus = os.cpus();
const [vram, gpuModel] = await Promise.all([getVramMb(), getGpuModel()]);
const info: HardwareInfo = {
totalRamMb: Math.round(os.totalmem() / 1024 / 1024),
cpuCores: cpus.length,
cpuModel: cpus[0]?.model?.trim() || 'unknown',
gpuModel,
vramMb: vram.mb,
vramSource: vram.source,
detectedAt: new Date().toISOString(),
schemaVersion: HARDWARE_SCHEMA_VERSION
};
Logger.info('Detected hardware', info);
return info;
};
const clampFarClip = (n: number) =>
Math.min(FARCLIP_CEILING, Math.max(FARCLIP_FLOOR, Math.round(n)));
export const recommendFarClip = (hw: HardwareInfo | null): number => {
if (!hw) return clampFarClip(1000);
const ramGb = hw.totalRamMb / 1024;
const cores = hw.cpuCores;
const vramTrusted = hw.vramSource !== 'none' && hw.vramMb != null;
const vramGb = vramTrusted ? (hw.vramMb as number) / 1024 : null;
if (ramGb < 6 || cores <= 2) return clampFarClip(FARCLIP_FLOOR);
if (vramGb === null)
return clampFarClip(ramGb >= 8 && cores >= 4 ? 1500 : 1000);
if (ramGb < 8 || vramGb < 2) return clampFarClip(1000);
if (ramGb >= 32 && vramGb >= 8 && cores >= 8) return clampFarClip(3000);
if (ramGb >= 16 && vramGb >= 4 && cores >= 6) return clampFarClip(2200);
if (ramGb >= 8 && vramGb >= 2 && cores >= 4) return clampFarClip(1500);
return clampFarClip(1000);
};
+60
View File
@@ -0,0 +1,60 @@
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';
// old installs may have a copied patch-<letter>.mpq that overrides patch-5; sweep by marker
const ALL_LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const MARKER = 'octolocale.marker';
const patchFile = (dataDir: string, letter: string) =>
path.join(dataDir, `patch-${letter}.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;
}
};
// remove locale patches we copied in (marker-carrying archives only); never throws
export const removeLegacyLocalePatches = async (
clientDir: string | undefined
): Promise<void> => {
if (!clientDir) return;
const dataDir = path.join(clientDir, 'Data');
if (!(await fs.pathExists(dataDir))) return;
for (const letter of ALL_LETTERS) {
const f = patchFile(dataDir, letter);
if (!isOurPatch(f)) continue;
try {
await fs.remove(f);
Logger.log(`Removed the retired locale patch patch-${letter}.mpq`);
} catch (e) {
Logger.error(`Could not remove patch-${letter}.mpq`, e);
}
}
// clear the stale tracking keys
if (Preferences.data.localePatchLetter || Preferences.data.localePatchLocale)
Preferences.data = {
localePatchLetter: undefined,
localePatchLocale: undefined
};
};
+665
View File
@@ -0,0 +1,665 @@
import path from 'path';
import { createHash } from 'crypto';
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,
DEFAULT_ENABLED_MODS,
type ModEntry,
type ModId,
getMod
} from '~common/mods';
import { type ModState } from '~common/schemas';
import Preferences from './preferences';
import { isTorrentMode } from './aria2';
import Observable from './observable';
import Updater from './updater';
import { addDll, removeDll, listDlls } from './dllsTxt';
import { enumerateDisplays } from './displays';
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
/** Files a mod installs on disk. */
const modTargetFiles = (m: ModEntry): string[] => {
if (m.source.kind === 'directFile') return [m.source.assetName];
if (m.source.kind === 'archive') return Object.values(m.source.extractMap);
return [];
};
// client-shipped DLLs that aren't injectable mods; not counted as custom mods
const RESERVED_DLLS = new Set([
'ace.dll',
'divxdecoder.dll',
'discordoverlay.dll',
'discord_game_sdk.dll',
'dbghelp.dll',
'fmod.dll',
'ijl15.dll',
'sdl.dll',
'scan.dll',
'unicows.dll',
'zlib1.dll'
]);
// files owned by an active built-in mod; a disabled mod's files are fair game to add by hand
const KNOWN_DLLS = new Set(
MODS.filter(m => !m.disabled)
.flatMap(m => [m.registerInDllsTxt, ...modTargetFiles(m)])
.filter((f): f is string => !!f)
.map(f => f.toLowerCase())
);
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;
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 CustomMod = { name: string; enabled: boolean };
export type ModsStatus = {
state: 'verifying' | 'idle' | 'busy';
dirty: boolean;
mods: ModRowStatus[];
custom: CustomMod[];
// enabled mods whose files are missing (AV quarantine or incomplete sync)
missingFiles: string[];
};
class ModsClass extends Observable<ModsStatus> {
protected _value: ModsStatus = {
state: 'verifying',
dirty: false,
mods: [],
custom: [],
missingFiles: []
};
// staged custom-DLL toggles, keyed lower-case; #customApplied mirrors dlls.txt
#customDesired = new Map<string, boolean>();
#customApplied = new Map<string, boolean>();
#customNames = new Map<string, string>();
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 {
if (this.#customDesired.size > 0) return true;
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.filter(m => !m.disabled).map(m => this.#initialRow(m)),
custom: this._value.custom,
missingFiles: []
};
}
// DLLs in the client dir we neither ship nor own
async #detectCustomDlls(clientDir: string): Promise<CustomMod[]> {
const inDllsTxt = await listDlls(clientDir);
const enabled = new Set(inDllsTxt.map(n => n.toLowerCase()));
const found = new Map<string, string>();
const consider = (name: string) => {
const lc = name.toLowerCase();
if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc) || found.has(lc)) return;
found.set(lc, name);
};
for (const f of await fs.readdir(clientDir).catch(() => [] as string[]))
if (/\.dll$/i.test(f)) consider(f);
inDllsTxt.forEach(consider);
const names = [...found.values()].sort((a, b) => a.localeCompare(b));
this.#customApplied = new Map(
names.map(n => [n.toLowerCase(), enabled.has(n.toLowerCase())])
);
this.#customNames = new Map(names.map(n => [n.toLowerCase(), n]));
// drop staged changes for DLLs no longer present
const present = new Set(names.map(n => n.toLowerCase()));
for (const lc of [...this.#customDesired.keys()])
if (!present.has(lc)) this.#customDesired.delete(lc);
return names.map(name => {
const lc = name.toLowerCase();
return {
name,
enabled: this.#customDesired.has(lc)
? !!this.#customDesired.get(lc)
: !!this.#customApplied.get(lc)
};
});
}
// flush staged custom-DLL changes to dlls.txt; a failed write stays staged (still pending)
async #applyCustomDlls(clientDir: string) {
for (const [lc, enabled] of [...this.#customDesired]) {
const name = this.#customNames.get(lc) ?? lc;
try {
await (enabled ? addDll(clientDir, name) : removeDll(clientDir, name));
this.#customDesired.delete(lc);
} catch (e) {
Logger.warn(`custom dll apply failed for ${name}`, e);
}
}
}
async #syncPreferredMonitor(clientDir: string) {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
if (!(await fs.pathExists(vmmfDll))) return;
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
const existsCfg = await fs.pathExists(vmmfCfg);
const current = existsCfg
? Number(
await fs
.readFile(vmmfCfg, 'utf8')
.then(s => s.trim())
.catch(() => '')
)
: NaN;
const hasCurrent = Number.isInteger(current);
const ours = Preferences.data?.vmmfWrittenIndex;
const devices = await enumerateDisplays();
const usable = devices?.filter(d => d.attached && d.width > 0);
if (!devices || !usable?.length) {
Logger.warn('Could not enumerate displays; preferred monitor unchanged');
return;
}
const primary = usable.find(d => d.primary) ?? usable[0];
if (hasCurrent && ours === undefined) {
const pinned = devices.find(d => d.index === current);
const broken = !pinned || !pinned.attached || !pinned.primary;
if (!broken) {
Preferences.data = { vmmfWrittenIndex: current };
Logger.info(`Adopting existing preferred monitor ${current} as chosen`);
return;
}
Logger.warn(
`Preferred monitor ${current} (${
pinned ? pinned.deviceName : 'missing'
}) is ${
!pinned || !pinned.attached
? 'not attached'
: 'not the primary display'
}; healing to ${primary.index}`
);
} else if (hasCurrent && current !== ours) {
Logger.info(
`Preferred monitor ${current} was set manually; leaving it alone`
);
Preferences.data = { vmmfWrittenIndex: current };
return;
} else if (hasCurrent && current === primary.index) {
return;
}
await fs
.writeFile(vmmfCfg, `${primary.index}\n`, 'utf8')
.then(() => {
Preferences.data = { vmmfWrittenIndex: primary.index };
Logger.info(
`Preferred monitor set to ${primary.index} (${primary.deviceName} ${primary.width}x${primary.height})`
);
})
.catch(e => Logger.warn('Failed to write preferred monitor', e));
}
async verify() {
this.load();
this._notifyObservers();
const clientDir = Preferences.data?.clientDir;
if (clientDir) {
await this.#syncPreferredMonitor(clientDir);
}
const missing: string[] = [];
for (const m of MODS) {
// disabled mods: leave dlls.txt and installed state untouched
if (m.disabled) continue;
const state = Preferences.data?.mods?.[m.id];
let installedVersion = state?.installedVersion;
// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
if (isTorrentMode()) {
const files = modTargetFiles(m);
const present =
!!clientDir &&
files.length > 0 &&
(
await Promise.all(
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
)
).every(Boolean);
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
installedVersion = enabled ? m.version : undefined;
if (enabled && files.length > 0 && !present) missing.push(m.name);
// only point dlls.txt at a file actually on disk
if (clientDir && m.registerInDllsTxt)
await (present && enabled
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(e => Logger.warn(`dlls.txt update failed for ${m.id}`, e));
this.#patchRow(m.id, {
installedVersion,
latestVersion: m.version,
enabled,
ignoreUpdates: true
});
continue;
}
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
});
}
}
if (clientDir && m.registerInDllsTxt)
await (installedVersion
? addDll(clientDir, m.registerInDllsTxt)
: removeDll(clientDir, m.registerInDllsTxt)
).catch(() => {});
this.#patchRow(m.id, {
installedVersion,
latestVersion: m.version,
enabled: !!state?.enabled,
ignoreUpdates: !!state?.ignoreUpdates
});
}
this._value = {
...this._value,
state: 'idle',
dirty: this.#computeDirty(),
custom: clientDir ? await this.#detectCustomDlls(clientDir) : [],
missingFiles: missing
};
this._notifyObservers();
}
async toggleCustom(name: string, enabled: boolean) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) return;
// stage; matching dlls.txt clears the pending change
const lc = name.toLowerCase();
if (enabled === !!this.#customApplied.get(lc))
this.#customDesired.delete(lc);
else this.#customDesired.set(lc, enabled);
this._value = {
...this._value,
custom: await this.#detectCustomDlls(clientDir)
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
}
async addCustomDll(
srcPath: string
): Promise<{ ok: boolean; error?: string }> {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) return { ok: false, error: 'No game folder is set.' };
const name = path.basename(srcPath);
if (!/\.dll$/i.test(name))
return { ok: false, error: 'Please choose a .dll file.' };
const lc = name.toLowerCase();
if (RESERVED_DLLS.has(lc) || KNOWN_DLLS.has(lc))
return {
ok: false,
error: `${name} is a built-in file and can't be added as a custom mod.`
};
try {
const dest = path.join(clientDir, name);
if (path.resolve(srcPath) !== path.resolve(dest))
await fs.copy(srcPath, dest, { overwrite: true });
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
// stage enabled; Apply writes dlls.txt
this.#customDesired.set(name.toLowerCase(), true);
this._value = {
...this._value,
custom: await this.#detectCustomDlls(clientDir)
};
this._value = { ...this._value, dirty: this.#computeDirty() };
this._notifyObservers();
return { ok: true };
}
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(opts: { repairOnly?: boolean } = {}) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.');
return;
}
// don't commit a mod set with an unmet dependency; dirty stays set. repair is exempt.
if (!opts.repairOnly) {
const enabledIds = new Set(
this._value.mods.filter(r => r.enabled).map(r => r.id)
);
const missingDeps = [
...new Set(
this._value.mods
.filter(r => r.enabled)
.flatMap(r => r.requires.filter(dep => !enabledIds.has(dep)))
)
];
if (missingDeps.length) {
Logger.warn(
`Not applying mods: unmet dependencies ${missingDeps.join(', ')}`
);
return;
}
}
// commit the player's own DLL toggles first
await this.#applyCustomDlls(clientDir);
// torrent mode: mods ship in the client; just reconcile dlls.txt
if (isTorrentMode()) {
await this.verify();
return;
}
if (this._value.state === 'busy') {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
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;
});
const failures = new Map<ModId, string>();
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 && !opts.repairOnly) {
await this.#uninstall(m);
await this.#install(m);
}
} catch (e) {
Logger.error(`Failed to apply ${m.id}:`, 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) {
// In torrent mode the mod binaries ship with the client; nothing is fetched.
if (isTorrentMode()) return;
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[] = [];
const missing: string[] = [];
if (m.source.kind === 'directFile') {
const dest = path.join(clientDir, m.source.assetName);
await this.#downloadTo(m.source.url, dest, m.source.sha256);
written.push(m.source.assetName);
} else if (m.source.kind === 'archive') {
const scratch = path.join(clientDir, '.octolauncher-tmp');
await fs.ensureDir(scratch);
const tmp = path.join(
scratch,
`${m.id}-${Date.now()}.${m.source.format}`
);
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
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) {
missing.push(src);
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(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))) {
missing.push(src);
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 (missing.length)
throw new Error(
`${m.name}: download is missing expected file(s): ${missing.join(', ')}`
);
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, sha256?: string) {
const res = await fetch(url, {
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();
if (sha256) {
const got = createHash('sha256').update(Buffer.from(buf)).digest('hex');
if (got !== sha256.toLowerCase())
throw new Error(
`Checksum mismatch for ${path.basename(
dest
)}: expected ${sha256}, got ${got}. Refusing to install.`
);
}
await fs.writeFile(dest, Buffer.from(buf));
if (!(await fs.pathExists(dest)))
throw new Error(
`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) {
const allMods = { ...(Preferences.data?.mods ?? {}), [id]: state };
Preferences.data = { mods: allMods };
}
}
const Mods = new ModsClass();
export default Mods;
+37
View File
@@ -0,0 +1,37 @@
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 (err) {
console.error('Observer threw, removing listener', err);
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;
+467
View File
@@ -0,0 +1,467 @@
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 { readPristineWow } from '~main/modules/aria2';
import { enumerateDisplays } from '~main/modules/displays';
const Servers = {
live: {
realmList: 'octowow.st',
patchList: 'octowow.st',
realmName: 'OctoWoW'
},
ptr: {
realmList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
patchList: import.meta.env.MAIN_VITE_PTR_REALMLIST || 'octowow.st',
realmName: 'OctoWoW PTR'
}
} as const;
const LOCALES = {
enUS: { tag: 'enUS', index: 0 },
deDE: { tag: 'deDE', index: 3 },
zhCN: { tag: 'zhCN', index: 4 },
ruRU: { tag: 'ruRU', index: 5 },
esES: { tag: 'esES', index: 6 },
ptBR: { tag: 'ptBR', index: 7 }
} as const satisfies Record<
PreferencesSchema['locale'],
{ tag: string; index: number }
>;
const LOCALE_NAMES = [
'enUS',
'koKR',
'frFR',
'deDE',
'zhCN',
'zhTW',
'esES',
'xxYY'
] as const;
const localeNameOffset = (index: number) => 0x45591c - index * 8;
const carrierName = (index: number) => LOCALE_NAMES[index];
type TweakKey =
| { synthetic?: false; key: keyof PreferencesSchema['config'] }
| { synthetic: true; key: string };
type Tweak = TweakKey & {
default?: unknown;
forced?: boolean;
} & (
| {
type: 'bytes';
tweaks: [number, number[], number[]?][];
}
| {
type: 'int8' | 'uint16' | 'float';
offset: number;
value?: number;
}
);
const hex = (bytes: number[]) =>
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
export const patchExecutable = async () => {
Logger.log('Patching WoW.exe...');
const { clientDir, config, locale } = Preferences.data;
if (!clientDir) return;
const exePath = path.join(clientDir, 'WoW.exe');
try {
Logger.log('Reading clean WoW.exe base...');
const buffer = await readPristineWow(clientDir);
const loc = LOCALES[locale];
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 },
{
synthetic: true,
key: 'skillUiGateHijack',
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
]
]
]
},
{
synthetic: true,
key: 'octowowUrlAllowlist',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x45ccd8,
[
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00
]
]
]
},
{
synthetic: true,
key: 'localeTag',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x1b2115,
[0xb8, ...Buffer.from(carrierName(loc.index), 'latin1').reverse()],
[0xa1, 0xa4, 0xa2, 0xc2, 0x00]
]
]
},
{
synthetic: true,
key: 'localeIndex',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
0x253c,
[0xbe, loc.index, 0x00, 0x00, 0x00, 0xeb, 0x1f],
[0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5]
]
]
},
{
synthetic: true,
key: 'localeName',
type: 'bytes',
default: true,
forced: true,
tweaks: [
[
localeNameOffset(loc.index),
[...Buffer.from(loc.tag, 'latin1')],
[...Buffer.from(LOCALE_NAMES[loc.index], 'latin1')]
]
]
}
] satisfies Tweak[];
Tweaks.forEach(t => {
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 number), t.offset);
} else if (t.type === 'int8') {
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 number), t.offset);
} else if (t.type === 'bytes') {
if (!t.forced && !val) return;
t.tweaks.forEach(
([offset, bytes, expect]: [number, number[], number[]?]) => {
if (expect) {
const current = buffer.subarray(offset, offset + expect.length);
if (current.equals(Buffer.from(bytes))) return;
if (!current.equals(Buffer.from(expect)))
throw new Error(
`"${t.key}" expected [${hex(expect)}] at 0x${offset.toString(
16
)} ` +
`but found [${hex([
...current
])}]; refusing to patch WoW.exe`
);
}
const written = Buffer.from(bytes).copy(buffer, offset);
if (written !== bytes.length)
Logger.error(
`"${t.key}" wrote ${written}/${bytes.length} bytes at ` +
`0x${offset.toString(16)}: past end of file (${
buffer.length
} bytes). ` +
'This tweak is a no-op; the offset is probably a virtual address.'
);
}
);
}
});
await fs.writeFile(exePath, buffer);
Preferences.data = { patchedLocale: locale };
Logger.log(`WoW.exe successfully patched (language: ${locale})`);
} catch (e) {
Logger.error('Failed to patch WoW.exe', e);
throw e instanceof Error ? e : new Error('Failed to patch WoW.exe');
}
};
const repairResolution = async (
clientDir: string,
current: string | undefined,
lastWritten: string | undefined
): Promise<{ gxResolution?: string }> => {
const devices = await enumerateDisplays();
if (!devices?.length) return {};
const pinnedRaw = await fs
.readFile(path.join(clientDir, 'VMMFix_preferred_monitor.txt'), 'utf8')
.then(s => s.trim())
.catch(() => '');
const pinned = pinnedRaw ? Number(pinnedRaw) : NaN;
const target =
devices.find(d => d.index === pinned && d.attached) ??
devices.find(d => d.primary && d.attached);
if (!target?.modes.length) return {};
const native = `${target.width}x${target.height}`;
if (!target.modes.includes(native)) return {};
let owned = !current || current === lastWritten;
if (!owned && lastWritten === undefined && current) {
const width = Number(current.split('x')[0]);
if (Number.isFinite(width) && width * 2 < target.width) {
Logger.warn(
`gxResolution ${current} is far below ${target.deviceName}'s ${native} and predates resolution tracking; treating it as a client fallback`
);
owned = true;
}
}
if (!owned) return {};
if (current === native) return {};
Logger.warn(
`gxResolution ${current ?? '<unset>'} is launcher-owned; correcting to ${
target.deviceName
}'s ${native}`
);
return { gxResolution: native };
};
const applyRealmlist = async (clientDir: string, host: string) => {
const body = `set realmlist "${host}"\n`;
const write = async (target: string) => {
const tmp = `${target}.tmp`;
try {
await fs.writeFile(tmp, body);
await fs.move(tmp, target, { overwrite: true });
} catch (e) {
await fs.remove(tmp).catch(() => undefined);
throw e;
}
};
await write(path.join(clientDir, 'realmlist.wtf'));
const dataDir = path.join(clientDir, 'Data');
if (await fs.pathExists(dataDir))
for (const entry of await fs.readdir(dataDir)) {
const scoped = path.join(dataDir, entry, 'realmlist.wtf');
if (!(await fs.pathExists(scoped))) continue;
try {
await write(scoped);
} catch (e) {
Logger.warn(`Could not rewrite ${scoped}: ${String(e)}`);
}
}
};
export const patchConfig = async (forceTweaks = false) => {
const { clientDir, config, locale } = Preferences.data;
if (!clientDir) return;
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
? 'ptr'
: 'live';
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' })
: '';
const configWtf = Object.fromEntries(
raw
.split(/\r?\n/)
.map(l => {
const [, k, v] = l.match(/SET (\w+) "(.*)"/) ?? [];
return !k || v === undefined ? undefined : [k, v];
})
.filter(isNotUndef)
);
const isFirstRun = Object.keys(configWtf).length === 0;
const primaryDisplay = screen.getPrimaryDisplay();
const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const seededResolution = `${width}x${height}`;
const seed = isFirstRun
? {
scriptMemory: 512000,
gxResolution: seededResolution,
gxColorBits: primaryDisplay.colorDepth,
gxDepthBits: primaryDisplay.colorDepth,
gxRefresh: 60,
gxMultisample: 8,
gxMultisampleQuality: 0,
gxTripleBuffer: 1,
anisotropic: 16,
frillDensity: 48,
fullAlpha: 1,
SmallCull: 0.01,
DistCull: 888.8,
shadowLevel: 0,
trilinear: 1,
specular: 1,
pixelShaders: 1,
M2UsePixelShaders: 1,
M2UseShaders: 1,
particleDensity: 1,
unitDrawDist: 300,
weatherDensity: 3,
movieSubtitle: 1,
minimapZoom: 0,
minimapInsideZoom: 0,
SoundZoneMusicNoDelay: 1,
gxWindow: 1,
gxMaximize: 1,
gxCursor: 1,
checkAddonVersion: 0,
farClip: config.farClip,
CameraDistanceMax: config.cameraDistance,
patchList: Servers[server].patchList,
realmName: Servers[server].realmName
}
: {};
const owned = {
locale: carrierName(LOCALES[locale].index),
patchList: configWtf['patchList'] ?? Servers[server].patchList,
realmName: configWtf['realmName'] ?? Servers[server].realmName,
hwDetect: 0,
BackgroundSound: config.soundInBackground ? 1 : 0
};
const repaired = await repairResolution(
clientDir,
configWtf['gxResolution'],
Preferences.data.lastWrittenResolution
);
const parsed = {
...seed,
...configWtf,
...repaired,
...owned,
...(forceTweaks
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
: {})
};
const body = Object.entries(parsed)
.filter(v => v[1] !== undefined && v[1] !== null)
.filter(([k]) => !/^realmlist$/i.test(k))
.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 });
await applyRealmlist(clientDir, Servers[server].realmList);
const chosen =
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
Preferences.data = { lastWrittenResolution: chosen };
Logger.log('Config.wtf successfully patched');
};
export const ensureDxvkConf = async (clientDir: string) => {
if (!(await fs.pathExists(path.join(clientDir, 'd3d9.dll')))) return;
const confPath = path.join(clientDir, 'dxvk.conf');
if (await fs.pathExists(confPath)) return;
await fs.writeFile(
confPath,
[
'# Cap the texture memory the 32-bit client believes it has so it cannot',
'# over-commit its address space (the common DXVK out-of-memory crash).',
'd3d9.maxAvailableMemory = 2048',
'd3d9.maxFrameLatency = 1',
'dxvk.numCompilerThreads = 2',
'dxvk.logLevel = none',
''
].join('\n')
);
Logger.log('Wrote dxvk.conf');
};
+270
View File
@@ -0,0 +1,270 @@
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 { DEFAULT_ENABLED_MODS } from '~common/mods';
import { omit } from '~common/utils';
import { isTorrentMode } from '~main/modules/aria2';
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
const errCode = (e: unknown) =>
e && typeof e === 'object' ? (e as NodeJS.ErrnoException).code : undefined;
const LOCK_CODES = ['EPERM', 'EACCES', 'EBUSY', 'EMFILE', 'ENFILE'];
const isLocked = (e: unknown) => LOCK_CODES.includes(errCode(e) ?? '');
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const readJsonRetrying = async (file: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.readJSON(file);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const renameRetrying = async (from: string, to: string, attempts = 5) => {
for (let i = 0; ; i++) {
try {
return await fs.rename(from, to);
} catch (e) {
if (i >= attempts - 1 || !isLocked(e)) throw e;
await delay(60 * (i + 1));
}
}
};
const writeJsonAtomic = async (file: string, data: unknown) => {
const tmp = `${file}.tmp`;
await fs.writeJSON(tmp, data, { spaces: 2 });
await renameRetrying(tmp, file);
};
const dropUndefined = <T extends object>(obj: T): Partial<T> =>
Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined)
) as Partial<T>;
abstract class Preferences {
static #data: z.infer<typeof PreferencesSchema>;
static #writeChain: Promise<void> = Promise.resolve();
static #readOnly = false;
static #rememberedClientDir?: string;
static #freshInstall = false;
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
: app.getPath('userData');
static readonly #settingsPath = path.join(
Preferences.userDataDir,
'settings.json'
);
static readonly #installPath = path.join(
Preferences.userDataDir,
'install.json'
);
static get isFreshInstall() {
return this.#freshInstall;
}
static async #detectFreshInstall() {
const [settings, install, pending] = await Promise.all([
fs.pathExists(this.#settingsPath),
fs.pathExists(this.#installPath),
fs.pathExists(`${this.#settingsPath}.tmp`)
]);
return !settings && !install && !pending;
}
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
const mods = { ...data.mods };
for (const id of DEFAULT_ENABLED_MODS)
mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
Logger.info(
`Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
);
return { ...data, mods };
}
static async load() {
this.#freshInstall = await this.#detectFreshInstall();
await fs.ensureDir(this.userDataDir);
const settingsPath = this.#settingsPath;
let json: Record<string, unknown> = {};
try {
json = await readJsonRetrying(settingsPath);
} catch (e) {
if (isLocked(e)) {
this.#readOnly = true;
Logger.error(
`Could not read ${settingsPath} (${errCode(e)}); running on ` +
'defaults and leaving settings untouched for this session.',
e
);
} else {
if (errCode(e) !== 'ENOENT') {
Logger.warn(`${settingsPath} is unreadable; keeping a copy`, e);
await fs
.copy(settingsPath, `${settingsPath}.corrupt`)
.catch(() => {});
}
const recovered = await fs
.readJSON(`${settingsPath}.tmp`)
.catch(() => null);
if (recovered && typeof recovered === 'object') {
Logger.warn(`Recovered settings from ${settingsPath}.tmp`);
json = recovered as Record<string, unknown>;
}
}
}
const merged = dropUndefined({
...json,
isPortable: !!portableDir,
clientDir: portableDir ?? json.clientDir
});
const parsed = PreferencesSchema.safeParse(merged);
if (parsed.success)
return this.#withKnownClientDir(
this.#withFreshInstallDefaults(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> = dropUndefined({
isPortable: !!portableDir,
// coerce to string/undefined; the shape loop never clears a set key, so a
// non-string would survive and throw at the final parse
clientDir:
portableDir ??
(typeof json.clientDir === 'string' ? json.clientDir : undefined)
});
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;
}
// defaults if even the salvaged set is invalid; never throw out of load()
const salvagedParsed = PreferencesSchema.safeParse(salvaged);
return this.#withKnownClientDir(
salvagedParsed.success ? salvagedParsed.data : PreferencesSchema.parse({})
);
}
static async #withKnownClientDir(data: PreferencesSchema) {
if (portableDir) return data;
const remembered = await fs
.readJSON(this.#installPath)
.then(j => {
const dir = (j as { clientDir?: unknown })?.clientDir;
return typeof dir === 'string' && dir ? dir : undefined;
})
.catch(() => undefined);
this.#rememberedClientDir = remembered;
if (await this.isValidClientDir(data.clientDir)) return data;
if (!remembered || remembered === data.clientDir) return data;
if (!(await this.isValidClientDir(remembered))) return data;
Logger.warn(
`No usable clientDir in settings.json; restored "${remembered}" from ` +
this.#installPath
);
return { ...data, clientDir: remembered };
}
static get data(): PreferencesSchema {
return this.#data;
}
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
this.#data = { ...this.#data, ...newData };
if (this.#readOnly) return;
const settingsPath = this.#settingsPath;
const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable'];
const delta = dropUndefined(
omit(newData, dropped as (keyof typeof newData)[])
);
const snapshot = dropUndefined(
omit(this.#data, dropped as (keyof PreferencesSchema)[])
);
this.#writeChain = this.#writeChain
.then(async () => {
let base: Record<string, unknown> | null = null;
try {
const onDisk = await readJsonRetrying(settingsPath);
base =
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
? (onDisk as Record<string, unknown>)
: null;
} catch (e) {
if (isLocked(e)) {
Logger.error(
`Skipping settings write; ${settingsPath} is locked (${errCode(
e
)})`,
e
);
return;
}
}
const merged = base ? { ...base, ...delta } : snapshot;
await writeJsonAtomic(settingsPath, merged);
const clientDir = (merged as { clientDir?: unknown }).clientDir;
if (
typeof clientDir === 'string' &&
clientDir &&
clientDir !== this.#rememberedClientDir
) {
this.#rememberedClientDir = clientDir;
await writeJsonAtomic(this.#installPath, { clientDir }).catch(e =>
Logger.warn(`Failed to write ${this.#installPath}`, e)
);
}
})
.catch(e => Logger.error('Failed to persist settings.json', e));
}
static save() {
return this.#writeChain;
}
static async isValidClientDir(clientDir?: string) {
if (!clientDir) return false;
if (await fs.exists(path.join(clientDir, 'WoW.exe'))) return true;
// torrent mode: no WoW.exe yet, accept a dir the download can populate
if (isTorrentMode())
return (
(await fs.exists(clientDir)) ||
(await fs.exists(path.dirname(clientDir)))
);
return false;
}
}
export default Preferences;
+122
View File
@@ -0,0 +1,122 @@
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;
};
+542
View File
@@ -0,0 +1,542 @@
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 Logger from 'electron-log/main';
import { nestedGet, nestedSet } from '~common/utils';
import { mainWindow } from '~main/index';
import { getClientVersion } from '~main/utils';
import {
torrentUrl,
fetchTorrentSha,
syncClient,
refreshPristineWow,
clearTorrentResumeState,
raidVisualsUrl,
clientPatchUrl,
pruneStaleArchives,
torrentTreeIntact,
torrentDownloadSelection,
startSeeding,
stopSeeding
} from '~main/modules/aria2';
import Preferences from './preferences';
import Observable from './observable';
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals';
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 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
);
};
const ownedDataArchives = async (): Promise<Set<string>> => {
try {
const j = await fs.readJSON(
path.join(Preferences.userDataDir, 'manifest.json')
);
const data = getManifestItem(j?.root ?? j, ['Data']);
if (!data || data.type === 'file' || data.type === 'del') return new Set();
return new Set(
data.files
.filter(f => f.type !== 'dir' && /\.mpq$/i.test(f.name))
.map(f => f.name.toLowerCase())
);
} catch {
return new Set();
}
};
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;
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 SIDECAR_TIMEOUT_MS = 30_000;
class UpdaterClass extends Observable<UpdaterStatus> {
#cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = this.#readCache();
#readCache(): CacheTree {
try {
return fs.existsSync(this.#cachePath)
? fs.readJSONSync(this.#cachePath)
: {};
} catch {
return {};
}
}
async #saveCache() {
await fs.writeJSON(this.#cachePath, this.#cache);
}
async #getHash(clientPath: string, ...filePath: string[]) {
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 refreshSeeding() {
if (
Preferences.data.shareDownloads !== false &&
Preferences.data.clientDir &&
this.status.state === 'upToDate'
)
await startSeeding(Preferences.data.clientDir);
else stopSeeding();
}
async #torrentVerify(clientPath: string) {
const url = torrentUrl();
if (!url) {
this.status = { state: 'serverUnreachable' };
return;
}
this.status = {
state: 'verifying',
progress: -1,
message: 'Checking for updates...'
};
try {
const sha = await fetchTorrentSha(url);
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
const haveExe = await fs.pathExists(path.join(clientPath, 'WoW.exe'));
if (
haveExe &&
sha !== Preferences.data.syncedTorrentHash &&
(await this.#torrentFastForward(clientPath, sha, url))
) {
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
return;
}
const upToDate =
haveExe &&
sha === Preferences.data.syncedTorrentHash &&
(await torrentTreeIntact(clientPath, url));
if (upToDate) {
const removed = await pruneStaleArchives(clientPath, url, new Set());
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
}
this.status = upToDate
? { state: 'upToDate', progress: 1 }
: { state: 'updateAvailable' };
await this.refreshSeeding();
} catch (e) {
Logger.error('Torrent verify failed', e);
this.status = { state: 'serverUnreachable' };
}
}
async #fetchSidecarFile(
url: string,
dest: string,
storedHash: string | undefined,
busyMessage: string
): Promise<string | undefined> {
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS);
const sha = await fetch(`${url}.sha256`, { signal: ac.signal })
.then(r => {
if (!r.ok) throw new Error(`sha256 HTTP ${r.status}`);
return r.text();
})
.then(s => s.trim())
.finally(() => clearTimeout(t));
if ((await fs.pathExists(dest)) && storedHash === sha) return undefined;
this.status = { state: 'updating', progress: -1, message: busyMessage };
const buf = await this.#downloadWithIdleTimeout(url);
if (crypto.createHash('sha256').update(buf).digest('hex') !== sha)
throw new Error('checksum mismatch');
await fs.ensureDir(path.dirname(dest));
await fs.writeFile(`${dest}.part`, buf);
await fs.move(`${dest}.part`, dest, { overwrite: true });
return sha;
}
async #downloadWithIdleTimeout(url: string): Promise<Buffer> {
const ac = new AbortController();
let idle: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
if (idle) clearTimeout(idle);
idle = setTimeout(() => ac.abort(), SIDECAR_TIMEOUT_MS);
};
arm();
try {
const res = await fetch(url, { signal: ac.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = res.body as NodeJS.ReadableStream | null;
if (!body) throw new Error('empty response body');
const chunks: Buffer[] = [];
for await (const chunk of body) {
arm();
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
} finally {
if (idle) clearTimeout(idle);
}
}
async #reconcileClientPatch(clientPath: string) {
const url = clientPatchUrl();
if (!url) return;
const dest = path.join(clientPath, 'Data', 'patch-5.mpq');
try {
const sha = await this.#fetchSidecarFile(
url,
dest,
Preferences.data.clientPatchHash,
'Updating game files...'
);
if (sha) Preferences.data = { clientPatchHash: sha };
} catch (e) {
Logger.warn('Content patch reconcile failed', e);
if (!(await fs.pathExists(dest)))
throw new Error(
'Could not download core game content. Please check your connection and try again.'
);
}
}
async #reconcileRaidVisuals(clientPath: string) {
const url = raidVisualsUrl();
if (!url) return;
const dest = path.join(clientPath, 'Data', 'patch-O.mpq');
try {
if (!Preferences.data.config?.raidVisuals) {
if (await fs.pathExists(dest)) {
await fs.remove(dest);
Preferences.data = { raidVisualsHash: undefined };
}
return;
}
const sha = await this.#fetchSidecarFile(
url,
dest,
Preferences.data.raidVisualsHash,
'Updating raid visuals...'
);
if (sha) Preferences.data = { raidVisualsHash: sha };
} catch (e) {
Logger.warn('Raid visuals reconcile failed', e);
}
}
async syncRaidVisuals() {
if (this.status?.state === 'verifying' || this.status?.state === 'updating')
return;
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (await isGameRunning(path.join(clientPath, 'WoW.exe'))) {
this.status = {
state: 'failed',
message: 'Please close WoW first, before updating.'
};
return;
}
if (!torrentUrl()) return this.verify();
await this.#reconcileRaidVisuals(clientPath);
this.status = { state: 'upToDate', progress: 1 };
}
async #torrentFastForward(
clientPath: string,
sha: string,
url: string
): Promise<boolean> {
if (!Preferences.data.syncedTorrentHash) return false;
if (!(await torrentTreeIntact(clientPath, url))) return false;
await refreshPristineWow(clientPath);
const removed = await pruneStaleArchives(
clientPath,
url,
await ownedDataArchives()
);
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
Preferences.data = {
syncedTorrentHash: sha,
version: await getClientVersion()
};
return true;
}
async #torrentUpdate(clientPath: string, clean?: boolean) {
const url = torrentUrl();
if (!url) {
this.status = { state: 'serverUnreachable' };
return;
}
try {
stopSeeding();
const sha = await fetchTorrentSha(url);
if (!clean && (await this.#torrentFastForward(clientPath, sha, url))) {
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
return;
}
const isUpdate = await fs.pathExists(path.join(clientPath, 'WoW.exe'));
const staleContext =
Preferences.data.activeTorrentHash !== sha ||
Preferences.data.activeClientDir !== clientPath;
if (staleContext) {
await clearTorrentResumeState();
Preferences.data = {
activeTorrentHash: sha,
activeClientDir: clientPath
};
}
const selection = clean
? null
: await torrentDownloadSelection(clientPath, url, staleContext);
const selectFiles =
selection && selection.length > 0 ? selection : undefined;
this.status = {
state: 'updating',
progress: -1,
message: clean
? 'Verifying game files...'
: isUpdate
? 'Connecting...'
: 'Preparing download...'
};
let downloading = false;
await syncClient({
torrentUrl: url,
clientDir: clientPath,
checkIntegrity: !!clean,
selectFiles,
seedTime: 0,
onProgress: p => {
if (p.bytesPerSecond > 0) downloading = true;
const phase = downloading
? 'Downloading'
: clean
? 'Verifying game files'
: isUpdate
? 'Connecting'
: 'Preparing';
this.status = {
state: 'updating',
progress: p.progress,
bytesDone: p.bytesDone,
bytesTotal: p.bytesTotal,
bytesPerSecond: p.bytesPerSecond,
message: `${phase}...`
};
}
});
if (!(await torrentTreeIntact(clientPath, url))) {
this.status = {
state: 'updateAvailable',
message: 'Download incomplete. Click update to finish.'
};
return;
}
await refreshPristineWow(clientPath);
const removed = await pruneStaleArchives(
clientPath,
url,
await ownedDataArchives()
);
if (removed.length)
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
await this.#reconcileClientPatch(clientPath);
await this.#reconcileRaidVisuals(clientPath);
Preferences.data = {
syncedTorrentHash: sha,
version: await getClientVersion()
};
this.status = { state: 'upToDate', progress: 1 };
await this.refreshSeeding();
} catch (e) {
Logger.error('Torrent update failed', e);
this.status = {
state: 'failed',
message: e instanceof Error ? e.message : 'Download failed'
};
}
}
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;
}
return this.#torrentVerify(clientPath);
}
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;
}
return this.#torrentUpdate(clientPath, clean);
}
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();
export default Updater;
+374
View File
@@ -0,0 +1,374 @@
import dgram from 'dgram';
import http from 'http';
import os from 'os';
import Logger from 'electron-log/main';
// UPnP-IGD port mapping (best effort) for a NAT'd seeder; node builtins only, no-ops on failure.
export type PortMapping = { stop: () => Promise<void> };
const NOOP: PortMapping = { stop: async () => {} };
const SSDP_ADDR = '239.255.255.250';
const SSDP_PORT = 1900;
const SEARCH = Buffer.from(
[
'M-SEARCH * HTTP/1.1',
`HOST: ${SSDP_ADDR}:${SSDP_PORT}`,
'MAN: "ssdp:discover"',
'MX: 2',
'ST: urn:schemas-upnp-org:device:InternetGatewayDevice:1',
'',
''
].join('\r\n')
);
// exposes AddPortMapping, newest first
const WAN_SERVICES = [
'urn:schemas-upnp-org:service:WANIPConnection:2',
'urn:schemas-upnp-org:service:WANIPConnection:1',
'urn:schemas-upnp-org:service:WANPPPConnection:1'
];
type Gateway = { location: string; address: string; localAddress: string };
type WanService = { controlUrl: string; serviceType: string };
class SoapError extends Error {
code?: string;
constructor(message: string, code?: string) {
super(message);
this.code = code;
}
}
const candidateAddresses = (): string[] =>
Object.values(os.networkInterfaces())
.flat()
.filter(
(a): a is os.NetworkInterfaceInfo =>
!!a &&
a.family === 'IPv4' &&
!a.internal &&
!a.address.startsWith('169.254.')
)
.map(a => a.address);
// a 0.0.0.0/empty host in LOCATION is really the address the datagram came from
const fixLocation = (location: string, responder: string): string => {
try {
const u = new URL(location);
if (u.hostname === '0.0.0.0' || u.hostname === '') u.hostname = responder;
return u.toString();
} catch {
return location;
}
};
// M-SEARCH one interface; collect every responder (more than one can answer)
const searchInterface = (
localAddress: string,
timeoutMs: number
): Promise<Gateway[]> =>
new Promise(resolve => {
const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
const found = new Map<string, Gateway>();
let retry: ReturnType<typeof setInterval> | undefined;
let done = false;
const finish = () => {
if (done) return;
done = true;
if (retry) clearInterval(retry);
try {
socket.close();
} catch {
// already closed
}
resolve([...found.values()]);
};
socket.on('message', (msg, rinfo) => {
const m = /^location:\s*(\S+)/im.exec(msg.toString('utf8'));
if (!m) return;
const location = fixLocation(m[1].trim(), rinfo.address);
if (!found.has(location))
found.set(location, { location, address: rinfo.address, localAddress });
});
socket.on('error', () => finish());
socket.bind(0, localAddress, () => {
try {
socket.setMulticastInterface(localAddress);
} catch {
// fall back to the default multicast interface
}
const send = () =>
socket.send(SEARCH, SSDP_PORT, SSDP_ADDR, () => {
/* fire-and-forget */
});
send();
// Routers sometimes miss the first datagram; re-ask until the window closes.
retry = setInterval(send, 700);
setTimeout(finish, timeoutMs);
});
});
// search all interfaces: a VPN often owns the default route
const discoverGateways = async (timeoutMs: number): Promise<Gateway[]> => {
const perInterface = await Promise.all(
candidateAddresses().map(a => searchInterface(a, timeoutMs))
);
const seen = new Set<string>();
const gateways: Gateway[] = [];
for (const list of perInterface)
for (const gw of list)
if (!seen.has(gw.location)) {
seen.add(gw.location);
gateways.push(gw);
}
return gateways;
};
// build the control URL from the host we reached; routers advertise a bogus URLBase
const controlUrlFrom = (descriptorUrl: string, controlPath: string): string => {
const desc = new URL(descriptorUrl);
let path: string;
try {
const c = new URL(controlPath, descriptorUrl);
path = `${c.pathname}${c.search}`;
} catch {
path = controlPath.startsWith('/') ? controlPath : `/${controlPath}`;
}
return `${desc.protocol}//${desc.host}${path}`;
};
// raw http, not fetch: many UPnP servers are non-compliant and undici rejects them
const httpRequest = (
url: string,
opts: {
method?: string;
headers?: Record<string, string>;
body?: string;
timeoutMs?: number;
} = {}
): Promise<{ status: number; body: string }> =>
new Promise((resolve, reject) => {
let u: URL;
try {
u = new URL(url);
} catch (e) {
reject(e as Error);
return;
}
const headers = { ...(opts.headers ?? {}) };
const body = opts.body ? Buffer.from(opts.body, 'utf8') : undefined;
if (body) headers['Content-Length'] = String(body.length);
const req = http.request(
{
hostname: u.hostname,
port: u.port || 80,
path: `${u.pathname}${u.search}`,
method: opts.method ?? 'GET',
headers
},
res => {
const chunks: Buffer[] = [];
res.on('data', c => chunks.push(c));
res.on('end', () =>
resolve({
status: res.statusCode ?? 0,
body: Buffer.concat(chunks).toString('utf8')
})
);
}
);
req.on('error', reject);
req.setTimeout(opts.timeoutMs ?? 5000, () =>
req.destroy(new Error('request timed out'))
);
if (body) req.write(body);
req.end();
});
// first WAN service + control URL from a device descriptor
const findWanService = (
xml: string,
descriptorUrl: string
): WanService | undefined => {
for (const block of xml.split(/<service>/i).slice(1)) {
const type = /<serviceType>\s*([^<]+?)\s*<\/serviceType>/i
.exec(block)?.[1]
?.trim();
const ctrl = /<controlURL>\s*([^<]+?)\s*<\/controlURL>/i
.exec(block)?.[1]
?.trim();
if (
type &&
ctrl &&
WAN_SERVICES.some(w => w.toLowerCase() === type.toLowerCase())
)
return {
controlUrl: controlUrlFrom(descriptorUrl, ctrl),
serviceType: type
};
}
return undefined;
};
const xmlEscape = (s: string): string =>
s.replace(
/[<>&'"]/g,
c =>
({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' }[
c
] as string)
);
const arg = (name: string, value: string | number): string =>
`<${name}>${value}</${name}>`;
const soap = async (
svc: WanService,
action: string,
body: string
): Promise<void> => {
const envelope =
'<?xml version="1.0"?>' +
'<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">' +
'<s:Body>' +
`<u:${action} xmlns:u="${svc.serviceType}">${body}</u:${action}>` +
'</s:Body></s:Envelope>';
const res = await httpRequest(svc.controlUrl, {
method: 'POST',
headers: {
'Content-Type': 'text/xml; charset="utf-8"',
'SOAPAction': `"${svc.serviceType}#${action}"`
},
body: envelope
});
if (res.status < 200 || res.status >= 300) {
const code = /<errorCode>\s*(\d+)/i.exec(res.body)?.[1];
throw new SoapError(
`${action} failed: HTTP ${res.status}${code ? ` (UPnP ${code})` : ''}`,
code
);
}
};
const addMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP',
client: string,
description: string,
lease: number
): Promise<void> =>
soap(
svc,
'AddPortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol) +
arg('NewInternalPort', port) +
arg('NewInternalClient', client) +
arg('NewEnabled', 1) +
arg('NewPortMappingDescription', xmlEscape(description)) +
arg('NewLeaseDuration', lease)
);
const deleteMapping = (
svc: WanService,
port: number,
protocol: 'TCP' | 'UDP'
): Promise<void> =>
soap(
svc,
'DeletePortMapping',
arg('NewRemoteHost', '') +
arg('NewExternalPort', port) +
arg('NewProtocol', protocol)
);
// map port (TCP+UDP), kept alive until stop(); returns a no-op handle when no gateway
export const mapPort = async (
port: number,
opts: { description?: string; ttlSeconds?: number } = {}
): Promise<PortMapping> => {
const description = opts.description ?? 'OctoWoW';
try {
const gateways = await discoverGateways(4000);
if (!gateways.length) {
Logger.log('UPnP: no gateway found; seeding without a port mapping');
return NOOP;
}
// take the first responder that exposes a WAN service
const probed = await Promise.all(
gateways.map(async gw => {
const res = await httpRequest(gw.location).catch(() => undefined);
const svc =
res && res.status < 400
? findWanService(res.body, gw.location)
: undefined;
return svc ? { svc, client: gw.localAddress } : undefined;
})
);
const target = probed.find(Boolean);
if (!target) {
Logger.log('UPnP: no gateway exposes a WAN service; skipping mapping');
return NOOP;
}
const { svc, client } = target;
// Some routers only grant permanent leases (UPnP error 725); fall back to one.
let lease = opts.ttlSeconds ?? 3600;
const mapped: ('TCP' | 'UDP')[] = [];
const mapOne = async (protocol: 'TCP' | 'UDP') => {
try {
await addMapping(svc, port, protocol, client, description, lease);
} catch (e) {
if (e instanceof SoapError && e.code === '725' && lease !== 0) {
lease = 0;
await addMapping(svc, port, protocol, client, description, lease);
} else throw e;
}
mapped.push(protocol);
};
try {
await mapOne('TCP');
await mapOne('UDP');
} catch (e) {
for (const p of mapped) await deleteMapping(svc, port, p).catch(() => {});
throw e;
}
// A finite lease self-heals if we exit uncleanly; renew ahead of expiry.
let renew: ReturnType<typeof setInterval> | undefined;
if (lease > 0) {
const period = Math.max(60_000, (lease - 60) * 1000);
renew = setInterval(() => {
addMapping(svc, port, 'TCP', client, description, lease).catch(
() => {}
);
addMapping(svc, port, 'UDP', client, description, lease).catch(
() => {}
);
}, period);
renew.unref?.();
}
Logger.log(
`UPnP: mapped ${port} TCP+UDP to ${client} (lease ${
lease || 'permanent'
})`
);
return {
stop: async () => {
if (renew) clearInterval(renew);
await deleteMapping(svc, port, 'TCP').catch(() => {});
await deleteMapping(svc, port, 'UDP').catch(() => {});
}
};
} catch (e) {
Logger.warn('UPnP: port mapping failed; seeding without it', e);
return NOOP;
}
};
+13
View File
@@ -0,0 +1,13 @@
export { type AppRouter } from './api/root';
export { type UpdaterStatus } from './modules/updater';
export { type AddonsStatus, type AddonData } from './modules/addons';
export {
type ModsStatus,
type ModRowStatus,
type CustomMod
} from './modules/mods';
export {
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
+69
View File
@@ -0,0 +1,69 @@
import { type Worker, type WorkerOptions } from 'node:worker_threads';
import path from 'node:path';
import Logger from 'electron-log/main';
import fs from 'fs-extra';
import Preferences from './modules/preferences';
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,
workerData: Record<string, unknown>,
callbacks?: Record<string, (...data: any[]) => void>
) =>
new Promise<T>((resolve, reject) =>
worker({ workerData })
.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 () => {
Logger.log('Reading client version...');
const exePath = path.join(Preferences.data.clientDir ?? '', 'WoW.exe');
if (!(await fs.exists(exePath))) {
Logger.log('Client not found...');
return undefined;
}
const file = await fs.readFile(exePath);
const buffer = Buffer.from(file);
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})`;
};
+46
View File
@@ -0,0 +1,46 @@
import { workerData, parentPort } from 'worker_threads';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
if (!port) throw new Error('IllegalState');
const { dir, url, ref } = workerData;
const tmpDir = `${dir}.tmp`;
const bakDir = `${dir}.bak`;
const run = async () => {
await fs.remove(tmpDir);
await git.clone({
dir: tmpDir,
fs,
http,
url,
ref,
singleBranch: !ref || ref === 'master' || ref === 'main',
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
});
await fs.remove(bakDir);
const hadExisting = await fs.pathExists(dir);
if (hadExisting) await fs.move(dir, bakDir);
try {
await fs.move(tmpDir, dir);
} catch (e) {
if (hadExisting) await fs.move(bakDir, dir).catch(() => undefined);
throw e;
}
await fs.remove(bakDir).catch(() => undefined);
};
run()
.then(() => port.postMessage(true))
.catch(async err => {
await fs.remove(tmpDir).catch(() => undefined);
throw err;
});
+56
View File
@@ -0,0 +1,56 @@
import { workerData, parentPort } from 'worker_threads';
import git from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
const port = parentPort;
if (!port) throw new Error('gitPull worker has no parentPort');
const { dir, remote, branch, ref } = workerData as {
dir: string;
remote: string;
branch: string;
ref?: string;
};
const onProgress = (...args: unknown[]) =>
port.postMessage({ cb: 'onProgress', args });
const run = async () => {
if (ref) {
await git.fetch({
fs,
http,
dir,
tags: true,
singleBranch: false,
onProgress
});
await git.checkout({ fs, dir, force: true, ref, onProgress });
return;
}
await git.checkout({
fs,
dir,
force: true,
ref: `${remote}/${branch}`,
onProgress
});
await git.pull({
fs,
http,
dir,
ref: branch,
singleBranch: true,
author: { name: 'Octo Launcher' },
onProgress
});
};
run()
.then(() => port.postMessage(true))
.catch(err => {
throw err;
});
+16
View File
@@ -0,0 +1,16 @@
import path from 'path';
import { contextBridge } from 'electron';
import { electronAPI } from '@electron-toolkit/preload';
import { exposeElectronTRPC } from 'electron-trpc/main';
try {
contextBridge.exposeInMainWorld('electron', electronAPI);
contextBridge.exposeInMainWorld('path', path);
} catch (error) {
console.error(error);
}
process.once('loaded', async () => {
exposeElectronTRPC();
});
+10
View File
@@ -0,0 +1,10 @@
import type path from 'path';
import { type ElectronAPI } from '@electron-toolkit/preload';
declare global {
interface Window {
electron: ElectronAPI;
path: typeof path;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from 'react';
import { api } from './utils/api';
import PageBackground from './assets/background.png';
import AntivirusModal from './components/AntivirusModal';
import Header from './components/Header';
import LaunchPanel from './components/LaunchPanel';
import SelfUpdateBanner from './components/SelfUpdateBanner';
import TabsPanel, { type TabType } from './components/TabsPanel';
import TopBar from './components/TopBar';
import IconSpinner from './components/styled/IconSpinner';
import usePreventDefaultEvents from './utils/usePreventDefaultEvents';
const App = () => {
const { isLoading } = api.preferences.get.useQuery();
const { data: appVersion } = api.general.appVersion.useQuery();
const [activeTab, setActiveTab] = useState<TabType>();
usePreventDefaultEvents();
return (
<div
className="relative flex grow flex-col gap-3 overflow-hidden bg-cover bg-top bg-no-repeat p-[44px]"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<TopBar />
<SelfUpdateBanner />
<Header {...{ activeTab, setActiveTab }} />
{isLoading ? (
<div className="flex flex-grow items-center justify-center">
<IconSpinner />
</div>
) : (
<>
<TabsPanel activeTab={activeTab} />
<LaunchPanel />
</>
)}
{appVersion && (
<span className="pointer-events-none absolute bottom-2 right-3 select-none font-mono text-[10px] uppercase tracking-wider text-white/40">
v{appVersion}
</span>
)}
<AntivirusModal />
</div>
);
};
export default App;
+91
View File
@@ -0,0 +1,91 @@
import { Clipboard, RefreshCw, ServerCrash } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import PageBackground from './assets/background.png';
import TextButton from './components/styled/TextButton';
type State = {
didCatch?: boolean;
error?: Error;
errorInfo?: ErrorInfo;
};
type Props = {
children: ReactNode;
};
const ErrorFallback = ({
error,
errorInfo
}: {
error?: Error;
errorInfo?: ErrorInfo;
}) => {
const t = useT();
const title = t('misc.uncaughtError', {
name: error?.name ?? '',
message: error?.message ?? t('misc.unknownError')
});
const detail = errorInfo?.componentStack?.slice(1);
return (
<div
className="relative flex h-screen w-screen grow flex-col overflow-hidden border border-blueGray/10 bg-cover bg-top bg-no-repeat p-3"
style={{ backgroundImage: `url(${PageBackground})` }}
>
<div className="tw-surface flex grow flex-col gap-3">
<div className="flex items-center gap-2">
<ServerCrash size={26} className="text-red" />
<h3 className="text-red">{t('misc.somethingWentWrong')}</h3>
</div>
<hr />
<div className="text-white">{title}</div>
<pre className="s1 -mt-2 grow overflow-auto text-blueGray">
{detail}
</pre>
<hr />
<div className="-mx-3 -mb-3 flex justify-end gap-2">
<TextButton
icon={Clipboard}
onClick={() =>
navigator.clipboard.writeText(
`\`\`\`\n${title}\n${detail}\n\`\`\``
)
}
>
{t('misc.copyError')}
</TextButton>
<TextButton
icon={RefreshCw}
onClick={() => window.location.reload()}
className="text-warmGreen"
>
{t('misc.reload')}
</TextButton>
</div>
</div>
</div>
);
};
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
log.error('Client crash:', error, errorInfo);
this.setState({ didCatch: true, error, errorInfo });
}
render() {
if (!this.state.didCatch) return this.props.children;
const { error, errorInfo } = this.state;
return <ErrorFallback error={error} errorInfo={errorInfo} />;
}
}
export default ErrorBoundary;
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+230
View File
@@ -0,0 +1,230 @@
import { useEffect, useRef, useState } from 'react';
import { ShieldAlert, HelpCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { api } from '~renderer/utils/api';
import { type ModsStatus, type UpdaterStatus } from '~main/types';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
const DETECTION = 'Trojan:Win32/Vigorf.A';
const AntivirusModal = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, { onData: setStatus });
// re-scan for AV blocks once the updater settles (not mid-download); catches an aborted
// download or a file quarantined after the fact
const [updateState, setUpdateState] = useState<UpdaterStatus['state']>();
api.updater.observe.useSubscription(undefined, {
onData: s => setUpdateState(s?.state)
});
const settled =
!!updateState && updateState !== 'verifying' && updateState !== 'updating';
const { data: quarantined, refetch: refetchQuarantined } =
api.general.antivirusBlocks.useQuery(undefined, {
enabled: false,
refetchOnWindowFocus: false,
staleTime: Infinity
});
useEffect(() => {
if (settled) refetchQuarantined();
}, [updateState, settled, refetchQuarantined]);
const addExclusion = api.general.addDefenderExclusion.useMutation();
const blocked = [
...new Set([
...(quarantined ?? []),
...(status?.mods ?? [])
.filter(m => m.state === 'error' && m.error?.includes('Defender'))
.map(m => m.name)
])
];
const blockedKey = blocked.join(',');
const dialogRef = useRef<HTMLDialogElement>(null);
const [view, setView] = useState<'av' | 'why' | null>(null);
useEffect(() => {
if (view) {
// showModal() throws (and crashes to the error screen) if already open, e.g. the av<->why switch
if (!dialogRef.current?.open) dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [view]);
useEffect(() => {
if (blockedKey) setView('av');
}, [blockedKey]);
// The settings dialog opens the explainer straight from its antivirus button.
useEffect(() => {
const open = () => setView('why');
window.addEventListener('av-help', open);
return () => window.removeEventListener('av-help', open);
}, []);
const names = blockedKey ? blockedKey.split(',') : [];
return createPortal(
<dialog
ref={dialogRef}
onClose={() => setView(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{view === 'av' && (
<div className="tw-dialog !w-fit min-w-[380px] max-w-[480px] !gap-3">
<h3 className="tw-color">{t('av.blockedTitle')}</h3>
<p className="s1">
{names.length === 1 ? t('av.blockedOne') : t('av.blockedMany')}
</p>
<ul className="gap-0.5 flex flex-col pl-1">
{names.map(n => (
<li key={n} className="s1 text-orange">
• {n}
</li>
))}
</ul>
<p className="s1 text-blueGray">{t('av.blockedExplain')}</p>
<TextButton
icon={HelpCircle}
onClick={() => setView('why')}
className="self-start text-yellow hocus:!text-yellow"
>
{t('av.whyHappening')}
</TextButton>
<TextButton
icon={ShieldAlert}
loading={addExclusion.isLoading}
onClick={() => addExclusion.mutate()}
className="self-start text-orange"
>
{t('av.allowThrough')}
</TextButton>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">{t('av.addedRetry')}</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
<div className="mt-1 flex justify-end">
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
{view === 'why' && (
<div className="tw-dialog !w-fit min-w-[420px] max-w-[560px] !gap-3">
<h3 className="tw-color">{t('av.whyTitle')}</h3>
<div className="flex max-h-[60vh] transform-gpu flex-col gap-3 overflow-y-auto overscroll-contain pr-1 [will-change:transform]">
<p className="s1 text-blueGray">
{t('av.whyIntro', { detection: DETECTION })}{' '}
<span className="text-warmGreen">{t('av.falsePositive')}</span>.
</p>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatSetsItOff')}
</p>
<p className="s1 text-blueGray">{t('av.whatSetsItOffIntro')}</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.dllInjection')}</span>:{' '}
{t('av.dllInjectionText')}
</li>
<li className="s1 text-blueGray">
<span className="text-orange">{t('av.exePatching')}</span>:{' '}
{t('av.exePatchingText')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">{t('av.heuristicNote')}</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatModsDo')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
<span className="text-warmGreen">VanillaFixes</span>:{' '}
{t('av.modVanillaFixes')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">nampower</span>:{' '}
{t('av.modNampower')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">SuperWoW and UnitXP</span>:{' '}
{t('av.modSuperWowUnitXp')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">DXVK</span>:{' '}
{t('av.modDxvk')}
</li>
</ul>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.howToVerify')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.howToVerifyText', {
detection: DETECTION
})}
</p>
</div>
<div>
<p className="tw-color text-[21px] leading-tight">
{t('av.whatAllowDoesTitle')}
</p>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesIntro')}{' '}
<span className="text-warmGreen">Add-MpPreference</span>{' '}
{t('av.whatAllowDoesIntroAfter')}
</p>
<ul className="mt-1 flex flex-col gap-1 pl-1">
<li className="s1 text-blueGray">
{t('av.exclusionFoldersBefore')}{' '}
<span className="text-warmGreen">{t('av.gameFolder')}</span>{' '}
{t('av.exclusionFoldersAnd')}{' '}
<span className="text-warmGreen">
{t('av.launcherFolder')}
</span>
{t('av.exclusionFoldersAfter')}
</li>
<li className="s1 text-blueGray">
<span className="text-warmGreen">WoW.exe</span>{' '}
{t('av.exclusionExesAnd')}{' '}
<span className="text-warmGreen">VanillaFixes.exe</span>{' '}
{t('av.exclusionExesAfter')}
</li>
</ul>
<p className="s1 mt-1 text-blueGray">
{t('av.whatAllowDoesOutro')}
</p>
</div>
</div>
<div className="flex items-center justify-end gap-3">
{names.length > 0 && (
<TextButton
onClick={() => setView('av')}
className="text-blueGray"
>
{t('av.back')}
</TextButton>
)}
<TextButton onClick={() => setView(null)} className="text-green">
{t('av.close')}
</TextButton>
</div>
</div>
)}
</dialog>,
document.body
);
};
export default AntivirusModal;
+148
View File
@@ -0,0 +1,148 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import { PreferencesSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import FilePickerInput from './form/FilePickerInput';
import CheckboxInput from './form/CheckboxInput';
import CloseButton from './styled/CloseButton';
type Props = { close: () => void };
const ClientDirDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const isValidClientDir = api.preferences.isValidClientDir.useQuery(
pref?.clientDir,
{ enabled: !!pref?.isPortable }
);
const verify = api.updater.verify.useMutation();
const {
register,
handleSubmit,
watch,
formState,
setValue,
setError,
reset
} = useForm({
defaultValues: { clientDir: pref?.clientDir ?? '' },
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
});
const chosen = watch('clientDir');
const [acceptEmpty, setAcceptEmpty] = useState(false);
const chosenIsClient = api.preferences.isValidClientDir.useQuery(chosen, {
enabled: !!chosen && !pref?.isPortable
});
const needsEmptyConfirm =
!!chosen && chosenIsClient.isFetched && chosenIsClient.data === false;
useEffect(() => {
setAcceptEmpty(false);
}, [chosen]);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
if (pref?.isPortable) {
return (
<form className="tw-dialog">
<CloseButton close={close} />
<h2 className="color mb-2 text-xl">
{t('prefs.installLocationTitle')}
</h2>
<p>{t('prefs.portableInfo')}</p>
{!isValidClientDir.isLoading && !isValidClientDir.data && (
<p>
<span className="text-secondary">{t('prefs.errorLabel')}</span>
{t('prefs.wowExeNotFound', { exe: 'WoW.exe' })}
</p>
)}
</form>
);
}
return (
<form
className="tw-dialog"
onSubmit={handleSubmit(async ({ clientDir }) => {
if (needsEmptyConfirm && !acceptEmpty) return;
try {
await setPref.mutateAsync({ clientDir });
verify.mutate();
close();
} catch (e) {
setError('clientDir', {
message: e instanceof Error ? e.message : JSON.stringify(e)
});
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.installLocationTitle')}</h3>
<hr />
<p className="text-blueGray">{t('prefs.selectDirectory')}</p>
<p className="text-blueGray">{t('prefs.upgradeExisting')}</p>
<div className="flex items-center gap-3">
<label htmlFor="clientDir">{t('prefs.installDirectory')}</label>
<FilePickerInput
{...register('clientDir')}
title={watch('clientDir') ?? undefined}
setValue={v =>
setValue('clientDir', v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
})
}
options={{ properties: ['openDirectory', 'createDirectory'] }}
/>
</div>
{formState.errors.clientDir && (
<p className="text-secondary text-sm">
{formState.errors.clientDir.message}
</p>
)}
{needsEmptyConfirm && (
<>
<p className="text-secondary text-sm">
{t('prefs.noClientHere', { exe: 'WoW.exe' })}
</p>
<CheckboxInput
value={acceptEmpty}
setValue={setAcceptEmpty}
label={t('prefs.noClientHereConfirm')}
/>
</>
)}
<TextButton
type="submit"
loading={formState.isSubmitting}
disabled={needsEmptyConfirm && !acceptEmpty}
className="self-end text-green"
>
{t('prefs.confirm')}
</TextButton>
</form>
);
};
export default ClientDirDialog;
+37
View File
@@ -0,0 +1,37 @@
import OctoLogo from '~renderer/assets/logo.png';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import { TabNames, type TabType } from './TabsPanel';
type Props = {
activeTab?: TabType;
setActiveTab: (tab?: TabType) => void;
};
const Header = ({ activeTab, setActiveTab }: Props) => {
const t = useT();
return (
<div className="-mb-3 flex select-none items-center gap-1">
<button
onClick={() => setActiveTab(undefined)}
className="z-10 -my-3 mx-3 w-[180px] cursor-pointer"
>
<img src={OctoLogo} alt="OctoWoW" className="pointer-events-none" />
</button>
{TabNames.map(tab => (
<TextButton
key={tab}
onClick={() => setActiveTab(tab)}
active={activeTab === tab}
className="uppercase"
>
{t(`tab.${tab}`)}
</TextButton>
))}
</div>
);
};
export default Header;
@@ -0,0 +1,91 @@
import { useEffect, useRef, useState } from 'react';
import { Globe } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { useLocale } from '~renderer/i18n';
import { type Lang } from '~renderer/i18n/translations';
const LOCALES: { value: Lang; code: string; label: string }[] = [
{ value: 'enUS', code: 'En', label: 'English' },
{ value: 'deDE', code: 'De', label: 'Deutsch' },
{ value: 'zhCN', code: 'Zh', label: '中文' },
{ value: 'esES', code: 'Es', label: 'Español' },
{ value: 'ptBR', code: 'Pt', label: 'Português' },
{ value: 'ruRU', code: 'Ru', label: 'Русский' }
];
const LanguageDropdown = () => {
const { lang, setLang, t } = useLocale();
const setPref = api.preferences.set.useMutation();
const code = LOCALES.find(l => l.value === lang)?.code ?? 'En';
const [open, setOpen] = useState(false);
const [pos, setPos] = useState<{ top: number; right: number }>();
const btnRef = useRef<HTMLButtonElement>(null);
const toggle = () => {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ top: r.bottom + 4, right: window.innerWidth - r.right });
setOpen(o => !o);
};
useEffect(() => {
if (!open) return;
const close = () => setOpen(false);
window.addEventListener('click', close);
return () => window.removeEventListener('click', close);
}, [open]);
const pick = (v: Lang) => {
setLang(v);
setPref.mutate({ locale: v });
setOpen(false);
};
return (
<>
<button
ref={btnRef}
type="button"
title={t('topbar.language')}
onClick={e => {
e.stopPropagation();
toggle();
}}
className="bg-transparent flex cursor-pointer items-center border-0 px-1 text-[12px] tracking-wide hocus:text-orange"
>
<Globe size={14} />
{code}
</button>
{open &&
pos &&
createPortal(
<div
onClick={e => e.stopPropagation()}
style={{ top: pos.top, right: pos.right }}
className="fixed z-50 flex flex-col border border-blueGray/30 bg-darkGray py-1 shadow-[0_8px_20px_rgba(0,0,0,0.5)]"
>
{LOCALES.map(l => (
<button
key={l.value}
type="button"
title={l.label}
onClick={() => pick(l.value)}
className={cls(
'bg-transparent cursor-pointer border-0 px-3 py-1 text-center text-[12px] hocus:bg-orange/20',
l.value === lang ? 'text-warmGreen' : 'text-white'
)}
>
{l.code}
</button>
))}
</div>,
document.body
)}
</>
);
};
export default LanguageDropdown;
+268
View File
@@ -0,0 +1,268 @@
import { useState, type ReactElement } from 'react';
import cls from 'classnames';
import log from 'electron-log/renderer';
import { type UpdaterStatus, type ModsStatus } from '~main/types';
import { formatFileSize } from '~common/utils';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
const formatDuration = (seconds: number) => {
const s = Math.max(0, Math.round(seconds));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
const rem = s % 60;
if (m < 60) return rem ? `${m}m ${rem}s` : `${m}m`;
const h = Math.floor(m / 60);
const minRem = m % 60;
return minRem ? `${h}h ${minRem}m` : `${h}h`;
};
const formatPercent = (progress: number) =>
`${parseFloat((progress * 100).toFixed(1))}%`;
const ProgressDetails = ({ status }: { status: UpdaterStatus }) => {
const t = useT();
const { bytesDone, bytesTotal, bytesPerSecond, etaSeconds, progress } =
status;
if (bytesTotal === undefined || bytesDone === undefined) return null;
const pct =
progress !== undefined && progress >= 0 ? formatPercent(progress) : '—';
return (
<p className="s1 text-blueGray">
<span className="tw-color">{pct}</span>
<span>
{' '}
· {formatFileSize(bytesDone)} / {formatFileSize(bytesTotal)}
</span>
{bytesPerSecond !== undefined && bytesPerSecond > 0 && (
<span> · {formatFileSize(bytesPerSecond, 1)}/s</span>
)}
<span>
{' · '}
{etaSeconds !== undefined
? `~${formatDuration(etaSeconds)} ${t('launch.remaining')}`
: t('launch.calculating')}
</span>
</p>
);
};
const LaunchPanel = () => {
const t = useT();
const [status, setStatus] = useState<UpdaterStatus>({ state: 'verifying' });
api.updater.observe.useSubscription(undefined, {
onData: setStatus,
onError: err => log.error('Updater subscription error:', err)
});
const { data: pref } = api.preferences.get.useQuery();
const [modsStatus, setModsStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, {
onData: setModsStatus
});
const verify = api.updater.verify.useMutation();
const update = api.updater.update.useMutation();
const start = api.launcher.start.useMutation();
const applyMods = api.mods.applyAll.useMutation();
const modRows = modsStatus?.mods ?? [];
const enabledIds = new Set(modRows.filter(m => m.enabled).map(m => m.id));
const missingDeps = [
...new Set(
modRows
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const modName = (id: string) => modRows.find(m => m.id === id)?.name ?? id;
const props: Record<
UpdaterStatus['state'],
{ button: ReactElement; helperText?: ReactElement }
> = {
verifying: { button: <Button disabled>{t('launch.verifying')}</Button> },
serverUnreachable: {
button: pref?.version ? (
<Button disabled={start.isLoading} onClick={() => start.mutateAsync()}>
{t('launch.play')}
</Button>
) : (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{t('launch.serverFail')}
</p>
<p className="s1 text-blueGray">
{pref?.version
? t('launch.localVersion', { version: pref.version })
: t('launch.tryLater')}
</p>
</div>
)
},
noClient: {
button: (
<DialogButton
clickAway
dialog={close => <ClientDirDialog close={close} />}
>
{open => (
<Button primary onClick={open}>
{t('launch.install')}
</Button>
)}
</DialogButton>
)
},
updateAvailable: {
button: (
<Button onClick={() => update.mutateAsync()}>
{t('launch.update')}
</Button>
),
helperText: (
<div className="-mb-2 flex flex-col gap-1">
<p>{t('launch.updateAvailable')}</p>
<p className="s1 text-blueGray">
{status.progress !== undefined &&
status.bytesDone !== undefined &&
status.bytesTotal !== undefined && (
<>
<span className="tw-color">
{formatPercent(status.progress)}
</span>
<span>
{' '}
· {formatFileSize(status.bytesDone)} /{' '}
{formatFileSize(status.bytesTotal)} {t('launch.onDisk')} ·{' '}
</span>
</>
)}
<span className="break-all">{status.message}</span>
</p>
</div>
)
},
updating: {
button: <Button disabled>{t('launch.updating')}</Button>,
helperText: (
<div className="-mb-2 flex flex-col gap-1">
{status.message && (
<p className="s1 truncate text-blueGray">{status.message}</p>
)}
<ProgressDetails status={status} />
</div>
)
},
upToDate: {
button: modsStatus?.dirty ? (
<Button
primary
onClick={() => applyMods.mutateAsync()}
disabled={
applyMods.isLoading ||
modsStatus?.state === 'busy' ||
missingDeps.length > 0
}
>
{modsStatus?.state === 'busy'
? t('launch.applying')
: t('mods.apply')}
</Button>
) : (
<Button
primary
disabled={start.isLoading}
onClick={() => start.mutateAsync()}
>
{t('launch.play')}
</Button>
),
helperText: (
<div className="-mb-2">
{modsStatus?.dirty ? (
missingDeps.length ? (
<p className="text-orange">
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
) : (
<p>{t('launch.modsChanged')}</p>
)
) : (
<p>{t('launch.upToDate')}</p>
)}
<p className="s1 text-blueGray">
{t('launch.version', { version: pref?.version ?? '' })}
</p>
</div>
)
},
failed: {
button: (
<Button onClick={() => verify.mutateAsync()}>
{t('launch.retry')}
</Button>
),
helperText: (
<div className="-mb-2">
<p>
<span className="text-orange">{t('launch.errorLabel')}</span>{' '}
{status.message}
</p>
<p className="s1 text-blueGray">{t('launch.verifyHint')}</p>
</div>
)
}
};
return (
<div className="flex gap-3">
<div className="flex flex-grow flex-col justify-end gap-3">
{props[status.state].helperText ??
(status.message && (
<p className="s1 -mb-2 text-blueGray">{status.message}</p>
))}
{start.data && !start.data.ok && start.data.error && (
<p className="s1 -mb-2 text-orange">{start.data.error}</p>
)}
<div className="tw-loading-wrapper">
{status.progress !== undefined && (
<div
className={cls('tw-loading', {
'tw-loading-unknown': status.progress === -1
})}
style={
status.progress !== -1
? {
clipPath: `inset(0 ${
100 - Math.ceil(Math.abs(status.progress) * 100)
}% 0 0)`
}
: undefined
}
/>
)}
</div>
</div>
{props[status.state].button}
</div>
);
};
export default LaunchPanel;
@@ -0,0 +1,229 @@
import { useForm } from 'react-hook-form';
import { useEffect, useState } from 'react';
import {
FilePen,
FolderOpen,
HelpCircle,
RefreshCw,
ScrollText,
ShieldAlert,
ShieldCheck
} from 'lucide-react';
import { PreferencesSchema } from '~common/schemas';
import { api } from '~renderer/utils/api';
import zodResolver from '~renderer/utils/zodResolver';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
import CheckboxInput from './form/CheckboxInput';
import DialogButton from './styled/DialogButton';
import ClientDirDialog from './ClientDirDialog';
import CloseButton from './styled/CloseButton';
const MirrorStatus = () => {
const t = useT();
const [state, setState] = useState<string>('verifying');
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) => setState(state)
});
if (state === 'serverUnreachable')
return <span className="s1 text-red">{t('prefs.mirrorOffline')}</span>;
if (state === 'verifying' || state === 'updating')
return (
<span className="s1 text-blueGray">{t('prefs.mirrorChecking')}</span>
);
return <span className="s1 text-warmGreen">{t('prefs.mirrorOnline')}</span>;
};
type Props = { close: () => void };
const PreferencesDialog = ({ close }: Props) => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const verify = api.updater.verify.useMutation();
const repair = api.mods.repair.useMutation();
const openInstallFolder = api.general.openInstallFolder.useMutation();
const openLogFile = api.general.openLogFile.useMutation();
const addExclusion = api.general.addDefenderExclusion.useMutation();
const { handleSubmit, watch, setValue, reset } = useForm({
defaultValues: pref ?? {},
resolver: zodResolver(PreferencesSchema)
});
const [saveError, setSaveError] = useState<string | null>(null);
useEffect(() => {
pref && reset(pref);
}, [reset, pref]);
const setBool = (key: keyof PreferencesSchema) => (v: boolean) =>
setValue(key, v, {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
});
return (
<form
className="tw-dialog !w-fit min-w-[480px] max-w-[640px] !gap-1"
onSubmit={handleSubmit(async v => {
setSaveError(null);
try {
await setPref.mutateAsync({
cleanWdb: v.cleanWdb,
minimizeToTrayOnPlay: v.minimizeToTrayOnPlay,
shareDownloads: v.shareDownloads
});
close();
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
}
})}
>
<CloseButton
close={() => {
reset();
close();
}}
/>
<h3 className="tw-color">{t('prefs.title')}</h3>
<hr className="mb-1" />
<div className="flex items-center gap-3">
<h4 className="tw-color">{t('prefs.installLocation')}</h4>
<TextButton
icon={FolderOpen}
size={14}
onClick={() => openInstallFolder.mutateAsync()}
className="!p-1 text-blueGray"
>
{t('prefs.openFolder')}
</TextButton>
</div>
<div className="flex items-center gap-2 border border-blueGray/20 bg-darkGray/40 px-3 py-1">
<span
title={pref?.clientDir}
className="min-w-0 shrink grow overflow-hidden text-ellipsis whitespace-nowrap"
>
{pref?.clientDir ?? t('prefs.notSelected')}
</span>
<DialogButton
dialog={closeInner => (
<ClientDirDialog
close={() => {
closeInner();
close();
}}
/>
)}
clickAway={pref?.isPortable}
>
{open => (
<TextButton
icon={FilePen}
size={14}
onClick={open}
className="!p-1"
>
{t('prefs.change')}
</TextButton>
)}
</DialogButton>
</div>
<div className="mt-1 flex items-center gap-3">
<h4 className="tw-color">{t('prefs.downloadMirror')}</h4>
</div>
<div className="flex items-center gap-2 pl-2">
<input type="radio" checked readOnly className="accent-warmGreen" />
<span>Iceland</span>
<MirrorStatus />
<TextButton
icon={RefreshCw}
size={12}
onClick={() => verify.mutateAsync()}
title={t('prefs.recheck')}
className="!p-0 text-blueGray"
/>
</div>
<div className="flex items-start gap-3">
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.troubleshooting')}</h4>
<TextButton
icon={ShieldCheck}
onClick={() => repair.mutateAsync().then(close)}
className="!items-start text-left text-warmGreen"
>
{t('prefs.verifyGameFiles')}
</TextButton>
<TextButton
icon={ScrollText}
onClick={() => openLogFile.mutateAsync()}
className="!items-start text-left text-pink"
>
{t('prefs.openLogFile')}
</TextButton>
<div className="flex items-start">
<TextButton
icon={ShieldAlert}
onClick={() => addExclusion.mutateAsync()}
loading={addExclusion.isLoading}
className="!items-start text-left text-orange"
>
{t('prefs.allowThroughAntivirus')}
</TextButton>
{/* sits on the label's first line even when a locale wraps it */}
<TextButton
icon={HelpCircle}
size={14}
onClick={() => window.dispatchEvent(new Event('av-help'))}
title={t('av.whatAllowDoesTitle')}
className="mt-[14px] !p-0 text-yellow hocus:!text-yellow"
/>
</div>
{addExclusion.data?.ok === true && (
<span className="s1 text-warmGreen">
{t('prefs.exclusionAdded')}
</span>
)}
{addExclusion.data?.ok === false && (
<span className="s1 text-orange">{addExclusion.data.error}</span>
)}
</div>
<div className="flex min-w-0 flex-col">
<h4 className="tw-color">{t('prefs.generalSettings')}</h4>
<CheckboxInput
value={!!watch('cleanWdb')}
setValue={setBool('cleanWdb')}
label={t('prefs.cleanWdb')}
/>
<CheckboxInput
value={!!watch('minimizeToTrayOnPlay')}
setValue={setBool('minimizeToTrayOnPlay')}
label={t('prefs.minimizeToTray')}
/>
<CheckboxInput
value={watch('shareDownloads') !== false}
setValue={setBool('shareDownloads')}
label={t('prefs.shareDownloads')}
/>
</div>
</div>
{saveError && (
<span className="s1 self-end text-orange">{saveError}</span>
)}
<TextButton type="submit" className="mt-1 self-end text-green">
{t('prefs.save')}
</TextButton>
</form>
);
};
export default PreferencesDialog;
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import Button from './styled/Button';
type Status =
| { 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 };
const SelfUpdateBanner = () => {
const t = useT();
const [status, setStatus] = useState<Status>({
state: 'idle',
currentVersion: ''
});
api.selfUpdater.observe.useSubscription(undefined, {
onData: setStatus
});
const install = api.selfUpdater.install.useMutation();
if (
status.state === 'idle' ||
status.state === 'checking' ||
status.state === 'unavailable'
) {
return null;
}
const tone = status.state === 'error' ? 'border-red/40' : 'border-tw/40';
const label =
status.state === 'error'
? t('misc.selfUpdateCheckFailed', { message: status.message })
: status.state === 'available'
? t('misc.selfUpdateAvailable', {
version: status.nextVersion
})
: status.state === 'downloading'
? t('misc.selfUpdateDownloading', {
version: status.nextVersion,
percent: Math.round(status.progress * 100)
})
: status.state === 'ready'
? t('misc.selfUpdateReady', { version: status.nextVersion })
: '';
return (
<div
className={`relative z-10 flex items-center gap-3 rounded-md border ${tone} bg-black/60 px-4 py-2 text-sm`}
>
<span className="flex-grow break-all">{label}</span>
{status.state === 'ready' && (
<Button
primary
onClick={() => install.mutateAsync()}
disabled={install.isLoading}
>
{t('misc.selfUpdateInstallNow')}
</Button>
)}
</div>
);
};
export default SelfUpdateBanner;
@@ -0,0 +1,94 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
import { useT } from '~renderer/i18n';
import TextButton from './styled/TextButton';
type Props = {
tabName: string;
children: ReactNode;
};
type State = {
error?: Error;
componentStack?: string;
};
type FallbackProps = {
tabName: string;
error: Error;
componentStack?: string;
onReset: () => void;
};
const TabErrorFallback = ({
tabName,
error,
componentStack,
onReset
}: FallbackProps) => {
const t = useT();
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-center gap-2">
<AlertTriangle size={22} className="text-red" />
<h4 className="text-red">{t('misc.tabCrashed', { tab: tabName })}</h4>
</div>
<hr />
<p className="text-white">
{error.name}: {error.message}
</p>
{componentStack && (
<pre className="s1 max-h-[200px] overflow-auto whitespace-pre-wrap text-blueGray">
{componentStack.trim()}
</pre>
)}
<hr />
<TextButton
icon={RefreshCw}
onClick={onReset}
className="self-end text-warmGreen"
>
{t('misc.tryAgain')}
</TextButton>
</div>
);
};
class TabErrorBoundary extends Component<Props, State> {
state: State = {};
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
log.error(`Tab "${this.props.tabName}" crashed:`, error, info);
this.setState({ error, componentStack: info.componentStack ?? undefined });
}
componentDidUpdate(prevProps: Props) {
if (prevProps.tabName !== this.props.tabName) {
this.setState({ error: undefined, componentStack: undefined });
}
}
#reset = () => this.setState({ error: undefined, componentStack: undefined });
render() {
if (!this.state.error) return this.props.children;
const { error, componentStack } = this.state;
return (
<TabErrorFallback
tabName={this.props.tabName}
error={error}
componentStack={componentStack}
onReset={this.#reset}
/>
);
}
}
export default TabErrorBoundary;
+30
View File
@@ -0,0 +1,30 @@
import AddonsTab from './tabs/AddonsTab';
import ModsTab from './tabs/ModsTab';
import NewsTab from './tabs/NewsTab';
import TweaksTab from './tabs/TweaksTab';
import TabErrorBoundary from './TabErrorBoundary';
const Tabs = {
news: NewsTab,
tweaks: TweaksTab,
addons: AddonsTab,
mods: ModsTab
} as const;
export const TabNames = Object.keys(Tabs) as TabType[];
export type TabType = keyof typeof Tabs;
type Props = { activeTab?: TabType };
const TabsPanel = ({ activeTab }: Props) => {
const tab: TabType = activeTab ?? 'news';
const Component = Tabs[tab];
return (
<TabErrorBoundary key={tab} tabName={tab}>
<Component />
</TabErrorBoundary>
);
};
export default TabsPanel;
+83
View File
@@ -0,0 +1,83 @@
import { Settings, Minus, X } from 'lucide-react';
import { useState } from 'react';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import DialogButton from './styled/DialogButton';
import PreferencesDialog from './PreferencesDialog';
import TextButton from './styled/TextButton';
import LanguageDropdown from './LanguageDropdown';
const TopBar = () => {
const t = useT();
const [safeToQuit, setSafeToQuit] = useState(true);
api.updater.observe.useSubscription(undefined, {
onData: ({ state }) =>
setSafeToQuit(state !== 'verifying' && state !== 'updating')
});
const minimize = api.general.minimize.useMutation();
const quit = api.general.quit.useMutation();
return (
<div
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
className="absolute left-0 right-0 top-0 flex justify-end pr-2 pt-2 opacity-50"
>
<div
style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
className="flex items-center"
>
<LanguageDropdown />
<DialogButton dialog={close => <PreferencesDialog close={close} />}>
{open => (
<TextButton
icon={Settings}
title={t('topbar.settings')}
onClick={open}
size={16}
className="!p-1"
/>
)}
</DialogButton>
<TextButton
icon={Minus}
title={t('topbar.minimize')}
onClick={() => minimize.mutateAsync()}
size={16}
className="!p-1"
/>
<DialogButton
dialog={close => (
<div className="tw-dialog">
<h3 className="tw-color">{t('quit.title')}</h3>
<hr />
<p className="text-blueGray">{t('quit.warn')}</p>
<div className="flex gap-2 self-end">
<TextButton onClick={close}>{t('quit.return')}</TextButton>
<TextButton
onClick={() => quit.mutateAsync()}
className="text-red"
>
{t('topbar.quit')}
</TextButton>
</div>
</div>
)}
>
{open => (
<TextButton
icon={X}
title={t('topbar.quit')}
onClick={() => (!safeToQuit ? open() : quit.mutateAsync())}
size={16}
className="!p-1 hocus:text-red"
/>
)}
</DialogButton>
</div>
</div>
);
};
export default TopBar;
@@ -0,0 +1,57 @@
import cls from 'classnames';
import { type ReactNode } from 'react';
import TextButton from '../styled/TextButton';
// mt centers this 16px box on the 26px label line box
const Checkbox = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="mt-[5px] shrink-0"
>
<rect
x="1"
y="1"
width="10"
height="10"
rx="1"
stroke="currentColor"
strokeWidth="1.5"
/>
<rect x="3.5" y="3.5" width="5" height="5" fill="white" />
</svg>
);
type Props = {
label?: ReactNode;
value: boolean;
setValue: (v: boolean) => void;
disabled?: boolean;
className?: cls.Value;
};
const CheckboxInput = ({
label,
value,
setValue,
disabled,
className
}: Props) => (
<TextButton
onClick={() => !disabled && setValue(!value)}
icon={Checkbox}
className={cls(
'!items-start text-left text-blueGray',
{ '[&_*]:fill-none': !value, 'pointer-events-none opacity-40': disabled },
className
)}
>
{label}
</TextButton>
);
export default CheckboxInput;
@@ -0,0 +1,47 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
import { AppWindow, FolderOpen } from 'lucide-react';
import { api, type RouterInputs } from '~renderer/utils/api';
import TextButton from '../styled/TextButton';
type Props = HTMLProps<HTMLInputElement> & {
setValue: (newVal: string) => void;
options: RouterInputs['general']['filePicker'];
};
const FilePickerInput = forwardRef<HTMLInputElement, Props>(
({ setValue, options, className, ...props }, ref) => {
const filePicker = api.general.filePicker.useMutation();
return (
<div className="relative flex grow">
<input
ref={ref}
id={props.name}
{...props}
className={cls(
'grow border-b border-blueGray bg-inherit p-1 pr-[44px] hocus:border-orange',
className
)}
/>
<TextButton
className="absolute right-1 top-0 h-full"
icon={
options.properties?.includes('openDirectory')
? FolderOpen
: AppWindow
}
title="Pick file"
onClick={async () => {
const r = await filePicker.mutateAsync(options);
if (r.canceled) return;
setValue(r.path[0]);
}}
/>
</div>
);
}
);
export default FilePickerInput;
@@ -0,0 +1,59 @@
import cls from 'classnames';
import { type ChangeEvent, type FocusEvent, type HTMLProps, forwardRef } from 'react';
type Props = Omit<
HTMLProps<HTMLInputElement>,
'value' | 'min' | 'max' | 'step'
> & {
setValue: (v: number) => void;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
};
const NumberGrabInput = forwardRef<HTMLInputElement, Props>(
(
{
setValue,
className,
max = Infinity,
min = -Infinity,
step: _step,
sensitivity: _sensitivity,
type: _ignored,
onChange,
onBlur,
...props
},
ref
) => (
<input
ref={ref}
type="text"
inputMode="numeric"
{...props}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
if (Number.isFinite(n) && n > max) {
e.currentTarget.value = String(max);
}
onChange?.(e);
}}
onBlur={(e: FocusEvent<HTMLInputElement>) => {
const n = Number(e.currentTarget.value);
const clamped = Math.max(
Math.min(Number.isFinite(n) ? n : min, max),
min
);
setValue(clamped);
onBlur?.(e);
}}
onWheel={e => !e.shiftKey && e.currentTarget.blur()}
className={cls(
className,
'w-[70px] cursor-text border-b border-blueGray bg-inherit p-1 text-center hocus:border-orange'
)}
/>
)
);
export default NumberGrabInput;
@@ -0,0 +1,44 @@
import cls from 'classnames';
import TextButton from '../styled/TextButton';
const Radio = () => (
<svg
width={16}
height={16}
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="6" cy="6" r="5.5" stroke="currentColor" />
<path
d="M8 6C8 7.10429 7.10429 8 6 8C4.89571 8 4 7.10429 4 6C4 4.8957 4.89571 4 6 4C7.10429 4 8 4.8957 8 6Z"
fill="white"
/>
</svg>
);
type Props<T> = {
value: T;
setValue: (val: T) => void;
options: { label: string; value: T }[];
};
const RadioInput = <const T,>({ value, setValue, options }: Props<T>) => (
<div className="flex justify-start">
{options.map(o => (
<TextButton
key={`${o.value}`}
onClick={() => setValue(o.value)}
icon={Radio}
className={cls('text-blueGray', {
'[&_*]:fill-none': value !== o.value
})}
>
{o.label}
</TextButton>
))}
</div>
);
export default RadioInput;
@@ -0,0 +1,17 @@
import cls from 'classnames';
import { forwardRef, type HTMLProps } from 'react';
const TextInput = forwardRef<HTMLInputElement, HTMLProps<HTMLInputElement>>(
(props, ref) => (
<input
ref={ref}
{...props}
className={cls(
'cursor-text border-b border-blueGray bg-inherit p-1 hocus:border-orange',
props.className
)}
/>
)
);
export default TextInput;
+44
View File
@@ -0,0 +1,44 @@
import type { ButtonHTMLAttributes } from 'react';
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import IconSpinner from './IconSpinner';
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
primary?: boolean;
loading?: boolean;
disabled?: boolean;
icon?: LucideIcon;
};
const Button = ({
primary,
loading,
disabled,
icon: Icon,
children,
className,
...props
}: Props) => (
<button
{...props}
onClick={props.onClick}
tabIndex={!!loading || !!disabled ? -1 : props.tabIndex}
className={cls('tw-button', className, {
'pointer-events-none': !!disabled || !!loading,
'grayscale': disabled,
'tw-button-primary': primary
})}
>
<span className={cls('select-none', { 'ml-[-12px]': !!loading || !!Icon })}>
{loading ? (
<IconSpinner size={23} strokeWidth={1.5} />
) : Icon ? (
<Icon size={23} strokeWidth={1.5} />
) : null}
{children}
</span>
</button>
);
export default Button;
@@ -0,0 +1,14 @@
import { X } from 'lucide-react';
import TextButton from './TextButton';
const CloseButton = ({ close }: { close: () => void }) => (
<TextButton
title="Close"
icon={X}
size={16}
onClick={close}
className="absolute right-1 top-1 text-blueGray hocus:text-red"
/>
);
export default CloseButton;
@@ -0,0 +1,72 @@
type Run = { text: string; color?: string };
// Keep the WoW "|c" color runs, strip every other "|" escape (textures, links, pipes).
const ESCAPE_RE =
/\|\||\|c([0-9a-f]{8})|\|r|\|T[^|]*\|t|\|H[^|]*\|h|\|h|\|./gi;
const tokenize = (s: string): Run[] => {
const runs: Run[] = [];
let color: string | undefined;
let buf = '';
let i = 0;
const flush = () => {
if (buf) runs.push({ text: buf, color });
buf = '';
};
let m: RegExpExecArray | null;
while ((m = ESCAPE_RE.exec(s)) !== null) {
buf += s.slice(i, m.index);
i = ESCAPE_RE.lastIndex;
const tok = m[0];
if (tok === '||') {
buf += '|';
} else if (m[1]) {
// drop the leading alpha byte, keep RGB
flush();
color = `#${m[1].slice(2).toLowerCase()}`;
} else if (tok.toLowerCase() === '|r') {
flush();
color = undefined;
}
}
buf += s.slice(i);
flush();
return runs.filter(r => r.text.length > 0);
};
export const stripColorCodes = (s: string) =>
tokenize(s)
.map(r => r.text)
.join('');
export const ColoredText = ({
children,
className,
style
}: {
children: string;
className?: string;
style?: React.CSSProperties;
}) => {
const runs = tokenize(children);
return (
<p className={className} style={style}>
{runs.map((r, i) =>
r.color ? (
<span
key={i}
className="text-size-inherit text-inherit"
style={{ color: r.color }}
>
{r.text}
</span>
) : (
<span key={i}>{r.text}</span>
)
)}
</p>
);
};
@@ -0,0 +1,79 @@
import cls from 'classnames';
import {
useRef,
type ReactElement,
useEffect,
useCallback,
type FC,
isValidElement
} from 'react';
import { createPortal } from 'react-dom';
type Props = {
clickAway?: boolean;
noBlur?: boolean;
focusOnOpen?: boolean;
afterClose?: () => void;
dialog: ReactElement | ((close: () => void) => ReactElement);
children: ReactElement | ((open: () => void) => ReactElement);
};
const DialogButton = ({
clickAway,
noBlur,
focusOnOpen,
afterClose,
dialog,
children
}: Props) => {
const ref = useRef<HTMLDialogElement>(null);
const open = useCallback(() => {
if (!ref.current) return;
!focusOnOpen && (ref.current.inert = true);
ref.current.showModal();
!focusOnOpen && (ref.current.inert = false);
}, [focusOnOpen]);
const close = useCallback(() => {
ref.current?.close();
}, []);
useEffect(() => {
if (!clickAway) return;
const callback = (e: MouseEvent) => e.target === ref.current && close();
window.addEventListener('click', callback);
return () => window.removeEventListener('click', callback);
}, [clickAway, close]);
useEffect(() => {
const callback = () => {
afterClose?.();
return (document.activeElement as HTMLElement)?.blur();
};
const r = ref.current;
r?.addEventListener('close', callback);
return () => r?.removeEventListener('close', callback);
}, [afterClose]);
return (
<>
{createPortal(
<dialog
ref={ref}
onSubmit={e => e.stopPropagation()}
className={cls(
'h-full w-full items-center justify-center bg-[transparent] [&[open]]:flex',
{ 'backdrop:backdrop-blur-md': !noBlur }
)}
>
{typeof dialog === 'function' ? dialog(close) : dialog}
</dialog>,
document.body
)}
{typeof children === 'function' ? children(open) : children}
</>
);
};
export default DialogButton;
@@ -0,0 +1,8 @@
import cls from 'classnames';
import { Loader2, type LucideProps } from 'lucide-react';
const IconSpinner = ({ className, ...props }: LucideProps) => (
<Loader2 {...props} className={cls(className, 'animate-spin')} />
);
export default IconSpinner;
@@ -0,0 +1,66 @@
import cls from 'classnames';
import { type LucideIcon } from 'lucide-react';
import { type ReactNode } from 'react';
import IconSpinner from './IconSpinner';
type Props = {
active?: boolean;
loading?: boolean;
disabled?: boolean;
size?: number;
className?: cls.Value;
style?: React.CSSProperties;
} & (
| { type: 'submit'; onClick?: never }
| { type?: never; onClick: () => void }
) &
(
| { children: ReactNode; icon?: LucideIcon; title?: never }
| { children?: never; icon: LucideIcon; title: string }
);
const TextButton = ({
title,
type,
active,
loading,
disabled,
icon: Icon,
size,
onClick,
className,
children,
...props
}: Props) => (
<button
title={title ?? (typeof children === 'string' ? children : undefined)}
type={type ?? 'button'}
onClick={onClick}
tabIndex={!!loading || !!disabled ? -1 : undefined}
className={cls(
'flex cursor-pointer items-center gap-2 border-0 p-2',
className,
{
'tw-color drop-shadow-[0px_0px_10px_white]':
active && !loading && !disabled,
'pointer-events-none text-gray': !!loading || !!disabled,
'tw-hocus': !loading && !disabled
}
)}
{...props}
>
{loading ? (
<IconSpinner size={size ?? 24} strokeWidth={1.5} />
) : (
Icon && <Icon size={size} className="shrink-0" />
)}
{children && (
<span className="cursor-pointer select-none tracking-wide text-inherit [font-size:_inherit]">
{children}
</span>
)}
</button>
);
export default TextButton;
+155
View File
@@ -0,0 +1,155 @@
import { useState } from 'react';
import { Plus, RefreshCw, Search } from 'lucide-react';
import { type AddonData, type AddonsStatus } from '~main/types';
import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import DialogButton from '../styled/DialogButton';
import IconSpinner from '../styled/IconSpinner';
import AddonList from './addons/AddonList';
import { type Dependencies } from './addons/AddonListItem';
import CustomAddonDialog from './addons/CustomAddonDialog';
const RECOMMENDED = new Set([
'AtlasLoot',
'pfExtend',
'pfQuest',
'pfQuest-turtle',
'SellValue',
'ShaguTweaks',
'ShaguTweaks-extras',
'TurtleMail'
]);
const localeFilter = (l: AddonData[], filter: string) => {
const seen = new Set<string>();
const deduped = l.filter(a => {
if (seen.has(a.folder)) return false;
seen.add(a.folder);
return true;
});
return deduped
.filter(a =>
a.folder.toLocaleLowerCase().includes(filter.toLocaleLowerCase())
)
.sort((a, b) => a.folder.localeCompare(b.folder));
};
const AddonsTab = () => {
const t = useT();
const [data, setData] = useState<AddonsStatus>({
state: 'verifying',
addons: {},
available: []
});
api.addons.observe.useSubscription(undefined, { onData: setData });
const isUpdateAvailable = Object.values(data.addons).some(
a => a.status === 'outOfDate' || a.status === 'downloading'
);
const dependencies: Dependencies = Object.fromEntries([
...data.available.map(a => [a.folder, 'available']),
...Object.values(data.addons).map(a => [
a.folder,
a.progress ?? (a.status === 'upToDate' ? 'installed' : 'available')
])
]);
const [filter, setFilter] = useState('');
const verify = api.addons.verify.useMutation();
const update = api.addons.update.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<div className="tw-surface relative flex min-h-0 flex-grow flex-col gap-3">
<div
ref={scrollRef}
className="relative -m-4 -mb-3 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pb-3"
>
<AddonList
title={t('addons.sectionInstalled')}
addons={localeFilter(Object.values(data.addons), filter)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionRecommended')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
<AddonList
title={t('addons.sectionAvailable')}
addons={localeFilter(
data.available.filter(
a => !(a.folder in data.addons) && !RECOMMENDED.has(a.folder)
),
filter
)}
dependencies={dependencies}
/>
</div>
<hr />
<div className="-mb-4 -mt-3 grid grid-cols-[1fr_1fr_1fr] items-center justify-between gap-2 py-2">
<TextButton
onClick={() => verify.mutateAsync()}
className="-ml-2 text-blueGray"
icon={RefreshCw}
size={18}
loading={data.state !== 'done'}
>
{t('addons.checkForUpdates')}
</TextButton>
<DialogButton
clickAway
dialog={close => <CustomAddonDialog close={close} />}
>
{open => (
<TextButton
icon={Plus}
size={18}
onClick={open}
className="s1 text-pink"
>
{t('addons.addCustomGitAddon')}
</TextButton>
)}
</DialogButton>
{data.state === 'verifying' ? (
<IconSpinner size={18} className="justify-self-end" />
) : isUpdateAvailable ? (
<TextButton
onClick={() => update.mutateAsync({})}
className="justify-self-end text-warmGreen"
>
{t('addons.updateAll')}
</TextButton>
) : (
<p className="s1 justify-self-end text-blueGray">
{t('addons.everythingUpToDate')}
</p>
)}
</div>
<div className="absolute right-3 top-3">
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
<input
className="cursor-text bg-inherit"
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<Search size={18} />
</div>
</div>
</div>
);
};
export default AddonsTab;
@@ -0,0 +1,12 @@
import { useT } from '~renderer/i18n';
const ComingSoonTab = () => {
const t = useT();
return (
<div className="tw-surface flex flex-grow flex-col items-center justify-center gap-2">
<p className="italic text-blueGray">{t('misc.comingSoon')}</p>
</div>
);
};
export default ComingSoonTab;
+304
View File
@@ -0,0 +1,304 @@
import { useEffect, useRef, useState } from 'react';
import { ExternalLink, AlertTriangle, Sparkles } from 'lucide-react';
import { createPortal } from 'react-dom';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import {
type ModRowStatus,
type ModsStatus,
type CustomMod
} from '~main/types';
import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput';
import IconSpinner from '../styled/IconSpinner';
const RowState = ({ row }: { row: ModRowStatus }) => {
const t = useT();
if (['downloading', 'installing', 'uninstalling'].includes(row.state))
return <IconSpinner className="text-blueGray" />;
if (row.state === 'error')
return (
<span title={row.error}>
<AlertTriangle size={14} className="text-red" />
</span>
);
if (
row.installedVersion &&
row.installedVersion !== row.latestVersion &&
!row.ignoreUpdates
)
return <span className="s1 text-pink">{t('mods.update')}</span>;
return null;
};
const ModRow = ({ row }: { row: ModRowStatus }) => {
const t = useT();
const toggle = api.mods.toggle.useMutation();
const setIgnore = api.mods.setIgnoreUpdates.useMutation();
const openLink = api.general.openLink.useMutation();
return (
<>
<div className="flex items-baseline gap-2">
{row.recommended && (
<Sparkles size={12} className="shrink-0 text-warmGreen" />
)}
<span className={cls(row.recommended && 'text-warmGreen')}>
{row.name}
</span>
<span className="s1 text-warmGreen">{row.latestVersion}</span>
</div>
<CheckboxInput
value={row.enabled}
setValue={v => toggle.mutate({ id: row.id, enabled: v })}
className="justify-self-center"
/>
<div className="flex items-center gap-2">
<p className="s1 text-blueGray">{row.description}</p>
<TextButton
icon={ExternalLink}
size={14}
title={row.repoUrl}
onClick={() => openLink.mutateAsync(row.repoUrl)}
className="!p-0 text-blueGray"
/>
<RowState row={row} />
</div>
<CheckboxInput
value={row.ignoreUpdates}
setValue={v => setIgnore.mutate({ id: row.id, ignore: v })}
label={<span className="s1">{t('mods.ignoreUpdates')}</span>}
/>
</>
);
};
const CustomRow = ({ row }: { row: CustomMod }) => {
const toggle = api.mods.toggleCustom.useMutation();
return (
<>
<span className="break-all">{row.name}</span>
<CheckboxInput
value={row.enabled}
setValue={v => toggle.mutate({ name: row.name, enabled: v })}
className="justify-self-center"
/>
</>
);
};
const AddDllButton = () => {
const t = useT();
const pick = api.general.filePicker.useMutation();
const add = api.mods.addCustomDll.useMutation();
const [error, setError] = useState<string | null>(null);
const onClick = async () => {
setError(null);
const res = await pick.mutateAsync({
title: t('mods.addDllTitle'),
filters: [{ name: 'DLL', extensions: ['dll'] }],
properties: ['openFile']
});
if (res.canceled) return;
const result = await add.mutateAsync({ path: res.path[0] });
if (!result.ok) setError(result.error ?? t('mods.addDllFailed'));
};
return (
<div className="flex items-center gap-2">
{error && <span className="s1 text-orange">{error}</span>}
<TextButton
onClick={onClick}
loading={pick.isLoading || add.isLoading}
className="text-green"
>
{t('mods.addDll')}
</TextButton>
</div>
);
};
const ModsTab = () => {
const t = useT();
const [status, setStatus] = useState<ModsStatus>();
api.mods.observe.useSubscription(undefined, {
onData: setStatus
});
const list = api.mods.list.useQuery(undefined, {
refetchOnMount: true
});
useEffect(() => {
if (!status && list.data) setStatus(list.data);
}, [list.data, status]);
const apply = api.mods.applyAll.useMutation();
const resync = api.updater.update.useMutation();
const revalidate = api.mods.verify.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>();
const mods = status?.mods ?? [];
const enabledIds = new Set(mods.filter(m => m.enabled).map(m => m.id));
const modName = (id: string) => mods.find(m => m.id === id)?.name ?? id;
const missingDeps = [
...new Set(
mods
.filter(m => m.enabled)
.flatMap(m => m.requires.filter(d => !enabledIds.has(d)))
)
];
const pendingDepMessage = missingDeps
.map(dep => {
const requiredBy = mods
.filter(m => m.enabled && m.requires.includes(dep))
.map(m => m.name)
.join(', ');
return t('mods.depRequired', { mod: modName(dep), requiredBy });
})
.join('\n');
const dialogRef = useRef<HTMLDialogElement>(null);
const [shownDepMessage, setShownDepMessage] = useState<string | null>(null);
useEffect(() => {
if (shownDepMessage) {
if (!dialogRef.current?.open) dialogRef.current?.showModal();
(document.activeElement as HTMLElement | null)?.blur();
} else dialogRef.current?.close();
}, [shownDepMessage]);
const [applied, setApplied] = useState(false);
const appliedTimer = useRef<number>();
useEffect(() => () => window.clearTimeout(appliedTimer.current), []);
const onApply = async () => {
if (missingDeps.length) {
setShownDepMessage(pendingDepMessage);
return;
}
setApplied(false);
await apply.mutateAsync();
setApplied(true);
window.clearTimeout(appliedTimer.current);
appliedTimer.current = window.setTimeout(() => setApplied(false), 2500);
};
const showApply =
!!status?.dirty || apply.isLoading || status?.state === 'busy';
return (
<div className="tw-surface flex min-h-0 flex-grow flex-col gap-3">
<div className="flex items-baseline justify-between">
<h4 className="tw-color">{t('mods.title')}</h4>
{status?.dirty ? (
<span className="s1 text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="s1 text-warmGreen">{t('mods.applied')}</span>
) : null}
</div>
<p className="s1 text-blueGray">
<span className="text-orange">⚠</span> {t('mods.warning')}
</p>
{missingDeps.length > 0 && (
<p className="s1 text-orange">
⚠{' '}
{t('mods.enableRequired', {
mods: missingDeps.map(modName).join(', ')
})}
</p>
)}
{!!status?.missingFiles?.length && (
<div className="s1 flex flex-col items-start gap-1 text-orange">
<span>
⚠ {t('mods.missingFiles', { mods: status.missingFiles.join(', ') })}
</span>
<TextButton
onClick={async () => {
await resync.mutateAsync();
await revalidate.mutateAsync();
}}
loading={resync.isLoading || revalidate.isLoading}
className="text-warmGreen"
>
{t('mods.reverify')}
</TextButton>
</div>
)}
<hr />
<div
ref={scrollRef}
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto p-4 pt-0"
>
<div className="grid grid-cols-[auto_auto_1fr_auto] content-start items-center gap-x-4 gap-y-2">
{status?.mods.map(row => (
<ModRow key={row.id} row={row} />
))}
</div>
<hr />
<div className="flex items-baseline justify-between gap-2">
<h4 className="tw-color">{t('mods.yourDlls')}</h4>
<AddDllButton />
</div>
{status?.custom?.length ? (
<div className="grid grid-cols-[1fr_auto] items-center gap-x-4 gap-y-1">
{status.custom.map(c => (
<CustomRow key={c.name} row={c} />
))}
</div>
) : (
<p className="s1 text-blueGray">{t('mods.yourDllsEmpty')}</p>
)}
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
{status?.dirty ? (
<span className="text-pink">{t('mods.unsavedChanges')}</span>
) : applied ? (
<span className="text-warmGreen">{t('mods.applied')}</span>
) : (
<>
<span className="text-warmGreen">{t('mods.highlighted')}</span>{' '}
{t('mods.highlightedRecommended')}
</>
)}
</p>
<TextButton
loading={apply.isLoading || status?.state === 'busy'}
onClick={onApply}
className={cls('text-green', !showApply && 'invisible')}
>
{t('mods.apply')}
</TextButton>
</div>
{createPortal(
<dialog
ref={dialogRef}
onClose={() => setShownDepMessage(null)}
className="h-full w-full items-center justify-center bg-[transparent] backdrop:backdrop-blur-sm [&[open]]:flex"
>
{shownDepMessage && (
<div className="tw-dialog !w-fit min-w-[360px] max-w-[460px] !gap-3">
<h3 className="tw-color">{t('mods.cantApplyYet')}</h3>
<p className="s1 whitespace-pre-line">{shownDepMessage}</p>
<TextButton
onClick={() => setShownDepMessage(null)}
className="self-end text-green"
>
{t('mods.close')}
</TextButton>
</div>
)}
</dialog>,
document.body
)}
</div>
);
};
export default ModsTab;
+122
View File
@@ -0,0 +1,122 @@
import { AlertTriangle, ExternalLink, RefreshCw } from 'lucide-react';
import { type NewsItem } from '~main/types';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '../styled/IconSpinner';
import TextButton from '../styled/TextButton';
const formatDate = (raw: string) => {
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return raw;
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const NewsEntry = ({ item }: { item: NewsItem }) => {
const t = useT();
const openLink = api.general.openLink.useMutation();
return (
<article className="flex flex-col gap-1 border-b border-blueGray/30 pb-3 last:border-0">
<div className="flex items-baseline justify-between gap-3">
<h5 className="tw-color">{item.title}</h5>
<span className="s1 shrink-0 text-blueGray">
{formatDate(item.date)}
</span>
</div>
{item.author && (
<span className="s1 italic text-blueGray">
{t('misc.newsByAuthor', { author: item.author })}
</span>
)}
<p className="whitespace-pre-wrap text-sm leading-relaxed">{item.body}</p>
{item.url && (
<TextButton
icon={ExternalLink}
size={14}
className="-ml-2 self-start text-pink"
onClick={() => openLink.mutateAsync(item.url!)}
>
{t('misc.newsReadMore')}
</TextButton>
)}
</article>
);
};
const NewsColumn = ({ forum, title }: { forum: number; title: string }) => {
const t = useT();
const query = api.news.list.useQuery(
{ forum },
{
staleTime: 5 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1
}
);
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<div className="tw-surface flex min-h-0 flex-1 flex-col gap-3">
<div className="flex items-center justify-between">
<h4 className="tw-color">{title}</h4>
<TextButton
icon={RefreshCw}
size={18}
className="-mr-2 text-blueGray"
loading={query.isFetching}
onClick={() => query.refetch()}
title={t('misc.refresh')}
/>
</div>
<hr />
<div
ref={scrollRef}
className="relative -m-4 -mt-0 flex flex-grow flex-col gap-3 overflow-y-auto overflow-x-hidden p-4 pt-0"
>
{query.isLoading ? (
<div className="flex flex-grow flex-col items-center justify-center gap-2">
<IconSpinner className="text-blueGray" />
<p className="italic text-blueGray">{t('misc.newsLoading')}</p>
</div>
) : query.isError ? (
<div className="flex flex-grow flex-col items-center justify-center gap-3">
<AlertTriangle size={32} className="text-red" />
<p className="italic text-blueGray">{t('misc.newsError')}</p>
<TextButton
icon={RefreshCw}
size={18}
className="text-pink"
onClick={() => query.refetch()}
>
{t('misc.tryAgain')}
</TextButton>
</div>
) : !query.data?.length ? (
<div className="flex flex-grow flex-col items-center justify-center">
<p className="italic text-blueGray">{t('misc.newsEmpty')}</p>
</div>
) : (
query.data.map(item => <NewsEntry key={item.id} item={item} />)
)}
</div>
</div>
);
};
const NewsTab = () => {
const t = useT();
return (
<div className="flex min-h-0 flex-grow gap-3">
<NewsColumn forum={2} title={t('misc.announcementsTitle')} />
<NewsColumn forum={4} title={t('misc.patchNotesTitle')} />
</div>
);
};
export default NewsTab;
+248
View File
@@ -0,0 +1,248 @@
import { useForm, type UseFormReturn } from 'react-hook-form';
import { useEffect } from 'react';
import cls from 'classnames';
import { api } from '~renderer/utils/api';
import { ConfigWtfSchema } from '~common/schemas';
import zodResolver from '~renderer/utils/zodResolver';
import useScrollHint from '~renderer/utils/useScrollHint';
import { useT } from '~renderer/i18n';
import TextButton from '../styled/TextButton';
import CheckboxInput from '../form/CheckboxInput';
import NumberGrabInput from '../form/NumberGrabInput';
type ItemProps = {
type?: 'checkbox' | 'number';
id: keyof ConfigWtfSchema;
label: string;
recommended?: boolean;
text: string;
min?: number;
max?: number;
step?: number;
sensitivity?: number;
form: UseFormReturn<ConfigWtfSchema>;
};
const Item = ({
type = 'checkbox',
id,
label,
recommended,
text,
form,
...props
}: ItemProps) => {
const { watch, setValue, register } = form;
const setOpts = {
shouldTouch: true,
shouldDirty: true,
shouldValidate: true
} as const;
const watched = type === 'checkbox' ? watch(id) : undefined;
const registered = type === 'number' ? register(id) : undefined;
return (
<>
<p className={cls({ 'text-warmGreen': recommended })}>{label}</p>
{type === 'checkbox' && (
<CheckboxInput
value={!!watched}
setValue={v => setValue(id, v, setOpts)}
className="justify-self-center"
/>
)}
{type === 'number' && registered && (
<NumberGrabInput
{...registered}
{...props}
setValue={v => setValue(id, v, setOpts)}
/>
)}
<p className="s1 text-blueGray">{text}</p>
</>
);
};
const TweaksTab = () => {
const t = useT();
const { data: pref } = api.preferences.get.useQuery();
const setPref = api.preferences.set.useMutation();
const applyPatch = api.patcher.apply.useMutation();
const syncRaidVisuals = api.updater.syncRaidVisuals.useMutation();
const form = useForm<ConfigWtfSchema>({
defaultValues: pref?.config ?? {},
resolver: zodResolver(ConfigWtfSchema)
});
const { handleSubmit, reset, formState } = form;
const { data: hw } = api.general.hardware.useQuery();
const recommendedFarClip = hw?.recommendedFarClip;
const farClipValue = form.watch('farClip');
const farClipText =
t('tweaks.farClip.text') +
(recommendedFarClip != null
? ' ' + t('tweaks.farClip.recommendedHint', { value: recommendedFarClip })
: '');
const isApplying =
setPref.isLoading || applyPatch.isLoading || syncRaidVisuals.isLoading;
useEffect(() => {
pref && reset(pref.config);
}, [reset, pref]);
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<form
onSubmit={handleSubmit(async config => {
await setPref.mutateAsync({ config, farClipUserSet: true });
await applyPatch.mutateAsync();
await syncRaidVisuals.mutateAsync();
reset(config);
})}
className="tw-surface flex min-h-0 flex-grow flex-col gap-3"
>
<div
ref={scrollRef}
className="relative -m-4 -mb-3 grid flex-grow grid-cols-[auto_auto_1fr] content-start items-center gap-x-3 gap-y-1 overflow-y-auto p-4 pb-3"
>
<Item
form={form}
id="alwaysAutoLoot"
label={t('tweaks.alwaysAutoLoot.label')}
text={t('tweaks.alwaysAutoLoot.text')}
/>
<Item
form={form}
id="raidVisuals"
label={t('tweaks.raidVisuals.label')}
text={t('tweaks.raidVisuals.text')}
/>
<Item
form={form}
id="largeAddress"
label={t('tweaks.largeAddress.label')}
text={t('tweaks.largeAddress.text')}
recommended
/>
<Item
form={form}
type="number"
id="nameplateRange"
label={t('tweaks.nameplateRange.label')}
text={t('tweaks.nameplateRange.text')}
min={0}
max={41}
/>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.cameraHeading')}
</h4>
<Item
form={form}
id="fieldOfView"
label={t('tweaks.fieldOfView.label')}
type="number"
text={t('tweaks.fieldOfView.text')}
min={90}
max={180}
step={5}
/>
<Item
form={form}
id="farClip"
label={t('tweaks.farClip.label')}
type="number"
text={farClipText}
recommended={
recommendedFarClip != null &&
Number(farClipValue) === recommendedFarClip
}
min={100}
max={10000}
sensitivity={3}
/>
<Item
form={form}
id="frillDistance"
label={t('tweaks.frillDistance.label')}
type="number"
text={t('tweaks.frillDistance.text')}
min={0}
max={300}
sensitivity={0.3}
/>
<Item
form={form}
id="cameraDistance"
label={t('tweaks.cameraDistance.label')}
type="number"
text={t('tweaks.cameraDistance.text')}
min={50}
max={100}
/>
<h4 className="tw-color col-span-3 mt-3">
{t('tweaks.soundsHeading')}
</h4>
<Item
form={form}
id="soundInBackground"
label={t('tweaks.soundInBackground.label')}
text={t('tweaks.soundInBackground.text')}
recommended
/>
</div>
<hr />
<div className="-mb-4 -mt-3 flex items-center gap-2 py-2">
<p className="s1 flex-grow text-blueGray">
{applyPatch.isError ? (
<span className="text-orange">
{t('tweaks.applyFailed', {
message: applyPatch.error?.message ?? ''
})}
</span>
) : (
<>
<span className="s1 text-warmGreen">
{t('tweaks.highlighted')}
</span>{' '}
{t('tweaks.recommendedNote')}
</>
)}
</p>
<TextButton
onClick={async () => {
const config =
recommendedFarClip != null
? {
...ConfigWtfSchema.parse({}),
farClip: recommendedFarClip,
raidVisuals: form.getValues('raidVisuals')
}
: {
...ConfigWtfSchema.parse({}),
raidVisuals: form.getValues('raidVisuals')
};
await setPref.mutateAsync({ config, farClipUserSet: false });
reset(config);
}}
>
{t('tweaks.reset')}
</TextButton>
{(formState.isDirty || isApplying) && (
<TextButton type="submit" loading={isApplying} className="text-green">
{t('tweaks.apply')}
</TextButton>
)}
</div>
</form>
);
};
export default TweaksTab;
@@ -0,0 +1,142 @@
import { type ReactNode, type PropsWithChildren } from 'react';
import {
AlertOctagon,
ExternalLink,
X,
AlertTriangle,
Check,
Dot,
DownloadCloud
} from 'lucide-react';
import { type AddonData } from '~main/types';
import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton';
import { ColoredText } from '~renderer/components/styled/ColoredText';
import useScrollHint from '~renderer/utils/useScrollHint';
import IconSpinner from '~renderer/components/styled/IconSpinner';
import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import { type LocalDependencies } from './AddonListItem';
const AddonDetailItem = ({
name,
children
}: PropsWithChildren<{ name: string }>) =>
children ? (
<div className="s1 pl-4 -indent-4 text-blueGray">
{name}:{' '}
{typeof children === 'string' ? (
<ColoredText className="inline">{children}</ColoredText>
) : (
children
)}
</div>
) : null;
type Props = AddonData & {
close: () => void;
warnings: { full: ReactNode; short: ReactNode }[];
dependencies: LocalDependencies;
};
const AddonDetail = ({ close, warnings, dependencies, ...addon }: Props) => {
const t = useT();
const openLink = api.general.openLink.useMutation();
const update = api.addons.update.useMutation();
const scrollRef = useScrollHint<HTMLDivElement>();
return (
<div
ref={scrollRef}
className="tw-surface flex max-h-[calc(100vh_-_256px)] w-full max-w-md flex-col gap-3 overflow-y-auto"
>
<CloseButton close={close} />
<ColoredText className="text-2xl">
{addon.toc?.Title ?? addon.folder}
</ColoredText>
<hr />
{addon.error && (
<p className="s1 text-red">
<AlertOctagon size={14} className="inline text-inherit" />{' '}
{addon.error}
</p>
)}
{warnings.map((w, i) => (
<p key={i} className="s1 text-yellow">
<AlertTriangle size={14} className="inline text-inherit" /> {w.full}
</p>
))}
{(addon.toc?.Notes || addon.description) && (
<ColoredText>{addon.toc?.Notes ?? addon.description ?? ''}</ColoredText>
)}
<div>
<AddonDetailItem name={t('addons.detailSource')}>
{addon.git && (
<TextButton
onClick={() => openLink.mutateAsync(addon.git)}
className="s1 -m-2 !inline"
>
{t('addons.openOnGithubShort')}
<ExternalLink size={12} className="ml-1 inline" />
</TextButton>
)}
</AddonDetailItem>
{addon.toc && (
<>
<AddonDetailItem name={t('addons.detailContributions')}>
{addon.toc.Author}
</AddonDetailItem>
<AddonDetailItem name={t('addons.detailAddonVersion')}>
{addon.toc.Version}
</AddonDetailItem>
<AddonDetailItem name={t('addons.detailDependencies')}>
{!!dependencies.length && (
<ul className="pl-2">
{dependencies.map(({ name, optional, status }) => (
<li key={name}>
{status === 'installed' ? (
<Check size={16} className="inline text-darkGreen" />
) : status === 'available' ? (
<TextButton
title={t('addons.download')}
icon={DownloadCloud}
size={16}
onClick={() =>
update.mutateAsync({ toUpdate: [name] })
}
className="-m-2 !inline text-warmGreen"
/>
) : status === 'missing' ? (
optional ? (
<Dot size={16} className="inline text-blueGray" />
) : (
<X size={16} className="inline text-red" />
)
) : (
<IconSpinner size={16} className="inline" />
)}
<p className="inline"> {name} </p>
{!['installed', 'available', 'missing'].includes(
status
) ? (
<p className="s1 inline text-blueGray">{status}</p>
) : optional ? (
<p className="s1 inline text-blueGray">
{t('addons.optional')}
</p>
) : null}
</li>
))}
</ul>
)}
</AddonDetailItem>
</>
)}
</div>
</div>
);
};
export default AddonDetail;
@@ -0,0 +1,37 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import log from 'electron-log/renderer';
type Props = { children: ReactNode; folder: string; row: number };
type State = { hasError: boolean; message?: string };
class AddonItemErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, message: error.message };
}
componentDidCatch(error: Error, info: ErrorInfo) {
log.error(
`[AddonListItem] crash row=${this.props.row} folder=${this.props.folder}:`,
error,
info
);
}
render() {
if (!this.state.hasError) return this.props.children;
return (
<div
className="contents"
style={{ gridRow: this.props.row + 1 }}
>
<div style={{ gridRow: this.props.row + 1, gridColumn: '1/5' }} className="-mx-4 px-4 py-1 text-red s1">
Failed to render &quot;{this.props.folder}&quot;: {this.state.message ?? 'unknown error'}
</div>
</div>
);
}
}
export default AddonItemErrorBoundary;
@@ -0,0 +1,56 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import cls from 'classnames';
import { type AddonData } from '~main/types';
import AddonListItem, { type Dependencies } from './AddonListItem';
import AddonItemErrorBoundary from './AddonItemErrorBoundary';
type Props = {
title: string;
addons: AddonData[];
dependencies: Dependencies;
};
const AddonList = ({ title, addons, dependencies }: Props) => {
const [open, setOpen] = useState(true);
if (!addons.length) return null;
return (
<div>
<button
type="button"
onClick={() => setOpen(o => !o)}
className="mb-2 flex cursor-pointer items-center gap-1 border-0 bg-transparent p-0"
>
{open ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
<h4 className="tw-color">{title}</h4>
</button>
<div
className={cls(
'grid grid-cols-[auto_auto_1fr_auto] items-center gap-x-3 gap-y-1',
!open && 'hidden'
)}
>
{addons.map((addon, i) => {
const { ref: gitRef, ...rest } = addon;
return (
<AddonItemErrorBoundary
key={`${addon.folder}#${i}`}
folder={addon.folder}
row={i}
>
<AddonListItem
row={i}
dependencies={dependencies}
gitRef={gitRef}
{...rest}
/>
</AddonItemErrorBoundary>
);
})}
</div>
</div>
);
};
export default AddonList;
@@ -0,0 +1,237 @@
import {
AlertOctagon,
AlertTriangle,
DownloadCloud,
Github,
HelpCircle,
Trash2
} from 'lucide-react';
import cls from 'classnames';
import { type AddonData } from '~main/types';
import { api } from '~renderer/utils/api';
import TextButton from '~renderer/components/styled/TextButton';
import { ColoredText } from '~renderer/components/styled/ColoredText';
import IconSpinner from '~renderer/components/styled/IconSpinner';
import DialogButton from '~renderer/components/styled/DialogButton';
import { isNotUndef } from '~common/utils';
import CloseButton from '~renderer/components/styled/CloseButton';
import { useT } from '~renderer/i18n';
import AddonDetail from './AddonDetail';
export type Dependencies = {
[folder: string]: 'installed' | 'available' | string;
};
export type LocalDependencies = {
name: string;
optional: boolean;
status: 'installed' | 'available' | 'missing' | string;
}[];
type Props = Omit<AddonData, 'ref'> & {
row: number;
dependencies: Dependencies;
gitRef?: string;
};
const toRepoUrl = (git?: string) =>
git ? git.replace(/\.git$/, '') : undefined;
const AddonListItem = ({ row, dependencies, ...addon }: Props) => {
const t = useT();
const update = api.addons.update.useMutation();
const remove = api.addons.remove.useMutation();
const openLink = api.general.openLink.useMutation();
const repoUrl = toRepoUrl(addon.git);
const localDependencies: LocalDependencies = [
...(addon.toc?.Dependencies?.split(', ')?.map(d => [d, false] as const) ??
[]),
...(addon.toc?.OptionalDeps?.split(', ')?.map(d => [d, true] as const) ??
[])
].map<LocalDependencies[number]>(([d, optional]) => ({
name: d,
optional,
status: dependencies[d] ?? 'missing'
}));
const warnings = [
addon.toc && addon.toc?.Interface !== '11200'
? {
full: t('addons.warnIncorrectVersionFull', {
version: addon.toc?.Interface ?? ''
}),
short: t('addons.warnIncorrectVersionShort')
}
: undefined,
localDependencies.some(d => d.status !== 'installed' && !d.optional)
? {
full: t('addons.warnMissingDependenciesFull', {
deps: localDependencies
.filter(d => d.status !== 'installed' && !d.optional)
.map(d => d.name)
.join(', ')
}),
short: t('addons.warnMissingDependenciesShort')
}
: undefined
].filter(isNotUndef);
return (
<div className="contents hover-row:bg-purple/30">
<div
className="-mx-4 h-full w-[200%]"
style={{ gridRow: row + 1, gridColumn: '1/4' }}
/>
{addon.status === 'fetching' ? (
<IconSpinner
className="text-blueGray"
size={18}
style={{ gridRow: row + 1, gridColumn: 1 }}
/>
) : (
<DialogButton
clickAway
dialog={close => (
<AddonDetail
close={close}
warnings={warnings}
dependencies={localDependencies}
{...addon}
/>
)}
>
{open => (
<TextButton
icon={
addon.status === 'invalid'
? AlertOctagon
: warnings.length
? AlertTriangle
: HelpCircle
}
onClick={open}
title={t('addons.details')}
size={18}
className={cls(
'-mx-2',
addon.status === 'invalid'
? 'text-red'
: warnings.length
? 'text-yellow'
: 'text-blueGray'
)}
style={{ gridRow: row + 1, gridColumn: 1 }}
/>
)}
</DialogButton>
)}
<div
className="-ml-2 flex items-center gap-1 whitespace-nowrap"
style={{ gridRow: row + 1, gridColumn: 2 }}
>
<ColoredText>{addon.toc?.Title ?? addon.folder}</ColoredText>
{repoUrl && (
<TextButton
icon={Github}
size={14}
title={t('addons.openOnGithub', { url: repoUrl })}
onClick={() => openLink.mutateAsync(repoUrl)}
className="!p-1 text-blueGray/60 hocus:text-pink"
/>
)}
</div>
<ColoredText
className="s1 py-1 text-blueGray"
style={{ gridRow: row + 1, gridColumn: 3 }}
>
{addon.toc?.Notes ?? addon.description ?? ''}
</ColoredText>
<div
className="-m-2 flex items-center justify-end gap-2"
style={{ gridRow: row + 1, gridColumn: 4 }}
>
{addon.status === 'downloading' ? (
<>
<p className="s1 text-blueGray">{addon.progress}</p>
<IconSpinner size={18} className="text-blueGray" />
</>
) : addon.status === 'invalid' ? (
<p className="s1 text-red">{addon.error}</p>
) : warnings.length ? (
<p className="s1 text-yellow">{warnings[0].short}</p>
) : (
<p className="s1 text-blueGray/50">
{addon.status === 'upToDate'
? t('addons.upToDate')
: !addon.git
? t('addons.notVersioned')
: ''}
</p>
)}
{addon.status === 'outOfDate' && (
<TextButton
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="s1 -mx-2 justify-self-end"
>
{t('addons.update')}
</TextButton>
)}
{addon.status === 'available' ? (
<TextButton
onClick={() => update.mutateAsync({ toUpdate: [addon.folder] })}
className="text-warmGreen"
icon={DownloadCloud}
size={18}
title={t('addons.download')}
/>
) : (
<DialogButton
clickAway
dialog={close => (
<div className="tw-dialog">
<CloseButton close={close} />
<h4 className="tw-color">{t('addons.deleteConfirmTitle')}</h4>
<hr />
<p className="text-blueGray">
{t('addons.deleteConfirmBody', { folder: addon.folder })}
</p>
<p className="text-blueGray">
{t('addons.deleteConfirmFiles')}
</p>
<TextButton
icon={Trash2}
onClick={async () => {
await remove.mutateAsync({ toDelete: [addon.folder] });
close();
}}
disabled={remove.isLoading}
className="self-end text-red"
>
{t('addons.delete')}
</TextButton>
</div>
)}
>
{open => (
<TextButton
onClick={open}
className="text-red/50"
icon={Trash2}
size={18}
title={t('addons.remove')}
/>
)}
</DialogButton>
)}
</div>
</div>
);
};
export default AddonListItem;
@@ -0,0 +1,82 @@
import { Check, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import CloseButton from '~renderer/components/styled/CloseButton';
import IconSpinner from '~renderer/components/styled/IconSpinner';
import TextButton from '~renderer/components/styled/TextButton';
import { api } from '~renderer/utils/api';
import { useT } from '~renderer/i18n';
const useDebounced = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timeout = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timeout);
}, [value, delay]);
return debouncedValue;
};
const CustomAddonDialog = ({ close }: { close: () => void }) => {
const t = useT();
const [url, setUrl] = useState('');
const debouncedUrl = useDebounced(url, 500);
const response = api.addons.checkGitUrl.useQuery(debouncedUrl, {
enabled: !!debouncedUrl
});
const update = api.addons.install.useMutation();
return (
<div className="tw-dialog">
<CloseButton close={close} />
<h3 className="tw-color">{t('addons.installAddon')}</h3>
<hr />
{response.data ? (
<img
src={response.data?.preview}
alt={t('addons.previewAlt')}
className="w-full"
/>
) : (
<div className="flex h-[191px] w-full items-center justify-center bg-darkPurple">
{response.isFetching && <IconSpinner />}
</div>
)}
<div className="flex items-center gap-1 border-b border-blueGray bg-darkGray/70 p-1 hocus:border-orange">
<input
className="w-full cursor-text bg-inherit"
value={url}
onChange={e => setUrl(e.target.value)}
/>
{response.isFetching ? (
<IconSpinner size={18} />
) : response.data ? (
<Check size={18} />
) : (
<X size={18} />
)}
</div>
<div className="flex items-center justify-end gap-2">
<p className="s1 text-blueGray">
{response.data
? t('addons.readyToInstall')
: t('addons.invalidGitUrl')}
</p>
<TextButton
onClick={() => {
if (!response.data) return;
update.mutateAsync(response.data);
close();
setUrl('');
}}
className={response.data ? 'text-warmGreen' : 'text-blueGray'}
disabled={!response.data || response.isLoading}
>
{t('addons.install')}
</TextButton>
</div>
</div>
);
};
export default CustomAddonDialog;
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly MAIN_VITE_SERVER_URL: string;
// forum base for the news feed; defaults to the live forum so PTR shows real posts
readonly MAIN_VITE_FORUM_URL: string;
readonly MAIN_VITE_CLIENT_VERSION: string;
// PTR realm/patch host; only set for PTR builds, live falls back to octowow.st.
readonly MAIN_VITE_PTR_REALMLIST: string;
// When set, sync the client from this web-seeded .torrent instead of the manifest.
readonly MAIN_VITE_CLIENT_TORRENT_URL: string;
// optional raid-visuals patch (patch-O.mpq); a ".sha256" sidecar drives change detection
readonly MAIN_VITE_RAID_VISUALS_URL: string;
// the content patch (patch-5.mpq), served outside the torrent; kept current by a ".sha256" sidecar
readonly MAIN_VITE_CLIENT_PATCH_URL: string;
}
+52
View File
@@ -0,0 +1,52 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode
} from 'react';
import { api } from '~renderer/utils/api';
import { translations, type Lang } from './translations';
type Params = Record<string, string | number>;
type Translate = (key: string, params?: Params) => string;
type LocaleCtx = {
lang: Lang;
setLang: (lang: Lang) => void;
t: Translate;
};
const LocaleContext = createContext<LocaleCtx>({
lang: 'enUS',
setLang: () => {},
t: key => key
});
export const LocaleProvider = ({ children }: { children: ReactNode }) => {
const { data: pref } = api.preferences.get.useQuery();
const [lang, setLang] = useState<Lang>('enUS');
useEffect(() => {
if (pref?.locale) setLang(pref.locale);
}, [pref?.locale]);
const t: Translate = (key, params) => {
let s = translations[lang]?.[key] ?? translations.enUS[key] ?? key;
if (params)
for (const [k, v] of Object.entries(params))
s = s.replace(`{${k}}`, String(v));
return s;
};
return (
<LocaleContext.Provider value={{ lang, setLang, t }}>
{children}
</LocaleContext.Provider>
);
};
export const useLocale = () => useContext(LocaleContext);
export const useT = () => useContext(LocaleContext).t;
File diff suppressed because it is too large Load Diff
+497
View File
@@ -0,0 +1,497 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: 'fontin';
src: url('./assets/FontinSans-Regular.otf');
}
@font-face {
font-family: 'din';
src: url('./assets/DINPro-Regular.otf');
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
height: 100vh;
}
body {
-webkit-user-select: text;
user-select: text;
}
::selection {
background: rgb(248 156 66 / 0.45);
color: #fff;
}
#root {
position: relative;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
overflow-x: auto;
}
*:focus {
outline: none;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
appearance: textfield;
}
::-webkit-scrollbar {
@apply w-2;
@apply h-2;
&-track {
background: transparent;
}
&-thumb {
display: none;
@apply bg-blueGray/40;
:hover& {
display: initial;
cursor: pointer;
}
&:hover {
@apply bg-blueGray;
}
}
&-corner {
display: none;
}
}
.gutter {
background: transparent;
@apply transition-colors;
&:hover {
@apply bg-orange/40;
}
&&-horizontal {
@apply -mx-1;
cursor: col-resize;
}
&&-vertical {
@apply -my-1;
cursor: row-resize;
}
}
@layer components {
:not(svg, svg *) {
color: white;
@apply font-din;
font-style: normal;
font-weight: 400;
font-size: 16px;
line-height: 26px;
cursor: default;
}
h1,
.h1 {
@apply font-fontin;
font-style: normal;
font-weight: 700;
font-size: 78px;
line-height: 76px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
h2,
.h2 {
@apply font-fontin;
font-weight: 700;
font-size: 54px;
line-height: 58px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
h3,
.h3 {
@apply font-fontin;
font-weight: 400;
font-size: 32px;
line-height: 38px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
h4,
.h4 {
@apply font-fontin;
font-weight: 400;
font-size: 20px;
line-height: 26px;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.l1 {
font-size: 18px;
line-height: 32px;
}
.l2 {
font-size: 24px;
line-height: 36px;
}
.s1 {
font-size: 14px;
line-height: 20px;
}
.tw-color {
display: inline;
@apply bg-gradient-to-t from-yellow to-pink;
-webkit-box-decoration-break: clone;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tw-surface {
position: relative;
@apply border border-blueGray/20 bg-darkGray/70 p-4;
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
& hr {
@apply -mx-4 text-blueGray/20;
}
}
.tw-dialog {
position: relative;
@apply border border-blueGray/20 bg-darkGray/70 p-3;
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
@apply relative flex w-3/5 flex-col gap-2;
& hr {
@apply -mx-3 text-blueGray/20;
}
}
.parchment-post {
position: relative;
color: #2b1a0c;
background-color: #e9dab5;
background-size: cover;
background-position: center;
@apply border p-4;
border-color: rgba(60, 40, 15, 0.4);
box-shadow: rgb(0 0 0 / 45%) 0px 25px 20px -20px;
& .parchment-post-title {
@apply font-fontin uppercase;
font-size: 18px;
line-height: 24px;
letter-spacing: 0.03em;
color: #3a230f;
}
& .parchment-post-meta {
font-size: 14px;
line-height: 20px;
font-style: italic;
color: #6b5836;
}
& .parchment-post-muted {
color: #7a6a47;
}
& hr {
@apply -mx-4;
border: 0;
border-top: 1px solid rgba(60, 40, 15, 0.25);
}
& button {
color: #6b3e15;
}
& button:hover,
& button:focus {
color: #9c4a12;
}
& .parchment-post-body,
& .parchment-post-body * {
color: #2b1a0c;
}
& .parchment-post-body {
font-size: 15px;
line-height: 24px;
word-break: break-word;
overflow-wrap: anywhere;
}
& .parchment-post-body a {
color: #6b3e15;
text-decoration: underline;
cursor: pointer;
}
& .parchment-post-body a:hover {
color: #9c4a12;
}
& .parchment-post-body p {
margin: 6px 0;
}
& .parchment-post-body strong,
& .parchment-post-body b {
font-weight: 700;
}
& .parchment-post-body em,
& .parchment-post-body i {
font-style: italic;
}
& .parchment-post-body ul {
list-style: disc;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body ol {
list-style: decimal;
padding-left: 20px;
margin: 6px 0;
}
& .parchment-post-body blockquote {
border-left: 3px solid #b08a57;
background: rgba(120, 80, 30, 0.08);
padding: 8px 12px;
margin: 8px 0;
font-style: italic;
}
& .parchment-post-body code,
& .parchment-post-body pre,
& .parchment-post-body .codebox {
font-family: monospace;
background: rgba(60, 40, 15, 0.1);
border: 1px solid rgba(60, 40, 15, 0.2);
border-radius: 2px;
padding: 8px;
display: block;
white-space: pre-wrap;
word-break: break-word;
}
& .parchment-post-body img {
max-width: 100%;
height: auto;
}
& .parchment-post-body h1,
& .parchment-post-body h2,
& .parchment-post-body h3,
& .parchment-post-body h4 {
color: #3a230f;
}
}
.tw-hocus {
@apply hocus:text-orange hocus:drop-shadow-[0px_0px_15px_white];
}
.tw-input {
@apply rounded-[1px];
@apply border border-gray/40 bg-darkerGray;
@apply p-2;
@apply placeholder:text-gray;
@apply focus:border-blueGray;
&-underline {
@apply tw-input;
@apply border-0;
@apply border-b;
@apply bg-[transparent];
}
}
.tw-button {
overflow: hidden;
position: relative;
cursor: pointer;
flex-shrink: 0;
@apply py-2;
@apply px-4;
@apply bg-darkGray;
@apply border;
@apply rounded-[1px];
&:not(&-primary) {
background: linear-gradient(#f1c22d40, #ff775740);
@apply border-darkBrown;
& > span {
background: linear-gradient(#f1c22d, #ff7757);
-webkit-background-clip: text;
}
& svg {
stroke: #fb9f3a;
}
&::before {
@apply bg-orange;
}
}
&&-primary {
@apply bg-darkGreen/30;
@apply border-[#C8FF0022];
& > span {
background: linear-gradient(#f7ff8a, #8dd958);
-webkit-background-clip: text;
}
& svg {
stroke: #ccf068;
}
&::before {
@apply bg-warmGreen;
}
&:hover,
&:focus {
&::after {
@apply bg-warmGreen;
}
}
}
& > span {
@apply flex items-center justify-center;
@apply gap-2;
@apply font-fontin;
@apply font-bold;
@apply uppercase;
font-size: 20px;
line-height: 30px;
letter-spacing: 2px;
-webkit-box-decoration-break: clone;
-webkit-text-fill-color: transparent;
}
& svg {
font-size: 10px;
line-height: 30px;
}
&:hover,
&:focus {
& > span {
background: white;
-webkit-background-clip: text;
}
& svg {
stroke: white;
}
&::before {
top: 9px;
bottom: 22px;
left: 22px;
right: 22px;
}
&::after {
content: '';
position: absolute;
top: 12px;
bottom: -46px;
left: 12px;
right: 12px;
border-radius: 50%;
@apply bg-orange;
opacity: 0.75;
mix-blend-mode: hard-light;
filter: blur(25px);
}
}
&::before {
content: '';
position: absolute;
top: 5px;
bottom: 5px;
left: 18px;
right: 18px;
border-radius: 50%;
opacity: 0.75;
mix-blend-mode: hard-light;
filter: blur(25px);
}
}
.tw-loading {
@apply absolute inset-0 transition-all;
background: linear-gradient(
90deg,
rgba(255, 119, 87, 0) 0%,
#f89c42 30%,
#f1c22d 50%,
#f89c42 70%,
rgba(255, 119, 87, 0) 100%
);
transition-duration: 300ms;
&-wrapper {
@apply relative w-full before:absolute;
height: 6px;
&::before {
@apply inset-0 opacity-20;
background: linear-gradient(
90deg,
rgba(146, 147, 145, 0) 0%,
#929391 13%,
#929391 87%,
rgba(146, 147, 145, 0) 100%
);
}
}
&-unknown {
@apply animate-progress opacity-20;
background-image: linear-gradient(
-45deg,
#929391,
#929391 33%,
transparent 33%,
transparent 66%,
#929391 66%,
#929391
);
background-size: 1rem 100%;
}
}
}
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Octo Launcher</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+68
View File
@@ -0,0 +1,68 @@
import { StrictMode } from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { loggerLink } from '@trpc/client';
import { ipcLink } from 'electron-trpc/renderer';
import superjson from 'superjson';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { getQueryKey } from '@trpc/react-query';
import log from 'electron-log/renderer';
import { api } from './utils/api';
import App from './App';
import ErrorBoundary from './ErrorBoundary';
import { LocaleProvider } from './i18n';
import './index.css';
window.addEventListener('error', e => {
log.error('Uncaught error:', e.error ?? e.message, e.filename, e.lineno);
});
window.addEventListener('unhandledrejection', e => {
log.error('Unhandled promise rejection:', e.reason);
});
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
refetchOnWindowFocus: false,
staleTime: Infinity
}
}
});
queryClient.setMutationDefaults(getQueryKey(api.preferences.set), {
onSuccess: v =>
queryClient.setQueryData(
getQueryKey(api.preferences.get, undefined, 'query'),
v
)
});
const trpcClient = api.createClient({
transformer: superjson,
links: [
loggerLink({
enabled: opts =>
process.env.NODE_ENV === 'development' ||
(opts.direction === 'down' && opts.result instanceof Error)
}),
ipcLink()
]
});
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<ErrorBoundary>
<api.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<LocaleProvider>
<App />
</LocaleProvider>
{import.meta.env.DEV && <ReactQueryDevtools />}
</QueryClientProvider>
</api.Provider>
</ErrorBoundary>
</StrictMode>
);
+9
View File
@@ -0,0 +1,9 @@
import { createTRPCReact } from '@trpc/react-query';
import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server';
import { type AppRouter } from '~main/types';
export const api = createTRPCReact<AppRouter>();
export type RouterInputs = inferRouterInputs<AppRouter>;
export type RouterOutputs = inferRouterOutputs<AppRouter>;
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
const allowedElements = ['INPUT', 'TEXTAREA'];
const isClipboardShortcut = (e: KeyboardEvent) =>
(e.ctrlKey || e.metaKey) &&
['a', 'c', 'v', 'x'].includes(e.key.toLowerCase());
const usePreventDefaultEvents = () => {
useEffect(() => {
const disableKeyboardEvents = (e: KeyboardEvent) => {
if (allowedElements.includes((e.target as HTMLElement).tagName)) return;
if (isClipboardShortcut(e)) return;
e.preventDefault();
};
window.addEventListener('keydown', disableKeyboardEvents, true);
window.addEventListener('keyup', disableKeyboardEvents, true);
return () => {
window.removeEventListener('keydown', disableKeyboardEvents, true);
window.removeEventListener('keyup', disableKeyboardEvents, true);
};
}, []);
};
export default usePreventDefaultEvents;
+61
View File
@@ -0,0 +1,61 @@
import { useLayoutEffect, useRef } from 'react';
const FADE_PX = 24;
const setScrollHint = (tar: HTMLElement) => {
const top = tar.scrollTop > 0 && tar.scrollHeight !== tar.clientHeight;
const bottom = tar.scrollTop < tar.scrollHeight - tar.offsetHeight;
const topStr = top ? 'true' : 'false';
const bottomStr = bottom ? 'true' : 'false';
if (topStr === tar.dataset.top && bottomStr === tar.dataset.bottom) return;
tar.dataset.top = topStr;
tar.dataset.bottom = bottomStr;
if (!top && !bottom) {
tar.style.webkitMaskImage = '';
return;
}
tar.style.webkitMaskImage = `linear-gradient(${
top ? `transparent, black calc(${FADE_PX}px)` : ''
}${top && bottom ? ', ' : ''}${
bottom ? `black calc(100% - ${FADE_PX}px), transparent` : ''
})`;
};
const useScrollHint = <T extends HTMLElement>() => {
const ref = useRef<T>(null);
useLayoutEffect(() => {
const current = ref.current;
if (!current) return;
let scheduled = false;
const schedule = () => {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
setScrollHint(current);
});
};
schedule();
const observer = new ResizeObserver(schedule);
observer.observe(current);
current.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
observer.disconnect();
current?.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, []);
return ref;
};
export default useScrollHint;
+9
View File
@@ -0,0 +1,9 @@
import { type Resolver, type FieldValues } from 'react-hook-form';
import type { z } from 'zod';
import { zodResolver as resolver } from '@hookform/resolvers/zod';
const zodResolver = <In extends FieldValues, Out extends FieldValues>(
schema: z.ZodType<In, z.ZodTypeDef, Out>
): Resolver<z.infer<z.ZodType<In, z.ZodTypeDef, Out>>> => resolver(schema);
export default zodResolver;