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:
OctoWoW
2026-08-14 01:45:50 +00:00
commit 5dca94a3fc
124 changed files with 24276 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
import fs from 'fs-extra';
import path from 'path';
import { defaultSources, type AddonSource } from './addons-sources.js';
const CACHE_TTL_MS = 60 * 60 * 1000;
const FETCH_CONCURRENCY = 8;
const FETCH_TIMEOUT_MS = 10_000;
const SOURCES_OVERRIDE_PATH = process.env.ADDONS_SOURCES_PATH ?? '';
export type TocData = Record<string, string>;
export type ResolvedAddon = {
name: string;
owner: string;
git: string;
branch?: string;
ref?: string;
toc?: TocData;
description?: string;
lastUpdated?: string;
stars?: number;
};
type CacheEntry = { at: number; data: ResolvedAddon[] };
let cache: CacheEntry | undefined;
let inFlight: Promise<ResolvedAddon[]> | undefined;
const normalizeColorCodes = (s: string): string =>
s.replace(/\|C(?=[0-9a-fA-F]{8})/g, '|c').replace(/\|R/g, '|r');
const parseToc = (content: string): TocData =>
content
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const idx = l.indexOf(':');
if (idx === -1) return null;
return [l.slice(0, idx).trim(), l.slice(idx + 1).trim()] as const;
})
.filter((e): e is readonly [string, string] => !!e)
.reduce<TocData>((acc, [k, v]) => {
acc[k] = normalizeColorCodes(v);
return acc;
}, {});
const fetchWithTimeout = async (url: string, init?: RequestInit) => {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(t);
}
};
type RepoMeta = {
description?: string;
defaultBranch?: string;
lastUpdated?: string;
stars?: number;
};
type RawMeta = {
description?: string | null;
default_branch?: string;
pushed_at?: string | null;
updated_at?: string | null;
stargazers_count?: number | null;
stars_count?: number | null;
};
type Provider = {
apiUrl: (owner: string, repo: string) => string;
apiHeaders: () => Record<string, string>;
mapMeta: (json: RawMeta) => RepoMeta;
tocUrl: (owner: string, repo: string, ref: string, name: string) => string;
};
const githubProvider: Provider = {
apiUrl: (o, r) => `https://api.github.com/repos/${o}/${r}`,
apiHeaders: () => ({
Accept: 'application/vnd.github+json',
...(process.env.GITHUB_TOKEN && {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`
})
}),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.pushed_at ?? undefined,
stars: j.stargazers_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`https://raw.githubusercontent.com/${o}/${r}/${ref}/${name}.toc`
};
const GITEA_API = 'https://octowow.st/git/api/v1';
const giteaProvider: Provider = {
apiUrl: (o, r) => `${GITEA_API}/repos/${o}/${r}`,
apiHeaders: () => ({ Accept: 'application/json' }),
mapMeta: j => ({
description: j.description ?? undefined,
defaultBranch: j.default_branch,
lastUpdated: j.updated_at ?? undefined,
stars: j.stars_count ?? undefined
}),
tocUrl: (o, r, ref, name) =>
`${GITEA_API}/repos/${o}/${r}/raw/${name}.toc?ref=${encodeURIComponent(
ref
)}`
};
const parseGitUrl = (
git: string
): { owner: string; repo: string; provider: Provider } => {
const gh = git.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gh && gh[1] && gh[2]) {
return { owner: gh[1], repo: gh[2], provider: githubProvider };
}
const gitea = git.match(/octowow\.st\/git\/([^/]+)\/([^/]+?)(?:\.git)?$/);
if (gitea && gitea[1] && gitea[2]) {
return { owner: gitea[1], repo: gitea[2], provider: giteaProvider };
}
throw Error(`Unsupported git URL: ${git}`);
};
const REQUIRED_TOC_KEYS = ['Interface'];
const tryFetchToc = async (
provider: Provider,
owner: string,
repo: string,
name: string,
ref: string
): Promise<TocData | undefined> => {
const res = await fetchWithTimeout(
provider.tocUrl(owner, repo, ref, name)
).catch(() => null);
if (!res?.ok) return undefined;
const parsed = parseToc(await res.text());
return REQUIRED_TOC_KEYS.every(k => typeof parsed[k] === 'string')
? parsed
: undefined;
};
const resolveOne = async (src: AddonSource): Promise<ResolvedAddon | null> => {
try {
const { owner, repo, provider } = parseGitUrl(src.git);
const name = src.name ?? repo;
const apiRes = await fetchWithTimeout(provider.apiUrl(owner, repo), {
headers: provider.apiHeaders()
}).catch(() => null);
let meta: RepoMeta | undefined;
if (apiRes?.ok) meta = provider.mapMeta((await apiRes.json()) as RawMeta);
const candidates = src.ref
? [src.ref]
: src.branch
? [src.branch]
: [
...new Set(
[meta?.defaultBranch, 'main', 'master'].filter(
(b): b is string => !!b
)
)
];
let toc: TocData | undefined;
let resolvedRef: string | undefined;
for (const ref of candidates) {
toc = await tryFetchToc(provider, owner, repo, name, ref);
if (toc) {
resolvedRef = ref;
break;
}
}
const effectiveBranch = src.ref
? undefined
: src.branch ?? resolvedRef ?? meta?.defaultBranch;
let description = meta?.description ?? undefined;
const lastUpdated = meta?.lastUpdated;
const stars = meta?.stars;
if (src.description) {
description = src.description;
if (toc) toc = { ...toc, Notes: src.description };
}
const result: ResolvedAddon = { name, owner, git: src.git };
if (effectiveBranch !== undefined) result.branch = effectiveBranch;
if (src.ref !== undefined) result.ref = src.ref;
if (toc !== undefined) result.toc = toc;
if (description !== undefined) result.description = description;
if (lastUpdated !== undefined) result.lastUpdated = lastUpdated;
if (stars !== undefined) result.stars = stars;
return result;
} catch (e) {
console.error(`Failed to resolve ${src.git}:`, e);
return null;
}
};
const poolMap = async <T, R>(
items: T[],
concurrency: number,
fn: (item: T) => Promise<R>
): Promise<R[]> => {
const results: R[] = new Array(items.length);
let idx = 0;
const worker = async () => {
while (true) {
const i = idx++;
if (i >= items.length) return;
const item = items[i];
if (item === undefined) return;
results[i] = await fn(item);
}
};
await Promise.all(Array.from({ length: concurrency }, worker));
return results;
};
const loadSources = async (): Promise<AddonSource[]> => {
if (!SOURCES_OVERRIDE_PATH) return defaultSources;
try {
if (await fs.pathExists(SOURCES_OVERRIDE_PATH)) {
const override = (await fs.readJSON(
SOURCES_OVERRIDE_PATH
)) as AddonSource[];
if (Array.isArray(override) && override.length > 0) {
console.log(
`Using addon sources override from ${SOURCES_OVERRIDE_PATH}`
);
return override;
}
}
} catch (e) {
console.error(
`Failed to read override at ${SOURCES_OVERRIDE_PATH}, using defaults:`,
e
);
}
return defaultSources;
};
const buildList = async (): Promise<ResolvedAddon[]> => {
const sources = await loadSources();
console.log(
`Resolving metadata for ${sources.length} addons (concurrency=${FETCH_CONCURRENCY})...`
);
const t0 = Date.now();
const results = await poolMap(sources, FETCH_CONCURRENCY, resolveOne);
const ok = results.filter((r): r is ResolvedAddon => r !== null);
ok.sort((a, b) => a.name.localeCompare(b.name));
console.log(
`Resolved ${ok.length}/${sources.length} addons in ${Date.now() - t0}ms`
);
return ok;
};
export const getAddons = async (force = false): Promise<ResolvedAddon[]> => {
if (!force && cache && Date.now() - cache.at < CACHE_TTL_MS) {
return cache.data;
}
if (inFlight) return inFlight;
inFlight = buildList()
.then(data => {
cache = { at: Date.now(), data };
return data;
})
.finally(() => {
inFlight = undefined;
});
return inFlight;
};
export const warmUp = () => {
getAddons().catch(e => console.error('Addon resolver warm-up failed:', e));
};
+253
View File
@@ -0,0 +1,253 @@
export type AddonSource = {
git: string;
branch?: string;
name?: string;
description?: string;
ref?: string;
};
export const defaultSources: AddonSource[] = [
{
git: 'https://github.com/Alukarho/AI_VoiceOver.git',
description: 'Adds AI-generated voice acting to NPC quest and gossip dialogue'
},
{
git: 'https://github.com/McPewPew/ArcHUD2.git',
description: 'Combat HUD showing health and power as arcs around your character'
},
{
git: 'https://octowow.st/git/shaga/AtlasLoot.git',
name: 'AtlasLoot',
branch: 'main',
description:
'Loot browser for every dungeon and raid, including the custom OctoWoW instances (Windhorn Canyon, Dragonmaw Retreat, and more)'
},
{
git: 'https://github.com/byCFM2/Atlas-TW.git',
name: 'Atlas-CFM'
},
{
git: 'https://github.com/Road-block/AuldLangSyne.git',
description: 'Adds personal notes to friends, ignore, and guild lists, remembered while offline'
},
{
git: 'https://github.com/FSuhas/AutoLFM.git',
description: 'Automated "Looking For More" broadcaster for Turtle WoW dungeons and raids'
},
{
git: 'https://github.com/shirsig/aux-addon-vanilla.git',
name: 'aux-addon',
description: 'Auction House replacement with advanced filtering and search'
},
{ git: 'https://github.com/absir/Bagshui.git' },
{ git: 'https://github.com/pepopo978/BetterCharacterStats.git', branch: 'main' },
{ git: 'https://github.com/pepopo978/BigWigs.git' },
{
git: 'https://github.com/DBFBlackbull/BitesCookBook.git',
description: 'Tracks which items are used in cooking and what they create'
},
{
git: 'https://github.com/brotalnia/BlizzPlates.git',
description: 'Adds castbars, debuffs, and class icons to the default Blizzard nameplates'
},
{
git: 'https://github.com/MDGitHubRepo/CallOfElements.git',
description: 'All-in-one Shaman totem bar and totem/healing manager'
},
{ git: 'https://github.com/bhhandley/CleveRoidMacros.git' },
{
git: 'https://github.com/Cinecom/ConsumesManager.git',
description: 'Tracks consumables and food buffs across alts, bank, and mail'
},
{
git: 'https://github.com/Kirchlive/cursive-raid.git',
name: 'Cursive-Raid',
description: 'Raid debuff tracker with profiles and multi-curse assist (SuperWoW)'
},
{
git: 'https://github.com/Zerf/Decursive.git',
description: 'Raid/party debuff-cleaning helper that dispels whoever needs it'
},
{
git: 'https://github.com/DeterminedPanda/DifficultBulletinBoard.git',
description: 'Organizes LFG, profession, and hardcore chat announcements into a bulletin board'
},
{ git: 'https://github.com/Player-Doite/DoiteAuras.git' },
{ git: 'https://github.com/Stormhand-dev/DragonflightUI-Reforged.git' },
{
git: 'https://github.com/Fiurs-Hearth/ExtraResourceBars.git',
description: 'Adds extra resource bars (mana, energy, rage) to the UI'
},
{
git: 'https://github.com/SeVeN7000/FishingBuddy.git',
description: 'Auto-equips fishing gear and tracks catches, fish, and zone info'
},
{ git: 'https://github.com/tilare/FlightTracker.git' },
{ git: 'https://github.com/lookino/Flyout.git' },
{
git: 'https://github.com/trumpetx/GetHead.git',
description: 'Recovers Onyxia and Nefarian heads from disenchant grief'
},
{
git: 'https://github.com/zanthor/GNS.git',
description: 'Custom naming for Goblin Brainwashing Device specializations'
},
{ git: 'https://github.com/vatichild/guda.git', name: 'Guda' },
{ git: 'https://github.com/vatichild/GudaPlates.git' },
{ git: 'https://github.com/andresuarezschou/HCDeaths.git' },
{
git: 'https://github.com/Arthur-Helias/InstanceJournal.git',
description: "Encounter Journal reimagined for Turtle WoW"
},
{
git: 'https://github.com/Einherjarn/ItemRack.git',
description: 'Item set manager with quick-swap menus for inventory'
},
{
git: 'https://github.com/CosminPOP/_LazyPig.git',
name: '_LazyPig',
description: 'Auto-dismount, auto-accept, auto-roll, and chat spam filter. /lp to configure'
},
{ git: 'https://github.com/Spartelfant/LevelRange-Turtle.git' },
{ git: 'https://github.com/tilare/MessageBox.git' },
{
git: 'https://github.com/tdymel/ModifiedPowerAuras.git',
description: "Advanced version of Sinesther's Power Auras"
},
{
git: 'https://github.com/tilare/ModernMapMarkers.git',
description: 'Shows dungeons, raids, world bosses, and travel routes on the world map'
},
{
git: 'https://github.com/vegeta1k95/ModernSpellBook.git',
description: 'Retail-style spellbook UI for vanilla'
},
{ git: 'https://github.com/tilare/MovementTracker.git' },
{
git: 'https://github.com/Dusk-92/NampowerSettings.git',
description: 'Settings panel for the Nampower spellqueue addon'
},
{
git: 'https://github.com/BlackHobbiT/necrosis-twow.git',
name: 'Necrosis',
description: 'Warlock helper: pets, soul shards, summoning, demon timers'
},
{
git: 'https://github.com/gnwl/NotGrid.git',
name: 'notgrid',
description: 'Grid-like compact party/raid frames with buff/debuff, aggro, and proximity tracking'
},
{
git: 'https://github.com/zanthor/OG-RaidHelper.git',
description: 'Raid management: roles, trade distribution, soft-reserve validation'
},
{
git: 'https://github.com/sica42/Outfitter.git',
description: 'Equipment set manager to save and quickly swap gear outfits, with Turtle mount fixes'
},
{
git: 'https://github.com/CosminPOP/PallyPower.git',
description: 'Paladin buff and assignment manager for raids and parties'
},
{
git: 'https://github.com/Cliencer/pfExtend.git',
description: 'pfQuest extension showing all monster drops and quest chains. /pfex'
},
{ git: 'https://github.com/shagu/pfQuest.git' },
{ git: 'https://github.com/shagu/pfQuest-turtle.git' },
{ git: 'https://github.com/shagu/pfUI.git' },
{
git: 'https://github.com/jrc13245/pfUI-addonskinner.git',
description: 'pfUI module that re-skins other addons to match the pfUI theme'
},
{
git: 'https://github.com/Bombg/pfUI-bettertotems.git',
description: 'pfUI module with improved Shaman totem timers'
},
{
git: 'https://github.com/Arthur-Helias/pfUI-LocationPlus.git',
name: 'pfUI-locplus',
description: 'Adds a location panel and zone info to pfUI'
},
{ git: 'https://github.com/acid9000/PizzaWorldBuffs.git' },
{
git: 'https://github.com/npfs666/ProcDoc.git',
description: 'Visual proc alerts with pulsing images so you never miss them'
},
{ git: 'https://github.com/SabineWren/Quiver.git' },
{
git: 'https://github.com/hazlema/Rested.git',
description: 'Progress bar showing your rested XP while resting'
},
{ git: 'https://github.com/Otari98/Rinse.git' },
{
git: 'https://github.com/anzz1/SellValue.git',
description: 'Shows item vendor sell value in tooltips when not at a vendor'
},
{ git: 'https://github.com/shagu/ShaguDPS.git' },
{
git: 'https://github.com/shagu/ShaguPlates.git',
description: 'Nameplates with castbars and class colors. /splates'
},
{ git: 'https://github.com/shagu/ShaguTweaks.git' },
{
git: 'https://github.com/shagu/ShaguTweaks-extras.git',
description: 'Extras module for ShaguTweaks (additional UI tweaks)'
},
{ git: 'https://github.com/pepopo978/SimpleActionSets.git' },
{
git: 'https://github.com/balakethelock/SuperAPI.git',
description: 'Companion compatibility addon bridging the SuperWoW client mod\'s expanded Lua API'
},
{
git: 'https://github.com/jrc13245/SuperMacro-turtle-SuperWoW.git',
name: 'SuperMacro',
description: 'Extended macros with long macros, keybind execution, item links, and a code editor'
},
{ git: 'https://github.com/Siventt/AttackBar.git' },
{
git: 'https://github.com/Player-Doite/Tactica.git',
description: 'Auto-build raids: invite/gearcheck, tactics, masterloot, role sync'
},
{
git: 'https://github.com/Otari98/Tmog.git',
description: 'Transmog item browser with collection info in tooltips'
},
{
git: 'https://github.com/whtmst/T-RestedXP.git',
description: 'Tracks 0% and 100% rested XP thresholds'
},
{ git: 'https://github.com/sica42/TurtleCalendar.git' },
{
git: 'https://github.com/sica42/TurtleMail.git',
description: 'Mailbox UI enhancement: bulk send, search, multi-mail'
},
{ git: 'https://github.com/tempranova/turtlerp.git', name: 'TurtleRP' },
{ git: 'https://github.com/CosminPOP/TWThreat.git' },
{
git: 'https://github.com/RetroCro/unitscan-turtle-hc.git',
description: 'Hardcore unitscan fork for Turtle WoW that alerts on rares, elites, and dangerous mobs'
},
{
git: 'https://github.com/whtmst/UnitXP_SP3_Addon.git',
description: 'Settings UI for the UnitXP SuperWoW client patch'
},
{
git: 'https://github.com/tdymel/VCB.git',
description: 'Smart consolidated buff frames with extensive customization'
},
{
git: 'https://octowow.st/git/shaga/LifeSafer_LowHealthWarning.git',
branch: 'main',
description: 'Low health and mana fullscreen flash warnings with heartbeat sound; re-enables the hidden Blizzard alert effect'
},
{
git: 'https://github.com/Fiurs-Hearth/WIIIUI.git',
description: 'Compact custom UI replacement for Turtle WoW'
},
{ git: 'https://github.com/refaim/WIM.git' },
{
git: 'https://github.com/Arthur-Helias/ZonesLevel.git',
description: "Shows zone level range under the title on the world map"
}
];
+346
View File
@@ -0,0 +1,346 @@
import crypto from 'crypto';
import path from 'path';
import fs from 'fs-extra';
const allowedExtra = [
'.launcher',
'Data',
'Errors',
'Interface\\AddOns',
'Logs',
'Screenshots',
'WDB',
'WTF\\Account'
];
const vanillaFixes = ['VfPatcher.dll', 'd3d9.dll', 'dxvk.conf'];
const raidVisuals = ['patch-O.mpq'];
const skipFiles = new Set([
'manifest.json',
'manifest.json.tmp',
'wow-client.zip',
'.gitkeep',
'.manifest-overrides.json'
]);
const skipPatterns: RegExp[] = [
/\.bak([.\-]|$)/,
/\.crashing(\.|$)/,
/\.torrent$/,
/^manifest\.json\./
];
const isSkipPattern = (file: string) => skipPatterns.some(p => p.test(file));
const skipDirsPosix = new Set([
'Interface/GlueXML',
'Interface/FrameXML',
'Errors',
'Logs',
'Screenshots',
'WDB',
'WTF/Account'
]);
const isSkipDir = (...filePath: string[]) =>
skipDirsPosix.has(filePath.join('/'));
type FolderTags = 'allowExtra';
type FileTags = 'vanillaFixes' | 'raidVisuals';
type FileManifest = { name: string } & (
| { type: 'dir'; files: FileManifest[]; tags?: FolderTags[] }
| { type: 'mpq'; files: FileManifest[]; hash: string; size: number }
| {
type: 'file';
hash: string;
version?: number;
size: number;
tags?: FileTags[];
}
| { type: 'del' }
);
export type BuildProgress = {
state: 'idle' | 'building' | 'ready' | 'failed';
done: number;
total: number;
currentFile: string;
startedAt: number | null;
finishedAt: number | null;
error: string | null;
};
export type ProgressCallback = (
p: Pick<BuildProgress, 'done' | 'total' | 'currentFile'>
) => void;
const getHash = (...filePath: string[]): Promise<string> =>
new Promise((resolve, reject) => {
const hash = crypto.createHash('sha1');
const stream = fs.createReadStream(path.join(...filePath));
stream.on('error', reject);
stream.on('data', (chunk: Buffer) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex').toLocaleUpperCase()));
});
const countFiles = async (
clientPath: string,
...filePath: string[]
): Promise<number> => {
let total = 0;
const dir = path.join(clientPath, ...filePath);
const files = await fs.readdir(dir);
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(dir, file));
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
if (file.match(/patch-./)) {
const mpqPath = path.join(dir, `${file}.mpq`);
if (await fs.pathExists(mpqPath)) total += 1;
}
total += await countFiles(clientPath, ...filePath, file);
} else {
total += 1;
}
}
return total;
};
export const buildCache = async (
clientPath: string,
onProgress?: ProgressCallback
) => {
console.log('Building cache...');
const prevManifestPath = path.join(clientPath, 'manifest.json');
let prevManifestMtimeMs = 0;
const prevHashByPath = new Map<string, string>();
const prevVersionByPath = new Map<string, number | undefined>();
const prevSizeByPath = new Map<string, number>();
try {
const prevStat = await fs.stat(prevManifestPath);
prevManifestMtimeMs = prevStat.mtimeMs;
const prev = await fs.readJSON(prevManifestPath);
const walk = (node: FileManifest, prefix: string[]) => {
if (node.type === 'dir' || node.type === 'mpq') {
const newPrefix = node.name ? [...prefix, node.name] : prefix;
if (node.type === 'mpq') {
const mpqKey = [...newPrefix.slice(0, -1), node.name + '.mpq'].join(
'/'
);
prevHashByPath.set(mpqKey, node.hash);
prevSizeByPath.set(mpqKey, node.size);
}
for (const child of node.files) walk(child, newPrefix);
} else {
const key = [...prefix, node.name].join('/');
prevHashByPath.set(key, node.hash);
prevVersionByPath.set(key, node.version);
prevSizeByPath.set(key, node.size);
}
};
walk(prev.root, []);
console.log(
`mtime-skip: loaded ${prevHashByPath.size} cached hashes from ` +
`prior manifest (mtime=${new Date(prevManifestMtimeMs).toISOString()})`
);
} catch (e) {
console.log(
'mtime-skip: no usable prior manifest, full rebuild ' +
`(${(e as Error).message})`
);
}
const total = await countFiles(clientPath);
let done = 0;
let reused = 0;
const tick = (currentFile: string) => {
done += 1;
onProgress?.({ done, total, currentFile });
};
console.log(`Building cache: ${total} files to hash...`);
const getHashCached = async (
relPath: string,
mtimeMs: number,
...filePath: string[]
): Promise<string> => {
if (prevManifestMtimeMs > 0 && mtimeMs <= prevManifestMtimeMs) {
const cached = prevHashByPath.get(relPath);
if (cached) {
reused++;
return cached;
}
}
return getHash(clientPath, ...filePath);
};
const buildTree = async (...filePath: string[]): Promise<FileManifest[]> => {
const files = await fs.readdir(path.join(clientPath, ...filePath));
const patches: string[] = [];
const tree: FileManifest[] = [];
for (const file of files.sort()) {
if (skipFiles.has(file)) continue;
if (isSkipPattern(file)) continue;
const stats = await fs.stat(path.join(clientPath, ...filePath, file));
if (stats.isDirectory()) {
if (isSkipDir(...filePath, file)) continue;
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);
const mpqRelPath = path
.join(...filePath, `${file}.mpq`)
.split(path.sep)
.join('/');
const mpqStat = await fs.stat(
path.join(clientPath, ...filePath, `${file}.mpq`)
);
tree.push({
type: 'mpq',
name: file,
files: await buildTree(...filePath, file),
size: mpqStat.size,
hash: await getHashCached(
mpqRelPath,
mpqStat.mtimeMs,
...filePath,
`${file}.mpq`
)
});
tick(mpqRelPath);
} else {
const tags: FolderTags[] = [];
allowedExtra.includes(path.join(...filePath, file)) &&
tags.push('allowExtra');
tree.push({
type: 'dir',
name: file,
files: await buildTree(...filePath, file),
tags: tags.length ? tags : undefined
});
}
continue;
}
if (patches.find(v => file.match(v))) continue;
const allowModifiedPaths = new Set([
'WTF/Config.wtf',
'Data/fonts.MPQ',
'Data/sound.MPQ',
'Data/speech.MPQ'
]);
const fullPath = path
.join(...filePath, file)
.split(path.sep)
.join('/');
const allowModified =
file === 'WoW.exe' || allowModifiedPaths.has(fullPath);
const tags: FileTags[] = [];
vanillaFixes.includes(file) && tags.push('vanillaFixes');
raidVisuals.includes(file) && tags.push('raidVisuals');
tree.push({
type: 'file',
name: file,
hash: await getHashCached(fullPath, stats.mtimeMs, ...filePath, file),
version: allowModified ? stats.mtimeMs : undefined,
size: stats.size,
tags: tags.length ? tags : undefined
});
tick(fullPath);
}
return tree;
};
const rootFiles = await buildTree();
const overridesPath = path.join(clientPath, '.manifest-overrides.json');
try {
if (await fs.pathExists(overridesPath)) {
const ov = await fs.readJSON(overridesPath);
const dels: string[] = Array.isArray(ov.del) ? ov.del : [];
for (const relPath of dels) {
const parts = relPath.split('/').filter(Boolean);
if (parts.length === 0) continue;
const fileName = parts.pop()!;
let dirNode: FileManifest = {
type: 'dir',
name: '',
files: rootFiles
} as FileManifest;
let ok = true;
for (const seg of parts) {
if (dirNode.type !== 'dir' && dirNode.type !== 'mpq') {
ok = false;
break;
}
let child = dirNode.files.find(f => f.name === seg);
if (!child) {
child = {
type: 'dir',
name: seg,
files: [],
tags: ['allowExtra']
};
dirNode.files.push(child);
}
dirNode = child;
}
if (!ok || (dirNode.type !== 'dir' && dirNode.type !== 'mpq')) {
console.warn(
`manifest-overrides: del path "${relPath}" hit a non-dir ` +
`node, skipping`
);
continue;
}
const exists = dirNode.files.some(
f => f.name === fileName && f.type === 'del'
);
if (!exists) {
dirNode.files.push({ type: 'del', name: fileName } as FileManifest);
console.log(
`manifest-overrides: inserted {type:'del', name:'${fileName}'} ` +
`under ${parts.join('/') || '<root>'}`
);
}
}
}
} catch (e) {
console.warn(
`manifest-overrides: failed to apply ${overridesPath}, continuing` +
`without overrides (${(e as Error).message})`
);
}
const finalPath = path.join(clientPath, 'manifest.json');
const tmpPath = path.join(clientPath, 'manifest.json.tmp');
await fs.writeJSON(tmpPath, {
build: 3,
buildName: '3',
root: {
type: 'dir',
name: '',
files: rootFiles
}
});
await fs.rename(tmpPath, finalPath);
if (prevManifestMtimeMs > 0) {
console.log(
`mtime-skip: reused ${reused}/${total} cached hashes ` +
`(re-hashed ${total - reused})`
);
}
};
+167
View File
@@ -0,0 +1,167 @@
import path from 'path';
import express from 'express';
import fs from 'fs-extra';
import { buildCache, type BuildProgress } from './cache.js';
import { getAddons, warmUp as warmUpAddons } from './addons-resolver.js';
const SourceDir = process.env.SOURCE_DIR || './client';
const app = express();
const port = 5000;
const buildProgress: BuildProgress = {
state: 'idle',
done: 0,
total: 0,
currentFile: '',
startedAt: null,
finishedAt: null,
error: null
};
let buildInFlight: Promise<void> | null = null;
const ensureManifestBuilt = (): Promise<void> => {
if (buildInFlight) return buildInFlight;
buildProgress.state = 'building';
buildProgress.done = 0;
buildProgress.total = 0;
buildProgress.currentFile = '';
buildProgress.startedAt = Date.now();
buildProgress.finishedAt = null;
buildProgress.error = null;
buildInFlight = buildCache(SourceDir, p => {
buildProgress.done = p.done;
buildProgress.total = p.total;
buildProgress.currentFile = p.currentFile;
})
.then(() => {
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
})
.catch(e => {
buildProgress.state = 'failed';
buildProgress.error = e instanceof Error ? e.message : String(e);
buildProgress.finishedAt = Date.now();
buildInFlight = null;
throw e;
});
return buildInFlight;
};
app.get('/api/build-status', (_req, res) => {
res.json(buildProgress);
});
app.get('/api/file/:version/manifest.json', async (_req, res) => {
console.log(`Fetching manifest`);
const filePath = path.join(SourceDir, 'manifest.json');
if (await fs.pathExists(filePath)) {
res.json(await fs.readJSON(filePath));
return;
}
void ensureManifestBuilt().catch(() => {});
res.setHeader('Retry-After', '5');
res.status(503).json({
error: 'manifest_building',
message:
'Manifest is being built for the first time on this server. ' +
'Poll /api/build-status for progress; retry this endpoint when ready.',
buildProgress
});
});
app.get(
'/api/file/:version/*',
async (req: express.Request<{ 0: string }>, res) => {
const filePath = req.params[0];
console.log(`Fetching file: ${filePath}`);
const root = path.resolve(SourceDir);
const target = path.resolve(SourceDir, filePath);
if (target !== root && !target.startsWith(root + path.sep)) {
res.status(403).end();
return;
}
res.sendFile(target);
}
);
app.get('/api/addons.json', async (req, res) => {
try {
const force = req.query.refresh === '1';
const addons = await getAddons(force);
res.json(addons);
} catch (e) {
console.error('Failed to resolve addons:', e);
res.status(500).json({ error: 'Failed to resolve addons' });
}
});
const newestSourceMtime = async (dir: string): Promise<number> => {
let newest = 0;
const entries = await fs.readdir(dir);
for (const name of entries) {
if (name === 'manifest.json' || name === 'manifest.json.tmp') continue;
const full = path.join(dir, name);
const stat = await fs.stat(full);
if (stat.isDirectory()) {
const inner = await newestSourceMtime(full);
if (inner > newest) newest = inner;
} else if (stat.mtimeMs > newest) {
newest = stat.mtimeMs;
}
}
return newest;
};
app.listen(port, () => {
console.log(`Server listening on port ${port}`);
warmUpAddons();
void (async () => {
const manifestPath = path.join(SourceDir, 'manifest.json');
if (!fs.existsSync(manifestPath)) {
console.log(`Pre-warming manifest cache for ${SourceDir}...`);
try {
await ensureManifestBuilt();
console.log(`Manifest cache pre-warm complete.`);
} catch (e) {
console.error(
'Manifest pre-warm failed (will fall back to lazy build on first request):',
e
);
}
return;
}
buildProgress.state = 'ready';
buildProgress.finishedAt = Date.now();
try {
const manifestStat = await fs.stat(manifestPath);
const newest = await newestSourceMtime(SourceDir);
if (newest > manifestStat.mtimeMs) {
console.log(
`Manifest is stale (newest source mtime ${new Date(
newest
).toISOString()} > manifest ${new Date(
manifestStat.mtimeMs
).toISOString()}); rebuilding in background.`
);
ensureManifestBuilt()
.then(() => console.log('Background manifest rebuild complete.'))
.catch(e => console.error('Background manifest rebuild failed:', e));
} else {
console.log(
`Manifest cache already on disk at ${manifestPath} and up to date; no rebuild needed.`
);
}
} catch (e) {
console.error('Manifest staleness check failed:', e);
}
})();
});