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:
Metona Desktop
2026-04-05 22:39:22 +08:00
parent 511f864221
commit 63fa7e2846
9 changed files with 5353 additions and 0 deletions
+107
View File
@@ -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);
});
}
+121
View File
@@ -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] 桌面集成完成 ✓');
})();
+431
View File
@@ -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) => {
// 保留自定义菜单
});
}
+54
View File
@@ -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);
}
});