Initial clean project import
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user