fix: resolve Electron desktop client white screen - add afterPack hook for node_modules, fix standalone server startup with fork+ELECTRON_RUN_AS_NODE, add debug logging and single-instance lock
This commit is contained in:
1 parent
6c390c3298
commit
731b0407c5
4 files changed
+228
-73
No files matched your search
BIN
Binary file not shown.
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* electron-builder afterPack hook
|
||||
* Copies node_modules from standalone into the packaged app resources,
|
||||
* because electron-builder filters out node_modules from extraResources.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
function copyDirSync(src, dest) {
|
||||
if (!fs.existsSync(src)) return;
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
const entries = fs.readdirSync(src, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const srcPath = path.join(src, entry.name);
|
||||
const destPath = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyDirSync(srcPath, destPath);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
// Resolve symlinks and copy the actual file/dir
|
||||
const realPath = fs.realpathSync(srcPath);
|
||||
if (fs.statSync(realPath).isDirectory()) {
|
||||
copyDirSync(realPath, destPath);
|
||||
} else {
|
||||
fs.copyFileSync(realPath, destPath);
|
||||
}
|
||||
} else {
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exports.default = async function afterPack(context) {
|
||||
const appOutDir = context.appOutDir; // e.g. dist/win-unpacked
|
||||
const standaloneNodeModules = path.join(__dirname, '.next', 'standalone', 'node_modules');
|
||||
const targetNodeModules = path.join(appOutDir, 'resources', 'standalone', 'node_modules');
|
||||
|
||||
console.log(`[afterPack] Copying node_modules from ${standaloneNodeModules}`);
|
||||
console.log(`[afterPack] To ${targetNodeModules}`);
|
||||
|
||||
if (!fs.existsSync(standaloneNodeModules)) {
|
||||
console.error('[afterPack] ERROR: standalone node_modules not found!');
|
||||
return;
|
||||
}
|
||||
|
||||
copyDirSync(standaloneNodeModules, targetNodeModules);
|
||||
console.log('[afterPack] node_modules copied successfully');
|
||||
};
|
||||
+156
-68
@@ -1,12 +1,32 @@
|
||||
const { app, BrowserWindow, shell } = require('electron');
|
||||
const { app, BrowserWindow, shell, dialog } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
const { fork } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
|
||||
// 日志文件 - 写到用户桌面方便查看
|
||||
const logFile = path.join(app.getPath('desktop'), 'author-debug.log');
|
||||
function log(msg) {
|
||||
const line = `[${new Date().toISOString()}] ${msg}\n`;
|
||||
console.log(msg);
|
||||
try { fs.appendFileSync(logFile, line); } catch (e) { }
|
||||
}
|
||||
|
||||
// 防止多开
|
||||
const gotTheLock = app.requestSingleInstanceLock();
|
||||
if (!gotTheLock) {
|
||||
log('Another instance is running, quitting.');
|
||||
app.quit();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let mainWindow;
|
||||
let nextServer;
|
||||
let serverProcess;
|
||||
|
||||
const isDev = process.argv.includes('--dev');
|
||||
const PORT = 3000;
|
||||
let loadRetries = 0;
|
||||
const MAX_LOAD_RETRIES = 10;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
@@ -15,13 +35,11 @@ function createWindow() {
|
||||
minWidth: 900,
|
||||
minHeight: 600,
|
||||
title: 'Author — AI-Powered Creative Writing',
|
||||
icon: path.join(__dirname, '..', 'public', 'icon.png'),
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
// 隐藏默认菜单栏,更简洁
|
||||
autoHideMenuBar: true,
|
||||
show: false,
|
||||
});
|
||||
@@ -30,11 +48,41 @@ function createWindow() {
|
||||
|
||||
mainWindow.once('ready-to-show', () => {
|
||||
mainWindow.show();
|
||||
// F12 打开开发者工具
|
||||
mainWindow.webContents.on('before-input-event', (event, input) => {
|
||||
if (input.key === 'F12') {
|
||||
mainWindow.webContents.toggleDevTools();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 加载失败时有限次重试
|
||||
mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
|
||||
loadRetries++;
|
||||
log(`Load failed (${loadRetries}/${MAX_LOAD_RETRIES}): ${errorDescription}`);
|
||||
if (loadRetries < MAX_LOAD_RETRIES) {
|
||||
setTimeout(() => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.loadURL(`http://localhost:${PORT}`);
|
||||
}
|
||||
}, 2000);
|
||||
} else {
|
||||
mainWindow.show();
|
||||
dialog.showErrorBox(
|
||||
'Author 启动失败',
|
||||
'无法连接到内置服务器。\n\n' +
|
||||
'查看日志: ' + logFile
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
log('Page loaded successfully');
|
||||
loadRetries = 0;
|
||||
});
|
||||
|
||||
// 外部链接在系统浏览器中打开
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http')) {
|
||||
if (url.startsWith('http') && !url.includes('localhost')) {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
@@ -46,94 +94,134 @@ function createWindow() {
|
||||
});
|
||||
}
|
||||
|
||||
function waitForServer(port, maxRetries = 30) {
|
||||
return new Promise((resolve) => {
|
||||
let retries = 0;
|
||||
const check = () => {
|
||||
const req = http.get(`http://localhost:${port}`, (res) => {
|
||||
resolve(true);
|
||||
});
|
||||
req.on('error', () => {
|
||||
retries++;
|
||||
if (retries >= maxRetries) {
|
||||
resolve(false);
|
||||
} else {
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
});
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
retries++;
|
||||
if (retries >= maxRetries) {
|
||||
resolve(false);
|
||||
} else {
|
||||
setTimeout(check, 1000);
|
||||
}
|
||||
});
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function startNextServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise(async (resolve) => {
|
||||
if (isDev) {
|
||||
// 开发模式:假设 dev server 已在运行
|
||||
console.log('[Author] Dev mode — connecting to existing dev server...');
|
||||
resolve();
|
||||
log('Dev mode — connecting to existing dev server...');
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Author] Starting production server...');
|
||||
const isPackaged = app.isPackaged;
|
||||
let standaloneDir;
|
||||
|
||||
// 生产模式:运行 next start
|
||||
const nextBin = path.join(__dirname, '..', 'node_modules', '.bin', 'next');
|
||||
const projectDir = path.join(__dirname, '..');
|
||||
if (isPackaged) {
|
||||
standaloneDir = path.join(process.resourcesPath, 'standalone');
|
||||
} else {
|
||||
standaloneDir = path.join(__dirname, '..', '.next', 'standalone');
|
||||
}
|
||||
|
||||
nextServer = spawn(
|
||||
process.platform === 'win32' ? `${nextBin}.cmd` : nextBin,
|
||||
['start', '-p', String(PORT)],
|
||||
{
|
||||
cwd: projectDir,
|
||||
env: { ...process.env, NODE_ENV: 'production' },
|
||||
const serverPath = path.join(standaloneDir, 'server.js');
|
||||
|
||||
log(`isPackaged: ${isPackaged}`);
|
||||
log(`resourcesPath: ${process.resourcesPath}`);
|
||||
log(`standaloneDir: ${standaloneDir}`);
|
||||
log(`serverPath: ${serverPath}`);
|
||||
log(`serverExists: ${fs.existsSync(serverPath)}`);
|
||||
|
||||
// 检查关键目录
|
||||
const staticDir = path.join(standaloneDir, '.next', 'static');
|
||||
const publicDir = path.join(standaloneDir, 'public');
|
||||
log(`staticDir exists: ${fs.existsSync(staticDir)}`);
|
||||
log(`publicDir exists: ${fs.existsSync(publicDir)}`);
|
||||
|
||||
if (!fs.existsSync(serverPath)) {
|
||||
const msg = '找不到 server.js\n路径: ' + serverPath;
|
||||
log('ERROR: ' + msg);
|
||||
dialog.showErrorBox('Author 启动失败', msg);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
log('Starting Next.js server via fork...');
|
||||
|
||||
serverProcess = fork(serverPath, [], {
|
||||
cwd: standaloneDir,
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
PORT: String(PORT),
|
||||
HOSTNAME: 'localhost',
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
},
|
||||
stdio: 'pipe',
|
||||
shell: true,
|
||||
}
|
||||
);
|
||||
|
||||
let started = false;
|
||||
|
||||
nextServer.stdout.on('data', (data) => {
|
||||
const output = data.toString();
|
||||
console.log('[Next.js]', output);
|
||||
if (!started && output.includes('Ready')) {
|
||||
started = true;
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
|
||||
nextServer.stderr.on('data', (data) => {
|
||||
console.error('[Next.js Error]', data.toString());
|
||||
serverProcess.stdout.on('data', (data) => {
|
||||
log('[Next.js stdout] ' + data.toString().trim());
|
||||
});
|
||||
|
||||
nextServer.on('error', (err) => {
|
||||
console.error('[Next.js] Failed to start:', err);
|
||||
reject(err);
|
||||
serverProcess.stderr.on('data', (data) => {
|
||||
log('[Next.js stderr] ' + data.toString().trim());
|
||||
});
|
||||
|
||||
nextServer.on('close', (code) => {
|
||||
if (!started) {
|
||||
reject(new Error(`Next.js server exited with code ${code}`));
|
||||
}
|
||||
serverProcess.on('error', (err) => {
|
||||
log('[Server process error] ' + err.message);
|
||||
});
|
||||
|
||||
// 超时保底 — 15秒内没有就绪也继续
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
console.log('[Author] Server startup timeout, loading anyway...');
|
||||
resolve();
|
||||
}
|
||||
}, 15000);
|
||||
serverProcess.on('close', (code) => {
|
||||
log('[Server process closed] code: ' + code);
|
||||
});
|
||||
|
||||
const ready = await waitForServer(PORT);
|
||||
log(`Server ready: ${ready}`);
|
||||
resolve(ready);
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
try {
|
||||
log('=== Author Desktop Starting ===');
|
||||
log(`Electron version: ${process.versions.electron}`);
|
||||
log(`Node version: ${process.versions.node}`);
|
||||
log(`Platform: ${process.platform} ${process.arch}`);
|
||||
log(`App path: ${app.getAppPath()}`);
|
||||
log(`Exe path: ${process.execPath}`);
|
||||
|
||||
await startNextServer();
|
||||
} catch (err) {
|
||||
console.error('[Author] Server start failed:', err);
|
||||
}
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (nextServer) {
|
||||
nextServer.kill();
|
||||
}
|
||||
if (serverProcess) serverProcess.kill();
|
||||
app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
|
||||
// 确保退出时清理子进程
|
||||
app.on('before-quit', () => {
|
||||
if (nextServer) {
|
||||
nextServer.kill();
|
||||
}
|
||||
if (serverProcess) serverProcess.kill();
|
||||
});
|
||||
+25
-5
@@ -56,13 +56,33 @@
|
||||
"output": "dist"
|
||||
},
|
||||
"files": [
|
||||
".next/**/*",
|
||||
"node_modules/**/*",
|
||||
"public/**/*",
|
||||
"electron/**/*",
|
||||
"package.json",
|
||||
"next.config.mjs"
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": ".next/standalone",
|
||||
"to": "standalone",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": ".next/static",
|
||||
"to": "standalone/.next/static",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "public",
|
||||
"to": "standalone/public",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"afterPack": "./afterPack.js",
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user