Files
nl-um-vue-ts/electron/main.ts
2026-08-24 15:24:00 +08:00

429 lines
16 KiB
TypeScript
Raw Permalink 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.
/**
* Electron 主进程入口
*
* 职责:
* 1. 应用生命周期管理:单实例锁、开机自启参数(--hidden)、退出行为
* 2. 主窗口创建与加载:开发环境加载 Vite Dev Server(代理仍生效),生产环境加载打包后的 dist/index.html
* 3. 桌面能力:系统托盘、任务栏未读角标/闪烁、系统通知、文件下载(另存为对话框)、外链跳系统浏览器
* 4. 应用内更新:electron-updater,更新包托管在自有服务器 /updates 目录(generic provider)
* 5. 通过 IPC 与渲染进程通信(渲染层只能访问 preload 暴露的 electronAPI,保证安全边界)
*/
import { app, BrowserWindow, Tray, Menu, ipcMain, shell, session, Notification, nativeImage } from 'electron'
// electron-updater 是 CJS 包且用 getter 定义导出,ESM 下具名导入可能解析失败,
// 必须默认导入后再解构(Node ESM 对 CJS 的默认导入即 module.exports 对象)
import electronUpdater from 'electron-updater'
import path from 'node:path'
import fs from 'node:fs'
import { fileURLToPath } from 'node:url'
const { autoUpdater } = electronUpdater
// ==================== 基础路径与环境 ====================
// 主进程以 ESM 产物运行(项目 package.json 为 "type": "module"),
// ESM 中没有全局 __dirname,需从 import.meta.url 派生(指向 dist-electron 目录)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// vite-plugin-electron 开发模式下会注入 VITE_DEV_SERVER_URL,以此区分开发/生产
const devServerUrl = process.env.VITE_DEV_SERVER_URL
const isDev = !!devServerUrl
// 静态资源目录:开发环境用项目 public/,生产环境用打包进 asar 的 dist/
const publicDir = isDev ? path.join(__dirname, '../public') : path.join(__dirname, '../dist')
const appIconPath = path.join(publicDir, 'icons/icon.png')
const trayIconPath = path.join(publicDir, 'icons/tray.png')
// ==================== 应用设置(持久化到 userData 目录) ====================
/** 应用本地设置:目前只有"关闭窗口时最小化到托盘",后续可扩展 */
interface AppSettings {
closeToTray: boolean
}
const DEFAULT_SETTINGS: AppSettings = { closeToTray: true }
let appSettings: AppSettings = { ...DEFAULT_SETTINGS }
/** 设置文件路径(userData 目录随安装用户隔离,卸载可选保留) */
function settingsFilePath(): string {
return path.join(app.getPath('userData'), 'app-settings.json')
}
/** 读取本地设置,文件不存在或损坏时回退默认值 */
function loadSettings(): AppSettings {
try {
const raw = fs.readFileSync(settingsFilePath(), 'utf-8')
return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
} catch {
return { ...DEFAULT_SETTINGS }
}
}
/** 持久化设置(同步写,数据量极小) */
function saveSettings(settings: AppSettings): void {
try {
fs.writeFileSync(settingsFilePath(), JSON.stringify(settings, null, 2), 'utf-8')
} catch (err) {
console.error('[main] 保存设置失败:', err)
}
}
// ==================== 窗口与托盘 ====================
let mainWindow: BrowserWindow | null = null
let tray: Tray | null = null
// 标记应用是否处于"真正退出"流程(托盘退出/安装更新时置 true,绕过关闭最小化逻辑)
let isQuitting = false
// 开机自启时带上 --hidden 参数:静默启动到托盘,不弹出主窗口打扰用户
const startHidden = process.argv.includes('--hidden')
/** 创建主窗口 */
function createMainWindow(): void {
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
show: false, // 等 ready-to-show 再显示,避免加载过程白屏闪烁
autoHideMenuBar: true,
backgroundColor: '#0f172a', // 与前端深色主题一致,减少启动闪白
icon: nativeImage.createFromPath(appIconPath),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
// 安全基线:开启上下文隔离、禁用渲染进程 Node 能力,
// 渲染层只能通过 preload 的 contextBridge 访问受控 API
contextIsolation: true,
nodeIntegration: false,
},
})
// 首帧渲染完成后再显示;开机自启(--hidden)时不显示,直接驻留托盘
mainWindow.once('ready-to-show', () => {
if (!startHidden) {
mainWindow?.show()
}
})
if (isDev) {
mainWindow.loadURL(devServerUrl!)
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
// 生产环境加载打包产物;渲染层使用 hash 路由,file:// 协议下可正常工作
mainWindow.loadFile(path.join(__dirname, '../dist/index.html'))
}
// 外链(如消息里的 URL 卡片 window.open)交给系统默认浏览器打开,不在应用内新开窗口
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:\/\//i.test(url)) {
shell.openExternal(url)
}
return { action: 'deny' }
})
// 安全兜底:阻止页面内导航到应用之外的地址(钓鱼链接/异常跳转),外部 http(s) 转交系统浏览器
mainWindow.webContents.on('will-navigate', (event, url) => {
const isInternal = isDev ? url.startsWith(devServerUrl!) : url.startsWith('file:')
if (!isInternal) {
event.preventDefault()
if (/^https?:\/\//i.test(url)) {
shell.openExternal(url)
}
}
})
// 窗口获得焦点时停止任务栏闪烁(配合新消息 flashFrame 提醒)
mainWindow.on('focus', () => {
mainWindow?.flashFrame(false)
})
// 关闭行为:默认最小化到托盘(IM 常驻习惯),可在设置中改为直接退出
mainWindow.on('close', (event) => {
if (!isQuitting && appSettings.closeToTray) {
event.preventDefault()
mainWindow?.hide()
}
})
mainWindow.on('closed', () => {
mainWindow = null
})
// F12 切换开发者工具(打包后保留,便于现场排查问题;应用菜单已移除,需手动注册按键)
mainWindow.webContents.on('before-input-event', (_event, input) => {
if (input.type === 'keyDown' && input.key === 'F12') {
mainWindow?.webContents.toggleDevTools()
}
})
}
/** 显示并聚焦主窗口(托盘点击/通知点击/二次启动共用) */
function showMainWindow(): void {
if (!mainWindow) {
createMainWindow()
return
}
if (mainWindow.isMinimized()) {
mainWindow.restore()
}
mainWindow.show()
mainWindow.focus()
}
/** 创建系统托盘:单击/双击显示主界面,右键菜单提供退出入口 */
function createTray(): void {
tray = new Tray(nativeImage.createFromPath(trayIconPath))
tray.setToolTip('NL-IM 即时通讯')
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示主界面', click: showMainWindow },
{ type: 'separator' },
{
label: '退出',
click: () => {
isQuitting = true
app.quit()
},
},
])
)
tray.on('click', showMainWindow)
tray.on('double-click', showMainWindow)
}
// ==================== 权限控制 ====================
// 渲染层需要的权限白名单:音视频通话/语音消息(media)、系统通知、全屏播放、剪贴板
const ALLOWED_PERMISSIONS = new Set([
'media',
'notifications',
'fullscreen',
'clipboard-sanitized-write',
'clipboard-read',
'pointerLock',
])
/** 按白名单放行页面权限请求(getUserMedia、Notification 等),其余一律拒绝 */
function setupPermissions(): void {
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
callback(ALLOWED_PERMISSIONS.has(permission))
})
session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
return ALLOWED_PERMISSIONS.has(permission)
})
}
// ==================== 文件下载 ====================
// 渲染层发起下载时记录期望文件名,用于 will-download 时设置"另存为"默认名
// (file:// 环境下 <a download> 跨域会失效,所以统一走主进程下载)。
// 用 URL→文件名 的 Map 而不是单变量:用户连续下载多个文件时,
// 单变量会被后一次调用覆盖,导致"另存为"默认名错乱或落到错误条目上
const pendingDownloadNames = new Map<string, string>()
/** 注册下载相关 IPC 与 will-download 处理:弹系统"另存为"对话框,完成后通知渲染层 */
function setupDownloads(): void {
ipcMain.handle('download:start', (_event, url: string, fileName: string) => {
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
return
}
if (typeof fileName === 'string' && fileName) {
pendingDownloadNames.set(url, fileName)
}
mainWindow?.webContents.downloadURL(url)
})
session.defaultSession.on('will-download', (_event, item) => {
// 用重定向前的原始 URL(URLChain 首项)匹配发起时登记的文件名,用完即删
const originURL = item.getURLChain()[0] || item.getURL()
const fileName = pendingDownloadNames.get(originURL) || item.getFilename() || `download_${Date.now()}`
pendingDownloadNames.delete(originURL)
// 弹出系统保存对话框,默认定位到系统"下载"目录
item.setSaveDialogOptions({
title: '保存文件',
defaultPath: path.join(app.getPath('downloads'), fileName),
})
item.once('done', (_ev, state) => {
// state: completed=成功 / cancelled=用户取消 / interrupted=失败
mainWindow?.webContents.send('download:done', {
state,
fileName,
savePath: state === 'completed' ? item.getSavePath() : '',
})
})
})
// "打开所在文件夹":下载完成 toast 的快捷操作
ipcMain.handle('shell:show-item-in-folder', (_event, fullPath: string) => {
if (typeof fullPath === 'string' && fullPath) {
shell.showItemInFolder(fullPath)
}
})
}
// ==================== 应用内更新(electron-updater) ====================
/**
* 更新流程说明:
* - 更新源由 electron-builder 的 publish(generic) 配置决定,指向自有服务器 /updates 目录
* - autoDownload 关闭:发现新版本后由用户在设置弹窗中确认再下载(避免流量/打扰)
* - 下载完成后用户可"立即重启安装",或退出应用时自动安装(autoInstallOnAppQuit)
*/
function setupUpdater(): void {
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
/** 统一向渲染层推送更新状态(设置弹窗/侧边栏红点据此展示) */
const sendStatus = (payload: Record<string, unknown>): void => {
mainWindow?.webContents.send('updater:status', payload)
}
autoUpdater.on('checking-for-update', () => sendStatus({ type: 'checking' }))
autoUpdater.on('update-available', (info) => sendStatus({ type: 'available', version: info.version }))
autoUpdater.on('update-not-available', () => sendStatus({ type: 'not-available' }))
autoUpdater.on('download-progress', (progress) =>
sendStatus({
type: 'downloading',
percent: progress.percent,
bytesPerSecond: progress.bytesPerSecond,
transferred: progress.transferred,
total: progress.total,
})
)
autoUpdater.on('update-downloaded', (info) => sendStatus({ type: 'downloaded', version: info.version }))
autoUpdater.on('error', (err) => sendStatus({ type: 'error', message: err?.message || String(err) }))
ipcMain.handle('updater:check', () => {
// 开发模式没有安装包上下文,electron-updater 无法工作,直接告知渲染层
if (!app.isPackaged) {
sendStatus({ type: 'dev' })
return
}
// 错误已通过 error 事件上报,这里 catch 避免未处理的 Promise 拒绝
autoUpdater.checkForUpdates().catch(() => {})
})
ipcMain.handle('updater:download', () => {
autoUpdater.downloadUpdate().catch(() => {})
})
ipcMain.handle('updater:install', () => {
// 置退出标记,绕过"关闭最小化到托盘"逻辑,否则 quitAndInstall 会被 close 拦截
isQuitting = true
autoUpdater.quitAndInstall()
})
}
// ==================== 其余 IPC(通知/角标/自启/设置) ====================
/** 注册通用 IPC 处理器 */
function setupIpc(): void {
// 应用版本号(设置弹窗"关于"区块展示)
ipcMain.handle('app:get-version', () => app.getVersion())
// 聚焦主窗口(渲染层可主动调用,如点击应用内横幅)
ipcMain.handle('win:focus', () => showMainWindow())
// 系统通知:点击通知聚焦主窗口。silent=true 是因为渲染层已有自己的提示音,避免双重响铃
ipcMain.handle('app:notify', (_event, options: { title?: string; body?: string }) => {
if (!Notification.isSupported()) {
return
}
const notification = new Notification({
title: options?.title || 'NL-IM',
body: options?.body || '',
icon: nativeImage.createFromPath(appIconPath),
silent: true,
})
notification.on('click', showMainWindow)
notification.show()
})
// 任务栏未读角标:主进程没有 DOM/Canvas,角标图由渲染层 canvas 生成后以 dataURL 传入
ipcMain.handle('win:set-badge', (_event, count: number, dataURL: string) => {
if (!mainWindow) {
return
}
if (count > 0 && dataURL) {
mainWindow.setOverlayIcon(nativeImage.createFromDataURL(dataURL), `${count} 条未读消息`)
} else {
mainWindow.setOverlayIcon(null, '')
}
})
// 任务栏闪烁:仅窗口未聚焦时闪烁,聚焦后由 focus 事件自动取消
ipcMain.handle('win:flash', () => {
if (mainWindow && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
}
})
// 开机自启查询/设置。开发模式 no-op:否则会把 electron.exe 写进系统自启项,污染开发机
ipcMain.handle('app:get-auto-launch', () => {
if (!app.isPackaged) {
return false
}
return app.getLoginItemSettings().openAtLogin
})
ipcMain.handle('app:set-auto-launch', (_event, enable: boolean) => {
if (!app.isPackaged) {
return false
}
// --hidden:开机自启时静默启动到托盘,不弹主窗口
app.setLoginItemSettings({ openAtLogin: !!enable, args: enable ? ['--hidden'] : [] })
return app.getLoginItemSettings().openAtLogin
})
// 应用设置读写(closeToTray 等)
ipcMain.handle('app:get-settings', () => appSettings)
ipcMain.handle('app:set-settings', (_event, patch: Partial<AppSettings>) => {
appSettings = { ...appSettings, ...patch }
saveSettings(appSettings)
return appSettings
})
}
// ==================== 应用启动引导 ====================
// 单实例锁:IM 应用不允许多开,二次启动时聚焦已有窗口
if (!app.requestSingleInstanceLock()) {
app.quit()
} else {
app.on('second-instance', showMainWindow)
// Windows 通知归属标识,必须与 electron-builder 的 appId 一致,否则系统通知不显示应用名/图标
app.setAppUserModelId('cn.nailaoyun.nlim')
// 移除默认应用菜单(File/Edit/View...),IM 客户端不需要
Menu.setApplicationMenu(null)
app.whenReady().then(() => {
appSettings = loadSettings()
setupPermissions()
setupIpc()
setupDownloads()
setupUpdater()
createMainWindow()
createTray()
})
// 托盘退出/系统关机等触发退出前,置标记绕过关闭拦截
app.on('before-quit', () => {
isQuitting = true
})
// 所有窗口关闭即退出(closeToTray 开启时 close 会被拦截为隐藏,不会走到这里)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
}
})
// macOS Dock 图标点击时恢复窗口(预留跨平台行为)
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow()
} else {
showMainWindow()
}
})
}