Files
OctoLauncher/src/main/modules/addons.ts
T
2026-05-08 00:00:00 +00:00

401 lines
9.9 KiB
TypeScript

import path from 'node:path';
import git, { type ProgressCallback } from 'isomorphic-git';
import http from 'isomorphic-git/http/node';
import fs from 'fs-extra';
import fetch from 'node-fetch';
import Logger from 'electron-log/main';
import { isNotUndef } from '~common/utils';
import { type AddonData, type TocData } from '~common/schemas';
import { runWorker } from '~main/utils';
import gitPull from '~main/workers/gitPull?nodeWorker';
import gitClone from '~main/workers/gitClone?nodeWorker';
import Preferences from './preferences';
import Observable from './observable';
export type AddonsStatus = {
state: 'verifying' | 'done';
addons: { [name: string]: AddonData };
available: AddonData[];
};
type AddonsList = {
name: string;
owner: string;
branch?: string;
ref?: string;
git: string;
toc?: TocData;
description?: string;
lastUpdated?: string;
stars?: number;
dependencies?: string[];
}[];
const readTocData = (content: string) =>
content
.split('\n')
.filter(l => l.startsWith('## '))
.map(l => l.slice(3))
.map(l => {
const [key, value] = l.split(':');
return [key.trim(), value.trim()];
})
.reduce((acc, [key, value]) => {
acc[key] = value;
return acc;
}, {} as TocData);
const fetchAddons = async () => {
try {
const response = await fetch(`${import.meta.env.MAIN_VITE_SERVER_URL || 'https://octowow.st'}/api/addons.json`);
return (await response.json()) as AddonsList;
} catch (e) {
Logger.error('Failed to reach update server', e);
return [];
}
};
class AddonsClass extends Observable<AddonsStatus> {
protected _value: AddonsStatus = {
state: 'done',
addons: {},
available: []
};
get status() {
return this._value;
}
private set status(v: AddonsStatus) {
this._value = v;
this._notifyObservers(v);
}
#onProgress =
(folder: string, data: AddonData): ProgressCallback =>
progress => {
const getPhase = (step: string) => {
switch (step) {
case 'Counting objects':
return 1;
case 'Compressing objects':
return 2;
case 'Receiving objects':
return 3;
case 'Resolving deltas':
return 4;
case 'Analyzing workdir':
return 5;
case 'Updating workdir':
return 6;
default:
return 0;
}
};
this.#setAddon(folder, {
...data,
progress: `${Math.round(
(progress.loaded / (progress.total ?? progress.loaded)) * 100
)}% (${getPhase(progress.phase)}/6)`
});
};
async checkGitUrl(url: string) {
const gitUrl = url.endsWith('.git') ? url : `${url}.git`;
try {
await git.getRemoteInfo({
http,
url: gitUrl
});
// Only fetch preview from known public git hosts to prevent SSRF.
const allowed = ['github.com', 'gitlab.com', 'gitea.com', 'codeberg.org'];
let preview: string | undefined;
try {
const host = new URL(url).hostname.toLowerCase();
if (allowed.some(h => host === h || host.endsWith('.' + h))) {
const response = await fetch(url).then(r => r.text());
preview = response.match(
/property="og:image" content="([^"]*)"/
)?.[1];
}
} catch {
// preview stays undefined
}
return {
status: 'available',
folder: gitUrl.slice(0, -4).split('/').at(-1),
git: gitUrl,
preview
} as AddonData;
} catch (e) {
return undefined;
}
}
async verify() {
if (this.status.state !== 'done') return;
this.status = {
...this.status,
state: 'verifying'
};
const remoteAddons = await fetchAddons();
const available: AddonData[] = remoteAddons.map(a => ({
status: 'available',
git: a.git,
toc: a.toc,
description: a.description,
folder: a.name,
branch: a.branch,
ref: a.ref
}));
const clientPath = Preferences.data.clientDir;
if (!clientPath) {
this.status = { state: 'done', addons: {}, available };
return;
}
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dirs = await fs.pathExists(addonsPath)
? await fs.readdir(addonsPath)
: [];
const addons: AddonsStatus['addons'] = Object.fromEntries(
dirs
.filter(d => !d.startsWith('Blizzard_'))
.map(name => [name, { status: 'fetching' as const, folder: name }])
);
this.status = { state: 'verifying', addons, available };
const verifyOne = async (folder: string) => {
const dir = path.join(addonsPath, folder);
if (!fs.existsSync(path.join(dir, `${folder}.toc`))) {
this.#setAddon(folder, {
status: 'invalid',
error: 'Missing .toc file',
folder
});
return;
}
const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r[0])
.catch(() => null);
const avail = remoteAddons.find(a => a.name === folder);
if (!remote) {
Logger.log(`Addon "${folder}" is not a git repository`);
this.#setAddon(
folder,
avail
? {
status: 'outOfDate',
git: avail.git,
toc,
description: avail.description,
folder
}
: { status: 'unknown', toc, folder }
);
return;
}
try {
await git.fetch({ fs, dir, http, tags: true });
const branch = await git.currentBranch({ fs, dir });
const localCommit = await git
.log({ fs, dir, ref: 'HEAD', depth: 1 })
.then(r => r[0].oid)
.catch(() => null);
const remoteCommit = avail?.ref
? await git
.resolveRef({ fs, dir, ref: avail.ref })
.catch(() => null)
: await git
.log({ fs, dir, ref: `${remote.remote}/${branch}`, depth: 1 })
.then(r => r[0].oid)
.catch(() => null);
const status = await git.statusMatrix({ fs, dir });
const hasChanges = status.some(
([_, HEAD, index, workdir]) => HEAD !== index || index !== workdir
);
const isUpToDate =
!hasChanges && remoteCommit && localCommit === remoteCommit;
this.#setAddon(folder, {
git: remote.url,
status: isUpToDate ? 'upToDate' : 'outOfDate',
toc,
description: avail?.description,
ref: avail?.ref,
folder
});
Logger.log(
isUpToDate
? `Addon "${folder}" is up to date${avail?.ref ? ` (pinned ${avail.ref})` : ''}`
: `Addon "${folder}" has an update available`
);
} catch (e) {
this.#setAddon(folder, {
git: remote.url,
status: 'invalid',
error: 'Failed to verify',
toc,
folder
});
Logger.error(`Addon "${folder}" failed to verify`, e);
}
};
const folders = Object.keys(addons);
const VERIFY_CONCURRENCY = 6;
let idx = 0;
await Promise.all(
Array.from({ length: Math.min(VERIFY_CONCURRENCY, folders.length) }, async () => {
while (true) {
const i = idx++;
if (i >= folders.length) return;
await verifyOne(folders[i]);
}
})
);
this.status = { ...this.status, state: 'done' };
}
async update(
toUpdate = Object.values(this.status.addons)
.filter(e => e.status === 'outOfDate')
.map(e => e.folder)
.filter(isNotUndef)
) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
for (const folder of toUpdate) {
if (this.status.addons[folder]?.status === 'downloading') continue;
const dir = path.join(addonsPath, folder);
const avail = this.status.available.find(a => a.folder === folder);
const data: AddonData = {
...avail,
...this.status.addons[folder],
status: 'downloading'
};
this.#setAddon(folder, data);
const remote = await git
.listRemotes({ fs, dir })
.then(r => r?.[0])
.catch(() => null);
try {
if (!remote) {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(folder, data) }
);
} else {
const branch =
(await git.currentBranch({ fs, dir })) ?? avail?.branch ?? 'master';
await runWorker(
gitPull,
{
dir,
remote: remote.remote,
branch,
ref: avail?.ref
},
{ onProgress: this.#onProgress(folder, data) }
);
}
const toc = await readTocData(
await fs.readFile(path.join(dir, `${folder}.toc`), 'utf-8')
);
this.#setAddon(folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Updated addon "${folder}"`);
} catch (e) {
this.#setAddon(folder, {
...data,
status: 'invalid',
error: 'Failed to update'
});
Logger.error(`Addon "${folder}" failed to update`, e);
}
}
}
async remove(toRemove: string[]) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
if (this.status.state !== 'done') return;
for (const folder of toRemove) {
const dir = path.join(clientPath, 'Interface', 'Addons', folder);
if (fs.existsSync(dir)) await fs.remove(dir);
this.#setAddon(folder);
Logger.log(`Removed addon "${folder}"`);
}
}
async install(data: AddonData) {
const clientPath = Preferences.data.clientDir;
if (!clientPath) return;
const addonsPath = path.join(clientPath, 'Interface', 'Addons');
const dir = path.join(addonsPath, data.folder);
try {
await runWorker(
gitClone,
{ dir, url: data.git, ref: data.ref ?? data.branch },
{ onProgress: this.#onProgress(data.folder, data) }
);
const toc = await readTocData(
await fs.readFile(path.join(dir, `${data.folder}.toc`), 'utf-8')
);
this.#setAddon(data.folder, { ...data, toc, status: 'upToDate' });
Logger.log(`Installed addon "${data.folder}"`);
} catch (e) {
this.#setAddon(data.folder, {
...data,
status: 'invalid',
error: 'Failed to install'
});
Logger.error(`Addon "${data.folder}" failed to install`, e);
}
}
#setAddon(folder: string, data?: AddonData) {
const { [folder]: _, ...addons } = this.status.addons;
this.status = {
...this.status,
addons: data ? { ...addons, [folder]: data } : addons
};
}
}
const Addons = new AddonsClass();
export default Addons;