forked from OctoWoW/OctoLauncher
OctoLauncher 1.3.1
Manifest-based CDN updater and mod manager for the OctoWoW 1.12.1 client: launcher-owned realmlist, torrent-backed content sync with bundled aria2c, antivirus and Defender exclusion handling, hardware-aware render distance, optional client tweaks and mods, and the in-launcher news feed.
This commit is contained in:
@@ -0,0 +1,476 @@
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
|
||||
import { app } from 'electron';
|
||||
import fs from 'fs-extra';
|
||||
import Logger from 'electron-log/main';
|
||||
|
||||
import { mapPort, type PortMapping } from './upnp';
|
||||
|
||||
const TORRENT_NAME = 'client';
|
||||
|
||||
const bin = () =>
|
||||
app.isPackaged
|
||||
? path.join(process.resourcesPath, 'aria2c.exe')
|
||||
: path.join(app.getAppPath(), 'resources', 'aria2c.exe');
|
||||
|
||||
type SyncOpts = {
|
||||
torrentUrl: string;
|
||||
clientDir: string;
|
||||
totalBytes?: number;
|
||||
checkIntegrity?: boolean;
|
||||
seedTime?: number;
|
||||
selectFiles?: number[];
|
||||
onProgress?: (p: SyncProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export type SyncProgress = {
|
||||
progress: number;
|
||||
bytesDone: number;
|
||||
bytesTotal: number;
|
||||
bytesPerSecond: number;
|
||||
};
|
||||
|
||||
const ensureJunction = async (clientDir: string): Promise<string> => {
|
||||
await fs.ensureDir(clientDir);
|
||||
const staging = path.join(app.getPath('userData'), 'torrent-root');
|
||||
await fs.ensureDir(staging);
|
||||
const link = path.join(staging, TORRENT_NAME);
|
||||
|
||||
const target = path.resolve(clientDir);
|
||||
try {
|
||||
const cur = await fs.lstat(link);
|
||||
if (cur.isSymbolicLink() || cur.isDirectory()) {
|
||||
const resolved = await fs.realpath(link).catch(() => '');
|
||||
if (path.resolve(resolved) === target) return staging;
|
||||
}
|
||||
await fs.remove(link);
|
||||
} catch {}
|
||||
await fs.symlink(target, link, 'junction');
|
||||
return staging;
|
||||
};
|
||||
|
||||
const SIZE_UNITS: Record<string, number> = {
|
||||
B: 1,
|
||||
KiB: 1024,
|
||||
MiB: 1024 ** 2,
|
||||
GiB: 1024 ** 3,
|
||||
TiB: 1024 ** 4
|
||||
};
|
||||
|
||||
const toBytes = (s: string): number => {
|
||||
const m = /^([\d.]+)(B|KiB|MiB|GiB|TiB)$/.exec(s.trim());
|
||||
if (!m) return 0;
|
||||
return parseFloat(m[1]) * (SIZE_UNITS[m[2]] ?? 1);
|
||||
};
|
||||
|
||||
const parseProgress = (
|
||||
line: string,
|
||||
totalHint: number
|
||||
): SyncProgress | undefined => {
|
||||
const frac =
|
||||
/([\d.]+(?:B|KiB|MiB|GiB|TiB))\/([\d.]+(?:B|KiB|MiB|GiB|TiB))\((\d+)%\)/.exec(
|
||||
line
|
||||
);
|
||||
if (!frac) return undefined;
|
||||
const dl = /DL:([\d.]+(?:B|KiB|MiB|GiB|TiB))/.exec(line);
|
||||
return {
|
||||
progress: parseInt(frac[3], 10) / 100,
|
||||
bytesDone: toBytes(frac[1]),
|
||||
bytesTotal: toBytes(frac[2]) || totalHint,
|
||||
bytesPerSecond: dl ? toBytes(dl[1]) : 0
|
||||
};
|
||||
};
|
||||
|
||||
export const syncClient = (opts: SyncOpts): Promise<void> =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
let child: ChildProcess | undefined;
|
||||
ensureJunction(opts.clientDir)
|
||||
.then(dir => {
|
||||
const args = [
|
||||
`--dir=${dir}`,
|
||||
`--seed-time=${opts.seedTime ?? 0}`,
|
||||
`--check-integrity=${opts.checkIntegrity ? 'true' : 'false'}`,
|
||||
'--bt-save-metadata=true',
|
||||
'--bt-remove-unselected-file=false',
|
||||
'--continue=true',
|
||||
'--allow-overwrite=true',
|
||||
'--auto-file-renaming=false',
|
||||
'--file-allocation=none',
|
||||
'--disk-cache=128M',
|
||||
'--stream-piece-selector=inorder',
|
||||
'--max-tries=0',
|
||||
'--retry-wait=5',
|
||||
'--bt-stop-timeout=120',
|
||||
'--auto-save-interval=15',
|
||||
'--summary-interval=1',
|
||||
'--console-log-level=warn',
|
||||
'--enable-dht=true',
|
||||
'--bt-enable-lpd=true',
|
||||
'--max-connection-per-server=8',
|
||||
'--split=16',
|
||||
'--min-split-size=1M',
|
||||
...(opts.selectFiles?.length
|
||||
? [`--select-file=${opts.selectFiles.join(',')}`]
|
||||
: []),
|
||||
'--stop-with-process=' + process.pid,
|
||||
opts.torrentUrl
|
||||
];
|
||||
Logger.log(`aria2c ${args.join(' ')}`);
|
||||
child = spawn(bin(), args, { windowsHide: true });
|
||||
|
||||
const onLine = (buf: Buffer) => {
|
||||
for (const line of buf.toString().split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const p = parseProgress(line, opts.totalBytes ?? 0);
|
||||
if (p) opts.onProgress?.(p);
|
||||
else Logger.log(`[aria2] ${line}`);
|
||||
}
|
||||
};
|
||||
child.stdout?.on('data', onLine);
|
||||
child.stderr?.on('data', onLine);
|
||||
|
||||
opts.signal?.addEventListener('abort', () => child?.kill());
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', code => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`aria2c exited with code ${code}`));
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
|
||||
export const aria2Available = () => fs.pathExists(bin());
|
||||
|
||||
export const downloadIsComplete = async (): Promise<boolean> => {
|
||||
const control = path.join(
|
||||
app.getPath('userData'),
|
||||
'torrent-root',
|
||||
`${TORRENT_NAME}.aria2`
|
||||
);
|
||||
return !(await fs.pathExists(control));
|
||||
};
|
||||
|
||||
export const clearTorrentResumeState = async (): Promise<void> => {
|
||||
const dir = path.join(app.getPath('userData'), 'torrent-root');
|
||||
await Promise.all([
|
||||
fs.remove(path.join(dir, `${TORRENT_NAME}.aria2`)),
|
||||
fs.remove(path.join(dir, `${TORRENT_NAME}.torrent`))
|
||||
]);
|
||||
};
|
||||
|
||||
export const torrentUrl = (): string | undefined =>
|
||||
import.meta.env.MAIN_VITE_CLIENT_TORRENT_URL || undefined;
|
||||
|
||||
export const isTorrentMode = (): boolean => !!torrentUrl();
|
||||
|
||||
export const raidVisualsUrl = (): string | undefined =>
|
||||
import.meta.env.MAIN_VITE_RAID_VISUALS_URL || undefined;
|
||||
|
||||
export const clientPatchUrl = (): string | undefined =>
|
||||
import.meta.env.MAIN_VITE_CLIENT_PATCH_URL || undefined;
|
||||
|
||||
export const fetchTorrentSha = async (url: string): Promise<string> => {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const buf = Buffer.from(await r.arrayBuffer());
|
||||
return crypto.createHash('sha1').update(buf).digest('hex');
|
||||
};
|
||||
|
||||
const bdecode = (buf: Buffer, pos = 0): [unknown, number] => {
|
||||
if (pos >= buf.length) throw new Error('bencode: unexpected end of data');
|
||||
const ch = buf[pos];
|
||||
if (ch === 0x69) {
|
||||
const end = buf.indexOf(0x65, pos);
|
||||
if (end === -1) throw new Error('bencode: unterminated integer');
|
||||
return [parseInt(buf.toString('latin1', pos + 1, end), 10), end + 1];
|
||||
}
|
||||
if (ch === 0x6c) {
|
||||
const list: unknown[] = [];
|
||||
let p = pos + 1;
|
||||
while (buf[p] !== 0x65) {
|
||||
if (p >= buf.length) throw new Error('bencode: unterminated list');
|
||||
const [v, np] = bdecode(buf, p);
|
||||
list.push(v);
|
||||
p = np;
|
||||
}
|
||||
return [list, p + 1];
|
||||
}
|
||||
if (ch === 0x64) {
|
||||
const dict: Record<string, unknown> = {};
|
||||
let p = pos + 1;
|
||||
while (buf[p] !== 0x65) {
|
||||
if (p >= buf.length) throw new Error('bencode: unterminated dict');
|
||||
const [k, kp] = bdecode(buf, p);
|
||||
const [v, vp] = bdecode(buf, kp);
|
||||
dict[k as string] = v;
|
||||
p = vp;
|
||||
}
|
||||
return [dict, p + 1];
|
||||
}
|
||||
const colon = buf.indexOf(0x3a, pos);
|
||||
if (colon === -1) throw new Error('bencode: unterminated string length');
|
||||
const len = parseInt(buf.toString('latin1', pos, colon), 10);
|
||||
if (!Number.isInteger(len) || len < 0 || colon + 1 + len > buf.length)
|
||||
throw new Error('bencode: invalid string length');
|
||||
const start = colon + 1;
|
||||
return [buf.toString('latin1', start, start + len), start + len];
|
||||
};
|
||||
|
||||
const torrentDataArchives = (torrentBytes: Buffer): Set<string> => {
|
||||
const [torrent] = bdecode(torrentBytes) as [
|
||||
{ info?: { files?: { path?: string[] }[] } },
|
||||
number
|
||||
];
|
||||
const files = torrent?.info?.files ?? [];
|
||||
return new Set(
|
||||
files
|
||||
.filter(
|
||||
f =>
|
||||
f.path?.length === 2 &&
|
||||
f.path[0] === 'Data' &&
|
||||
/\.mpq$/i.test(f.path[1])
|
||||
)
|
||||
.map(f => f.path![1].toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
const LOCALE_DIRS = new Set([
|
||||
'enus',
|
||||
'engb',
|
||||
'encn',
|
||||
'entw',
|
||||
'kokr',
|
||||
'frfr',
|
||||
'dede',
|
||||
'zhcn',
|
||||
'zhtw',
|
||||
'eses',
|
||||
'esmx',
|
||||
'ruru',
|
||||
'ptbr',
|
||||
'ptpt',
|
||||
'itit'
|
||||
]);
|
||||
|
||||
const torrentDataDirs = (torrentBytes: Buffer): Set<string> => {
|
||||
const [torrent] = bdecode(torrentBytes) as [
|
||||
{ info?: { files?: { path?: string[] }[] } },
|
||||
number
|
||||
];
|
||||
const files = torrent?.info?.files ?? [];
|
||||
return new Set(
|
||||
files
|
||||
.filter(f => (f.path?.length ?? 0) >= 3 && f.path![0] === 'Data')
|
||||
.map(f => f.path![1].toLowerCase())
|
||||
);
|
||||
};
|
||||
|
||||
export const pruneStaleArchives = async (
|
||||
clientDir: string,
|
||||
url: string,
|
||||
_owned: Set<string>
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return [];
|
||||
const bytes = Buffer.from(await r.arrayBuffer());
|
||||
const expected = torrentDataArchives(bytes);
|
||||
if (!expected.size) return [];
|
||||
for (const u of [clientPatchUrl(), raidVisualsUrl()])
|
||||
if (u) expected.add(path.basename(u).toLowerCase());
|
||||
const usedDirs = torrentDataDirs(bytes);
|
||||
const dataDir = path.join(clientDir, 'Data');
|
||||
const onDisk = await fs.readdir(dataDir).catch(() => []);
|
||||
const removed: string[] = [];
|
||||
for (const name of onDisk) {
|
||||
const lc = name.toLowerCase();
|
||||
const full = path.join(dataDir, name);
|
||||
const st = await fs.stat(full).catch(() => null);
|
||||
if (!st) continue;
|
||||
if (st.isDirectory()) {
|
||||
if (LOCALE_DIRS.has(lc) && !usedDirs.has(lc)) {
|
||||
await fs.remove(full);
|
||||
removed.push(name + '/');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!/\.mpq$/i.test(name) || expected.has(lc)) continue;
|
||||
await fs.remove(full);
|
||||
removed.push(name);
|
||||
}
|
||||
return removed;
|
||||
} catch (e) {
|
||||
Logger.warn('Prune of stale archives failed', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const torrentDownloadSelection = async (
|
||||
clientDir: string,
|
||||
url: string,
|
||||
dropMismatched = false
|
||||
): Promise<number[] | null> => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return null;
|
||||
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
|
||||
{ info?: { files?: { path?: string[]; length?: number }[] } },
|
||||
number
|
||||
];
|
||||
const files = torrent?.info?.files ?? [];
|
||||
if (!files.length) return null;
|
||||
const need: number[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
if (!f.path?.length || typeof f.length !== 'number') return null;
|
||||
const dest = path.join(clientDir, ...f.path);
|
||||
const st = await fs.stat(dest).catch(() => null);
|
||||
if (!st) {
|
||||
need.push(i + 1);
|
||||
continue;
|
||||
}
|
||||
if (st.size !== f.length) {
|
||||
if (dropMismatched || st.size > f.length)
|
||||
await fs.remove(dest).catch(() => {});
|
||||
need.push(i + 1);
|
||||
}
|
||||
}
|
||||
return need;
|
||||
} catch (e) {
|
||||
Logger.warn('Torrent selection computation failed', e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const torrentTreeIntact = async (
|
||||
clientDir: string,
|
||||
url: string
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) return false;
|
||||
const [torrent] = bdecode(Buffer.from(await r.arrayBuffer())) as [
|
||||
{ info?: { files?: { path?: string[]; length?: number }[] } },
|
||||
number
|
||||
];
|
||||
const files = torrent?.info?.files ?? [];
|
||||
if (!files.length) return false;
|
||||
for (const f of files) {
|
||||
if (!f.path?.length || typeof f.length !== 'number') return false;
|
||||
const st = await fs
|
||||
.stat(path.join(clientDir, ...f.path))
|
||||
.catch(() => null);
|
||||
if (!st || st.size !== f.length) return false;
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
Logger.warn('Torrent tree check failed', e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const LOCALE_ASSERT_OFFSET = 0x1b2115;
|
||||
const pristineWowPath = () =>
|
||||
path.join(app.getPath('userData'), 'base-WoW.exe');
|
||||
|
||||
export const refreshPristineWow = async (clientDir: string): Promise<void> => {
|
||||
const exe = path.join(clientDir, 'WoW.exe');
|
||||
if (!(await fs.pathExists(exe))) return;
|
||||
const fd = await fs.open(exe, 'r');
|
||||
try {
|
||||
const b = Buffer.alloc(1);
|
||||
await fs.read(fd, b, 0, 1, LOCALE_ASSERT_OFFSET);
|
||||
if (b[0] === 0xa1) {
|
||||
await fs.copy(exe, pristineWowPath(), { overwrite: true });
|
||||
Logger.log('Cached pristine WoW.exe base');
|
||||
}
|
||||
} finally {
|
||||
await fs.close(fd);
|
||||
}
|
||||
};
|
||||
|
||||
export const readPristineWow = async (clientDir: string): Promise<Buffer> => {
|
||||
const cache = pristineWowPath();
|
||||
if (await fs.pathExists(cache)) return fs.readFile(cache);
|
||||
return fs.readFile(path.join(clientDir, 'WoW.exe'));
|
||||
};
|
||||
|
||||
let seeder: ChildProcess | undefined;
|
||||
let mapping: Promise<PortMapping> | undefined;
|
||||
let wantSeeding = false;
|
||||
let starting = false;
|
||||
|
||||
const SEED_PORT = 6881;
|
||||
const SEED_TIME_MINUTES = 525600;
|
||||
|
||||
export const isSeeding = (): boolean => !!seeder;
|
||||
|
||||
const releaseMapping = (): void => {
|
||||
const m = mapping;
|
||||
mapping = undefined;
|
||||
if (m) void m.then(x => x.stop()).catch(() => {});
|
||||
};
|
||||
|
||||
export const stopSeeding = (): void => {
|
||||
wantSeeding = false;
|
||||
seeder?.kill();
|
||||
seeder = undefined;
|
||||
releaseMapping();
|
||||
};
|
||||
|
||||
export const startSeeding = async (
|
||||
clientDir: string,
|
||||
uploadLimit = '2M'
|
||||
): Promise<void> => {
|
||||
wantSeeding = true;
|
||||
if (seeder || starting) return;
|
||||
starting = true;
|
||||
try {
|
||||
const url = torrentUrl();
|
||||
if (!url) return;
|
||||
const dir = await ensureJunction(clientDir);
|
||||
if (!wantSeeding || seeder) return;
|
||||
const args = [
|
||||
`--dir=${dir}`,
|
||||
'--bt-seed-unverified=true',
|
||||
`--seed-time=${SEED_TIME_MINUTES}`,
|
||||
'--check-integrity=false',
|
||||
'--continue=true',
|
||||
'--bt-save-metadata=true',
|
||||
'--enable-dht=true',
|
||||
'--bt-enable-lpd=true',
|
||||
`--listen-port=${SEED_PORT}`,
|
||||
`--dht-listen-port=${SEED_PORT}`,
|
||||
`--max-overall-upload-limit=${uploadLimit}`,
|
||||
'--summary-interval=0',
|
||||
'--console-log-level=warn',
|
||||
'--stop-with-process=' + process.pid,
|
||||
url
|
||||
];
|
||||
Logger.log('aria2c (seed) ' + args.join(' '));
|
||||
const child = spawn(bin(), args, { windowsHide: true });
|
||||
seeder = child;
|
||||
const onExit = () => {
|
||||
if (seeder === child) {
|
||||
seeder = undefined;
|
||||
releaseMapping();
|
||||
}
|
||||
};
|
||||
child.on('close', onExit);
|
||||
child.on('error', e => {
|
||||
Logger.warn('Seeder failed', e);
|
||||
onExit();
|
||||
});
|
||||
|
||||
mapping = mapPort(SEED_PORT, { description: 'OctoWoW' });
|
||||
void mapping.catch(() => undefined);
|
||||
} catch (e) {
|
||||
Logger.warn('startSeeding failed', e);
|
||||
} finally {
|
||||
starting = false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user