436 lines
13 KiB
JavaScript
436 lines
13 KiB
JavaScript
/**
|
|
* Metona Ollama Desktop - 主进程
|
|
*
|
|
* 职责:
|
|
* 1. 应用生命周期管理
|
|
* 2. BrowserWindow 创建与配置
|
|
* 3. 原生菜单、系统托盘
|
|
* 4. IPC 通信桥梁(文件对话框、通知、窗口控制)
|
|
* 5. 单实例锁
|
|
*/
|
|
|
|
const { app, BrowserWindow, ipcMain, dialog, Menu, Tray, nativeImage, shell, Notification } = require('electron');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
|
|
// ── 常量 ──
|
|
const APP_NAME = 'Metona Ollama';
|
|
const ICON_PATH = path.join(__dirname, '..', 'assets', 'icons', 'llama.png');
|
|
const ICO_PATH = path.join(__dirname, '..', 'assets', 'icons', 'llama.ico');
|
|
const IS_DEV = !app.isPackaged;
|
|
|
|
let mainWindow = null;
|
|
let tray = null;
|
|
let isQuitting = false;
|
|
|
|
// ── 单实例锁 ──
|
|
const gotTheLock = app.requestSingleInstanceLock();
|
|
if (!gotTheLock) {
|
|
app.quit();
|
|
} else {
|
|
app.on('second-instance', () => {
|
|
if (mainWindow) {
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 创建主窗口 ──
|
|
function createMainWindow() {
|
|
// 读取上次窗口大小
|
|
const userDataPath = app.getPath('userData');
|
|
const configPath = path.join(userDataPath, 'window-state.json');
|
|
let windowState = { width: 1200, height: 800, x: undefined, y: undefined, maximized: false };
|
|
|
|
try {
|
|
if (fs.existsSync(configPath)) {
|
|
windowState = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
} catch { /* 使用默认值 */ }
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: windowState.width || 1200,
|
|
height: windowState.height || 800,
|
|
x: windowState.x,
|
|
y: windowState.y,
|
|
minWidth: 800,
|
|
minHeight: 600,
|
|
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH,
|
|
title: APP_NAME,
|
|
backgroundColor: '#0a0a1a',
|
|
show: false, // 等 ready-to-show 再显示,避免闪烁
|
|
autoHideMenuBar: true,
|
|
menuBarVisible: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
sandbox: false,
|
|
// 关闭同源策略,避免 Ollama CORS 问题
|
|
webSecurity: false,
|
|
allowRunningInsecureContent: false
|
|
}
|
|
});
|
|
|
|
// 加载应用
|
|
mainWindow.loadFile(path.join(__dirname, '..', 'index.html'));
|
|
|
|
// 彻底隐藏菜单栏
|
|
mainWindow.setMenuBarVisibility(false);
|
|
|
|
// 窗口准备好后显示
|
|
mainWindow.once('ready-to-show', () => {
|
|
if (windowState.maximized) {
|
|
mainWindow.maximize();
|
|
}
|
|
mainWindow.show();
|
|
});
|
|
|
|
// 保存窗口状态
|
|
const saveWindowState = () => {
|
|
if (!mainWindow || mainWindow.isMaximized() || mainWindow.isMinimized()) return;
|
|
const bounds = mainWindow.getBounds();
|
|
try {
|
|
fs.writeFileSync(configPath, JSON.stringify(bounds));
|
|
} catch { /* 忽略写入错误 */ }
|
|
};
|
|
|
|
mainWindow.on('resize', saveWindowState);
|
|
mainWindow.on('move', saveWindowState);
|
|
|
|
mainWindow.on('maximize', () => {
|
|
try {
|
|
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
state.maximized = true;
|
|
fs.writeFileSync(configPath, JSON.stringify(state));
|
|
} catch { /* 忽略 */ }
|
|
});
|
|
|
|
mainWindow.on('unmaximize', () => {
|
|
try {
|
|
const state = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
state.maximized = false;
|
|
fs.writeFileSync(configPath, JSON.stringify(state));
|
|
} catch { /* 忽略 */ }
|
|
});
|
|
|
|
// 关闭窗口时:最小化到托盘而非退出
|
|
mainWindow.on('close', (e) => {
|
|
if (!isQuitting && tray) {
|
|
e.preventDefault();
|
|
mainWindow.hide();
|
|
// 首次最小化到托盘时显示通知
|
|
if (!global._trayHintShown) {
|
|
global._trayHintShown = true;
|
|
showNotification('Metona 已最小化到系统托盘', '点击托盘图标可重新打开');
|
|
}
|
|
}
|
|
});
|
|
|
|
mainWindow.on('closed', () => {
|
|
mainWindow = null;
|
|
});
|
|
|
|
// 外部链接用系统浏览器打开
|
|
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
|
shell.openExternal(url);
|
|
return { action: 'deny' };
|
|
}
|
|
return { action: 'allow' };
|
|
});
|
|
|
|
return mainWindow;
|
|
}
|
|
|
|
// ── 创建系统托盘 ──
|
|
function createTray() {
|
|
const icon = nativeImage.createFromPath(process.platform === 'win32' ? ICO_PATH : ICON_PATH);
|
|
tray = new Tray(icon.resize({ width: 16, height: 16 }));
|
|
|
|
const contextMenu = Menu.buildFromTemplate([
|
|
{
|
|
label: '打开 Metona',
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '新建会话',
|
|
click: () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
mainWindow.webContents.send('tray-action', 'new-chat');
|
|
}
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '退出',
|
|
click: () => {
|
|
isQuitting = true;
|
|
app.quit();
|
|
}
|
|
}
|
|
]);
|
|
|
|
tray.setToolTip(APP_NAME);
|
|
tray.setContextMenu(contextMenu);
|
|
|
|
// 双击托盘图标打开窗口
|
|
tray.on('double-click', () => {
|
|
if (mainWindow) {
|
|
mainWindow.show();
|
|
mainWindow.focus();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 原生菜单 ──
|
|
function createMenu() {
|
|
const template = [
|
|
{
|
|
label: '文件',
|
|
submenu: [
|
|
{
|
|
label: '新建会话',
|
|
accelerator: 'Ctrl+N',
|
|
click: () => mainWindow?.webContents.send('menu-action', 'new-chat')
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '导入会话',
|
|
click: () => mainWindow?.webContents.send('menu-action', 'import')
|
|
},
|
|
{
|
|
label: '导出会话',
|
|
accelerator: 'Ctrl+Shift+E',
|
|
click: () => mainWindow?.webContents.send('menu-action', 'export')
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '退出',
|
|
accelerator: 'Ctrl+Q',
|
|
click: () => { isQuitting = true; app.quit(); }
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: '编辑',
|
|
submenu: [
|
|
{ role: 'undo', label: '撤销' },
|
|
{ role: 'redo', label: '重做' },
|
|
{ type: 'separator' },
|
|
{ role: 'cut', label: '剪切' },
|
|
{ role: 'copy', label: '复制' },
|
|
{ role: 'paste', label: '粘贴' },
|
|
{ role: 'selectAll', label: '全选' }
|
|
]
|
|
},
|
|
{
|
|
label: '视图',
|
|
submenu: [
|
|
{ role: 'reload', label: '刷新' },
|
|
{ role: 'forceReload', label: '强制刷新' },
|
|
{ type: 'separator' },
|
|
{ role: 'zoomIn', label: '放大' },
|
|
{ role: 'zoomOut', label: '缩小' },
|
|
{ role: 'resetZoom', label: '重置缩放' },
|
|
{ type: 'separator' },
|
|
{ role: 'togglefullscreen', label: '全屏' }
|
|
]
|
|
},
|
|
{
|
|
label: '窗口',
|
|
submenu: [
|
|
{ role: 'minimize', label: '最小化' },
|
|
{
|
|
label: '最大化/还原',
|
|
click: () => {
|
|
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
|
else mainWindow?.maximize();
|
|
}
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '置顶',
|
|
type: 'checkbox',
|
|
checked: false,
|
|
click: (menuItem) => {
|
|
mainWindow?.setAlwaysOnTop(menuItem.checked);
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
label: '帮助',
|
|
submenu: [
|
|
{
|
|
label: '使用帮助',
|
|
click: () => mainWindow?.webContents.send('menu-action', 'help')
|
|
},
|
|
{ type: 'separator' },
|
|
{
|
|
label: '关于 Ollama',
|
|
click: () => shell.openExternal('https://ollama.com')
|
|
},
|
|
{
|
|
label: '关于 Metona',
|
|
click: () => {
|
|
dialog.showMessageBox(mainWindow, {
|
|
type: 'info',
|
|
title: '关于 Metona Ollama',
|
|
message: 'Metona Ollama Desktop v1.0.0',
|
|
detail: '基于原生 JavaScript 的 Ollama AI 聊天客户端\n桌面版由 Electron 驱动\n\nhttps://gitee.com/thzxx/metona-ollama',
|
|
icon: process.platform === 'win32' ? ICO_PATH : ICON_PATH
|
|
});
|
|
}
|
|
}
|
|
]
|
|
}
|
|
];
|
|
|
|
// 开发环境增加开发者工具菜单
|
|
if (IS_DEV) {
|
|
template.push({
|
|
label: 'Dev',
|
|
submenu: [
|
|
{
|
|
label: '切换开发者工具',
|
|
accelerator: 'F12',
|
|
click: () => mainWindow?.webContents.toggleDevTools()
|
|
}
|
|
]
|
|
});
|
|
}
|
|
|
|
const menu = Menu.buildFromTemplate(template);
|
|
Menu.setApplicationMenu(menu);
|
|
}
|
|
|
|
// ── 系统通知 ──
|
|
function showNotification(title, body) {
|
|
if (Notification.isSupported()) {
|
|
new Notification({ title, body, icon: ICON_PATH }).show();
|
|
}
|
|
}
|
|
|
|
// ── IPC 处理 ──
|
|
function setupIPC() {
|
|
// 打开文件对话框
|
|
ipcMain.handle('dialog:openFile', async (_, options) => {
|
|
const result = await dialog.showOpenDialog(mainWindow, {
|
|
properties: ['openFile', 'multiSelections'],
|
|
filters: options?.filters || [
|
|
{ name: '所有文件', extensions: ['*'] }
|
|
]
|
|
});
|
|
if (result.canceled) return null;
|
|
return result.filePaths;
|
|
});
|
|
|
|
// 保存文件对话框
|
|
ipcMain.handle('dialog:saveFile', async (_, options) => {
|
|
const result = await dialog.showSaveDialog(mainWindow, {
|
|
defaultPath: options?.defaultPath || 'export',
|
|
filters: options?.filters || [
|
|
{ name: '所有文件', extensions: ['*'] }
|
|
]
|
|
});
|
|
if (result.canceled) return null;
|
|
return result.filePath;
|
|
});
|
|
|
|
// 读取文件内容
|
|
ipcMain.handle('fs:readFile', async (_, filePath) => {
|
|
try {
|
|
const content = await fs.promises.readFile(filePath, 'utf-8');
|
|
return { success: true, content, name: path.basename(filePath) };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
});
|
|
|
|
// 写入文件
|
|
ipcMain.handle('fs:writeFile', async (_, filePath, content) => {
|
|
try {
|
|
await fs.promises.writeFile(filePath, content, 'utf-8');
|
|
return { success: true };
|
|
} catch (err) {
|
|
return { success: false, error: err.message };
|
|
}
|
|
});
|
|
|
|
// 发送系统通知
|
|
ipcMain.handle('notify', (_, title, body) => {
|
|
showNotification(title, body);
|
|
});
|
|
|
|
// 获取应用信息
|
|
ipcMain.handle('app:info', () => ({
|
|
version: app.getVersion(),
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
electronVersion: process.versions.electron,
|
|
userDataPath: app.getPath('userData')
|
|
}));
|
|
|
|
// 窗口控制
|
|
ipcMain.handle('window:minimize', () => mainWindow?.minimize());
|
|
ipcMain.handle('window:maximize', () => {
|
|
if (mainWindow?.isMaximized()) mainWindow.unmaximize();
|
|
else mainWindow?.maximize();
|
|
});
|
|
ipcMain.handle('window:close', () => mainWindow?.close());
|
|
|
|
// 打开外部链接
|
|
ipcMain.handle('shell:openExternal', (_, url) => {
|
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')) {
|
|
shell.openExternal(url);
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── 应用生命周期 ──
|
|
app.whenReady().then(() => {
|
|
setupIPC();
|
|
createMainWindow();
|
|
createTray();
|
|
createMenu();
|
|
|
|
// macOS: 点击 Dock 图标时重新创建窗口
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) {
|
|
createMainWindow();
|
|
} else if (mainWindow) {
|
|
mainWindow.show();
|
|
}
|
|
});
|
|
});
|
|
|
|
// 所有窗口关闭时不退出(有托盘)
|
|
app.on('window-all-closed', () => {
|
|
// macOS 习惯保留
|
|
if (process.platform !== 'darwin') {
|
|
// 有托盘时不退出
|
|
if (!tray) app.quit();
|
|
}
|
|
});
|
|
|
|
app.on('before-quit', () => {
|
|
isQuitting = true;
|
|
});
|
|
|
|
// 移除默认菜单(Windows 生产环境)
|
|
if (process.platform === 'win32' && !IS_DEV) {
|
|
app.on('browser-window-created', (_, win) => {
|
|
// 保留自定义菜单
|
|
});
|
|
}
|