Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance

Squashed sync from upstream. Highlights:
- Updater no longer reports already-applied deletes as a pending update on
  every launch (guard the del branch on the target still existing)
- Derive the packaged CSP image origin from the configured server URL
- Forum Announcements panel + News tab; hardware-aware farClip recommendation;
  parchment UI; localization and tweak updates
- Addon source refresh; schema and mod-state fixes

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
OctoWoW
2026-07-04 16:02:46 -07:00
parent 16e442ea0f
commit fbad749f0c
35 changed files with 763 additions and 56 deletions
+2
View File
@@ -6,6 +6,7 @@ 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';
@@ -17,6 +18,7 @@ export const appRouter = createTRPCRouter({
patcher: patcherRouter,
updater: updaterRouter,
news: newsRouter,
forum: forumRouter,
mods: modsRouter,
selfUpdater: selfUpdaterRouter
});
+44
View File
@@ -0,0 +1,44 @@
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_SERVER_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;
}
})
});
+10
View File
@@ -5,11 +5,21 @@ import { z } from 'zod';
import { mainWindow } from '~main/index';
import Preferences from '~main/modules/preferences';
import { addDefenderExclusions } 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
+13
View File
@@ -1,6 +1,10 @@
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';
@@ -15,5 +19,14 @@ export const modsRouter = createTRPCRouter({
.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())
});
+3 -1
View File
@@ -8,7 +8,9 @@ import { createTRPCRouter, publicProcedure } from '../trpc';
const FETCH_TIMEOUT_MS = 8_000;
const fetchNews = async (): Promise<NewsItem[]> => {
const url = `${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/news.json`;
const url = `${
import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'
}/forum/octonews.php?mode=list&forum=2&limit=3`;
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
+49 -1
View File
@@ -1,6 +1,6 @@
import { join } from 'path';
import { app, shell, BrowserWindow, screen } from 'electron';
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';
@@ -13,6 +13,11 @@ 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();
@@ -134,8 +139,51 @@ if (!gotSingleInstanceLock) {
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);
});
-2
View File
@@ -53,7 +53,6 @@ const readTocData = (content: string) =>
const isUnsafeFolder = (name?: string) =>
!name || name === '.' || name === '..' || /[/\\]/.test(name);
// only allow known git hosts over https
const ALLOWED_GIT_HOSTS = [
'github.com',
'gitlab.com',
@@ -150,7 +149,6 @@ class AddonsClass extends Observable<AddonsStatus> {
)?.[1];
}
} catch {
/* ignore */
}
const folder = gitUrl.slice(0, -4).split('/').at(-1);
+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);
};
-1
View File
@@ -97,7 +97,6 @@ export const applyLocalePatch = async (
)
return;
} catch {
/* ignore */
}
try {
+4 -3
View File
@@ -114,7 +114,7 @@ class ModsClass extends Observable<ModsStatus> {
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg)))
await fs.writeFile(vmmfCfg, '1\n', 'utf8').catch(() => {});
await fs.writeFile(vmmfCfg, '0\n', 'utf8').catch(() => {});
}
for (const m of MODS) {
@@ -182,7 +182,7 @@ class ModsClass extends Observable<ModsStatus> {
this.#patchRow(id, { ignoreUpdates: ignore });
}
async applyAll() {
async applyAll(opts: { repairOnly?: boolean } = {}) {
const clientDir = Preferences.data?.clientDir;
if (!clientDir) {
Logger.warn('No clientDir set; cannot apply mods.');
@@ -192,6 +192,7 @@ class ModsClass extends Observable<ModsStatus> {
Logger.warn('applyAll already running; ignoring re-entrant call.');
return;
}
await this.verify();
this._value = { ...this._value, state: 'busy' };
this._notifyObservers();
@@ -219,7 +220,7 @@ class ModsClass extends Observable<ModsStatus> {
await this.#install(m);
} else if (!wantInstalled && isInstalled) {
await this.#uninstall(m);
} else if (wantInstalled && updateAvailable) {
} else if (wantInstalled && updateAvailable && !opts.repairOnly) {
await this.#uninstall(m);
await this.#install(m);
}
+19 -2
View File
@@ -97,7 +97,6 @@ export const patchExecutable = async () => {
[0x006e62a8, [0x006e62a9]]
]
},
// version-pinned in-place patch of the WoW.exe routine at this offset
{
synthetic: true,
key: 'skillUiGateHijack',
@@ -127,6 +126,22 @@ export const patchExecutable = async () => {
]
]
]
},
{
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
]
]
]
}
] satisfies Tweak[];
@@ -180,7 +195,9 @@ export const patchConfig = async (forceTweaks = false) => {
);
const primaryDisplay = screen.getPrimaryDisplay();
const { width, height } = primaryDisplay.bounds;
const scale = primaryDisplay.scaleFactor || 1;
const width = Math.round(primaryDisplay.bounds.width * scale);
const height = Math.round(primaryDisplay.bounds.height * scale);
const parsed = {
scriptMemory: 512000,
+16 -6
View File
@@ -423,9 +423,15 @@ class UpdaterClass extends Observable<UpdaterStatus> {
#clientTotalBytes = 0;
#bytesAlreadyOnDisk = 0;
#cachePath = path.join(Preferences.userDataDir, 'cache.json');
#cache: CacheTree = fs.existsSync(this.#cachePath)
? fs.readJSONSync(this.#cachePath)
: {};
#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);
@@ -618,7 +624,11 @@ class UpdaterClass extends Observable<UpdaterStatus> {
const item = getManifestItem(hashTree, filePath);
if (!item) return undefined;
if (item.type === 'del') return item;
if (item.type === 'del') {
if (await fs.exists(path.join(clientPath, ...filePath)))
return item;
return undefined;
}
if (item.type === 'dir') {
const files = (
@@ -832,7 +842,6 @@ class UpdaterClass extends Observable<UpdaterStatus> {
try {
if (clean) {
// never wipe a drive root or a non-client dir
const resolvedClientPath = path.resolve(clientPath);
const isFilesystemRoot =
path.parse(resolvedClientPath).root === resolvedClientPath;
@@ -854,9 +863,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
message: 'Cleaning up old files...'
};
const preserve = new Set(['octolauncher.exe', 'wtf', 'interface', 'screenshots']);
const files = await fs.readdir(clientPath);
for (const file of files) {
if (file === 'OctoLauncher.exe') continue;
if (preserve.has(file.toLowerCase())) continue;
await fs.rm(path.join(clientPath, file), {
recursive: true,
force: true
+5 -1
View File
@@ -5,4 +5,8 @@ export {
type ModsStatus,
type ModRowStatus
} from './modules/mods';
export { type NewsItem, type NewsFeed } from '../common/schemas';
export {
type NewsItem,
type NewsFeed,
type ForumAnnouncement
} from '../common/schemas';
-1
View File
@@ -48,7 +48,6 @@ export const getClientVersion = async () => {
const file = await fs.readFile(exePath);
const buffer = Buffer.from(file);
// Fixed addresses in the 1.12.1 client binary.
const VERSION_OFFSET = 0x00437c04;
const VERSION_LEN = 6;
const BUILD_OFFSET = 0x00437bfc;