Files

451 lines
15 KiB
JavaScript

const { app, BrowserWindow, shell, dialog, ipcMain } = require('electron');
const path = require('path');
const { fork, execSync, spawn } = require('child_process');
const http = require('http');
const https = require('https');
const net = require('net');
const fs = require('fs');
const os = require('os');
// 加载 .env.local(轻量实现,无需 dotenv 依赖)
(function loadEnvFile() {
const envPaths = [
path.join(__dirname, '..', '.env.local'),
path.join(__dirname, '..', '.env'),
];
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
const lines = fs.readFileSync(envPath, 'utf8').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
if (key && !process.env[key]) {
process.env[key] = value;
}
}
break; // 只加载第一个找到的文件
}
}
})();
// 日志文件 - 写到应用安装根目录(exe 同级),方便查找
const logFile = path.join(path.dirname(process.execPath), '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 serverProcess;
const isDev = process.argv.includes('--dev');
const BASE_PORT = parseInt(process.env.PORT, 10) || 3000;
let actualPort = BASE_PORT;
let loadRetries = 0;
const MAX_LOAD_RETRIES = 10;
let serverReady = false; // 追踪服务器是否真正就绪
function createWindow() {
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 900,
minHeight: 600,
title: 'Author — AI-Powered Creative Writing',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
autoHideMenuBar: true,
show: false,
});
mainWindow.loadURL(`http://localhost:${actualPort}`);
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:${actualPort}`);
}
}, 2000);
} else {
mainWindow.show();
dialog.showErrorBox(
'Author 启动失败',
'无法连接到内置服务器。\n\n' +
'查看日志: ' + logFile
);
}
});
// 只有真正加载了 localhost 页面才重置重试计数器
mainWindow.webContents.on('did-finish-load', () => {
const url = mainWindow.webContents.getURL();
if (url.includes('localhost')) {
log('Page loaded successfully: ' + url);
loadRetries = 0;
}
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (url.startsWith('http') && !url.includes('localhost')) {
shell.openExternal(url);
return { action: 'deny' };
}
return { action: 'allow' };
});
// 确保下载文件使用正确的文件名(而非 blob UUID)
mainWindow.webContents.session.on('will-download', (event, item) => {
const suggestedName = item.getFilename();
// 如果文件名看起来像 UUID(没有扩展名或是 blob hash),尝试用 Content-Disposition
if (suggestedName && !suggestedName.match(/^[0-9a-f-]{36}/i)) {
// 文件名正常,不需要干预
return;
}
// Electron 有时已经能从 a.download 获取到正确名称,这里做兜底
log(`[Download] Original filename: ${suggestedName}`);
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
// 检测端口是否可用
function isPortAvailable(port) {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => resolve(false));
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port, '127.0.0.1');
});
}
// 查找可用端口
async function findAvailablePort(startPort, maxTries = 10) {
for (let i = 0; i < maxTries; i++) {
const port = startPort + i;
if (await isPortAvailable(port)) {
return port;
}
log(`Port ${port} is in use, trying next...`);
}
return null;
}
// 尝试杀掉占用端口的进程 (Windows)
function tryKillPortProcess(port) {
try {
if (process.platform === 'win32') {
const result = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: 'utf8', timeout: 5000 });
const lines = result.trim().split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') {
log(`Killing process ${pid} on port ${port}`);
try { execSync(`taskkill /F /PID ${pid}`, { timeout: 5000 }); } catch (e) { }
}
}
}
} catch (e) {
// 没有进程占用或命令失败,忽略
}
}
function waitForServer(port, maxRetries = 60) {
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(3000, () => {
req.destroy();
retries++;
if (retries >= maxRetries) {
resolve(false);
} else {
setTimeout(check, 1000);
}
});
};
check();
});
}
function startNextServer() {
return new Promise(async (resolve) => {
if (isDev) {
log('Dev mode — connecting to existing dev server...');
resolve(true);
return;
}
const isPackaged = app.isPackaged;
let standaloneDir;
if (isPackaged) {
standaloneDir = path.join(process.resourcesPath, 'standalone');
} else {
standaloneDir = path.join(__dirname, '..', '.next', 'standalone');
}
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;
}
// 尝试释放被占用的端口
tryKillPortProcess(BASE_PORT);
// 等待一下让端口释放
await new Promise(r => setTimeout(r, 500));
// 查找可用端口
actualPort = await findAvailablePort(BASE_PORT);
if (!actualPort) {
const msg = `端口 ${BASE_PORT}-${BASE_PORT + 9} 全部被占用,无法启动服务器。`;
log('ERROR: ' + msg);
dialog.showErrorBox('Author 启动失败', msg);
resolve(false);
return;
}
log(`Using port: ${actualPort}`);
log('Starting Next.js server via fork...');
serverProcess = fork(serverPath, [], {
cwd: standaloneDir,
env: {
...process.env,
NODE_ENV: 'production',
PORT: String(actualPort),
HOSTNAME: '0.0.0.0',
},
stdio: 'pipe',
});
serverProcess.stdout.on('data', (data) => {
log('[Next.js stdout] ' + data.toString().trim());
});
serverProcess.stderr.on('data', (data) => {
log('[Next.js stderr] ' + data.toString().trim());
});
serverProcess.on('error', (err) => {
log('[Server process error] ' + err.message);
});
serverProcess.on('close', (code) => {
log('[Server process closed] code: ' + code);
serverReady = false;
});
const ready = await waitForServer(actualPort);
serverReady = ready;
log(`Server ready: ${ready}`);
resolve(ready);
});
}
app.whenReady().then(async () => {
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}`);
const ready = await startNextServer();
if (!ready) {
log('Server failed to start. Showing error dialog.');
dialog.showErrorBox(
'Author 启动失败',
'内置服务器无法启动。\n\n' +
'可能原因:\n' +
'1. 端口被其他程序占用\n' +
'2. 缺少运行文件\n' +
'3. 防火墙或杀毒软件拦截\n\n' +
'查看日志: ' + logFile
);
app.quit();
return;
}
createWindow();
});
// ==================== 自动下载更新 ====================
function httpsGet(url) {
return new Promise((resolve, reject) => {
https.get(url, { headers: { 'User-Agent': 'Author-App' } }, (res) => {
// 跟随重定向
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
httpsGet(res.headers.location).then(resolve).catch(reject);
return;
}
resolve(res);
}).on('error', reject);
});
}
ipcMain.handle('download-and-install-update', async (event) => {
try {
log('=== Auto-update started ===');
// 1. 获取最新 release 信息
const releaseRes = await new Promise((resolve, reject) => {
https.get('https://api.github.com/repos/YuanShiJiLoong/author/releases/latest', {
headers: { 'Accept': 'application/vnd.github.v3+json', 'User-Agent': 'Author-App' },
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
// 2. 找到 .exe 安装包
const exeAsset = releaseRes.assets?.find(a => a.name.endsWith('.exe'));
if (!exeAsset) {
log('No .exe asset found in release');
return { success: false, error: '未找到安装包' };
}
const downloadUrl = exeAsset.browser_download_url;
const fileName = exeAsset.name;
const totalSize = exeAsset.size;
const savePath = path.join(os.tmpdir(), fileName);
log(`Downloading: ${fileName} (${(totalSize / 1024 / 1024).toFixed(1)} MB)`);
log(`URL: ${downloadUrl}`);
log(`Save to: ${savePath}`);
// 3. 下载文件(跟随重定向)
const res = await httpsGet(downloadUrl);
if (res.statusCode !== 200) {
log(`Download failed, status: ${res.statusCode}`);
return { success: false, error: `下载失败 (HTTP ${res.statusCode})` };
}
const file = fs.createWriteStream(savePath);
let downloaded = 0;
let lastProgress = 0;
await new Promise((resolve, reject) => {
res.on('data', (chunk) => {
downloaded += chunk.length;
const progress = Math.floor(downloaded / totalSize * 100);
if (progress > lastProgress) {
lastProgress = progress;
// 发送进度到渲染进程
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('update-download-progress', { progress, downloaded, total: totalSize });
}
}
});
res.pipe(file);
file.on('finish', () => { file.close(); resolve(); });
file.on('error', reject);
res.on('error', reject);
});
log(`Download complete: ${savePath}`);
// 4. 启动安装程序(显示进度条 + 完成对话框)并退出当前应用
log('Launching installer...');
const installer = spawn(savePath, [], {
detached: true,
stdio: 'ignore',
});
installer.unref();
// 稍等一下确保安装程序启动
await new Promise(r => setTimeout(r, 1000));
log('Quitting app for update...');
app.quit();
return { success: true };
} catch (err) {
log(`Auto-update error: ${err.message}`);
return { success: false, error: err.message };
}
});
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
}
});
app.on('window-all-closed', () => {
if (serverProcess) serverProcess.kill();
app.quit();
});
app.on('before-quit', () => {
if (serverProcess) serverProcess.kill();
});