Files
WYF-koubo/electron/mapi/resourceManager.ts
T
2026-06-19 18:45:55 +08:00

351 lines
12 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import axios from 'axios';
import yauzl from 'yauzl';
import { AppEnv } from './env';
import { fetchElectronSystemConfig } from './systemConfig';
import Log from './log/main';
let statusListener: ((data: any) => void) | null = null;
export function setResourceStatusListener(listener: ((data: any) => void) | null) {
statusListener = listener;
}
function emitStatus(data: any) {
if (statusListener) {
statusListener(data);
}
}
export interface ResourceBundleInfo {
version: string;
archive: string;
url: string;
sha256: string;
size: number;
required: boolean;
extractTo: string;
source?: string;
}
export interface ResourceManifest {
manifestVersion: number;
generatedAt: string;
appVersion: string;
bundles: Record<string, ResourceBundleInfo>;
}
interface LocalResourceState {
manifestVersion: number;
appVersion: string;
bundles: Record<string, { version: string; sha256: string; installedAt: string; path: string }>;
}
const getEmbeddedManifestPath = () => {
const packagedPath = path.join(process.resourcesPath || AppEnv.appRoot, 'extra', 'common', 'resource-manifest.json');
if (fs.existsSync(packagedPath)) {
return packagedPath;
}
return path.join(AppEnv.appRoot, 'electron', 'resources', 'extra', 'common', 'resource-manifest.json');
};
const getLocalStatePath = () => path.join(AppEnv.resourceStateRoot, 'resource-state.json');
const getBundleRoot = (bundleName: string) => path.join(AppEnv.resourceBundleRoot, bundleName);
const getArchivePath = (bundle: ResourceBundleInfo) => path.join(AppEnv.resourceBundleRoot, bundle.archive);
const getBundleTempRoot = () => path.join(AppEnv.tempRoot, 'resource-install');
function readJsonFile<T>(filePath: string, fallback: T): T {
try {
if (!fs.existsSync(filePath)) return fallback;
return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T;
} catch {
return fallback;
}
}
function writeJsonFile(filePath: string, data: any) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8');
}
function rmIfExists(targetPath: string) {
if (fs.existsSync(targetPath)) {
fs.rmSync(targetPath, { recursive: true, force: true });
}
}
function ensureDir(dir: string) {
fs.mkdirSync(dir, { recursive: true });
}
const bundleSentinels: Record<string, string[]> = {
'python-runtime': ['python.exe', 'subtitle_cover_generator_simple.py'],
ffmpeg: ['ffmpeg.exe', 'ffprobe.exe'],
models: ['u2net.onnx'],
ziti: ['NotoSerifCJK-VF.ttf.ttc'],
};
function isBundleReady(bundleName: string, bundleRoot: string): boolean {
if (!fs.existsSync(bundleRoot)) {
return false;
}
try {
const entries = fs.readdirSync(bundleRoot);
if (entries.length === 0) {
return false;
}
} catch {
return false;
}
const sentinels = bundleSentinels[bundleName] || [];
return sentinels.every((relativePath) => fs.existsSync(path.join(bundleRoot, relativePath)));
}
function downloadToFile(url: string, filePath: string, onProgress?: (downloaded: number, total: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
ensureDir(path.dirname(filePath));
const writer = fs.createWriteStream(filePath);
let settled = false;
const fail = (error: any) => {
if (settled) {
return;
}
settled = true;
try {
writer.destroy();
} catch {
// ignore cleanup failure
}
rmIfExists(filePath);
reject(error);
};
axios({ method: 'get', url, responseType: 'stream', timeout: 10 * 60 * 1000 })
.then((response) => {
const total = Number(response.headers['content-length'] || 0);
let downloaded = 0;
response.data.on('data', (chunk: Buffer) => {
downloaded += chunk.length;
onProgress?.(downloaded, total);
});
response.data.on('error', fail);
response.data.pipe(writer);
writer.on('finish', () => {
if (settled) {
return;
}
settled = true;
resolve();
});
writer.on('error', fail);
})
.catch(fail);
});
}
function extractZip(zipPath: string, destination: string): Promise<void> {
return new Promise((resolve, reject) => {
ensureDir(destination);
yauzl.open(zipPath, { lazyEntries: true }, (error, zipFile) => {
if (error || !zipFile) {
reject(error || new Error('打开 zip 失败'));
return;
}
zipFile.readEntry();
zipFile.on('entry', (entry) => {
const destPath = path.join(destination, entry.fileName);
if (/\/$/.test(entry.fileName)) {
ensureDir(destPath);
zipFile.readEntry();
return;
}
ensureDir(path.dirname(destPath));
zipFile.openReadStream(entry, (streamError, readStream) => {
if (streamError || !readStream) {
reject(streamError || new Error('读取 zip entry 失败'));
return;
}
const writeStream = fs.createWriteStream(destPath);
readStream.pipe(writeStream);
writeStream.on('finish', () => zipFile.readEntry());
writeStream.on('error', reject);
});
});
zipFile.on('end', () => resolve());
zipFile.on('error', reject);
});
});
}
function fileSha256(filePath: string): string {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
async function resolveManifest(): Promise<ResourceManifest | null> {
const embedded = readJsonFile<ResourceManifest | null>(getEmbeddedManifestPath(), null);
if (embedded) return embedded;
try {
const config = await fetchElectronSystemConfig();
const baseUrl = config.update_url?.replace(/\/$/, '');
if (!baseUrl) return null;
const response = await fetch(`${baseUrl}/resource-manifest.json`);
if (!response.ok) return null;
return await response.json() as ResourceManifest;
} catch (error: any) {
Log.warn('[ResourceManager] resolveManifest failed', error?.message || error);
return null;
}
}
async function ensureArchive(bundleName: string, bundle: ResourceBundleInfo): Promise<string> {
const archivePath = getArchivePath(bundle);
if (fs.existsSync(archivePath) && fileSha256(archivePath) === bundle.sha256) {
emitStatus({ status: 'resource-ready', bundle: bundleName, archive: bundle.archive });
return archivePath;
}
if (!/^https?:\/\//i.test(bundle.url)) {
const config = await fetchElectronSystemConfig();
const baseUrl = config.update_url?.replace(/\/$/, '');
if (!baseUrl) {
throw new Error(`资源 ${bundle.archive} 缺少可下载地址`);
}
bundle.url = `${baseUrl}/${bundle.url.replace(/^\//, '')}`;
}
emitStatus({ status: 'resource-downloading', bundle: bundleName, archive: bundle.archive, percent: 0, transferred: 0, total: bundle.size || 0 });
Log.info('[ResourceManager] downloading archive', { archive: bundle.archive, url: bundle.url });
await downloadToFile(bundle.url, archivePath, (downloaded, total) => {
const safeTotal = total || bundle.size || 0;
emitStatus({
status: 'resource-downloading',
bundle: bundleName,
archive: bundle.archive,
transferred: downloaded,
total: safeTotal,
percent: safeTotal > 0 ? Math.round((downloaded / safeTotal) * 100) : 0,
});
});
const sha256 = fileSha256(archivePath);
if (sha256 !== bundle.sha256) {
throw new Error(`资源包校验失败: ${bundle.archive}`);
}
emitStatus({ status: 'resource-downloaded', bundle: bundleName, archive: bundle.archive, percent: 100 });
return archivePath;
}
async function installBundle(bundleName: string, bundle: ResourceBundleInfo, state: LocalResourceState) {
emitStatus({ status: 'resource-installing', bundle: bundleName, archive: bundle.archive, step: 'download' });
const archivePath = await ensureArchive(bundleName, bundle);
const installTempRoot = getBundleTempRoot();
const bundleTempDir = path.join(installTempRoot, `${bundleName}-${Date.now()}`);
const bundleRoot = getBundleRoot(bundleName);
const backupDir = `${bundleRoot}.bak`;
rmIfExists(bundleTempDir);
ensureDir(bundleTempDir);
emitStatus({ status: 'resource-installing', bundle: bundleName, archive: bundle.archive, step: 'extract' });
await extractZip(archivePath, bundleTempDir);
rmIfExists(backupDir);
if (fs.existsSync(bundleRoot)) {
fs.renameSync(bundleRoot, backupDir);
}
try {
ensureDir(path.dirname(bundleRoot));
emitStatus({ status: 'resource-installing', bundle: bundleName, archive: bundle.archive, step: 'replace' });
fs.renameSync(bundleTempDir, bundleRoot);
rmIfExists(backupDir);
} catch (error) {
rmIfExists(bundleRoot);
if (fs.existsSync(backupDir)) {
fs.renameSync(backupDir, bundleRoot);
}
throw error;
}
state.bundles[bundleName] = {
version: bundle.version,
sha256: bundle.sha256,
installedAt: new Date().toISOString(),
path: bundleRoot,
};
emitStatus({ status: 'resource-installed', bundle: bundleName, archive: bundle.archive, percent: 100 });
}
export async function checkResourceBundles(options: { installMissing?: boolean } = {}) {
const manifest = await resolveManifest();
if (!manifest) {
return { manifest: null, missingRequired: [], pending: [], installed: [] };
}
const state = readJsonFile<LocalResourceState>(getLocalStatePath(), {
manifestVersion: manifest.manifestVersion,
appVersion: manifest.appVersion,
bundles: {},
});
const pending: string[] = [];
const missingRequired: string[] = [];
const installed: string[] = [];
ensureDir(AppEnv.resourceBundleRoot);
ensureDir(AppEnv.resourceStateRoot);
ensureDir(getBundleTempRoot());
for (const [bundleName, bundle] of Object.entries(manifest.bundles)) {
const bundleDir = getBundleRoot(bundleName);
const local = state.bundles[bundleName];
const bundleReady = isBundleReady(bundleName, bundleDir);
if (bundleReady) {
state.bundles[bundleName] = {
version: bundle.version,
sha256: bundle.sha256,
installedAt: new Date().toISOString(),
path: bundleDir,
};
emitStatus({ status: 'resource-ready', bundle: bundleName, archive: bundle.archive, prebundled: true });
continue;
}
if (local?.version === bundle.version) {
Log.warn('[ResourceManager] bundle directory invalid, forcing reinstall', { bundleName, bundleDir });
}
pending.push(bundleName);
if (bundle.required) {
missingRequired.push(bundleName);
}
if (options.installMissing) {
try {
await installBundle(bundleName, bundle, state);
installed.push(bundleName);
} catch (error: any) {
emitStatus({ status: 'error', message: `bundle ${bundleName}: ${error?.message || String(error)}` });
Log.error('[ResourceManager] install bundle failed', { bundleName, message: error?.message || String(error) });
if (bundle.required) {
throw error;
}
}
}
}
state.manifestVersion = manifest.manifestVersion;
state.appVersion = manifest.appVersion;
writeJsonFile(getLocalStatePath(), state);
return { manifest, missingRequired, pending, installed };
}