Initial commit
This commit is contained in:
217
electron/main.ts
Normal file
217
electron/main.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import { app, BrowserWindow, shell, ipcMain, dialog, nativeTheme, globalShortcut, clipboard } from 'electron';
|
||||
import path from 'path';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
import fs from 'fs';
|
||||
import log from 'electron-log';
|
||||
import { generatePdf } from './services/pdfGenerator';
|
||||
import { selectHtmlFile } from './services/htmlImport';
|
||||
import { electronState } from './state';
|
||||
|
||||
log.initialize();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
process.env.NOTEBOOK_ROOT = path.join(app.getPath('documents'), 'XCNote');
|
||||
|
||||
if (!fs.existsSync(process.env.NOTEBOOK_ROOT)) {
|
||||
try {
|
||||
fs.mkdirSync(process.env.NOTEBOOK_ROOT, { recursive: true });
|
||||
} catch (err) {
|
||||
log.error('Failed to create notebook directory:', err);
|
||||
}
|
||||
}
|
||||
|
||||
electronState.setDevelopment(!app.isPackaged);
|
||||
|
||||
let lastClipboardText = '';
|
||||
|
||||
function startClipboardWatcher() {
|
||||
lastClipboardText = clipboard.readText();
|
||||
|
||||
setInterval(() => {
|
||||
try {
|
||||
const currentText = clipboard.readText();
|
||||
if (currentText && currentText !== lastClipboardText) {
|
||||
lastClipboardText = currentText;
|
||||
log.info('Clipboard changed, syncing to remote');
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send('remote-clipboard-auto-sync', currentText);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function createWindow() {
|
||||
const initialSymbolColor = nativeTheme.shouldUseDarkColors ? '#ffffff' : '#000000';
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1280,
|
||||
height: 800,
|
||||
minWidth: 1600,
|
||||
minHeight: 900,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: {
|
||||
color: '#00000000',
|
||||
symbolColor: initialSymbolColor,
|
||||
height: 32,
|
||||
},
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
webviewTag: true,
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
},
|
||||
});
|
||||
|
||||
electronState.setMainWindow(mainWindow);
|
||||
mainWindow.setMenu(null);
|
||||
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (url.startsWith('http:') || url.startsWith('https:')) {
|
||||
shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
}
|
||||
return { action: 'allow' };
|
||||
});
|
||||
|
||||
if (electronState.isDevelopment()) {
|
||||
log.info('Loading development URL...');
|
||||
try {
|
||||
await mainWindow.loadURL('http://localhost:5173');
|
||||
} catch (e) {
|
||||
log.error('Failed to load dev URL. Make sure npm run electron:dev is used.', e);
|
||||
}
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
log.info(`Loading production URL with port ${electronState.getServerPort()}...`);
|
||||
await mainWindow.loadURL(`http://localhost:${electronState.getServerPort()}`);
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('export-pdf', async (event, title, htmlContent) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!win) return { success: false, error: 'No window found' };
|
||||
|
||||
try {
|
||||
const { filePath } = await dialog.showSaveDialog(win, {
|
||||
title: '导出 PDF',
|
||||
defaultPath: `${title}.pdf`,
|
||||
filters: [{ name: 'PDF Files', extensions: ['pdf'] }]
|
||||
});
|
||||
|
||||
if (!filePath) return { success: false, canceled: true };
|
||||
|
||||
if (!htmlContent) {
|
||||
throw new Error('No HTML content provided for PDF export');
|
||||
}
|
||||
|
||||
const pdfData = await generatePdf(htmlContent);
|
||||
fs.writeFileSync(filePath, pdfData);
|
||||
|
||||
return { success: true, filePath };
|
||||
} catch (error: any) {
|
||||
log.error('Export PDF failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('select-html-file', async (event) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
return selectHtmlFile(win);
|
||||
});
|
||||
|
||||
ipcMain.handle('update-titlebar-buttons', async (event, symbolColor: string) => {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
if (win) {
|
||||
win.setTitleBarOverlay({ symbolColor });
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
});
|
||||
|
||||
ipcMain.handle('clipboard-read-text', async () => {
|
||||
try {
|
||||
const text = clipboard.readText();
|
||||
return { success: true, text };
|
||||
} catch (error: any) {
|
||||
log.error('Clipboard read failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('clipboard-write-text', async (event, text: string) => {
|
||||
try {
|
||||
clipboard.writeText(text);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
log.error('Clipboard write failed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer() {
|
||||
if (electronState.isDevelopment()) {
|
||||
log.info('In dev mode, assuming external servers are running.');
|
||||
return;
|
||||
}
|
||||
|
||||
const serverPath = path.join(__dirname, '../dist-api/server.js');
|
||||
const serverUrl = pathToFileURL(serverPath).href;
|
||||
|
||||
log.info(`Starting internal server from: ${serverPath}`);
|
||||
try {
|
||||
const serverModule = await import(serverUrl);
|
||||
if (serverModule.startServer) {
|
||||
const port = await serverModule.startServer();
|
||||
electronState.setServerPort(port);
|
||||
log.info(`Internal server started successfully on port ${port}`);
|
||||
} else {
|
||||
log.warn('startServer function not found in server module, using default port 3001');
|
||||
}
|
||||
} catch (e) {
|
||||
log.error('Failed to start internal server:', e);
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await startServer();
|
||||
await createWindow();
|
||||
|
||||
startClipboardWatcher();
|
||||
|
||||
globalShortcut.register('CommandOrControl+Shift+C', () => {
|
||||
log.info('Global shortcut: sync clipboard to remote');
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send('remote-clipboard-sync-to-remote');
|
||||
}
|
||||
});
|
||||
|
||||
globalShortcut.register('CommandOrControl+Shift+V', () => {
|
||||
log.info('Global shortcut: sync clipboard from remote');
|
||||
const win = electronState.getMainWindow();
|
||||
if (win) {
|
||||
win.webContents.send('remote-clipboard-sync-from-remote');
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
globalShortcut.unregisterAll();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
24
electron/preload.ts
Normal file
24
electron/preload.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
console.log('--- PRELOAD SCRIPT LOADED SUCCESSFULLY ---')
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
exportPDF: (title: string, htmlContent?: string) => ipcRenderer.invoke('export-pdf', title, htmlContent),
|
||||
selectHtmlFile: () => ipcRenderer.invoke('select-html-file'),
|
||||
updateTitlebarButtons: (symbolColor: string) => ipcRenderer.invoke('update-titlebar-buttons', symbolColor),
|
||||
onRemoteClipboardSyncToRemote: (callback: () => void) => {
|
||||
ipcRenderer.on('remote-clipboard-sync-to-remote', callback);
|
||||
return () => ipcRenderer.removeListener('remote-clipboard-sync-to-remote', callback);
|
||||
},
|
||||
onRemoteClipboardSyncFromRemote: (callback: () => void) => {
|
||||
ipcRenderer.on('remote-clipboard-sync-from-remote', callback);
|
||||
return () => ipcRenderer.removeListener('remote-clipboard-sync-from-remote', callback);
|
||||
},
|
||||
onRemoteClipboardAutoSync: (callback: (text: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, text: string) => callback(text);
|
||||
ipcRenderer.on('remote-clipboard-auto-sync', handler);
|
||||
return () => ipcRenderer.removeListener('remote-clipboard-auto-sync', handler);
|
||||
},
|
||||
clipboardReadText: () => ipcRenderer.invoke('clipboard-read-text'),
|
||||
clipboardWriteText: (text: string) => ipcRenderer.invoke('clipboard-write-text', text),
|
||||
})
|
||||
36
electron/server.ts
Normal file
36
electron/server.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import app from '../api/app';
|
||||
import path from 'path';
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { logger } from '../api/utils/logger';
|
||||
import { AddressInfo } from 'net';
|
||||
import { startWatcher } from '../api/watcher/watcher.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
startWatcher();
|
||||
|
||||
const distPath = path.join(__dirname, '../dist');
|
||||
|
||||
app.use(express.static(distPath));
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
|
||||
export const startServer = (): Promise<number> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
const port = address.port;
|
||||
logger.info(`Electron internal server running on port ${port}`);
|
||||
resolve(port);
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
logger.error('Failed to start server:', err);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
};
|
||||
69
electron/services/htmlImport.ts
Normal file
69
electron/services/htmlImport.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { dialog, BrowserWindow } from 'electron'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import log from 'electron-log'
|
||||
|
||||
export interface HtmlImportResult {
|
||||
success: boolean
|
||||
canceled?: boolean
|
||||
error?: string
|
||||
htmlPath?: string
|
||||
htmlDir?: string
|
||||
htmlFileName?: string
|
||||
assetsDirName?: string
|
||||
assetsFiles?: string[]
|
||||
}
|
||||
|
||||
export const selectHtmlFile = async (win: BrowserWindow | null): Promise<HtmlImportResult> => {
|
||||
if (!win) return { success: false, error: 'No window found' }
|
||||
|
||||
try {
|
||||
const { filePaths, canceled } = await dialog.showOpenDialog(win, {
|
||||
title: '选择 HTML 文件',
|
||||
filters: [
|
||||
{ name: 'HTML Files', extensions: ['html', 'htm'] }
|
||||
],
|
||||
properties: ['openFile']
|
||||
})
|
||||
|
||||
if (canceled || filePaths.length === 0) {
|
||||
return { success: false, canceled: true }
|
||||
}
|
||||
|
||||
const htmlPath = filePaths[0]
|
||||
const htmlDir = path.dirname(htmlPath)
|
||||
const htmlFileName = path.basename(htmlPath, path.extname(htmlPath))
|
||||
|
||||
const assetsDirName = `${htmlFileName}_files`
|
||||
const assetsDirPath = path.join(htmlDir, assetsDirName)
|
||||
|
||||
const assetsFiles: string[] = []
|
||||
if (fs.existsSync(assetsDirPath)) {
|
||||
const collectFiles = (dir: string, baseDir: string) => {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
collectFiles(fullPath, baseDir)
|
||||
} else {
|
||||
const relPath = path.relative(baseDir, fullPath)
|
||||
assetsFiles.push(relPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
collectFiles(assetsDirPath, assetsDirPath)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
htmlPath,
|
||||
htmlDir,
|
||||
htmlFileName,
|
||||
assetsDirName,
|
||||
assetsFiles
|
||||
}
|
||||
} catch (error: any) {
|
||||
log.error('Select HTML file failed:', error)
|
||||
return { success: false, error: error.message }
|
||||
}
|
||||
}
|
||||
46
electron/services/pdfGenerator.ts
Normal file
46
electron/services/pdfGenerator.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { BrowserWindow } from 'electron';
|
||||
|
||||
/**
|
||||
* 生成 PDF 的服务
|
||||
* @param htmlContent 完整的 HTML 字符串
|
||||
* @returns PDF 文件的二进制数据
|
||||
*/
|
||||
export async function generatePdf(htmlContent: string): Promise<Uint8Array> {
|
||||
const printWin = new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: false, // 与 main.ts 保持一致,确保脚本执行权限
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await printWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
||||
|
||||
// 等待资源加载完成 (由 generatePrintHtml 注入的脚本控制)
|
||||
await printWin.webContents.executeJavaScript(`
|
||||
new Promise(resolve => {
|
||||
const check = () => {
|
||||
if (window.__PRINT_READY__) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
}
|
||||
check();
|
||||
})
|
||||
`);
|
||||
|
||||
const pdfData = await printWin.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
margins: { top: 0, bottom: 0, left: 0, right: 0 }
|
||||
});
|
||||
|
||||
return pdfData;
|
||||
} finally {
|
||||
// 确保窗口被关闭,防止内存泄漏
|
||||
printWin.close();
|
||||
}
|
||||
}
|
||||
49
electron/state.ts
Normal file
49
electron/state.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { BrowserWindow } from 'electron'
|
||||
|
||||
interface ElectronAppState {
|
||||
mainWindow: BrowserWindow | null
|
||||
serverPort: number
|
||||
isDev: boolean
|
||||
}
|
||||
|
||||
class ElectronState {
|
||||
private state: ElectronAppState = {
|
||||
mainWindow: null,
|
||||
serverPort: 3001,
|
||||
isDev: false,
|
||||
}
|
||||
|
||||
getMainWindow(): BrowserWindow | null {
|
||||
return this.state.mainWindow
|
||||
}
|
||||
|
||||
setMainWindow(window: BrowserWindow | null): void {
|
||||
this.state.mainWindow = window
|
||||
}
|
||||
|
||||
getServerPort(): number {
|
||||
return this.state.serverPort
|
||||
}
|
||||
|
||||
setServerPort(port: number): void {
|
||||
this.state.serverPort = port
|
||||
}
|
||||
|
||||
isDevelopment(): boolean {
|
||||
return this.state.isDev
|
||||
}
|
||||
|
||||
setDevelopment(isDev: boolean): void {
|
||||
this.state.isDev = isDev
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.state = {
|
||||
mainWindow: null,
|
||||
serverPort: 3001,
|
||||
isDev: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const electronState = new ElectronState()
|
||||
Reference in New Issue
Block a user