429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
/**
|
||
* 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()
|
||
}
|
||
})
|
||
}
|