500 lines
15 KiB
JavaScript
500 lines
15 KiB
JavaScript
// 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 等其他应用的同键操作。
|
||
})
|