feat: Electron Windows 桌面客户端 v1.0
- 新增 electron/main.js: 主进程(窗口管理、系统托盘、原生菜单、IPC) - 新增 electron/preload.js: contextBridge 安全 API 暴露 - 新增 electron/desktop-bridge.js: 渲染进程桥接层 + Web 降级 - 新增 electron/desktop-integration.js: 菜单/托盘事件响应、原生文件对话框 - 更新 index.html: 集成桌面脚本 - 新增 package.json: Electron + electron-builder 配置 - 新增 DESKTOP_README.md: 桌面版开发文档 - 支持: NSIS 安装包 + 便携版、系统托盘、原生菜单、窗口记忆 - 安全: contextIsolation + IPC 白名单、单实例锁
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 渲染进程桥接模块
|
||||
*
|
||||
* 在 Web 应用启动前加载,将桌面能力注入到 window 对象
|
||||
* 提供优雅降级:Web 环境下所有 desktop API 返回 null/空函数
|
||||
*/
|
||||
|
||||
// Desktop API 由 preload.js 通过 contextBridge 注入
|
||||
// 这里做一层封装,提供统一的降级处理
|
||||
|
||||
const desktop = window.metonaDesktop || null;
|
||||
|
||||
const bridge = {
|
||||
/** 是否在 Electron 桌面环境中 */
|
||||
isDesktop: !!desktop,
|
||||
|
||||
/** 应用信息 */
|
||||
async getInfo() {
|
||||
if (!desktop) return { platform: 'web', version: '1.0.0' };
|
||||
return desktop.info();
|
||||
},
|
||||
|
||||
/** 打开原生文件选择对话框 */
|
||||
async openFile(filters) {
|
||||
if (!desktop) return null;
|
||||
return desktop.dialog.openFile(filters);
|
||||
},
|
||||
|
||||
/** 打开原生保存对话框 */
|
||||
async saveFile(options) {
|
||||
if (!desktop) return null;
|
||||
return desktop.dialog.saveFile(options);
|
||||
},
|
||||
|
||||
/** 读取文件内容 */
|
||||
async readFile(filePath) {
|
||||
if (!desktop) return { success: false, error: 'Not in desktop' };
|
||||
return desktop.fs.readFile(filePath);
|
||||
},
|
||||
|
||||
/** 写入文件 */
|
||||
async writeFile(filePath, content) {
|
||||
if (!desktop) return { success: false, error: 'Not in desktop' };
|
||||
return desktop.fs.writeFile(filePath, content);
|
||||
},
|
||||
|
||||
/** 发送系统通知 */
|
||||
notify(title, body) {
|
||||
if (desktop) {
|
||||
desktop.notify(title, body);
|
||||
} else if ('Notification' in window && Notification.permission === 'granted') {
|
||||
new Notification(title, { body });
|
||||
}
|
||||
},
|
||||
|
||||
/** 最小化窗口 */
|
||||
minimize() {
|
||||
desktop?.window.minimize();
|
||||
},
|
||||
|
||||
/** 最大化/还原 */
|
||||
maximize() {
|
||||
desktop?.window.maximize();
|
||||
},
|
||||
|
||||
/** 关闭窗口 */
|
||||
close() {
|
||||
desktop?.window.close();
|
||||
},
|
||||
|
||||
/** 打开外部链接 */
|
||||
openExternal(url) {
|
||||
if (desktop) {
|
||||
desktop.openExternal(url);
|
||||
} else {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
},
|
||||
|
||||
/** 监听主进程菜单事件 */
|
||||
onMenuAction(callback) {
|
||||
desktop?.onMenuAction(callback);
|
||||
},
|
||||
|
||||
/** 监听托盘事件 */
|
||||
onTrayAction(callback) {
|
||||
desktop?.onTrayAction(callback);
|
||||
},
|
||||
|
||||
/** 注销监听 */
|
||||
off(channel) {
|
||||
desktop?.removeAllListeners(channel);
|
||||
}
|
||||
};
|
||||
|
||||
// 暴露到全局
|
||||
window.__metonaBridge = bridge;
|
||||
|
||||
// 打印环境信息
|
||||
if (bridge.isDesktop) {
|
||||
bridge.getInfo().then(info => {
|
||||
console.log('[Desktop] Metona Ollama Desktop');
|
||||
console.log('[Desktop] Platform:', info.platform, info.arch);
|
||||
console.log('[Desktop] Electron:', info.electronVersion);
|
||||
console.log('[Desktop] User Data:', info.userDataPath);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - 桌面端集成
|
||||
*
|
||||
* 在 DOM 加载后执行,为现有 Web 应用添加桌面特性:
|
||||
* 1. 主进程菜单/托盘事件响应
|
||||
* 2. 原生文件对话框替换浏览器 input[type=file]
|
||||
* 3. 桌面端快捷键增强
|
||||
* 4. 标题栏集成
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const bridge = window.__metonaBridge;
|
||||
if (!bridge || !bridge.isDesktop) {
|
||||
// 非桌面环境,跳过
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Desktop Integration] 初始化桌面集成...');
|
||||
|
||||
// ── 1. 菜单/托盘事件响应 ──
|
||||
bridge.onMenuAction((action) => {
|
||||
switch (action) {
|
||||
case 'new-chat':
|
||||
document.querySelector('#btnNewChat')?.click();
|
||||
break;
|
||||
case 'import':
|
||||
document.querySelector('#btnImportSessions')?.click();
|
||||
break;
|
||||
case 'export':
|
||||
document.querySelector('#btnExportAll')?.click();
|
||||
break;
|
||||
case 'help':
|
||||
document.querySelector('#btnHelp')?.click();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
bridge.onTrayAction((action) => {
|
||||
switch (action) {
|
||||
case 'new-chat':
|
||||
document.querySelector('#btnNewChat')?.click();
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 2. 增强文件上传 - 使用原生文件对话框 ──
|
||||
function setupNativeFilePicker(btnId, inputId, fileFilters) {
|
||||
const btn = document.querySelector(btnId);
|
||||
const input = document.querySelector(inputId);
|
||||
if (!btn || !input) return;
|
||||
|
||||
// 拦截点击事件,使用原生对话框
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const filePaths = await bridge.openFile(fileFilters);
|
||||
if (!filePaths || filePaths.length === 0) return;
|
||||
|
||||
// 读取所有文件
|
||||
for (const filePath of filePaths) {
|
||||
const result = await bridge.readFile(filePath);
|
||||
if (result.success) {
|
||||
// 创建 File 对象注入到原有流程
|
||||
const blob = new Blob([result.content], { type: 'text/plain' });
|
||||
const file = new File([blob], result.name, { type: 'text/plain' });
|
||||
|
||||
// 使用 DataTransfer 注入文件到 input,触发 change 事件
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(file);
|
||||
input.files = dt.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}, true); // capture phase, 优先于原有事件
|
||||
}
|
||||
|
||||
// DOM 就绪后设置
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', setupEnhancements);
|
||||
} else {
|
||||
setupEnhancements();
|
||||
}
|
||||
|
||||
function setupEnhancements() {
|
||||
// 代码/文本文件上传
|
||||
setupNativeFilePicker(
|
||||
'#btnAttachFile',
|
||||
'#textFileInput',
|
||||
[
|
||||
{ name: '代码文件', extensions: ['py', 'js', 'ts', 'tsx', 'java', 'go', 'rs', 'cpp', 'c', 'h', 'rb', 'php', 'sh', 'swift', 'kt', 'lua'] },
|
||||
{ name: '配置文件', extensions: ['json', 'yaml', 'yml', 'toml', 'xml', 'ini', 'conf'] },
|
||||
{ name: '文档', extensions: ['txt', 'md', 'log', 'csv', 'html', 'css'] },
|
||||
{ name: '所有文件', extensions: ['*'] }
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. 标题更新 - 显示连接/模型状态 ──
|
||||
const origTitle = document.title;
|
||||
window.addEventListener('metona:model-changed', (e) => {
|
||||
if (e.detail?.model) {
|
||||
document.title = `${e.detail.model} - Metona Ollama`;
|
||||
} else {
|
||||
document.title = origTitle;
|
||||
}
|
||||
});
|
||||
|
||||
// ── 4. 桌面端键盘快捷键 ──
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl+N 新建会话(与菜单同步)
|
||||
if (e.ctrlKey && e.key === 'n') {
|
||||
e.preventDefault();
|
||||
document.querySelector('#btnNewChat')?.click();
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Desktop Integration] 桌面集成完成 ✓');
|
||||
})();
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* 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: 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.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) => {
|
||||
// 保留自定义菜单
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Metona Ollama Desktop - Preload 脚本
|
||||
*
|
||||
* 通过 contextBridge 安全暴露 IPC 接口给渲染进程
|
||||
* 所有 API 都是白名单模式,不暴露 Node.js 全局对象
|
||||
*/
|
||||
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// ── 暴露 Desktop API ──
|
||||
contextBridge.exposeInMainWorld('metonaDesktop', {
|
||||
// 是否在桌面环境中
|
||||
isDesktop: true,
|
||||
|
||||
// 版本信息
|
||||
info: () => ipcRenderer.invoke('app:info'),
|
||||
|
||||
// ── 文件对话框 ──
|
||||
dialog: {
|
||||
openFile: (filters) => ipcRenderer.invoke('dialog:openFile', { filters }),
|
||||
saveFile: (options) => ipcRenderer.invoke('dialog:saveFile', options)
|
||||
},
|
||||
|
||||
// ── 文件系统 ──
|
||||
fs: {
|
||||
readFile: (filePath) => ipcRenderer.invoke('fs:readFile', filePath),
|
||||
writeFile: (filePath, content) => ipcRenderer.invoke('fs:writeFile', filePath, content)
|
||||
},
|
||||
|
||||
// ── 系统通知 ──
|
||||
notify: (title, body) => ipcRenderer.invoke('notify', title, body),
|
||||
|
||||
// ── 窗口控制 ──
|
||||
window: {
|
||||
minimize: () => ipcRenderer.invoke('window:minimize'),
|
||||
maximize: () => ipcRenderer.invoke('window:maximize'),
|
||||
close: () => ipcRenderer.invoke('window:close')
|
||||
},
|
||||
|
||||
// ── 外部链接 ──
|
||||
openExternal: (url) => ipcRenderer.invoke('shell:openExternal', url),
|
||||
|
||||
// ── 主进程事件监听 ──
|
||||
onMenuAction: (callback) => {
|
||||
ipcRenderer.on('menu-action', (_, action) => callback(action));
|
||||
},
|
||||
onTrayAction: (callback) => {
|
||||
ipcRenderer.on('tray-action', (_, action) => callback(action));
|
||||
},
|
||||
// 移除监听
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user