274 lines
9.8 KiB
JavaScript
274 lines
9.8 KiB
JavaScript
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-app:require 会向上解析到 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;
|
||
}
|
||
});
|