Sync launcher: stop phantom update prompt, forum News panel, hardware-aware render distance
Build check / build (push) Has been cancelled
Build check / build (push) Has been cancelled
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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -97,7 +97,6 @@ export const applyLocalePatch = async (
|
||||
)
|
||||
return;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user