const { app, BrowserWindow, dialog, ipcMain } = require('electron'); const path = require('path'); const fs = require('fs'); let mainWindow = null; let activeFilePath = null; // Currently active tab's file path let pendingFilePath = null; let fileWatcher = null; let closeTimeout = null; let isClosing = false; // Prevent re-entrant close let isSelfWriting = false; // Skip fs.watch events triggered by our own writes // ===== Single Instance Lock ===== const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', (event, commandLine, workingDirectory) => { // A second instance was launched (e.g. double-clicking a .md file) // Focus the existing window and open the file in it if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.focus(); // Extract file path from the second instance's command line args const filePath = getFilePathFromArgs(commandLine); if (filePath) { openFileInTab(filePath); } } }); } // Extract a valid file path from command line arguments (skip Electron flags) function getFilePathFromArgs(args) { for (let i = 1; i < args.length; i++) { const arg = args[i]; if (!arg.startsWith('--') && !arg.startsWith('-')) { try { if (fs.existsSync(arg)) return arg; } catch (e) { /* ignore */ } } } return null; } function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, icon: path.join(__dirname, 'assets', 'icon.ico'), webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false }, titleBarStyle: 'default', show: false }); mainWindow.webContents.on('did-finish-load', () => { if (pendingFilePath) { openFileInTab(pendingFilePath); pendingFilePath = null; } }); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.once('ready-to-show', () => { mainWindow.show(); }); mainWindow.setMenu(null); // Unsaved changes warning on close mainWindow.on('close', (e) => { if (isClosing) return; // Prevent re-entrant close isClosing = true; e.preventDefault(); try { // 检查 webContents 是否仍可用 if (!mainWindow.webContents.isDestroyed()) { mainWindow.webContents.send('window:confirmClose'); } else { // 渲染进程已销毁,直接关闭 mainWindow.removeAllListeners('close'); mainWindow.close(); return; } } catch (err) { mainWindow.removeAllListeners('close'); mainWindow.close(); return; } // 5s safety timeout in case renderer is unresponsive closeTimeout = setTimeout(() => { closeTimeout = null; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.removeAllListeners('close'); mainWindow.close(); } }, 5000); }); mainWindow.on('closed', () => { stopWatching(); mainWindow = null; }); } // File watcher function startWatching(filePath) { stopWatching(); if (!filePath) return; try { fileWatcher = fs.watch(filePath, (eventType) => { if (eventType === 'change' && mainWindow && !mainWindow.isDestroyed()) { // 跳过自身写入触发的 change 事件 if (isSelfWriting) return; mainWindow.webContents.send('file:externallyModified', filePath); } }); } catch (err) { // Silently ignore } } function stopWatching() { if (fileWatcher) { fileWatcher.close(); fileWatcher = null; } } async function showOpenDialog() { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openFile'], filters: [ { name: 'Markdown 文件', extensions: ['md', 'markdown', 'txt'] } ] }); if (!result.canceled && result.filePaths.length > 0) { return result.filePaths; } return null; } function readFileContent(filePath) { return fs.readFileSync(filePath, 'utf-8'); } // Open file in a new tab (renderer manages tabs) function openFileInTab(filePath) { if (!mainWindow || mainWindow.isDestroyed()) return; try { const content = readFileContent(filePath); activeFilePath = filePath; startWatching(filePath); mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); mainWindow.webContents.send('file:openInTab', { filePath, content }); } catch (err) { dialog.showErrorBox('错误', `无法读取文件: ${err.message}`); } } // Switch active file (for tab switching) function switchActiveFile(filePath) { activeFilePath = filePath || null; startWatching(filePath); if (mainWindow && !mainWindow.isDestroyed()) { if (filePath) { mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); } else { mainWindow.setTitle('MarkLite'); } } } // IPC Handlers // Save file to a specific path with watcher management function saveToPath(filePath, content) { stopWatching(); try { isSelfWriting = true; fs.writeFileSync(filePath, content, 'utf-8'); // 写入后短暂延迟再恢复监听,让 fs.watch 错过自身写入的事件 isSelfWriting = false; } catch (err) { isSelfWriting = false; startWatching(activeFilePath); // Restore watcher on failure return { success: false, error: err.message }; } activeFilePath = filePath; // 延迟 300ms 重新监听,确保文件系统的 change 事件已过期 setTimeout(() => { if (activeFilePath === filePath) { startWatching(filePath); } }, 300); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.setTitle(`MarkLite - ${path.basename(filePath)}`); } return { success: true, filePath }; } // Open single file via dialog ipcMain.handle('dialog:openFile', async () => { try { const files = await showOpenDialog(); if (files && files.length > 0) { const content = readFileContent(files[0]); activeFilePath = files[0]; startWatching(files[0]); mainWindow.setTitle(`MarkLite - ${path.basename(files[0])}`); return { filePath: files[0], content }; } return null; } catch (err) { return { error: err.message }; } }); ipcMain.handle('file:read', async (event, filePath) => { try { const content = readFileContent(filePath); return { success: true, content }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:save', async (event, { filePath, content }) => { try { if (filePath) { return saveToPath(filePath, content); } else { const result = await dialog.showSaveDialog(mainWindow, { filters: [ { name: 'Markdown 文件', extensions: ['md'] } ] }); if (!result.canceled) { return saveToPath(result.filePath, content); } return { success: false, canceled: true }; } } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:saveAs', async (event, { content }) => { try { const result = await dialog.showSaveDialog(mainWindow, { filters: [ { name: 'Markdown 文件', extensions: ['md'] } ] }); if (!result.canceled) { return saveToPath(result.filePath, content); } return { success: false, canceled: true }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:getCurrentPath', () => { return activeFilePath; }); ipcMain.handle('file:stats', async (event, filePath) => { try { const stats = fs.statSync(filePath); return { success: true, size: stats.size, mtime: stats.mtime.toISOString() }; } catch (err) { return { success: false, error: err.message }; } }); ipcMain.handle('file:reload', async () => { if (!activeFilePath) return { success: false, error: '没有打开的文件' }; try { const content = readFileContent(activeFilePath); return { success: true, content, filePath: activeFilePath }; } catch (err) { return { success: false, error: err.message }; } }); // Tab switched - update active file tracking ipcMain.handle('tab:switched', (event, filePath) => { switchActiveFile(filePath); }); ipcMain.handle('window:forceClose', () => { if (closeTimeout) { clearTimeout(closeTimeout); closeTimeout = null; } // 窗口已销毁则无需操作(超时兜底可能已经关闭了窗口) if (!mainWindow || mainWindow.isDestroyed()) return; stopWatching(); mainWindow.removeAllListeners('close'); mainWindow.close(); }); ipcMain.handle('window:cancelClose', () => { if (closeTimeout) { clearTimeout(closeTimeout); closeTimeout = null; } isClosing = false; // Reset so close can be triggered again }); // Command line file (first instance) function getCommandLineFile() { return getFilePathFromArgs(process.argv); } // App lifecycle app.whenReady().then(() => { app.on('open-file', (event, filePath) => { event.preventDefault(); if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents.isLoading()) { pendingFilePath = filePath; } else if (mainWindow && !mainWindow.isDestroyed()) { openFileInTab(filePath); } else { pendingFilePath = filePath; } }); createWindow(); const fileToOpen = getCommandLineFile(); if (fileToOpen) { pendingFilePath = fileToOpen; } }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); });