116 lines
5.7 KiB
TypeScript
116 lines
5.7 KiB
TypeScript
// main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测
|
||
import { app, BrowserWindow, ipcMain, protocol, net, dialog, shell } from 'electron';
|
||
import nodePath from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { promises as fs } from 'node:fs';
|
||
import { VaultService } from './services/vault-service.js';
|
||
import { registerIpc } from './ipc/handlers.js';
|
||
import { IPC } from '@hunter/shared';
|
||
|
||
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
||
|
||
// FR-E6:注册 vaultasset:// 协议——渲染层用相对路径展示库内本地图片(须在 app ready 前注册)
|
||
protocol.registerSchemesAsPrivileged([
|
||
{ scheme: 'vaultasset', privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
|
||
]);
|
||
|
||
let win: BrowserWindow | null = null;
|
||
// 多窗口支持:窗口控制作用于当前聚焦窗口,其次主窗口
|
||
const focusedWin = () => BrowserWindow.getFocusedWindow() ?? win;
|
||
|
||
function createWindow() {
|
||
const w = new BrowserWindow({
|
||
width: 1200, height: 760, minWidth: 900, minHeight: 580,
|
||
center: true, frame: false, show: false,
|
||
transparent: false, backgroundColor: '#f6f7f9', backgroundMaterial: 'none',
|
||
// UI 重新设计:窗口不透明,backgroundColor 用 L0 空间底色,稳定呈现玻璃材质(玻璃透过的是窗口内的空间层)
|
||
webPreferences: {
|
||
preload: nodePath.join(__dirname, '../preload/index.mjs'),
|
||
contextIsolation: true, nodeIntegration: false, sandbox: false
|
||
}
|
||
});
|
||
if (!win) win = w;
|
||
w.on('ready-to-show', () => w.show());
|
||
// 安全:外部链接一律交给系统默认浏览器,不在应用窗口内导航(避免丢失编辑器状态)
|
||
w.webContents.setWindowOpenHandler(({ url }) => {
|
||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||
return { action: 'deny' };
|
||
});
|
||
w.webContents.on('will-navigate', (e, url) => {
|
||
const current = w.webContents.getURL();
|
||
// 仅拦截跳转到其它 http(s) 站点的导航;应用自身 (vaultasset/file/app) 不拦
|
||
if (/^https?:\/\//i.test(url) && url !== current) {
|
||
e.preventDefault();
|
||
void shell.openExternal(url);
|
||
}
|
||
});
|
||
// 转发渲染进程控制台消息到主进程终端(便于开发调试)
|
||
w.webContents.on('console-message', (e, level, message, line, sourceId) => {
|
||
const lv = ['log', 'info', 'warn', 'error'][level] || 'log';
|
||
console.log('[renderer:' + lv + '] ' + message + (sourceId ? ' (' + sourceId + ':' + line + ')' : ''));
|
||
});
|
||
w.on('closed', () => { if (win === w) win = null; });
|
||
if (process.env.ELECTRON_RENDERER_URL) w.loadURL(process.env.ELECTRON_RENDERER_URL);
|
||
else w.loadFile(nodePath.join(__dirname, '../renderer/index.html'));
|
||
return w;
|
||
}
|
||
|
||
// 窗口控制(FR-W2 + 多窗口安全 + Typora 快捷键:全屏 F11 / 新窗口 Ctrl+Shift+N)
|
||
ipcMain.on(IPC.winMinimize, () => focusedWin()?.minimize());
|
||
ipcMain.on(IPC.winMaximize, () => { const w = focusedWin(); if (w?.isMaximized()) w.unmaximize(); else w?.maximize(); });
|
||
ipcMain.handle(IPC.winIsMaximized, () => focusedWin()?.isMaximized() ?? false);
|
||
ipcMain.on(IPC.winClose, () => focusedWin()?.close());
|
||
ipcMain.on(IPC.winFullscreen, () => { const w = focusedWin(); if (w) w.setFullScreen(!w.isFullScreen()); });
|
||
ipcMain.on(IPC.winNew, () => { createWindow(); });
|
||
// Ctrl+O 打开文件(选 .md → 返回绝对路径,渲染层换算为库内相对路径)
|
||
ipcMain.handle(IPC.fileOpen, async () => {
|
||
const w = focusedWin();
|
||
const r = await dialog.showOpenDialog(w ?? undefined as never, { properties: ['openFile'], filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||
if (r.canceled || r.filePaths.length === 0) return null;
|
||
return r.filePaths[0];
|
||
});
|
||
// Ctrl+Shift+S 另存为:弹保存对话框写入正文副本
|
||
ipcMain.handle(IPC.docSaveAs, async (_e, payload: { body: string; title: string }) => {
|
||
const w = focusedWin();
|
||
const r = await dialog.showSaveDialog(w ?? undefined as never, { defaultPath: (payload.title || 'untitled') + '.md', filters: [{ name: 'Markdown', extensions: ['md'] }] });
|
||
if (r.canceled || !r.filePath) return null;
|
||
await fs.writeFile(r.filePath, payload.body, 'utf8');
|
||
return r.filePath;
|
||
});
|
||
|
||
app.whenReady().then(async () => {
|
||
const vault = new VaultService();
|
||
await vault.init();
|
||
// FR-E6:vaultasset:// 协议处理器——安全提供库内文件给渲染层(限定在库根内)
|
||
protocol.handle('vaultasset', async (req) => {
|
||
const root = vault.fileService?.getRoot();
|
||
if (!root) return new Response('no vault', { status: 503 });
|
||
const rel = decodeURIComponent(new URL(req.url).pathname).replace(/^\/+/, '');
|
||
const abs = nodePath.resolve(root, rel);
|
||
const relFromRoot = nodePath.relative(root, abs);
|
||
if (relFromRoot.startsWith('..') || nodePath.isAbsolute(relFromRoot)) return new Response('forbidden', { status: 403 });
|
||
try {
|
||
return await net.fetch(pathToFileURL(abs).toString());
|
||
} catch {
|
||
return new Response('not found', { status: 404 });
|
||
}
|
||
});
|
||
// FR-A5:GPU 探测——软渲染时自动禁用实时模糊
|
||
let gpuStatus = { softwareRendering: false, vendor: '', renderer: '' };
|
||
try {
|
||
const gi = await (app as any).getGPUInfo('full');
|
||
gpuStatus = {
|
||
softwareRendering: gi.softwareRendering === true || /SwiftShader|llvmpipe|Software/.test(gi.gpuDevice?.[0]?.renderer ?? ''),
|
||
vendor: gi.gpuDevice?.[0]?.vendorString ?? '',
|
||
renderer: gi.gpuDevice?.[0]?.renderer ?? '',
|
||
};
|
||
} catch { /* getGPUInfo 在某些环境不可用 */ }
|
||
ipcMain.handle(IPC.gpuInfo, () => gpuStatus);
|
||
|
||
registerIpc(vault);
|
||
createWindow();
|
||
app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });
|
||
});
|
||
|
||
app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); });
|