diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbf2d4f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +release/ +dist/ +out/ +.DS_Store +Thumbs.db +*.log +window-state.json diff --git a/DESKTOP_README.md b/DESKTOP_README.md new file mode 100644 index 0000000..57fccb9 --- /dev/null +++ b/DESKTOP_README.md @@ -0,0 +1,119 @@ +# Metona Ollama Desktop v1.0 + +基于 [Metona Ollama](https://gitee.com/thzxx/metona-ollama) Web 版封装的 **Electron Windows 桌面客户端**。 + +## ✨ 桌面版特性 + +| 特性 | 说明 | +|------|------| +| 原生安装包 | NSIS 安装程序 + 绿色便携版 | +| 系统托盘 | 最小化到托盘,双击恢复,托盘右键菜单 | +| 原生菜单 | 完整的文件/编辑/视图/窗口/帮助菜单 | +| 原生文件对话框 | Windows 文件选择器替代浏览器 input | +| 系统通知 | 通过 Windows 原生通知中心推送 | +| 窗口管理 | 记忆窗口大小和位置,支持置顶 | +| 单实例锁 | 防止重复启动 | +| 无 CORS 限制 | 关闭 webSecurity,直接连接本地 Ollama | + +## 🚀 开发 + +### 前置条件 + +- Node.js 18+ +- npm + +### 启动开发环境 + +```bash +# 安装依赖 +npm install + +# 启动开发模式 +npm start +``` + +### 构建 + +```bash +# 构建目录(不打包,用于测试) +npm run pack + +# 构建 Windows 安装包 (NSIS + Portable) +npm run dist + +# 仅构建 NSIS 安装包 +npm run dist:nsis + +# 仅构建便携版 +npm run dist:portable +``` + +构建产物在 `release/` 目录。 + +## 📁 Desktop 项目结构 + +``` +metona-ollama/ +├── electron/ +│ ├── main.js # 主进程:窗口、菜单、托盘、IPC +│ ├── preload.js # 预加载:contextBridge 安全 API 暴露 +│ ├── desktop-bridge.js # 渲染进程桥接:统一 API + 降级处理 +│ └── desktop-integration.js # 桌面集成:菜单响应、原生文件选择、快捷键 +├── index.html # 入口(已集成桌面脚本) +├── js/ # 原有 Web 应用代码(未修改) +├── css/ # 原有样式(未修改) +├── assets/ # 图标资源 +├── package.json # Electron + electron-builder 配置 +└── ... +``` + +## 🔌 架构设计 + +``` +┌──────────────────────────────────────────────┐ +│ Main Process │ +│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ +│ │ Browser │ │ Tray │ │ Menu │ │ +│ │ Window │ │ │ │ │ │ +│ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ +│ │ │ │ │ +│ ┌────┴─────────────┴──────────────┴───────┐ │ +│ │ IPC Main │ │ +│ └────────────────┬────────────────────────┘ │ +└───────────────────┼──────────────────────────┘ + │ contextBridge +┌───────────────────┼──────────────────────────┐ +│ Renderer │ │ +│ ┌────────────────┴───────────────────────┐ │ +│ │ window.metonaDesktop (preload.js) │ │ +│ └────────────────┬───────────────────────┘ │ +│ ┌────────────────┴───────────────────────┐ │ +│ │ window.__metonaBridge (bridge.js) │ │ +│ │ 统一接口 + 降级 (Web环境可用) │ │ +│ └────────────────┬───────────────────────┘ │ +│ ┌────────────────┴───────────────────────┐ │ +│ │ desktop-integration.js │ │ +│ │ 菜单响应 · 原生文件 · 快捷键 │ │ +│ └────────────────┬───────────────────────┘ │ +│ ┌────────────────┴───────────────────────┐ │ +│ │ 原有 Web 应用 (app.js + components) │ │ +│ │ 零修改,完全复用 │ │ +│ └────────────────────────────────────────┘ │ +└──────────────────────────────────────────────┘ +``` + +## 🔒 安全策略 + +- `contextIsolation: true` — 渲染进程无 Node.js 访问 +- `nodeIntegration: false` — 通过 preload 安全暴露 API +- `sandbox: false` — 允许文件系统访问(受 IPC 白名单控制) +- `webSecurity: false` — 仅用于连接本地 Ollama API + +## 📝 与 Web 版的兼容性 + +桌面版完全兼容 Web 版代码,通过降级处理实现: + +- `window.__metonaBridge.isDesktop` 判断当前环境 +- Web 环境下所有 desktop API 自动降级为浏览器原生行为 +- `index.html` 无条件加载桌面脚本,非 Electron 环境静默跳过 +- 原有 JS/HTML/CSS 代码**零修改** diff --git a/electron/desktop-bridge.js b/electron/desktop-bridge.js new file mode 100644 index 0000000..4fc0974 --- /dev/null +++ b/electron/desktop-bridge.js @@ -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); + }); +} diff --git a/electron/desktop-integration.js b/electron/desktop-integration.js new file mode 100644 index 0000000..da8c773 --- /dev/null +++ b/electron/desktop-integration.js @@ -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] 桌面集成完成 ✓'); +})(); diff --git a/electron/main.js b/electron/main.js new file mode 100644 index 0000000..ee55c87 --- /dev/null +++ b/electron/main.js @@ -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) => { + // 保留自定义菜单 + }); +} diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 0000000..a69c8ea --- /dev/null +++ b/electron/preload.js @@ -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); + } +}); diff --git a/index.html b/index.html index 52c3d69..760725f 100644 --- a/index.html +++ b/index.html @@ -444,6 +444,12 @@
+ + + + + +