Sync launcher 1.2.2: monitor-aware resolution ownership, SuperWoW mod and mods-on-by-default, Patch-O raid visuals toggle, background-sound CVar, launch/quit stability fixes
Build check / build (push) Has been cancelled
Build check / build (push) Has been cancelled
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "octo-launcher",
|
"name": "octo-launcher",
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"description": "An Electron application for launching and updating the OctoWoW client",
|
"description": "An Electron application for launching and updating the OctoWoW client",
|
||||||
"author": "OctoWoW",
|
"author": "OctoWoW",
|
||||||
"copyright": "Copyright © 2026 OctoWoW",
|
"copyright": "Copyright © 2026 OctoWoW",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const FILL = 0x78;
|
||||||
|
|
||||||
|
function pathVariants(p) {
|
||||||
|
const set = new Set([p, p.replace(/\\/g, '/'), p.replace(/\//g, '\\')]);
|
||||||
|
return [...set].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const home = os.homedir();
|
||||||
|
let username = '';
|
||||||
|
try {
|
||||||
|
username = os.userInfo().username;
|
||||||
|
} catch {
|
||||||
|
username = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = [];
|
||||||
|
if (root.length > 2) secrets.push(...pathVariants(root));
|
||||||
|
if (home.length > 2 && home !== root) secrets.push(...pathVariants(home));
|
||||||
|
if (username.length >= 4) secrets.push(username);
|
||||||
|
|
||||||
|
const needles = [...new Set(secrets)]
|
||||||
|
.filter(s => s.length > 0)
|
||||||
|
.map(s => s.toLowerCase())
|
||||||
|
.sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
|
function scanAndFill(buf, s, stride) {
|
||||||
|
const n = s.length;
|
||||||
|
const span = n * stride;
|
||||||
|
if (span === 0 || span > buf.length) return 0;
|
||||||
|
let hits = 0;
|
||||||
|
outer: for (let i = 0; i + span <= buf.length; i++) {
|
||||||
|
for (let j = 0; j < n; j++) {
|
||||||
|
const at = i + j * stride;
|
||||||
|
let b = buf[at];
|
||||||
|
if (b >= 0x41 && b <= 0x5a) b += 0x20;
|
||||||
|
if (b !== s.charCodeAt(j)) continue outer;
|
||||||
|
if (stride === 2 && buf[at + 1] !== 0x00) continue outer;
|
||||||
|
}
|
||||||
|
buf.fill(FILL, i, i + span);
|
||||||
|
hits++;
|
||||||
|
i += span - 1;
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redact(buf) {
|
||||||
|
let hits = 0;
|
||||||
|
for (const s of needles) {
|
||||||
|
hits += scanAndFill(buf, s, 1);
|
||||||
|
hits += scanAndFill(buf, s, 2);
|
||||||
|
}
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(dir, out) {
|
||||||
|
let entries;
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const e of entries) {
|
||||||
|
const p = path.join(dir, e.name);
|
||||||
|
if (e.isDirectory()) collect(p, out);
|
||||||
|
else if (e.isFile() && p.endsWith('.node')) out.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addons = [];
|
||||||
|
collect(path.join(root, 'node_modules'), addons);
|
||||||
|
|
||||||
|
let files = 0;
|
||||||
|
let total = 0;
|
||||||
|
const redacted = [];
|
||||||
|
for (const file of addons) {
|
||||||
|
try {
|
||||||
|
const buf = fs.readFileSync(file);
|
||||||
|
const hits = redact(buf);
|
||||||
|
if (hits > 0) {
|
||||||
|
fs.writeFileSync(file, buf);
|
||||||
|
files++;
|
||||||
|
total += hits;
|
||||||
|
redacted.push(`${path.relative(root, file)} (${hits})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`scrub-native-paths: skipped ${path.basename(file)} (${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`scrub-native-paths: redacted ${total} path reference(s) across ${files} addon(s)`);
|
||||||
|
for (const r of redacted) console.log(` ${r}`);
|
||||||
+9
-1
@@ -15,6 +15,7 @@ const allowedExtra = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
|
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
|
||||||
|
const raidVisuals = ['patch-O.mpq'];
|
||||||
|
|
||||||
const skipFiles = new Set([
|
const skipFiles = new Set([
|
||||||
'manifest.json',
|
'manifest.json',
|
||||||
@@ -45,7 +46,7 @@ const isSkipDir = (...filePath: string[]) =>
|
|||||||
skipDirsPosix.has(filePath.join('/'));
|
skipDirsPosix.has(filePath.join('/'));
|
||||||
|
|
||||||
type FolderTags = 'allowExtra';
|
type FolderTags = 'allowExtra';
|
||||||
type FileTags = 'vanillaFixes';
|
type FileTags = 'vanillaFixes' | 'raidVisuals';
|
||||||
|
|
||||||
type FileManifest = { name: string } & (
|
type FileManifest = { name: string } & (
|
||||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||||
@@ -191,6 +192,12 @@ export const buildCache = async (
|
|||||||
if (stats.isDirectory()) {
|
if (stats.isDirectory()) {
|
||||||
if (isSkipDir(...filePath, file)) continue;
|
if (isSkipDir(...filePath, file)) continue;
|
||||||
if (file.match(/patch-./)) {
|
if (file.match(/patch-./)) {
|
||||||
|
if (raidVisuals.includes(`${file}.mpq`))
|
||||||
|
throw new Error(
|
||||||
|
`${file}/ exists beside ${file}.mpq. Opt-in archives must stay ` +
|
||||||
|
'whole-file: an mpq node carries no tags, so this would ' +
|
||||||
|
'ship the patch to every player regardless of preference.'
|
||||||
|
);
|
||||||
patches.push(file);
|
patches.push(file);
|
||||||
const mpqRelPath = path
|
const mpqRelPath = path
|
||||||
.join(...filePath, `${file}.mpq`)
|
.join(...filePath, `${file}.mpq`)
|
||||||
@@ -243,6 +250,7 @@ export const buildCache = async (
|
|||||||
|
|
||||||
const tags: FileTags[] = [];
|
const tags: FileTags[] = [];
|
||||||
vanillaFixes.includes(file) && tags.push('vanillaFixes');
|
vanillaFixes.includes(file) && tags.push('vanillaFixes');
|
||||||
|
raidVisuals.includes(file) && tags.push('raidVisuals');
|
||||||
|
|
||||||
tree.push({
|
tree.push({
|
||||||
type: 'file',
|
type: 'file',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const ModIdSchema = z.enum([
|
|||||||
'dxvk',
|
'dxvk',
|
||||||
'nampower',
|
'nampower',
|
||||||
'multiMonitorFix',
|
'multiMonitorFix',
|
||||||
|
'superWow',
|
||||||
'transmogFix',
|
'transmogFix',
|
||||||
'unitXp',
|
'unitXp',
|
||||||
'vanillaFixes',
|
'vanillaFixes',
|
||||||
@@ -19,6 +20,7 @@ export type ModSource =
|
|||||||
apiUrl?: string;
|
apiUrl?: string;
|
||||||
pinnedTag?: string;
|
pinnedTag?: string;
|
||||||
assetName: string;
|
assetName: string;
|
||||||
|
sha256?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
kind: 'archive';
|
kind: 'archive';
|
||||||
@@ -28,6 +30,7 @@ export type ModSource =
|
|||||||
pinnedTag?: string;
|
pinnedTag?: string;
|
||||||
format: 'zip' | 'tar.gz';
|
format: 'zip' | 'tar.gz';
|
||||||
extractMap: Record<string, string>;
|
extractMap: Record<string, string>;
|
||||||
|
sha256?: string;
|
||||||
}
|
}
|
||||||
| { kind: 'managed' };
|
| { kind: 'managed' };
|
||||||
|
|
||||||
@@ -98,6 +101,28 @@ export const MODS: ModEntry[] = [
|
|||||||
},
|
},
|
||||||
registerInDllsTxt: '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'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'transmogFix',
|
id: 'transmogFix',
|
||||||
name: 'transmogFix',
|
name: 'transmogFix',
|
||||||
@@ -177,3 +202,9 @@ export const MODS: ModEntry[] = [
|
|||||||
|
|
||||||
export const getMod = (id: ModId): ModEntry | undefined =>
|
export const getMod = (id: ModId): ModEntry | undefined =>
|
||||||
MODS.find(m => m.id === id);
|
MODS.find(m => m.id === id);
|
||||||
|
|
||||||
|
const NOT_DEFAULT_ENABLED: ModId[] = [];
|
||||||
|
|
||||||
|
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
|
||||||
|
m => !NOT_DEFAULT_ENABLED.includes(m.id)
|
||||||
|
).map(m => m.id);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const f = {
|
|||||||
|
|
||||||
export const ConfigWtfSchema = z.object({
|
export const ConfigWtfSchema = z.object({
|
||||||
vanillaFixes: f.boolean(),
|
vanillaFixes: f.boolean(),
|
||||||
|
raidVisuals: f.boolean(),
|
||||||
largeAddress: f.boolean(true),
|
largeAddress: f.boolean(true),
|
||||||
nameplateRange: f.number(41),
|
nameplateRange: f.number(41),
|
||||||
alwaysAutoLoot: f.boolean(),
|
alwaysAutoLoot: f.boolean(),
|
||||||
@@ -62,6 +63,8 @@ export const PreferencesSchema = z.object({
|
|||||||
.default('enUS'),
|
.default('enUS'),
|
||||||
localePatchLetter: z.string().optional(),
|
localePatchLetter: z.string().optional(),
|
||||||
localePatchLocale: z.string().optional(),
|
localePatchLocale: z.string().optional(),
|
||||||
|
vmmfWrittenIndex: z.number().int().nonnegative().optional(),
|
||||||
|
lastWrittenResolution: z.string().optional(),
|
||||||
rememberPosition: f.boolean(),
|
rememberPosition: f.boolean(),
|
||||||
windowPosition: z
|
windowPosition: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ export const launcherRouter = createTRPCRouter({
|
|||||||
await fs.remove(path.join(clientDir, 'WDB'));
|
await fs.remove(path.join(clientDir, 'WDB'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Logger.log('Syncing preferred monitor...');
|
||||||
|
await Mods.verify();
|
||||||
|
|
||||||
Logger.log('Checking Config.wtf...');
|
Logger.log('Checking Config.wtf...');
|
||||||
await patchConfig();
|
await patchConfig();
|
||||||
|
|
||||||
@@ -62,21 +65,17 @@ export const launcherRouter = createTRPCRouter({
|
|||||||
'launching WoW.exe directly (mods will not load).'
|
'launching WoW.exe directly (mods will not load).'
|
||||||
);
|
);
|
||||||
|
|
||||||
const octoLocale = Preferences.data.locale || 'enUS';
|
|
||||||
const gameEnv = { ...process.env, OCTO_LOCALE: octoLocale };
|
|
||||||
Logger.log(
|
Logger.log(
|
||||||
useLoader
|
useLoader
|
||||||
? `Launching via VanillaFixes (OCTO_LOCALE=${octoLocale})...`
|
? 'Launching via VanillaFixes...'
|
||||||
: `Launching ${exePath} (OCTO_LOCALE=${octoLocale})...`
|
: `Launching ${exePath}...`
|
||||||
);
|
);
|
||||||
const child = useLoader
|
const child = useLoader
|
||||||
? spawn(loaderPath, ['WoW.exe'], {
|
? spawn(loaderPath, ['WoW.exe'], {
|
||||||
env: gameEnv,
|
|
||||||
cwd: clientDir,
|
cwd: clientDir,
|
||||||
detached: !minimizeToTrayOnPlay
|
detached: !minimizeToTrayOnPlay
|
||||||
})
|
})
|
||||||
: spawn(exePath, {
|
: spawn(exePath, {
|
||||||
env: gameEnv,
|
|
||||||
cwd: clientDir,
|
cwd: clientDir,
|
||||||
detached: !minimizeToTrayOnPlay
|
detached: !minimizeToTrayOnPlay
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
|
|
||||||
Logger.initialize();
|
Logger.initialize();
|
||||||
Logger.errorHandler.startCatching();
|
Logger.errorHandler.startCatching();
|
||||||
|
Logger.transports.ipc.level = false;
|
||||||
Logger.info('Launcher starting...');
|
Logger.info('Launcher starting...');
|
||||||
|
|
||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration();
|
||||||
@@ -191,6 +192,16 @@ if (!gotSingleInstanceLock) {
|
|||||||
await createWindow();
|
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.on('window-all-closed', () => {
|
||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class AddonsClass extends Observable<AddonsStatus> {
|
|||||||
: [];
|
: [];
|
||||||
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
const addons: AddonsStatus['addons'] = Object.fromEntries(
|
||||||
dirs
|
dirs
|
||||||
.filter(d => !d.startsWith('Blizzard_'))
|
.filter(d => !d.startsWith('Blizzard_') && !/\.(tmp|bak)$/.test(d))
|
||||||
.map(name => [name, { status: 'fetching' as const, folder: name }])
|
.map(name => [name, { status: 'fetching' as const, folder: name }])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,21 @@ import { spawn } from 'node:child_process';
|
|||||||
|
|
||||||
import Logger from 'electron-log/main';
|
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 = [
|
const SCRIPT = [
|
||||||
'$ErrorActionPreference = "Stop"',
|
'$ErrorActionPreference = "Stop"',
|
||||||
|
"$ProgressPreference = 'SilentlyContinue'",
|
||||||
"Add-Type -TypeDefinition @'",
|
"Add-Type -TypeDefinition @'",
|
||||||
'using System;',
|
'using System;',
|
||||||
'using System.Runtime.InteropServices;',
|
'using System.Runtime.InteropServices;',
|
||||||
@@ -18,30 +31,79 @@ const SCRIPT = [
|
|||||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
|
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceID;',
|
||||||
' [MarshalAs(UnmanagedType.ByValTStr, SizeConst=128)] public string DeviceKey;',
|
' [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)]',
|
' [DllImport("user32.dll", EntryPoint="EnumDisplayDevicesA", CharSet=CharSet.Ansi)]',
|
||||||
' public static extern bool EnumDisplayDevices(string lpDevice, uint iDevNum, ref DISPLAY_DEVICE lpDisplayDevice, uint dwFlags);',
|
' 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);',
|
||||||
'}',
|
'}',
|
||||||
"'@",
|
"'@",
|
||||||
'$dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
|
'for ($i = 0; ; $i++) {',
|
||||||
'$dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
|
' $dd = New-Object VmmfDisplays+DISPLAY_DEVICE',
|
||||||
'for ($i = 0; [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0); $i++) {',
|
' $dd.cb = [System.Runtime.InteropServices.Marshal]::SizeOf($dd)',
|
||||||
' if ($dd.StateFlags -band 4) { Write-Output $i; exit 0 }',
|
' if (-not [VmmfDisplays]::EnumDisplayDevices([NullString]::Value, $i, [ref]$dd, 0)) { break }',
|
||||||
'}',
|
' $dm = New-Object VmmfDisplays+DEVMODE',
|
||||||
'exit 1'
|
' $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');
|
].join('\n');
|
||||||
|
|
||||||
export const detectPrimaryDisplayIndex = (): Promise<number> => {
|
const parseRow = (line: string): DisplayDevice | undefined => {
|
||||||
if (os.platform() !== 'win32') return Promise.resolve(0);
|
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');
|
const encoded = Buffer.from(SCRIPT, 'utf16le').toString('base64');
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
const finish = (index: number) => {
|
const finish = (v: DisplayDevice[] | null) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
resolve(index);
|
resolve(v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
@@ -52,25 +114,33 @@ export const detectPrimaryDisplayIndex = (): Promise<number> => {
|
|||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
child.kill();
|
child.kill();
|
||||||
Logger.warn('Primary display detection timed out');
|
Logger.warn('Display enumeration timed out');
|
||||||
finish(0);
|
finish(null);
|
||||||
}, 8000);
|
}, 10000);
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
child.stdout.on('data', d => (stdout += String(d)));
|
child.stdout.on('data', d => (stdout += String(d)));
|
||||||
child.on('error', e => {
|
child.on('error', e => {
|
||||||
Logger.warn('Primary display detection failed to launch PowerShell', e);
|
Logger.warn('Display enumeration failed to launch PowerShell', e);
|
||||||
finish(0);
|
finish(null);
|
||||||
});
|
});
|
||||||
child.on('exit', code => {
|
child.on('exit', code => {
|
||||||
const index = Number(stdout.trim());
|
const devices = stdout
|
||||||
if (code === 0 && Number.isInteger(index) && index >= 0) {
|
.split(/\r?\n/)
|
||||||
Logger.info(`Detected primary display at device index ${index}`);
|
.map(parseRow)
|
||||||
finish(index);
|
.filter((d): d is DisplayDevice => d !== undefined);
|
||||||
|
if (code === 0 && devices.length) {
|
||||||
|
Logger.info(`Enumerated ${devices.length} display device(s)`);
|
||||||
|
finish(devices);
|
||||||
} else {
|
} else {
|
||||||
Logger.warn('Primary display detection failed, defaulting to 0');
|
Logger.warn('Display enumeration returned nothing usable');
|
||||||
finish(0);
|
finish(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const detectPrimaryDisplayIndex = async (): Promise<number | null> => {
|
||||||
|
const devices = await enumerateDisplays();
|
||||||
|
return devices?.find(d => d.primary && d.attached)?.index ?? null;
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import Logger from 'electron-log/main';
|
|||||||
import Preferences from './preferences';
|
import Preferences from './preferences';
|
||||||
|
|
||||||
const PREFERRED = 'L';
|
const PREFERRED = 'L';
|
||||||
const LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
const ALL_LETTERS = 'BCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
const RAID_LETTER = 'O';
|
||||||
|
const ALLOC_LETTERS = ALL_LETTERS.filter(l => l !== RAID_LETTER);
|
||||||
const MARKER = 'octolocale.marker';
|
const MARKER = 'octolocale.marker';
|
||||||
|
|
||||||
const patchFile = (dataDir: string, letter: string) =>
|
const patchFile = (dataDir: string, letter: string) =>
|
||||||
@@ -42,7 +44,7 @@ const usableSlot = (dataDir: string, letter: string): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const removeOurPatch = async (dataDir: string) => {
|
const removeOurPatch = async (dataDir: string) => {
|
||||||
for (const l of LETTERS) {
|
for (const l of ALL_LETTERS) {
|
||||||
const f = patchFile(dataDir, l);
|
const f = patchFile(dataDir, l);
|
||||||
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -79,10 +81,12 @@ export const applyLocalePatch = async (
|
|||||||
|
|
||||||
const tracked = Preferences.data.localePatchLetter;
|
const tracked = Preferences.data.localePatchLetter;
|
||||||
const letter =
|
const letter =
|
||||||
(tracked && usableSlot(dataDir, tracked) ? tracked : undefined) ??
|
(tracked && tracked !== RAID_LETTER && usableSlot(dataDir, tracked)
|
||||||
|
? tracked
|
||||||
|
: undefined) ??
|
||||||
(usableSlot(dataDir, PREFERRED)
|
(usableSlot(dataDir, PREFERRED)
|
||||||
? PREFERRED
|
? PREFERRED
|
||||||
: LETTERS.find(l => usableSlot(dataDir, l)));
|
: ALLOC_LETTERS.find(l => usableSlot(dataDir, l)));
|
||||||
if (!letter) {
|
if (!letter) {
|
||||||
Logger.warn('Locale patch: no usable patch slot');
|
Logger.warn('Locale patch: no usable patch slot');
|
||||||
return;
|
return;
|
||||||
@@ -100,7 +104,7 @@ export const applyLocalePatch = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (const l of LETTERS) {
|
for (const l of ALL_LETTERS) {
|
||||||
if (l === letter) continue;
|
if (l === letter) continue;
|
||||||
const f = patchFile(dataDir, l);
|
const f = patchFile(dataDir, l);
|
||||||
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
if (isOurPatch(f)) await fs.remove(f).catch(() => {});
|
||||||
|
|||||||
+78
-10
@@ -1,4 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
import fs from 'fs-extra';
|
import fs from 'fs-extra';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
@@ -13,7 +14,7 @@ import Preferences from './preferences';
|
|||||||
import Observable from './observable';
|
import Observable from './observable';
|
||||||
import Updater from './updater';
|
import Updater from './updater';
|
||||||
import { addDll, removeDll } from './dllsTxt';
|
import { addDll, removeDll } from './dllsTxt';
|
||||||
import { detectPrimaryDisplayIndex } from './displays';
|
import { enumerateDisplays } from './displays';
|
||||||
|
|
||||||
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
|
const MOD_DOWNLOAD_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
@@ -105,6 +106,69 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async verify() {
|
||||||
this.load();
|
this.load();
|
||||||
this._notifyObservers();
|
this._notifyObservers();
|
||||||
@@ -112,12 +176,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
const clientDir = Preferences.data?.clientDir;
|
const clientDir = Preferences.data?.clientDir;
|
||||||
|
|
||||||
if (clientDir) {
|
if (clientDir) {
|
||||||
const vmmfDll = path.join(clientDir, 'VanillaMultiMonitorFix.dll');
|
await this.#syncPreferredMonitor(clientDir);
|
||||||
const vmmfCfg = path.join(clientDir, 'VMMFix_preferred_monitor.txt');
|
|
||||||
if ((await fs.pathExists(vmmfDll)) && !(await fs.pathExists(vmmfCfg))) {
|
|
||||||
const index = await detectPrimaryDisplayIndex();
|
|
||||||
await fs.writeFile(vmmfCfg, `${index}\n`, 'utf8').catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const m of MODS) {
|
for (const m of MODS) {
|
||||||
@@ -258,7 +317,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
|
|
||||||
if (m.source.kind === 'directFile') {
|
if (m.source.kind === 'directFile') {
|
||||||
const dest = path.join(clientDir, m.source.assetName);
|
const dest = path.join(clientDir, m.source.assetName);
|
||||||
await this.#downloadTo(m.source.url, dest);
|
await this.#downloadTo(m.source.url, dest, m.source.sha256);
|
||||||
written.push(m.source.assetName);
|
written.push(m.source.assetName);
|
||||||
} else if (m.source.kind === 'archive') {
|
} else if (m.source.kind === 'archive') {
|
||||||
const scratch = path.join(clientDir, '.octolauncher-tmp');
|
const scratch = path.join(clientDir, '.octolauncher-tmp');
|
||||||
@@ -267,7 +326,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
scratch,
|
scratch,
|
||||||
`${m.id}-${Date.now()}.${m.source.format}`
|
`${m.id}-${Date.now()}.${m.source.format}`
|
||||||
);
|
);
|
||||||
await this.#downloadTo(m.source.url, tmp);
|
await this.#downloadTo(m.source.url, tmp, m.source.sha256);
|
||||||
this.#patchRow(m.id, { state: 'installing' });
|
this.#patchRow(m.id, { state: 'installing' });
|
||||||
|
|
||||||
const map = m.source.extractMap;
|
const map = m.source.extractMap;
|
||||||
@@ -360,7 +419,7 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
|
this.#patchRow(m.id, { state: 'idle', installedVersion: undefined });
|
||||||
}
|
}
|
||||||
|
|
||||||
async #downloadTo(url: string, dest: string) {
|
async #downloadTo(url: string, dest: string, sha256?: string) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { 'User-Agent': 'OctoLauncher' },
|
headers: { 'User-Agent': 'OctoLauncher' },
|
||||||
timeout: MOD_DOWNLOAD_TIMEOUT_MS
|
timeout: MOD_DOWNLOAD_TIMEOUT_MS
|
||||||
@@ -368,6 +427,15 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
if (!res.ok) throw new Error(`Download failed ${res.status}: ${url}`);
|
||||||
await fs.ensureDir(path.dirname(dest));
|
await fs.ensureDir(path.dirname(dest));
|
||||||
const buf = await res.arrayBuffer();
|
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));
|
await fs.writeFile(dest, Buffer.from(buf));
|
||||||
if (!(await fs.pathExists(dest)))
|
if (!(await fs.pathExists(dest)))
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
+147
-42
@@ -8,6 +8,7 @@ import Preferences from '~main/modules/preferences';
|
|||||||
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
|
import { ConfigWtfSchema, type PreferencesSchema } from '~common/schemas';
|
||||||
import { isNotUndef } from '~common/utils';
|
import { isNotUndef } from '~common/utils';
|
||||||
import { fetchFile } from '~main/modules/updater';
|
import { fetchFile } from '~main/modules/updater';
|
||||||
|
import { enumerateDisplays } from '~main/modules/displays';
|
||||||
|
|
||||||
const Servers = {
|
const Servers = {
|
||||||
live: {
|
live: {
|
||||||
@@ -32,7 +33,7 @@ type Tweak = TweakKey & {
|
|||||||
} & (
|
} & (
|
||||||
| {
|
| {
|
||||||
type: 'bytes';
|
type: 'bytes';
|
||||||
tweaks: [number, number[]][];
|
tweaks: [number, number[], number[]?][];
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'int8' | 'uint16' | 'float';
|
type: 'int8' | 'uint16' | 'float';
|
||||||
@@ -41,6 +42,9 @@ type Tweak = TweakKey & {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hex = (bytes: number[]) =>
|
||||||
|
bytes.map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||||||
|
|
||||||
export const patchExecutable = async () => {
|
export const patchExecutable = async () => {
|
||||||
Logger.log('Patching WoW.exe...');
|
Logger.log('Patching WoW.exe...');
|
||||||
|
|
||||||
@@ -127,6 +131,20 @@ export const patchExecutable = async () => {
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
synthetic: true,
|
||||||
|
key: 'levitateAnimRangeBounds',
|
||||||
|
type: 'bytes',
|
||||||
|
default: true,
|
||||||
|
forced: true,
|
||||||
|
tweaks: [
|
||||||
|
[
|
||||||
|
0x313d5f,
|
||||||
|
[0x57, 0x8b, 0x55, 0x0c, 0x3b, 0xd0, 0x73, 0x0e],
|
||||||
|
[0x85, 0xc0, 0x57, 0x74, 0x11, 0x8b, 0x55, 0x0c]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
synthetic: true,
|
synthetic: true,
|
||||||
key: 'octowowUrlAllowlist',
|
key: 'octowowUrlAllowlist',
|
||||||
@@ -137,8 +155,8 @@ export const patchExecutable = async () => {
|
|||||||
[
|
[
|
||||||
0x45ccd8,
|
0x45ccd8,
|
||||||
[
|
[
|
||||||
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74,
|
0x6f, 0x63, 0x74, 0x6f, 0x77, 0x6f, 0x77, 0x2e, 0x73, 0x74, 0x00,
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
0x00, 0x00, 0x00, 0x00, 0x00
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@@ -160,9 +178,24 @@ export const patchExecutable = async () => {
|
|||||||
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
|
buffer.writeUInt16LE(t.value ?? (val as number), t.offset);
|
||||||
} else if (t.type === 'bytes') {
|
} else if (t.type === 'bytes') {
|
||||||
if (!t.forced && !val) return;
|
if (!t.forced && !val) return;
|
||||||
t.tweaks.forEach(([offset, bytes]) =>
|
t.tweaks.forEach(([offset, bytes, expect]) => {
|
||||||
Buffer.from(bytes).copy(buffer, offset)
|
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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,6 +207,48 @@ export const patchExecutable = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
|
||||||
export const patchConfig = async (forceTweaks = false) => {
|
export const patchConfig = async (forceTweaks = false) => {
|
||||||
const { clientDir, server, config, locale } = Preferences.data;
|
const { clientDir, server, config, locale } = Preferences.data;
|
||||||
if (!clientDir) return;
|
if (!clientDir) return;
|
||||||
@@ -194,50 +269,74 @@ export const patchConfig = async (forceTweaks = false) => {
|
|||||||
.filter(isNotUndef)
|
.filter(isNotUndef)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isFirstRun = Object.keys(configWtf).length === 0;
|
||||||
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
const scale = primaryDisplay.scaleFactor || 1;
|
const scale = primaryDisplay.scaleFactor || 1;
|
||||||
const width = Math.round(primaryDisplay.bounds.width * scale);
|
const width = Math.round(primaryDisplay.bounds.width * scale);
|
||||||
const height = Math.round(primaryDisplay.bounds.height * scale);
|
const height = Math.round(primaryDisplay.bounds.height * scale);
|
||||||
|
|
||||||
const parsed = {
|
const seededResolution = `${width}x${height}`;
|
||||||
scriptMemory: 512000,
|
|
||||||
gxResolution: `${width}x${height}`,
|
const seed = isFirstRun
|
||||||
gxColorBits: primaryDisplay.colorDepth,
|
? {
|
||||||
gxDepthBits: primaryDisplay.colorDepth,
|
scriptMemory: 512000,
|
||||||
gxRefresh: 60,
|
gxResolution: seededResolution,
|
||||||
gxMultisample: 8,
|
gxColorBits: primaryDisplay.colorDepth,
|
||||||
gxMultisampleQuality: 0,
|
gxDepthBits: primaryDisplay.colorDepth,
|
||||||
gxTripleBuffer: 1,
|
gxRefresh: 60,
|
||||||
anisotropic: 16,
|
gxMultisample: 8,
|
||||||
frillDensity: 48,
|
gxMultisampleQuality: 0,
|
||||||
fullAlpha: 1,
|
gxTripleBuffer: 1,
|
||||||
SmallCull: 0.01,
|
anisotropic: 16,
|
||||||
DistCull: 888.8,
|
frillDensity: 48,
|
||||||
shadowLevel: 0,
|
fullAlpha: 1,
|
||||||
trilinear: 1,
|
SmallCull: 0.01,
|
||||||
specular: 1,
|
DistCull: 888.8,
|
||||||
pixelShaders: 1,
|
shadowLevel: 0,
|
||||||
M2UsePixelShaders: 1,
|
trilinear: 1,
|
||||||
particleDensity: 1,
|
specular: 1,
|
||||||
unitDrawDist: 300,
|
pixelShaders: 1,
|
||||||
weatherDensity: 3,
|
M2UsePixelShaders: 1,
|
||||||
movieSubtitle: 1,
|
M2UseShaders: 1,
|
||||||
minimapZoom: 0,
|
particleDensity: 1,
|
||||||
minimapInsideZoom: 0,
|
unitDrawDist: 300,
|
||||||
SoundZoneMusicNoDelay: 1,
|
weatherDensity: 3,
|
||||||
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
movieSubtitle: 1,
|
||||||
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
minimapZoom: 0,
|
||||||
gxWindow: configWtf['gxWindow'] ?? 1,
|
minimapInsideZoom: 0,
|
||||||
gxMaximize: configWtf['gxMaximize'] ?? 1,
|
SoundZoneMusicNoDelay: 1,
|
||||||
gxCursor: configWtf['gxCursor'] ?? 1,
|
gxWindow: 1,
|
||||||
checkAddonVersion: configWtf['checkAddonVersion'] ?? 0,
|
gxMaximize: 1,
|
||||||
farClip: configWtf['farClip'] ?? config.farClip,
|
gxCursor: 1,
|
||||||
CameraDistanceMax: configWtf['CameraDistanceMax'] ?? config.cameraDistance,
|
checkAddonVersion: 0,
|
||||||
...configWtf,
|
farClip: config.farClip,
|
||||||
|
CameraDistanceMax: config.cameraDistance,
|
||||||
|
patchList: Servers[server].patchList,
|
||||||
|
realmName: Servers[server].realmName
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
const owned = {
|
||||||
locale,
|
locale,
|
||||||
realmList: Servers[server].realmList,
|
realmList: Servers[server].realmList,
|
||||||
|
patchList: configWtf['patchList'] ?? Servers[server].patchList,
|
||||||
|
realmName: configWtf['realmName'] ?? Servers[server].realmName,
|
||||||
hwDetect: 0,
|
hwDetect: 0,
|
||||||
M2UseShaders: 1,
|
BackgroundSound: config.soundInBackground ? 1 : 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const repaired = await repairResolution(
|
||||||
|
clientDir,
|
||||||
|
configWtf['gxResolution'],
|
||||||
|
Preferences.data.lastWrittenResolution
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = {
|
||||||
|
...seed,
|
||||||
|
...configWtf,
|
||||||
|
...repaired,
|
||||||
|
...owned,
|
||||||
...(forceTweaks
|
...(forceTweaks
|
||||||
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
|
? { farClip: config.farClip, CameraDistanceMax: config.cameraDistance }
|
||||||
: {})
|
: {})
|
||||||
@@ -250,5 +349,11 @@ export const patchConfig = async (forceTweaks = false) => {
|
|||||||
const tmpPath = `${configPath}.tmp`;
|
const tmpPath = `${configPath}.tmp`;
|
||||||
await fs.writeFile(tmpPath, body);
|
await fs.writeFile(tmpPath, body);
|
||||||
await fs.move(tmpPath, configPath, { overwrite: true });
|
await fs.move(tmpPath, configPath, { overwrite: true });
|
||||||
|
|
||||||
|
const chosen =
|
||||||
|
repaired.gxResolution ?? (isFirstRun ? seededResolution : undefined);
|
||||||
|
if (chosen && chosen !== Preferences.data.lastWrittenResolution)
|
||||||
|
Preferences.data = { lastWrittenResolution: chosen };
|
||||||
|
|
||||||
Logger.log('Config.wtf successfully patched');
|
Logger.log('Config.wtf successfully patched');
|
||||||
};
|
};
|
||||||
|
|||||||
+179
-34
@@ -6,40 +6,143 @@ import { app } from 'electron';
|
|||||||
import Logger from 'electron-log/main';
|
import Logger from 'electron-log/main';
|
||||||
|
|
||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
|
import { DEFAULT_ENABLED_MODS } from '~common/mods';
|
||||||
import { omit } from '~common/utils';
|
import { omit } from '~common/utils';
|
||||||
|
|
||||||
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
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 {
|
abstract class Preferences {
|
||||||
static #data: z.infer<typeof PreferencesSchema>;
|
static #data: z.infer<typeof PreferencesSchema>;
|
||||||
static #writeChain: Promise<void> = Promise.resolve();
|
static #writeChain: Promise<void> = Promise.resolve();
|
||||||
|
static #readOnly = false;
|
||||||
|
static #rememberedClientDir?: string;
|
||||||
|
static #freshInstall = false;
|
||||||
|
|
||||||
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
|
static readonly userDataDir = process.env.PORTABLE_EXECUTABLE_DIR
|
||||||
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
|
? path.join(process.env.PORTABLE_EXECUTABLE_DIR, '.launcher')
|
||||||
: app.getPath('userData');
|
: app.getPath('userData');
|
||||||
|
|
||||||
static async load() {
|
static readonly #settingsPath = path.join(
|
||||||
await fs.ensureDir(this.userDataDir);
|
Preferences.userDataDir,
|
||||||
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
'settings.json'
|
||||||
|
);
|
||||||
|
|
||||||
let json: Record<string, unknown>;
|
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 {
|
try {
|
||||||
json = await fs.readJSON(settingsPath);
|
json = await readJsonRetrying(settingsPath);
|
||||||
} catch {
|
} catch (e) {
|
||||||
return PreferencesSchema.parse({
|
if (isLocked(e)) {
|
||||||
isPortable: !!portableDir,
|
this.#readOnly = true;
|
||||||
clientDir: portableDir
|
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 = {
|
const merged = dropUndefined({
|
||||||
...json,
|
...json,
|
||||||
isPortable: !!portableDir,
|
isPortable: !!portableDir,
|
||||||
clientDir: portableDir ?? json.clientDir
|
clientDir: portableDir ?? json.clientDir
|
||||||
};
|
});
|
||||||
|
|
||||||
const parsed = PreferencesSchema.safeParse(merged);
|
const parsed = PreferencesSchema.safeParse(merged);
|
||||||
if (parsed.success) return parsed.data;
|
if (parsed.success)
|
||||||
|
return this.#withKnownClientDir(
|
||||||
|
this.#withFreshInstallDefaults(parsed.data)
|
||||||
|
);
|
||||||
|
|
||||||
Logger.warn(
|
Logger.warn(
|
||||||
'settings.json failed validation; salvaging valid fields',
|
'settings.json failed validation; salvaging valid fields',
|
||||||
@@ -47,17 +150,40 @@ abstract class Preferences {
|
|||||||
);
|
);
|
||||||
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
|
await fs.copy(settingsPath, `${settingsPath}.corrupt`).catch(() => {});
|
||||||
|
|
||||||
const salvaged: Record<string, unknown> = {
|
const salvaged: Record<string, unknown> = dropUndefined({
|
||||||
isPortable: !!portableDir,
|
isPortable: !!portableDir,
|
||||||
clientDir: portableDir ?? json.clientDir
|
clientDir: portableDir ?? json.clientDir
|
||||||
};
|
});
|
||||||
const shape = PreferencesSchema.shape;
|
const shape = PreferencesSchema.shape;
|
||||||
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
|
for (const key of Object.keys(shape) as (keyof typeof shape)[]) {
|
||||||
if (!(key in merged)) continue;
|
if (!(key in merged)) continue;
|
||||||
const value = (merged as Record<string, unknown>)[key];
|
const value = (merged as Record<string, unknown>)[key];
|
||||||
if (shape[key].safeParse(value).success) salvaged[key] = value;
|
if (shape[key].safeParse(value).success) salvaged[key] = value;
|
||||||
}
|
}
|
||||||
return PreferencesSchema.parse(salvaged);
|
return this.#withKnownClientDir(PreferencesSchema.parse(salvaged));
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
static get data(): PreferencesSchema {
|
||||||
@@ -67,31 +193,50 @@ abstract class Preferences {
|
|||||||
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
static set data(newData: Partial<Omit<PreferencesSchema, 'portableDir'>>) {
|
||||||
this.#data = { ...this.#data, ...newData };
|
this.#data = { ...this.#data, ...newData };
|
||||||
|
|
||||||
const settingsPath = path.join(this.userDataDir, 'settings.json');
|
if (this.#readOnly) return;
|
||||||
const delta = omit(
|
|
||||||
newData,
|
const settingsPath = this.#settingsPath;
|
||||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
const dropped = portableDir ? ['isPortable', 'clientDir'] : ['isPortable'];
|
||||||
|
const delta = dropUndefined(
|
||||||
|
omit(newData, dropped as (keyof typeof newData)[])
|
||||||
);
|
);
|
||||||
const snapshot = omit(
|
const snapshot = dropUndefined(
|
||||||
this.#data,
|
omit(this.#data, dropped as (keyof PreferencesSchema)[])
|
||||||
portableDir ? ['isPortable', 'clientDir'] : ['isPortable']
|
|
||||||
);
|
);
|
||||||
this.#writeChain = this.#writeChain
|
this.#writeChain = this.#writeChain
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
let onDisk: unknown = null;
|
let base: Record<string, unknown> | null = null;
|
||||||
try {
|
try {
|
||||||
onDisk = await fs.readJSON(settingsPath);
|
const onDisk = await readJsonRetrying(settingsPath);
|
||||||
} catch {
|
base =
|
||||||
onDisk = null;
|
!!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 base =
|
|
||||||
!!onDisk && typeof onDisk === 'object' && !Array.isArray(onDisk)
|
|
||||||
? (onDisk as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const merged = base ? { ...base, ...delta } : snapshot;
|
const merged = base ? { ...base, ...delta } : snapshot;
|
||||||
const tmp = `${settingsPath}.tmp`;
|
await writeJsonAtomic(settingsPath, merged);
|
||||||
await fs.writeJSON(tmp, merged, { spaces: 2 });
|
|
||||||
await fs.move(tmp, settingsPath, { overwrite: true });
|
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));
|
.catch(e => Logger.error('Failed to persist settings.json', e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const friendlyError = (e: unknown): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type FolderTags = 'allowExtra';
|
type FolderTags = 'allowExtra';
|
||||||
type FileTags = 'vanillaFixes';
|
type FileTags = 'vanillaFixes' | 'raidVisuals';
|
||||||
type FileManifest = { name: string } & (
|
type FileManifest = { name: string } & (
|
||||||
| { type: 'del' }
|
| { type: 'del' }
|
||||||
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
|
||||||
@@ -558,6 +558,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
const vanillaFixes = Preferences.data.config.vanillaFixes;
|
||||||
|
const raidVisuals = Preferences.data.config.raidVisuals;
|
||||||
const modOwnedFiles = new Set<string>();
|
const modOwnedFiles = new Set<string>();
|
||||||
for (const state of Object.values(Preferences.data.mods ?? {}))
|
for (const state of Object.values(Preferences.data.mods ?? {}))
|
||||||
for (const rel of state?.installedFiles ?? [])
|
for (const rel of state?.installedFiles ?? [])
|
||||||
@@ -709,6 +710,12 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.tags?.includes('raidVisuals') && !raidVisuals) {
|
||||||
|
if (await fs.exists(path.join(clientPath, ...filePath)))
|
||||||
|
return { type: 'del', name: item.name };
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
this.status = {
|
this.status = {
|
||||||
state: 'verifying',
|
state: 'verifying',
|
||||||
progress: i / totalSize,
|
progress: i / totalSize,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ if (!port) throw new Error('IllegalState');
|
|||||||
const { dir, url, ref } = workerData;
|
const { dir, url, ref } = workerData;
|
||||||
|
|
||||||
const tmpDir = `${dir}.tmp`;
|
const tmpDir = `${dir}.tmp`;
|
||||||
|
const bakDir = `${dir}.bak`;
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
await fs.remove(tmpDir);
|
await fs.remove(tmpDir);
|
||||||
@@ -23,8 +24,18 @@ const run = async () => {
|
|||||||
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
onProgress: (...args) => port.postMessage({ cb: 'onProgress', args })
|
||||||
});
|
});
|
||||||
|
|
||||||
await fs.remove(dir);
|
await fs.remove(bakDir);
|
||||||
await fs.move(tmpDir, dir);
|
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()
|
run()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { PreferencesSchema } from '~common/schemas';
|
import { PreferencesSchema } from '~common/schemas';
|
||||||
import zodResolver from '~renderer/utils/zodResolver';
|
import zodResolver from '~renderer/utils/zodResolver';
|
||||||
@@ -8,6 +8,7 @@ import { useT } from '~renderer/i18n';
|
|||||||
|
|
||||||
import TextButton from './styled/TextButton';
|
import TextButton from './styled/TextButton';
|
||||||
import FilePickerInput from './form/FilePickerInput';
|
import FilePickerInput from './form/FilePickerInput';
|
||||||
|
import CheckboxInput from './form/CheckboxInput';
|
||||||
import CloseButton from './styled/CloseButton';
|
import CloseButton from './styled/CloseButton';
|
||||||
|
|
||||||
type Props = { close: () => void };
|
type Props = { close: () => void };
|
||||||
@@ -36,6 +37,19 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
resolver: zodResolver(PreferencesSchema.pick({ clientDir: true }))
|
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(() => {
|
useEffect(() => {
|
||||||
pref && reset(pref);
|
pref && reset(pref);
|
||||||
}, [reset, pref]);
|
}, [reset, pref]);
|
||||||
@@ -62,6 +76,7 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
<form
|
<form
|
||||||
className="tw-dialog"
|
className="tw-dialog"
|
||||||
onSubmit={handleSubmit(async ({ clientDir }) => {
|
onSubmit={handleSubmit(async ({ clientDir }) => {
|
||||||
|
if (needsEmptyConfirm && !acceptEmpty) return;
|
||||||
try {
|
try {
|
||||||
await setPref.mutateAsync({ clientDir });
|
await setPref.mutateAsync({ clientDir });
|
||||||
verify.mutate();
|
verify.mutate();
|
||||||
@@ -105,9 +120,23 @@ const ClientDirDialog = ({ close }: Props) => {
|
|||||||
</p>
|
</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
|
<TextButton
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={formState.isSubmitting}
|
loading={formState.isSubmitting}
|
||||||
|
disabled={needsEmptyConfirm && !acceptEmpty}
|
||||||
className="self-end text-green"
|
className="self-end text-green"
|
||||||
>
|
>
|
||||||
{t('prefs.confirm')}
|
{t('prefs.confirm')}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ const NewsEntry = ({ item }: { item: NewsItem }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The "Announcements" list — most-recent forum topics as short previews + links.
|
|
||||||
const AnnouncementsBox = () => {
|
const AnnouncementsBox = () => {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const query = api.news.list.useQuery(undefined, {
|
const query = api.news.list.useQuery(undefined, {
|
||||||
@@ -108,9 +107,6 @@ const AnnouncementsBox = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The News tab holds both boxes side by side: the parchment "newsletter" (the
|
|
||||||
// featured Nautilus News Network post, biggest) and the "Announcements" list.
|
|
||||||
// Living inside the tab means they only show on News — not on Tweaks/Addons/Mods.
|
|
||||||
const NewsTab = () => (
|
const NewsTab = () => (
|
||||||
<div className="flex min-h-0 flex-grow gap-3">
|
<div className="flex min-h-0 flex-grow gap-3">
|
||||||
<ForumAnnouncementPanel />
|
<ForumAnnouncementPanel />
|
||||||
|
|||||||
@@ -117,6 +117,12 @@ const TweaksTab = () => {
|
|||||||
label={t('tweaks.alwaysAutoLoot.label')}
|
label={t('tweaks.alwaysAutoLoot.label')}
|
||||||
text={t('tweaks.alwaysAutoLoot.text')}
|
text={t('tweaks.alwaysAutoLoot.text')}
|
||||||
/>
|
/>
|
||||||
|
<Item
|
||||||
|
form={form}
|
||||||
|
id="raidVisuals"
|
||||||
|
label={t('tweaks.raidVisuals.label')}
|
||||||
|
text={t('tweaks.raidVisuals.text')}
|
||||||
|
/>
|
||||||
<Item
|
<Item
|
||||||
form={form}
|
form={form}
|
||||||
id="largeAddress"
|
id="largeAddress"
|
||||||
@@ -214,8 +220,15 @@ const TweaksTab = () => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const config =
|
const config =
|
||||||
recommendedFarClip != null
|
recommendedFarClip != null
|
||||||
? { ...ConfigWtfSchema.parse({}), farClip: recommendedFarClip }
|
? {
|
||||||
: ConfigWtfSchema.parse({});
|
...ConfigWtfSchema.parse({}),
|
||||||
|
farClip: recommendedFarClip,
|
||||||
|
raidVisuals: form.getValues('raidVisuals')
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...ConfigWtfSchema.parse({}),
|
||||||
|
raidVisuals: form.getValues('raidVisuals')
|
||||||
|
};
|
||||||
await setPref.mutateAsync({ config, farClipUserSet: false });
|
await setPref.mutateAsync({ config, farClipUserSet: false });
|
||||||
reset(config);
|
reset(config);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ const enUS: Dict = {
|
|||||||
'launch.remaining': 'remaining',
|
'launch.remaining': 'remaining',
|
||||||
'launch.calculating': 'calculating…',
|
'launch.calculating': 'calculating…',
|
||||||
'launch.onDisk': 'on disk',
|
'launch.onDisk': 'on disk',
|
||||||
|
'tweaks.raidVisuals.label': 'Updated Raid Visuals',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'Optional ~9 MB download. Adds clearer ground markers and sounds for raid boss abilities, kept in sync with the server automatically. (AKA Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': 'Always auto-loot',
|
'tweaks.alwaysAutoLoot.label': 'Always auto-loot',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
|
'Reverses auto-loot behavior to always auto-loot and disable auto-with bound key.',
|
||||||
@@ -224,6 +227,10 @@ const enUS: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.',
|
'You may also choose a directory with an existing Turtle WoW or Vanilla WoW installation, and it will be automatically upgraded.',
|
||||||
'prefs.installDirectory': 'Install directory:',
|
'prefs.installDirectory': 'Install directory:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'No {exe} in this folder. The launcher will download a fresh client here, and it will not contain your existing addons or settings. If you already have an install, pick that folder instead.',
|
||||||
|
'prefs.noClientHereConfirm':
|
||||||
|
'I understand — download a fresh client into this folder',
|
||||||
'prefs.confirm': 'Confirm'
|
'prefs.confirm': 'Confirm'
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -260,6 +267,9 @@ const deDE: Dict = {
|
|||||||
'launch.remaining': 'verbleibend',
|
'launch.remaining': 'verbleibend',
|
||||||
'launch.calculating': 'wird berechnet…',
|
'launch.calculating': 'wird berechnet…',
|
||||||
'launch.onDisk': 'auf der Festplatte',
|
'launch.onDisk': 'auf der Festplatte',
|
||||||
|
'tweaks.raidVisuals.label': 'Aktualisierte Raid-Effekte',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'Optionaler Download (~9 MB). Fügt deutlichere Bodenmarkierungen und Sounds für Raidboss-Fähigkeiten hinzu, automatisch mit dem Server synchron gehalten. (auch bekannt als Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern',
|
'tweaks.alwaysAutoLoot.label': 'Immer automatisch plündern',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.',
|
'Kehrt das Auto-Plündern-Verhalten um, sodass immer automatisch geplündert wird und das Auto-Plündern per Tastenkombination deaktiviert ist.',
|
||||||
@@ -429,6 +439,10 @@ const deDE: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.',
|
'Du kannst auch ein Verzeichnis mit einer vorhandenen Turtle-WoW- oder Vanilla-WoW-Installation wählen, und es wird automatisch aktualisiert.',
|
||||||
'prefs.installDirectory': 'Installationsverzeichnis:',
|
'prefs.installDirectory': 'Installationsverzeichnis:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'In diesem Ordner ist keine {exe}. Der Launcher lädt hier einen neuen Client herunter, der deine vorhandenen Addons und Einstellungen nicht enthält. Wenn du bereits eine Installation hast, wähle stattdessen deren Ordner.',
|
||||||
|
'prefs.noClientHereConfirm':
|
||||||
|
'Verstanden — einen neuen Client in diesen Ordner herunterladen',
|
||||||
'prefs.confirm': 'Bestätigen',
|
'prefs.confirm': 'Bestätigen',
|
||||||
'misc.newsTitle': 'Neuigkeiten',
|
'misc.newsTitle': 'Neuigkeiten',
|
||||||
'misc.newsByAuthor': 'von {author}',
|
'misc.newsByAuthor': 'von {author}',
|
||||||
@@ -485,6 +499,9 @@ const zhCN: Dict = {
|
|||||||
'launch.remaining': '剩余',
|
'launch.remaining': '剩余',
|
||||||
'launch.calculating': '计算中…',
|
'launch.calculating': '计算中…',
|
||||||
'launch.onDisk': '在磁盘上',
|
'launch.onDisk': '在磁盘上',
|
||||||
|
'tweaks.raidVisuals.label': '团队副本视觉增强',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'可选下载(约 9 MB)。为团队首领技能添加更清晰的地面标记和音效,并自动与服务器保持同步。(又称 Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': '始终自动拾取',
|
'tweaks.alwaysAutoLoot.label': '始终自动拾取',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
|
'反转自动拾取行为,改为始终自动拾取,并禁用按住绑定键的自动拾取。',
|
||||||
@@ -638,6 +655,9 @@ const zhCN: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
|
'你也可以选择一个已有 Turtle WoW 或 Vanilla WoW 安装的目录,它将被自动升级。',
|
||||||
'prefs.installDirectory': '安装目录:',
|
'prefs.installDirectory': '安装目录:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'此文件夹中没有 {exe}。启动器将在此处下载全新的客户端,其中不会包含你现有的插件和设置。如果你已经安装过,请改为选择原有的安装目录。',
|
||||||
|
'prefs.noClientHereConfirm': '我已了解——在此文件夹下载全新客户端',
|
||||||
'prefs.confirm': '确认',
|
'prefs.confirm': '确认',
|
||||||
'misc.newsTitle': '新闻',
|
'misc.newsTitle': '新闻',
|
||||||
'misc.newsByAuthor': '作者:{author}',
|
'misc.newsByAuthor': '作者:{author}',
|
||||||
@@ -693,6 +713,9 @@ const esES: Dict = {
|
|||||||
'launch.remaining': 'restante',
|
'launch.remaining': 'restante',
|
||||||
'launch.calculating': 'calculando…',
|
'launch.calculating': 'calculando…',
|
||||||
'launch.onDisk': 'en disco',
|
'launch.onDisk': 'en disco',
|
||||||
|
'tweaks.raidVisuals.label': 'Efectos de banda mejorados',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'Descarga opcional de ~9 MB. Añade marcadores de suelo y sonidos más claros para las habilidades de los jefes de banda, sincronizados automáticamente con el servidor. (también conocido como Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre',
|
'tweaks.alwaysAutoLoot.label': 'Saqueo automático siempre',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.',
|
'Invierte el comportamiento del saqueo automático para saquear siempre de forma automática y desactivar el saqueo automático con tecla asignada.',
|
||||||
@@ -861,6 +884,10 @@ const esES: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.',
|
'También puedes elegir un directorio con una instalación existente de Turtle WoW o Vanilla WoW, y se actualizará automáticamente.',
|
||||||
'prefs.installDirectory': 'Directorio de instalación:',
|
'prefs.installDirectory': 'Directorio de instalación:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'No hay ningún {exe} en esta carpeta. El launcher descargará aquí un cliente nuevo, que no incluirá tus addons ni tu configuración actuales. Si ya tienes una instalación, elige esa carpeta.',
|
||||||
|
'prefs.noClientHereConfirm':
|
||||||
|
'Lo entiendo: descargar un cliente nuevo en esta carpeta',
|
||||||
'prefs.confirm': 'Confirmar',
|
'prefs.confirm': 'Confirmar',
|
||||||
'misc.newsTitle': 'Noticias',
|
'misc.newsTitle': 'Noticias',
|
||||||
'misc.newsByAuthor': 'por {author}',
|
'misc.newsByAuthor': 'por {author}',
|
||||||
@@ -919,6 +946,9 @@ const ptBR: Dict = {
|
|||||||
'launch.remaining': 'restante',
|
'launch.remaining': 'restante',
|
||||||
'launch.calculating': 'calculando…',
|
'launch.calculating': 'calculando…',
|
||||||
'launch.onDisk': 'no disco',
|
'launch.onDisk': 'no disco',
|
||||||
|
'tweaks.raidVisuals.label': 'Efeitos de raide atualizados',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'Download opcional de ~9 MB. Adiciona marcações de chão e sons mais claros para as habilidades dos chefes de raide, mantidos em sincronia com o servidor automaticamente. (também conhecido como Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo',
|
'tweaks.alwaysAutoLoot.label': 'Saque automático sempre ativo',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.',
|
'Inverte o comportamento do saque automático para saquear sempre automaticamente e desativa o saque automático com a tecla atribuída.',
|
||||||
@@ -1086,6 +1116,9 @@ const ptBR: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.',
|
'Você também pode escolher um diretório com uma instalação existente do Turtle WoW ou Vanilla WoW, que será atualizada automaticamente.',
|
||||||
'prefs.installDirectory': 'Diretório de instalação:',
|
'prefs.installDirectory': 'Diretório de instalação:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'Não há {exe} nesta pasta. O launcher vai baixar um cliente novo aqui, sem os seus addons e configurações atuais. Se você já tem uma instalação, escolha a pasta dela.',
|
||||||
|
'prefs.noClientHereConfirm': 'Entendi — baixar um cliente novo nesta pasta',
|
||||||
'prefs.confirm': 'Confirmar',
|
'prefs.confirm': 'Confirmar',
|
||||||
'misc.newsTitle': 'Notícias',
|
'misc.newsTitle': 'Notícias',
|
||||||
'misc.newsByAuthor': 'por {author}',
|
'misc.newsByAuthor': 'por {author}',
|
||||||
@@ -1142,6 +1175,9 @@ const ruRU: Dict = {
|
|||||||
'launch.remaining': 'осталось',
|
'launch.remaining': 'осталось',
|
||||||
'launch.calculating': 'вычисление…',
|
'launch.calculating': 'вычисление…',
|
||||||
'launch.onDisk': 'на диске',
|
'launch.onDisk': 'на диске',
|
||||||
|
'tweaks.raidVisuals.label': 'Улучшенные эффекты рейдов',
|
||||||
|
'tweaks.raidVisuals.text':
|
||||||
|
'Дополнительная загрузка (~9 МБ). Добавляет более заметные отметки на земле и звуки для способностей рейдовых боссов, автоматически синхронизируется с сервером. (также известен как Patch-O)',
|
||||||
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
|
'tweaks.alwaysAutoLoot.label': 'Всегда автосбор',
|
||||||
'tweaks.alwaysAutoLoot.text':
|
'tweaks.alwaysAutoLoot.text':
|
||||||
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
|
'Меняет поведение автосбора на противоположное: всегда автоматически собирать добычу, а ручной сбор включается зажатой клавишей.',
|
||||||
@@ -1305,6 +1341,9 @@ const ruRU: Dict = {
|
|||||||
'prefs.upgradeExisting':
|
'prefs.upgradeExisting':
|
||||||
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
|
'Вы также можете выбрать папку с уже установленным Turtle WoW или Vanilla WoW, и она будет автоматически обновлена.',
|
||||||
'prefs.installDirectory': 'Папка установки:',
|
'prefs.installDirectory': 'Папка установки:',
|
||||||
|
'prefs.noClientHere':
|
||||||
|
'В этой папке нет {exe}. Лаунчер скачает сюда новый клиент, в котором не будет ваших текущих аддонов и настроек. Если игра уже установлена, выберите её папку.',
|
||||||
|
'prefs.noClientHereConfirm': 'Понятно — скачать новый клиент в эту папку',
|
||||||
'prefs.confirm': 'Подтвердить',
|
'prefs.confirm': 'Подтвердить',
|
||||||
'misc.newsTitle': 'Новости',
|
'misc.newsTitle': 'Новости',
|
||||||
'misc.newsByAuthor': 'от {author}',
|
'misc.newsByAuthor': 'от {author}',
|
||||||
|
|||||||
Reference in New Issue
Block a user