Files
BaiLongma/electron/main.cjs
Geliebte a57c360b06 fix: 修复托盘菜单语法错误与激活页缺失,完善 Windows 打包流程
- electron/main.cjs: 修复托盘菜单损坏的合并代码(SyntaxError),托盘 3D 意识空间与 ipc 路由改用正确的 backendPort 变量
- 新增 activation.html 激活页(对接 /activate 接口,支持自动识别/8 个服务商)
- scripts/package-win.ps1: 打包前切换 better-sqlite3 到 Electron ABI、打包后还原 Node ABI,避免 dev 后端崩溃;内置 npmmirror 工具链镜像
- scripts/gen-icons.ps1 + build/icon.ico: 生成安装包图标(electron-builder 必需)
- package.json: build 脚本改用 package-win.ps1(不再依赖 VS 工具链)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 01:54:08 +08:00

500 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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:${backendPort}/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:${backendPort}/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()
// 不再注册任何系统级 globalShortcutF11 / F12 / Ctrl+R 已由 mainWindow
// 的 before-input-event 处理(见 createWindow只在窗口获焦时生效
// 不会劫持浏览器/IDE 等其他应用的同键操作。
})