forked from OctoWoW/OctoLauncher
OctoLauncher 1.3.5
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
MAIN_VITE_SERVER_URL=https://octowow.st
|
MAIN_VITE_SERVER_URL=https://octowow.st
|
||||||
MAIN_VITE_CLIENT_VERSION=latest
|
MAIN_VITE_CLIENT_VERSION=latest
|
||||||
|
MAIN_VITE_CLIENT_TORRENT_URL=https://dl.octowow.st/download/client.torrent
|
||||||
|
MAIN_VITE_RAID_VISUALS_URL=https://dl.octowow.st/client/latest/Data/patch-O.mpq
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ extraResources:
|
|||||||
win:
|
win:
|
||||||
artifactName: ${productName}.${ext}
|
artifactName: ${productName}.${ext}
|
||||||
target:
|
target:
|
||||||
- portable
|
|
||||||
- nsis
|
- nsis
|
||||||
nsis:
|
nsis:
|
||||||
artifactName: ${productName}_Installer.${ext}
|
# versioned: differential updates need the old blockmap to stay fetchable
|
||||||
|
artifactName: ${productName}_Installer-${version}.${ext}
|
||||||
uninstallDisplayName: ${productName}
|
uninstallDisplayName: ${productName}
|
||||||
oneClick: false
|
oneClick: false
|
||||||
removeDefaultUninstallWelcomePage: true
|
removeDefaultUninstallWelcomePage: true
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "octo-launcher",
|
"name": "octo-launcher",
|
||||||
"version": "1.3.1",
|
"version": "1.3.5",
|
||||||
"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",
|
||||||
|
|||||||
+6
-5
@@ -207,8 +207,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[] = [];
|
// fallback for profiles with no stored state: enabled, so legacy installs
|
||||||
|
// keep their mods; fresh installs seed explicit off rows instead (do NOT
|
||||||
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(
|
// flip this list to change defaults, it strips mods from legacy profiles)
|
||||||
m => !m.disabled && !NOT_DEFAULT_ENABLED.includes(m.id)
|
export const DEFAULT_ENABLED_MODS: ModId[] = MODS.filter(m => !m.disabled).map(
|
||||||
).map(m => m.id);
|
m => m.id
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,14 +2,21 @@ import { patchConfig, patchExecutable } from '~main/modules/patcher';
|
|||||||
import Preferences from '~main/modules/preferences';
|
import Preferences from '~main/modules/preferences';
|
||||||
import Updater from '~main/modules/updater';
|
import Updater from '~main/modules/updater';
|
||||||
import { getClientVersion } from '~main/utils';
|
import { getClientVersion } from '~main/utils';
|
||||||
|
import { stopSeeding } from '~main/modules/aria2';
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from '../trpc';
|
import { createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
export const patcherRouter = createTRPCRouter({
|
export const patcherRouter = createTRPCRouter({
|
||||||
apply: publicProcedure.mutation(async () => {
|
apply: publicProcedure.mutation(async () => {
|
||||||
await patchExecutable();
|
// release the seeder's file handles so the patchers can write
|
||||||
await patchConfig(true);
|
stopSeeding();
|
||||||
await Updater.recordPatchedWow();
|
try {
|
||||||
Preferences.data = { version: await getClientVersion() };
|
await patchExecutable();
|
||||||
|
await patchConfig(true);
|
||||||
|
await Updater.recordPatchedWow();
|
||||||
|
Preferences.data = { version: await getClientVersion() };
|
||||||
|
} finally {
|
||||||
|
await Updater.refreshSeeding();
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -269,6 +269,16 @@ const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// archives the old client shipped under names the current one no longer uses;
|
||||||
|
// matched by name AND exact size so player mods reusing a name are never touched
|
||||||
|
const LEGACY_ARCHIVES: Record<string, number> = {
|
||||||
|
'patch-6.mpq': 451195806,
|
||||||
|
'patch-7.mpq': 175256564,
|
||||||
|
'patch-8.mpq': 484649870,
|
||||||
|
'patch-9.mpq': 506808141,
|
||||||
|
'patch-a.mpq': 241751337
|
||||||
|
};
|
||||||
|
|
||||||
export const pruneStaleArchives = async (
|
export const pruneStaleArchives = async (
|
||||||
clientDir: string,
|
clientDir: string,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -299,6 +309,7 @@ export const pruneStaleArchives = async (
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
|
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
|
||||||
|
if (LEGACY_ARCHIVES[lc] !== st.size) continue;
|
||||||
await fs.remove(full);
|
await fs.remove(full);
|
||||||
removed.push(name);
|
removed.push(name);
|
||||||
}
|
}
|
||||||
@@ -309,6 +320,12 @@ export const pruneStaleArchives = async (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// files the torrent ships but a mod toggle owns; the sync must not re-add
|
||||||
|
// them or count their absence as an incomplete tree
|
||||||
|
const LAUNCHER_OWNED_FILES = new Set(['d3d9.dll']);
|
||||||
|
const isLauncherOwned = (parts: string[]) =>
|
||||||
|
parts.length === 1 && LAUNCHER_OWNED_FILES.has(parts[0].toLowerCase());
|
||||||
|
|
||||||
export const torrentDownloadSelection = async (
|
export const torrentDownloadSelection = async (
|
||||||
clientDir: string,
|
clientDir: string,
|
||||||
url: string,
|
url: string,
|
||||||
@@ -327,6 +344,7 @@ export const torrentDownloadSelection = async (
|
|||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const f = files[i];
|
const f = files[i];
|
||||||
if (!f.path?.length || typeof f.length !== 'number') return null;
|
if (!f.path?.length || typeof f.length !== 'number') return null;
|
||||||
|
if (isLauncherOwned(f.path)) continue;
|
||||||
const dest = path.join(clientDir, ...f.path);
|
const dest = path.join(clientDir, ...f.path);
|
||||||
const st = await fs.stat(dest).catch(() => null);
|
const st = await fs.stat(dest).catch(() => null);
|
||||||
if (!st) {
|
if (!st) {
|
||||||
@@ -361,6 +379,7 @@ export const torrentTreeIntact = async (
|
|||||||
if (!files.length) return false;
|
if (!files.length) return false;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (!f.path?.length || typeof f.length !== 'number') return false;
|
if (!f.path?.length || typeof f.length !== 'number') return false;
|
||||||
|
if (isLauncherOwned(f.path)) continue;
|
||||||
const st = await fs
|
const st = await fs
|
||||||
.stat(path.join(clientDir, ...f.path))
|
.stat(path.join(clientDir, ...f.path))
|
||||||
.catch(() => null);
|
.catch(() => null);
|
||||||
@@ -439,6 +458,8 @@ export const startSeeding = async (
|
|||||||
'--bt-seed-unverified=true',
|
'--bt-seed-unverified=true',
|
||||||
`--seed-time=${SEED_TIME_MINUTES}`,
|
`--seed-time=${SEED_TIME_MINUTES}`,
|
||||||
'--check-integrity=false',
|
'--check-integrity=false',
|
||||||
|
// no prealloc: the seeder must not recreate missing files as zeros
|
||||||
|
'--file-allocation=none',
|
||||||
'--continue=true',
|
'--continue=true',
|
||||||
'--bt-save-metadata=true',
|
'--bt-save-metadata=true',
|
||||||
'--enable-dht=true',
|
'--enable-dht=true',
|
||||||
|
|||||||
@@ -145,8 +145,15 @@ export const detectAntivirusBlocks = async (): Promise<string[]> => {
|
|||||||
const blocked = new Set<string>();
|
const blocked = new Set<string>();
|
||||||
|
|
||||||
if (clientDir && Preferences.data.syncedTorrentHash)
|
if (clientDir && Preferences.data.syncedTorrentHash)
|
||||||
for (const name of SENSITIVE_FILES)
|
for (const name of SENSITIVE_FILES) {
|
||||||
|
// d3d9.dll is deliberately parked while dxvk is off, not blocked
|
||||||
|
if (
|
||||||
|
name === 'd3d9.dll' &&
|
||||||
|
Preferences.data.mods?.dxvk?.enabled === false
|
||||||
|
)
|
||||||
|
continue;
|
||||||
if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name);
|
if (!fs.existsSync(path.join(clientDir, name))) blocked.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
const script =
|
const script =
|
||||||
'Get-MpThreatDetection | Where-Object ' +
|
'Get-MpThreatDetection | Where-Object ' +
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { type ModState } from '~common/schemas';
|
import { type ModState } from '~common/schemas';
|
||||||
|
|
||||||
import Preferences from './preferences';
|
import Preferences from './preferences';
|
||||||
import { isTorrentMode } from './aria2';
|
import { isTorrentMode, stopSeeding } from './aria2';
|
||||||
import Observable from './observable';
|
import Observable from './observable';
|
||||||
import Updater from './updater';
|
import Updater from './updater';
|
||||||
import { addDll, removeDll, listDlls } from './dllsTxt';
|
import { addDll, removeDll, listDlls } from './dllsTxt';
|
||||||
@@ -287,6 +287,33 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
|
|
||||||
// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
|
// torrent mode: DLLs ship in the client; a missing file goes to `missing`, not dirty
|
||||||
if (isTorrentMode()) {
|
if (isTorrentMode()) {
|
||||||
|
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
|
||||||
|
// dxvk loads by file presence, not dlls.txt; disabled parks
|
||||||
|
// d3d9.dll as .off, enabled restores it
|
||||||
|
if (m.id === 'dxvk' && clientDir) {
|
||||||
|
const live = path.join(clientDir, 'd3d9.dll');
|
||||||
|
const off = path.join(clientDir, 'd3d9.dll.off');
|
||||||
|
const liveStat = await fs.stat(live).catch(() => null);
|
||||||
|
if (!enabled && liveStat && liveStat.size === 0) {
|
||||||
|
// 0-byte aria2 stub, never park it over a real copy
|
||||||
|
await fs.remove(live).catch(() => undefined);
|
||||||
|
} else if (!enabled && liveStat) {
|
||||||
|
await fs.remove(off).catch(() => undefined);
|
||||||
|
await fs
|
||||||
|
.move(live, off)
|
||||||
|
.then(() => Logger.info('dxvk disabled: parked d3d9.dll'))
|
||||||
|
.catch(e => Logger.warn('Could not park d3d9.dll', e));
|
||||||
|
} else if (
|
||||||
|
enabled &&
|
||||||
|
!(await fs.pathExists(live)) &&
|
||||||
|
(await fs.pathExists(off))
|
||||||
|
) {
|
||||||
|
await fs
|
||||||
|
.move(off, live)
|
||||||
|
.then(() => Logger.info('dxvk enabled: restored d3d9.dll'))
|
||||||
|
.catch(e => Logger.warn('Could not restore d3d9.dll', e));
|
||||||
|
}
|
||||||
|
}
|
||||||
const files = modTargetFiles(m);
|
const files = modTargetFiles(m);
|
||||||
const present =
|
const present =
|
||||||
!!clientDir &&
|
!!clientDir &&
|
||||||
@@ -296,7 +323,6 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
|
files.map(rel => fs.pathExists(path.join(clientDir, rel)))
|
||||||
)
|
)
|
||||||
).every(Boolean);
|
).every(Boolean);
|
||||||
const enabled = state?.enabled ?? DEFAULT_ENABLED_MODS.includes(m.id);
|
|
||||||
installedVersion = enabled ? m.version : undefined;
|
installedVersion = enabled ? m.version : undefined;
|
||||||
if (enabled && files.length > 0 && !present) missing.push(m.name);
|
if (enabled && files.length > 0 && !present) missing.push(m.name);
|
||||||
// only point dlls.txt at a file actually on disk
|
// only point dlls.txt at a file actually on disk
|
||||||
@@ -452,9 +478,15 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
}
|
}
|
||||||
// commit the player's own DLL toggles first
|
// commit the player's own DLL toggles first
|
||||||
await this.#applyCustomDlls(clientDir);
|
await this.#applyCustomDlls(clientDir);
|
||||||
// torrent mode: mods ship in the client; just reconcile dlls.txt
|
// torrent mode: mods ship in the client; reconcile dlls.txt. The
|
||||||
|
// seeder holds files open, so release it for the dxvk park/restore.
|
||||||
if (isTorrentMode()) {
|
if (isTorrentMode()) {
|
||||||
await this.verify();
|
stopSeeding();
|
||||||
|
try {
|
||||||
|
await this.verify();
|
||||||
|
} finally {
|
||||||
|
await Updater.refreshSeeding().catch(() => undefined);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (this._value.state === 'busy') {
|
if (this._value.state === 'busy') {
|
||||||
@@ -508,9 +540,33 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #install(m: ModEntry) {
|
async #install(m: ModEntry) {
|
||||||
// In torrent mode the mod binaries ship with the client; nothing is fetched.
|
|
||||||
if (isTorrentMode()) return;
|
|
||||||
const clientDir = Preferences.data?.clientDir;
|
const clientDir = Preferences.data?.clientDir;
|
||||||
|
// dxvk: restoring a parked copy is the only enable path that works in
|
||||||
|
// torrent mode (nothing is fetched there, the sync ignores d3d9.dll)
|
||||||
|
if (m.id === 'dxvk' && clientDir) {
|
||||||
|
const live = path.join(clientDir, 'd3d9.dll');
|
||||||
|
const off = path.join(clientDir, 'd3d9.dll.off');
|
||||||
|
if (!(await fs.pathExists(live)) && (await fs.pathExists(off))) {
|
||||||
|
Logger.info('Restoring parked d3d9.dll for dxvk');
|
||||||
|
await fs.move(off, live);
|
||||||
|
await this.#savePref(m.id, {
|
||||||
|
enabled: true,
|
||||||
|
installedVersion: m.version,
|
||||||
|
installedFiles: ['d3d9.dll'],
|
||||||
|
ignoreUpdates:
|
||||||
|
Preferences.data?.mods?.[m.id]?.ignoreUpdates ?? false
|
||||||
|
});
|
||||||
|
this.#patchRow(m.id, {
|
||||||
|
state: 'idle',
|
||||||
|
installedVersion: m.version,
|
||||||
|
progress: 1
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// torrent mode ships mod binaries with the client; dxvk is the
|
||||||
|
// exception (unsynced), a fresh enable with no parked copy downloads
|
||||||
|
if (isTorrentMode() && m.id !== 'dxvk') return;
|
||||||
if (!clientDir) throw new Error('No client dir');
|
if (!clientDir) throw new Error('No client dir');
|
||||||
if (m.source.kind === 'managed') return;
|
if (m.source.kind === 'managed') return;
|
||||||
|
|
||||||
@@ -605,7 +661,20 @@ class ModsClass extends Observable<ModsStatus> {
|
|||||||
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
|
this.#patchRow(m.id, { state: 'uninstalling', error: undefined });
|
||||||
|
|
||||||
const cur = Preferences.data?.mods?.[m.id];
|
const cur = Preferences.data?.mods?.[m.id];
|
||||||
const files = cur?.installedFiles ?? [];
|
// dxvk: park instead of delete so re-enable is instant and offline
|
||||||
|
const files = [...(cur?.installedFiles ?? [])].filter(
|
||||||
|
f => !(m.id === 'dxvk' && /d3d9\.dll$/i.test(f))
|
||||||
|
);
|
||||||
|
if (m.id === 'dxvk') {
|
||||||
|
const live = path.join(clientDir, 'd3d9.dll');
|
||||||
|
const off = path.join(clientDir, 'd3d9.dll.off');
|
||||||
|
if (await fs.pathExists(live)) {
|
||||||
|
await fs.remove(off).catch(() => undefined);
|
||||||
|
await fs
|
||||||
|
.move(live, off)
|
||||||
|
.catch(err => Logger.warn(`Couldn't park ${live}:`, err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const rel of files) {
|
for (const rel of files) {
|
||||||
const fullPath = path.join(clientDir, rel);
|
const fullPath = path.join(clientDir, rel);
|
||||||
|
|||||||
@@ -85,6 +85,42 @@ export const patchExecutable = async () => {
|
|||||||
|
|
||||||
const loc = LOCALES[locale];
|
const loc = LOCALES[locale];
|
||||||
|
|
||||||
|
// revert any previous locale patch to the pristine bytes first, so a
|
||||||
|
// language switch (or an adopted pre-patched exe) can re-patch cleanly
|
||||||
|
const TAG_OFFSET = 0x1b2115;
|
||||||
|
const INDEX_OFFSET = 0x253c;
|
||||||
|
const PRISTINE_TAG = [0xa1, 0xa4, 0xa2, 0xc2, 0x00];
|
||||||
|
const PRISTINE_INDEX = [0x33, 0xf6, 0x8b, 0xff, 0x8b, 0x04, 0xb5];
|
||||||
|
if (
|
||||||
|
buffer[TAG_OFFSET] === 0xb8 &&
|
||||||
|
buffer[INDEX_OFFSET] === 0xbe &&
|
||||||
|
buffer[INDEX_OFFSET + 5] === 0xeb
|
||||||
|
) {
|
||||||
|
const prevIndex = buffer[INDEX_OFFSET + 1];
|
||||||
|
const prevCarrier = LOCALE_NAMES[prevIndex] as string | undefined;
|
||||||
|
const prevTag = prevCarrier
|
||||||
|
? Buffer.from([
|
||||||
|
0xb8,
|
||||||
|
...Buffer.from(prevCarrier, 'latin1').reverse()
|
||||||
|
])
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
prevCarrier &&
|
||||||
|
prevTag &&
|
||||||
|
buffer.subarray(TAG_OFFSET, TAG_OFFSET + 5).equals(prevTag)
|
||||||
|
) {
|
||||||
|
Logger.log(
|
||||||
|
`Reverting previous locale patch (index ${prevIndex}) to the clean base`
|
||||||
|
);
|
||||||
|
Buffer.from(PRISTINE_TAG).copy(buffer, TAG_OFFSET);
|
||||||
|
Buffer.from(PRISTINE_INDEX).copy(buffer, INDEX_OFFSET);
|
||||||
|
Buffer.from(prevCarrier, 'latin1').copy(
|
||||||
|
buffer,
|
||||||
|
localeNameOffset(prevIndex)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const Tweaks = [
|
const Tweaks = [
|
||||||
{
|
{
|
||||||
key: 'largeAddress',
|
key: 'largeAddress',
|
||||||
@@ -110,11 +146,12 @@ export const patchExecutable = async () => {
|
|||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// shipped exe carries the enabled bytes; off must write 0x74 back
|
||||||
key: 'alwaysAutoLoot',
|
key: 'alwaysAutoLoot',
|
||||||
type: 'bytes',
|
type: 'bytes',
|
||||||
tweaks: [
|
tweaks: [
|
||||||
[0x0c1ecf, [0x75]],
|
[0x0c1ecf, [0x75], [0x74]],
|
||||||
[0x0c2b25, [0x75]]
|
[0x0c2b25, [0x75], [0x74]]
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
{ key: 'nameplateRange', type: 'float', offset: 0x40c448 },
|
||||||
@@ -223,7 +260,22 @@ export const patchExecutable = async () => {
|
|||||||
if (!t.forced && !val) return;
|
if (!t.forced && !val) return;
|
||||||
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) {
|
||||||
|
// disabled: revert sites carrying the enabled bytes to the
|
||||||
|
// stock bytes when known; unknown bytes stay untouched
|
||||||
|
t.tweaks.forEach(
|
||||||
|
([offset, bytes, expect]: [number, number[], number[]?]) => {
|
||||||
|
if (!expect) return;
|
||||||
|
const current = buffer.subarray(
|
||||||
|
offset,
|
||||||
|
offset + bytes.length
|
||||||
|
);
|
||||||
|
if (current.equals(Buffer.from(bytes)))
|
||||||
|
Buffer.from(expect).copy(buffer, offset);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
t.tweaks.forEach(
|
t.tweaks.forEach(
|
||||||
([offset, bytes, expect]: [number, number[], number[]?]) => {
|
([offset, bytes, expect]: [number, number[], number[]?]) => {
|
||||||
if (expect) {
|
if (expect) {
|
||||||
@@ -309,6 +361,11 @@ const repairResolution = async (
|
|||||||
const applyRealmlist = async (clientDir: string, host: string) => {
|
const applyRealmlist = async (clientDir: string, host: string) => {
|
||||||
const body = `set realmlist "${host}"\n`;
|
const body = `set realmlist "${host}"\n`;
|
||||||
const write = async (target: string) => {
|
const write = async (target: string) => {
|
||||||
|
// already correct: leave it alone (the seeder may hold the file open)
|
||||||
|
const current = await fs
|
||||||
|
.readFile(target, { encoding: 'utf-8' })
|
||||||
|
.catch(() => null);
|
||||||
|
if (current === body) return;
|
||||||
const tmp = `${target}.tmp`;
|
const tmp = `${target}.tmp`;
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(tmp, body);
|
await fs.writeFile(tmp, body);
|
||||||
@@ -332,6 +389,26 @@ const applyRealmlist = async (clientDir: string, host: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// rewrite realmlist.wtf when missing, empty, or wrong; an interrupted sync
|
||||||
|
// can leave a 0-byte placeholder that disconnects direct game launches
|
||||||
|
export const healRealmlist = async (clientDir: string) => {
|
||||||
|
const server: keyof typeof Servers = import.meta.env.MAIN_VITE_PTR_REALMLIST
|
||||||
|
? 'ptr'
|
||||||
|
: 'live';
|
||||||
|
const expected = `set realmlist "${Servers[server].realmList}"\n`;
|
||||||
|
const target = path.join(clientDir, 'realmlist.wtf');
|
||||||
|
const current = await fs
|
||||||
|
.readFile(target, { encoding: 'utf-8' })
|
||||||
|
.catch(() => null);
|
||||||
|
if (current === expected) return;
|
||||||
|
Logger.log(
|
||||||
|
`realmlist.wtf ${
|
||||||
|
current === null ? 'missing' : current.trim() ? 'wrong' : 'empty'
|
||||||
|
}; rewriting`
|
||||||
|
);
|
||||||
|
await applyRealmlist(clientDir, Servers[server].realmList);
|
||||||
|
};
|
||||||
|
|
||||||
export const patchConfig = async (forceTweaks = false) => {
|
export const patchConfig = async (forceTweaks = false) => {
|
||||||
const { clientDir, config, locale } = Preferences.data;
|
const { clientDir, config, locale } = Preferences.data;
|
||||||
if (!clientDir) return;
|
if (!clientDir) return;
|
||||||
|
|||||||
@@ -90,13 +90,13 @@ abstract class Preferences {
|
|||||||
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
|
static #withFreshInstallDefaults(data: PreferencesSchema): PreferencesSchema {
|
||||||
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
|
if (!this.#freshInstall || Object.keys(data.mods).length) return data;
|
||||||
|
|
||||||
|
// fresh installs seed every mod EXPLICITLY off; a missing row falls
|
||||||
|
// back to enabled, which keeps legacy profiles untouched
|
||||||
const mods = { ...data.mods };
|
const mods = { ...data.mods };
|
||||||
for (const id of DEFAULT_ENABLED_MODS)
|
for (const id of DEFAULT_ENABLED_MODS)
|
||||||
mods[id] = { enabled: true, installedFiles: [], ignoreUpdates: false };
|
mods[id] = { enabled: false, installedFiles: [], ignoreUpdates: false };
|
||||||
|
|
||||||
Logger.info(
|
Logger.info('Fresh install: all mods start disabled (opt-in)');
|
||||||
`Fresh install: enabling ${DEFAULT_ENABLED_MODS.join(', ')} by default`
|
|
||||||
);
|
|
||||||
return { ...data, mods };
|
return { ...data, mods };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
stopSeeding
|
stopSeeding
|
||||||
} from '~main/modules/aria2';
|
} from '~main/modules/aria2';
|
||||||
|
|
||||||
|
import { healRealmlist } from '~main/modules/patcher';
|
||||||
|
|
||||||
import Preferences from './preferences';
|
import Preferences from './preferences';
|
||||||
import Observable from './observable';
|
import Observable from './observable';
|
||||||
|
|
||||||
@@ -189,10 +191,14 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async refreshSeeding() {
|
async refreshSeeding() {
|
||||||
|
// no seeding while dxvk is off: aria2 would recreate the parked
|
||||||
|
// d3d9.dll as a 0-byte stub and break the game's d3d9 import
|
||||||
|
const dxvkOff = Preferences.data.mods?.dxvk?.enabled === false;
|
||||||
if (
|
if (
|
||||||
Preferences.data.shareDownloads !== false &&
|
Preferences.data.shareDownloads !== false &&
|
||||||
Preferences.data.clientDir &&
|
Preferences.data.clientDir &&
|
||||||
this.status.state === 'upToDate'
|
this.status.state === 'upToDate' &&
|
||||||
|
!dxvkOff
|
||||||
)
|
)
|
||||||
await startSeeding(Preferences.data.clientDir);
|
await startSeeding(Preferences.data.clientDir);
|
||||||
else stopSeeding();
|
else stopSeeding();
|
||||||
@@ -209,6 +215,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
progress: -1,
|
progress: -1,
|
||||||
message: 'Checking for updates...'
|
message: 'Checking for updates...'
|
||||||
};
|
};
|
||||||
|
// an interrupted sync can leave realmlist.wtf as a 0-byte placeholder
|
||||||
|
await healRealmlist(clientPath).catch(e =>
|
||||||
|
Logger.warn('realmlist heal failed', e)
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
const sha = await fetchTorrentSha(url);
|
const sha = await fetchTorrentSha(url);
|
||||||
await this.#reconcileClientPatch(clientPath);
|
await this.#reconcileClientPatch(clientPath);
|
||||||
@@ -359,8 +369,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
sha: string,
|
sha: string,
|
||||||
url: string
|
url: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!Preferences.data.syncedTorrentHash) return false;
|
|
||||||
if (!(await torrentTreeIntact(clientPath, url))) return false;
|
if (!(await torrentTreeIntact(clientPath, url))) return false;
|
||||||
|
// adopt a complete tree even on a fresh profile; empty delta selection
|
||||||
|
// would otherwise fall through to a full re-download
|
||||||
|
await clearTorrentResumeState().catch(() => {});
|
||||||
await refreshPristineWow(clientPath);
|
await refreshPristineWow(clientPath);
|
||||||
const removed = await pruneStaleArchives(
|
const removed = await pruneStaleArchives(
|
||||||
clientPath,
|
clientPath,
|
||||||
@@ -444,6 +456,10 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!(await torrentTreeIntact(clientPath, url))) {
|
if (!(await torrentTreeIntact(clientPath, url))) {
|
||||||
|
// stale resume state makes aria2 skip pieces it thinks are
|
||||||
|
// done; drop it so the next attempt starts from the real files
|
||||||
|
await clearTorrentResumeState().catch(() => undefined);
|
||||||
|
await healRealmlist(clientPath).catch(() => undefined);
|
||||||
this.status = {
|
this.status = {
|
||||||
state: 'updateAvailable',
|
state: 'updateAvailable',
|
||||||
message: 'Download incomplete. Click update to finish.'
|
message: 'Download incomplete. Click update to finish.'
|
||||||
@@ -458,6 +474,15 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
);
|
);
|
||||||
if (removed.length)
|
if (removed.length)
|
||||||
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
|
Logger.log(`Removed stale archives: ${removed.join(', ')}`);
|
||||||
|
// a clean sync restores d3d9.dll; re-park it if dxvk is off
|
||||||
|
if (Preferences.data.mods?.dxvk?.enabled === false) {
|
||||||
|
const live = path.join(clientPath, 'd3d9.dll');
|
||||||
|
const off = path.join(clientPath, 'd3d9.dll.off');
|
||||||
|
if (await fs.pathExists(live)) {
|
||||||
|
await fs.remove(off).catch(() => undefined);
|
||||||
|
await fs.move(live, off).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
await this.#reconcileClientPatch(clientPath);
|
await this.#reconcileClientPatch(clientPath);
|
||||||
await this.#reconcileRaidVisuals(clientPath);
|
await this.#reconcileRaidVisuals(clientPath);
|
||||||
Preferences.data = {
|
Preferences.data = {
|
||||||
@@ -468,6 +493,7 @@ class UpdaterClass extends Observable<UpdaterStatus> {
|
|||||||
await this.refreshSeeding();
|
await this.refreshSeeding();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Logger.error('Torrent update failed', e);
|
Logger.error('Torrent update failed', e);
|
||||||
|
await healRealmlist(clientPath).catch(() => undefined);
|
||||||
this.status = {
|
this.status = {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
message: e instanceof Error ? e.message : 'Download failed'
|
message: e instanceof Error ? e.message : 'Download failed'
|
||||||
|
|||||||
Reference in New Issue
Block a user