// Windows: 把控制台代码页切到 UTF-8,避免中文 stdout 显示为乱码 if (process.platform === 'win32') { try { require('child_process').execSync('chcp 65001', { stdio: 'ignore', windowsHide: true }) } catch (_) {} } const { app, BrowserWindow, shell, dialog, Menu, ipcMain, Tray, nativeImage } = require('electron') const path = require('path') const fs = require('fs') const net = require('net') const http = require('http') const { EventEmitter } = require('events') const { pathToFileURL } = require('url') const { autoUpdater } = require('electron-updater') const IS_DEV = !app.isPackaged const WINDOWS_APP_USER_MODEL_ID = 'com.xiaoyuanda.bailongma' const USER_DIR = app.getPath('userData') const CODE_ROOT = app.getAppPath() const RESOURCE_ROOT = CODE_ROOT const BACKEND_ENTRY = path.join(CODE_ROOT, 'src', 'index.js') // 持久化日志:把 console.* 镜像到 USER_DIR/logs/bailongma.log, // 安装版没有 stdout 的情况下,卡死/崩溃后还能 tail 这个文件复盘。 // 简易 rotate:> 5MB 时把当前文件改名 .old(覆盖上一份 .old),下次写入重开。 const LOG_DIR = path.join(USER_DIR, 'logs') const LOG_FILE = path.join(LOG_DIR, 'bailongma.log') const LOG_FILE_OLD = path.join(LOG_DIR, 'bailongma.old.log') const LOG_MAX_BYTES = 5 * 1024 * 1024 try { fs.mkdirSync(LOG_DIR, { recursive: true }) } catch {} function rotateLogIfNeeded() { try { const stat = fs.statSync(LOG_FILE) if (stat.size > LOG_MAX_BYTES) { try { fs.rmSync(LOG_FILE_OLD, { force: true }) } catch {} try { fs.renameSync(LOG_FILE, LOG_FILE_OLD) } catch {} } } catch {} } function writeLog(level, args) { let line try { line = args.map(a => { if (typeof a === 'string') return a if (a instanceof Error) return a.stack || a.message try { return JSON.stringify(a) } catch { return String(a) } }).join(' ') } catch { line = '[log-serialize-failed]' } const ts = new Date().toISOString() const out = `${ts} [${level}] ${line}\n` try { fs.appendFileSync(LOG_FILE, out) } catch {} } // Hijack 一次就够;后端 import 在同一进程,console.* 引用的是同一个 console 对象。 // 把原始方法存起来,appendFile 失败时仍能输出到 stdout/stderr(开发模式可见)。 ;(function installLogHijack() { const levels = ['log', 'info', 'warn', 'error', 'debug'] for (const level of levels) { const original = console[level]?.bind(console) || (() => {}) console[level] = (...args) => { try { original(...args) } catch {} try { rotateLogIfNeeded() writeLog(level, args) } catch {} } } })() process.on('unhandledRejection', (reason) => { console.error('[unhandledRejection]', reason instanceof Error ? (reason.stack || reason.message) : String(reason)) }) process.on('uncaughtException', (err) => { console.error('[uncaughtException]', err?.stack || err?.message || String(err)) }) console.log(`[main] Bailongma ${app.getVersion()} starting, logs → ${LOG_FILE}`) let mainWindow = null let backendPort = 0 let tray = null let focusBannerWindow = null // 后端通过 global.focusBannerBridge 控制横幅窗口 const focusBannerBridge = new EventEmitter() global.focusBannerBridge = focusBannerBridge global.bailongmaAppControl = { restart() { console.log('[main] restart requested') app.isQuiting = true app.relaunch() app.quit() }, } if (process.platform === 'win32') { app.setAppUserModelId(WINDOWS_APP_USER_MODEL_ID) } function sendUpdaterStatus(payload = {}) { if (!mainWindow || mainWindow.isDestroyed()) return mainWindow.webContents.send('updater:status', { currentVersion: app.getVersion(), ...payload, }) } async function bootstrapBackend(port) { process.env.BAILONGMA_USER_DIR ||= USER_DIR process.env.BAILONGMA_RESOURCES_DIR ||= RESOURCE_ROOT process.env.BAILONGMA_PORT = String(port) await import(pathToFileURL(BACKEND_ENTRY).href) } const gotLock = app.requestSingleInstanceLock() if (!gotLock) { app.quit() process.exit(0) } async function findFreePort(preferred = 3721) { for (const port of [preferred, 0]) { try { const actual = await new Promise((resolve, reject) => { const server = net.createServer() server.once('error', reject) server.listen(port, '127.0.0.1', () => { const address = server.address() server.close(() => resolve(address.port)) }) }) return actual } catch {} } throw new Error('Unable to find a free local port') } function waitForBackend(port, timeoutMs = 30000) { const startedAt = Date.now() const url = `http://127.0.0.1:${port}/activation-status` return new Promise((resolve, reject) => { const tick = () => { if (Date.now() - startedAt > timeoutMs) { reject(new Error('Backend startup timed out')) return } const req = http.get(url, res => { res.resume() resolve() }) req.on('error', () => setTimeout(tick, 300)) req.setTimeout(1500, () => { req.destroy() setTimeout(tick, 300) }) } tick() }) } async function createWindow() { mainWindow = new BrowserWindow({ width: 1280, height: 840, minWidth: 900, minHeight: 600, backgroundColor: '#0b0b0e', title: 'Bailongma', icon: path.join(RESOURCE_ROOT, 'build', 'icon.png'), webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'preload.cjs'), }, }) // 授予麦克风权限(语音输入需要) mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => { if (permission === 'media') return callback(true) callback(false) }) mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission) => { if (permission === 'media') return true return false }) // 窗口级快捷键(不用 globalShortcut,避免劫持其他应用的 F11/Ctrl+R 等) // F12 → 切换 DevTools // F11 → 切换全屏 // Ctrl+R → reload(仅 dev) mainWindow.webContents.on('before-input-event', (event, input) => { if (input.type !== 'keyDown') return if (input.key === 'F12') { mainWindow.webContents.toggleDevTools() event.preventDefault() return } if (input.key === 'F11') { mainWindow.setFullScreen(!mainWindow.isFullScreen()) event.preventDefault() return } if (IS_DEV && (input.control || input.meta) && input.key.toLowerCase() === 'r') { mainWindow.webContents.reload() event.preventDefault() return } }) mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (/^https?:\/\//i.test(url)) { shell.openExternal(url) return { action: 'deny' } } return { action: 'allow' } }) await mainWindow.loadURL(`http://127.0.0.1:${backendPort}/`) // 关闭主窗口时最小化到托盘,不退出 mainWindow.on('close', (e) => { if (!app.isQuiting) { e.preventDefault() mainWindow.hide() } }) mainWindow.on('closed', () => { mainWindow = null }) } function setupTray() { const iconPath = path.join(RESOURCE_ROOT, 'build', 'icon.ico') tray = new Tray(nativeImage.createFromPath(iconPath)) tray.setToolTip('Bailongma') const contextMenu = Menu.buildFromTemplate([ { label: '显示主界面', click: () => { if (mainWindow) { mainWindow.show() mainWindow.focus() } }, }, { type: 'separator' }, { { label: '3D 意识空间', click: () => { if (mainWindow) { mainWindow.loadURL(`http://127.0.0.1:`+port+`/consciousness-3d`) mainWindow.show() mainWindow.focus() } }, }, { type: 'separator' }, label: '退出', click: () => { app.isQuiting = true app.quit() }, }, ]) tray.setContextMenu(contextMenu) tray.on('double-click', () => { if (mainWindow) { mainWindow.show() mainWindow.focus() } }) } function createFocusBannerWindow({ task = '', current_step = '', tasks = [] } = {}) { if (focusBannerWindow && !focusBannerWindow.isDestroyed()) { focusBannerWindow.webContents.send('focus-banner:update', { task, current_step, tasks }) return } const { width: screenW } = require('electron').screen.getPrimaryDisplay().workAreaSize focusBannerWindow = new BrowserWindow({ width: 280, height: 60, x: Math.round(screenW / 2 - 140), y: 48, frame: false, transparent: true, alwaysOnTop: true, skipTaskbar: true, resizable: false, movable: true, minimizable: false, maximizable: false, focusable: true, webPreferences: { contextIsolation: true, nodeIntegration: false, preload: path.join(__dirname, 'focus-banner-preload.cjs'), }, }) // 给 banner 窗口的 session 也授权麦克风 focusBannerWindow.webContents.session.setPermissionRequestHandler((wc, permission, callback) => { if (permission === 'media') return callback(true) callback(false) }) focusBannerWindow.webContents.session.setPermissionCheckHandler((wc, permission) => { if (permission === 'media') return true return false }) focusBannerWindow.loadFile(path.join(RESOURCE_ROOT, 'focus-banner.html')) focusBannerWindow.webContents.once('did-finish-load', () => { if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return // 先发端口配置,让语音识别结果能发回后端 focusBannerWindow.webContents.send('focus-banner:config', { port: backendPort }) focusBannerWindow.webContents.send('focus-banner:update', { task, current_step, tasks }) autoResizeBannerWindow() }) focusBannerWindow.on('closed', () => { focusBannerWindow = null }) } function autoResizeBannerWindow() { if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return focusBannerWindow.webContents.executeJavaScript(` (() => { const b = document.getElementById('banner') return b ? { w: b.offsetWidth, h: b.offsetHeight } : null })() `).then(size => { if (!size || !focusBannerWindow || focusBannerWindow.isDestroyed()) return const padW = 0 const padH = 0 focusBannerWindow.setSize(Math.max(160, size.w + padW), Math.max(40, size.h + padH)) }).catch(() => {}) } // Focus Banner IPC handlers ipcMain.on('focus-banner:close', () => { if (focusBannerWindow && !focusBannerWindow.isDestroyed()) { focusBannerWindow.close() focusBannerWindow = null } }) ipcMain.on('focus-banner:set-expanded', (_e, { expanded }) => { if (!focusBannerWindow || focusBannerWindow.isDestroyed()) return setTimeout(() => autoResizeBannerWindow(), 50) }) ipcMain.on('focus-banner:request-resize', () => { setTimeout(() => autoResizeBannerWindow(), 30) }) ipcMain.on('focus-banner:toggle-task', (_e, { idx, done }) => { // 任务勾选状态更改,横幅已在前端自行更新,无需额外操作 }) // 后端 bridge 事件监听 focusBannerBridge.on('command', ({ action, task, current_step, tasks }) => { if (action === 'show' || action === 'update') { createFocusBannerWindow({ task, current_step, tasks }) } }) focusBannerBridge.on('hide', () => { if (focusBannerWindow && !focusBannerWindow.isDestroyed()) { focusBannerWindow.close() focusBannerWindow = null } }) function setupAutoUpdater() { autoUpdater.autoDownload = false autoUpdater.autoInstallOnAppQuit = true autoUpdater.on('checking-for-update', () => { sendUpdaterStatus({ stage: 'checking' }) }) autoUpdater.on('update-available', info => { console.log('[updater] update available', info?.version) sendUpdaterStatus({ stage: 'available', version: info?.version }) }) autoUpdater.on('download-progress', progress => { sendUpdaterStatus({ stage: 'downloading', percent: Number(progress?.percent || 0), transferred: progress?.transferred || 0, total: progress?.total || 0, }) }) autoUpdater.on('update-downloaded', info => { console.log('[updater] update downloaded', info?.version) sendUpdaterStatus({ stage: 'downloaded', version: info?.version }) }) autoUpdater.on('update-not-available', info => { sendUpdaterStatus({ stage: 'up-to-date', version: info?.version || app.getVersion(), }) }) autoUpdater.on('error', err => { const message = err?.message || String(err || 'Update failed') console.warn('[updater] update failed', message) sendUpdaterStatus({ stage: 'error', message }) }) if (!IS_DEV) { autoUpdater.checkForUpdates().catch(() => {}) } } ipcMain.handle('app:get-version', () => app.getVersion()) ipcMain.handle('updater:check-for-updates', async () => { if (IS_DEV) { sendUpdaterStatus({ stage: 'dev' }) return { ok: false, skipped: true, reason: 'dev' } } try { sendUpdaterStatus({ stage: 'checking' }) const result = await autoUpdater.checkForUpdates() return { ok: true, updateInfo: result?.updateInfo || null } } catch (error) { const message = error?.message || String(error || 'Update check failed') sendUpdaterStatus({ stage: 'error', message }) return { ok: false, message } } }) ipcMain.handle('updater:start-download', async () => { try { await autoUpdater.downloadUpdate() return { ok: true } } catch (error) { const message = error?.message || String(error || 'Download failed') sendUpdaterStatus({ stage: 'error', message }) return { ok: false, message } } }) ipcMain.handle('updater:quit-and-install', () => { autoUpdater.quitAndInstall() }) app.on('second-instance', () => { if (!mainWindow) return if (mainWindow.isMinimized()) mainWindow.restore() if (!mainWindow.isVisible()) mainWindow.show() mainWindow.focus() }) app.on('window-all-closed', () => { // 主窗口关闭后保持后台运行(Focus Banner 等桌面功能继续工作) // 只有托盘菜单「退出」才真正退出 }) app.whenReady().then(async () => { ipcMain.on('open-consciousness-3d', () => { if (mainWindow) { mainWindow.loadURL(`http://127.0.0.1:`+port+`/consciousness-3d`) } }) Menu.setApplicationMenu(null) try { backendPort = await findFreePort(3721) await bootstrapBackend(backendPort) await waitForBackend(backendPort) } catch (err) { dialog.showErrorBox('Startup failed', `Unable to start the Bailongma backend:\n${err.message}`) app.quit() return } await createWindow() setupTray() setupAutoUpdater() // 不再注册任何系统级 globalShortcut;F11 / F12 / Ctrl+R 已由 mainWindow // 的 before-input-event 处理(见 createWindow),只在窗口获焦时生效, // 不会劫持浏览器/IDE 等其他应用的同键操作。 })