Files
2026-06-30 15:07:31 +08:00

274 lines
9.8 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, Menu } = require('electron');
const path = require('path');
const fs = require('fs');
// 显式固定 userData 目录,使开发模式与打包 exe 路径完全一致,防止 productName 变更导致路径漂移
const USERDATA_DIR = path.join(app.getPath('appData'), 'localminidrama-desktop');
app.setPath('userData', USERDATA_DIR);
const MAIN_STARTUP_LOG = path.join(USERDATA_DIR, 'main-startup.log');
function writeMainLog(msg) {
const line = `${new Date().toISOString()} ${msg}\n`;
try {
if (!fs.existsSync(USERDATA_DIR)) fs.mkdirSync(USERDATA_DIR, { recursive: true });
fs.appendFileSync(MAIN_STARTUP_LOG, line);
} catch (_) {}
}
process.on('uncaughtException', (err) => {
writeMainLog(`uncaughtException: ${err && err.stack ? err.stack : err}`);
});
process.on('unhandledRejection', (reason) => {
const text = reason instanceof Error ? reason.stack : String(reason);
writeMainLog(`unhandledRejection: ${text}`);
});
writeMainLog(`main.js loaded packaged=${app.isPackaged} exec=${process.execPath}`);
// 兼容迁移:若旧路径 LocalMiniDrama 有数据而新路径为空,自动迁移
;(function migrateOldUserData() {
const oldPath = path.join(app.getPath('appData'), 'LocalMiniDrama');
if (fs.existsSync(oldPath) && !fs.existsSync(USERDATA_DIR)) {
try {
fs.renameSync(oldPath, USERDATA_DIR);
} catch (e) {
// rename 跨驱动器时会失败,此时静默忽略,用户数据仍可手动迁移
}
}
})();
const BACKEND_APP_PATH = path.join(__dirname, 'backend-app');
const BACKEND_NODE_PATH = path.join(__dirname, '..', 'backend-node');
const DEFAULT_PORT = 5679;
let serverInstance = null;
/** 开发模式用 backend-node(改代码即生效);打包后用 backend-app */
function getBackendModulePath() {
if (app.isPackaged) return BACKEND_APP_PATH;
// Electron 开发模式必须用 backend-apprequire 会向上解析到 desktop/node_modules
// 其中 better-sqlite3 已由 postinstall 的 electron-rebuild 对准当前 Electron ABI。
// 若直接用 backend-node,则会加载 backend-node/node_modules(多为本机 Node 编的 ABI,必炸)。
if (process.versions.electron && fs.existsSync(path.join(BACKEND_APP_PATH, 'src', 'app.js'))) {
return BACKEND_APP_PATH;
}
return fs.existsSync(BACKEND_NODE_PATH) ? BACKEND_NODE_PATH : BACKEND_APP_PATH;
}
function getBackendCwd() {
if (app.isPackaged) {
return path.join(app.getPath('userData'), 'backend');
}
return getBackendModulePath();
}
function ensureBackendCwd(backendCwd) {
if (!fs.existsSync(backendCwd)) {
fs.mkdirSync(backendCwd, { recursive: true });
}
const configsDir = path.join(backendCwd, 'configs');
const dataDir = path.join(backendCwd, 'data');
const logsDir = path.join(backendCwd, 'logs');
const configPath = path.join(configsDir, 'config.yaml');
if (!fs.existsSync(configsDir)) fs.mkdirSync(configsDir, { recursive: true });
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(logsDir)) fs.mkdirSync(logsDir, { recursive: true });
// 首次安装时,从打包内置的 config.yaml 复制到用户数据目录
const bundledConfig = path.join(getBackendModulePath(), 'configs', 'config.yaml');
if (!fs.existsSync(configPath) && fs.existsSync(bundledConfig)) {
fs.copyFileSync(bundledConfig, configPath);
}
// 每次启动时,将内置 config.yaml 中的 vendor_lock 节强制同步到用户 config.yaml
// 确保打包时配置的锁定策略对所有用户生效,不受首次安装后遗留旧配置影响。
if (fs.existsSync(bundledConfig) && fs.existsSync(configPath)) {
try {
const yaml = require('js-yaml');
const userCfg = yaml.load(fs.readFileSync(configPath, 'utf8')) || {};
const bundledCfg = yaml.load(fs.readFileSync(bundledConfig, 'utf8')) || {};
if (bundledCfg.vendor_lock !== undefined) {
userCfg.vendor_lock = bundledCfg.vendor_lock;
fs.writeFileSync(configPath, yaml.dump(userCfg, { lineWidth: -1 }), 'utf8');
}
} catch (e) {
console.warn('[config] Failed to sync vendor_lock from bundled config:', e.message);
}
}
}
/**
* 首次启动时,将打包内置的 ffmpeg 自动复制到 userData/backend/tools/ffmpeg/。
* 来源:process.resourcesPath/ffmpeg/(由 electron-builder extraResources 写入)。
* 已存在则跳过,不会重复覆盖,也不影响用户手动替换版本。
*/
function ensureFfmpeg(backendCwd) {
if (!app.isPackaged) return;
const isWin = process.platform === 'win32';
const ffmpegName = isWin ? 'ffmpeg.exe' : 'ffmpeg';
const ffprobeName = isWin ? 'ffprobe.exe' : 'ffprobe';
const destDir = path.join(backendCwd, 'tools', 'ffmpeg');
const destFfmpeg = path.join(destDir, ffmpegName);
// 已存在则跳过(支持用户手动替换)
if (fs.existsSync(destFfmpeg)) {
console.log('[ffmpeg] Already exists at', destFfmpeg);
return;
}
const srcDir = path.join(process.resourcesPath, 'ffmpeg');
const srcFfmpeg = path.join(srcDir, ffmpegName);
if (!fs.existsSync(srcFfmpeg)) {
console.warn(
'[ffmpeg] Bundled ffmpeg not found, skipping auto-extract. Expected:',
srcFfmpeg,
'(打包前请将 ffmpeg.exe 放入 backend-node/tools/ffmpeg,并确保 package.json 的 extraResources 包含该目录)'
);
return;
}
try {
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
fs.copyFileSync(srcFfmpeg, destFfmpeg);
if (!isWin) fs.chmodSync(destFfmpeg, 0o755);
const srcFfprobe = path.join(srcDir, ffprobeName);
if (fs.existsSync(srcFfprobe)) {
const destFfprobe = path.join(destDir, ffprobeName);
fs.copyFileSync(srcFfprobe, destFfprobe);
if (!isWin) fs.chmodSync(destFfprobe, 0o755);
}
console.log('[ffmpeg] Auto-extracted to', destDir);
} catch (e) {
console.warn('[ffmpeg] Auto-extract failed:', e.message);
}
}
function getWebDistPath() {
if (app.isPackaged) {
return path.join(process.resourcesPath, 'frontweb', 'dist');
}
return path.join(__dirname, '..', 'frontweb', 'dist');
}
/**
* 探测端口是否空闲:优先使用 preferredPort,被占用时让 OS 分配一个随机空闲端口。
* 返回最终可用的端口号。
*/
function findFreePort(preferredPort) {
const net = require('net');
return new Promise((resolve) => {
const probe = net.createServer();
probe.once('error', () => {
// 首选端口被占,让 OS 随机分配
const fallback = net.createServer();
fallback.listen(0, '127.0.0.1', () => {
const port = fallback.address().port;
fallback.close(() => resolve(port));
});
});
probe.listen(preferredPort, '127.0.0.1', () => {
probe.close(() => resolve(preferredPort));
});
});
}
function createWindow(port) {
Menu.setApplicationMenu(null);
const win = new BrowserWindow({
width: 1280,
height: 800,
webPreferences: { nodeIntegration: false, contextIsolation: true },
show: false,
});
win.once('ready-to-show', () => {
win.show();
writeMainLog('window ready-to-show');
});
// 若页面长期不触发 ready-to-show,避免用户误以为“点了没反应”
setTimeout(() => {
if (!win.isDestroyed() && !win.isVisible()) {
win.show();
writeMainLog('window shown (fallback timeout, check page load)');
}
}, 8000);
win.webContents.on('did-fail-load', (_e, code, desc, url) => {
writeMainLog(`did-fail-load code=${code} desc=${desc} url=${url}`);
});
writeMainLog(`createWindow loadURL http://127.0.0.1:${port}`);
win.loadURL(`http://127.0.0.1:${port}`);
win.on('closed', () => app.quit());
if (process.env.LOCALMINIDRAMA_DEVTOOLS === '1') {
win.webContents.openDevTools();
}
}
/** 后端始终在主进程内运行(打包用子进程会重复启动 exe 导致大量进程,故取消) */
async function startBackend() {
const backendCwd = getBackendCwd();
ensureBackendCwd(backendCwd);
ensureFfmpeg(backendCwd);
process.env.WEB_DIST_PATH = getWebDistPath();
if (app.isPackaged) {
process.env.LOG_FILE = path.join(backendCwd, 'logs', 'app.log');
process.env.EXAMPLE_DRAMA_PATH = path.join(process.resourcesPath, 'example_drama');
} else {
process.env.EXAMPLE_DRAMA_PATH = path.join(__dirname, '..', 'example_drama');
}
process.chdir(backendCwd);
const backendModulePath = getBackendModulePath();
try {
require(path.join(backendModulePath, 'src', 'db', 'migrate.js'));
} catch (err) {
console.warn('Migration warning:', err.message);
}
const { createApp } = require(path.join(backendModulePath, 'src', 'app.js'));
const { createServer } = require('http');
const { app: expressApp, config } = createApp();
const preferredPort = config.server?.port || DEFAULT_PORT;
// 自动探测空闲端口:优先默认端口,被占时由 OS 分配,支持多实例同时运行
const port = await findFreePort(preferredPort);
if (port !== preferredPort) {
console.log(`Port ${preferredPort} in use, using ${port}`);
}
return new Promise((resolve, reject) => {
const server = createServer(expressApp);
serverInstance = server;
server.on('error', reject);
server.listen(port, '127.0.0.1', () => {
console.log('Backend listening on', port);
resolve(port);
});
});
}
app.whenReady().then(async () => {
writeMainLog('app.whenReady');
let port;
try {
port = await startBackend();
writeMainLog(`startBackend ok port=${port}`);
} catch (err) {
const stack = err && err.stack ? err.stack : String(err);
writeMainLog(`Failed to start backend\n${stack}`);
console.error('Failed to start backend', err);
app.quit();
return;
}
// startBackend 的 Promise 在 listen 回调中 resolve,服务器此时已就绪,直接建窗口
createWindow(port);
});
app.on('before-quit', () => {
if (serverInstance) {
serverInstance.close();
serverInstance = null;
}
});