import { join, dirname, sep } from 'path' import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard, nativeTheme, screen } from 'electron' import { Worker } from 'worker_threads' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import fs from 'fs' import { parseState, clampBounds, type AppState } from './windowState' import { decodeText, type DecodeResult } from './decode' import { scanFolders, SEMANTIC_MAX_BYTES, type ScanCacheEntry } from './folderScan' import { resolveScanArgs } from './scanIpc' import { parseScanCacheStore, putScanCache, getScanCache, emptyScanCacheStore, type ScanCacheStoreData } from './scanCacheStore' /** * 文本类扩展名清单(小写,不含点): * 与渲染进程 TEXT_EXTENSIONS(src/renderer/src/diff/textUtils.ts)完全对齐,两进程无法共享模块, * 改动需双向同步。供打开文件对话框筛选与文件夹语义判等的文本文件判定共用。 */ const TEXT_FILE_EXTENSIONS = [ 'txt', 'text', 'md', 'markdown', 'json', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'css', 'scss', 'less', 'html', 'htm', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'conf', 'config', 'properties', 'env', 'py', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cc', 'cs', 'swift', 'kt', 'rb', 'php', 'pl', 'sh', 'zsh', 'bash', 'fish', 'bat', 'cmd', 'ps1', 'log', 'csv', 'tsv', 'sql', 'vue', 'svelte', 'graphql', 'gql', 'gradle', 'lock' ] /** 相对路径是否为文本文件(按扩展名,语义判等文本判定共用) */ function isTextRel(rel: string): boolean { const dot = rel.lastIndexOf('.') if (dot < 0) return true return TEXT_FILE_EXTENSIONS.includes(rel.slice(dot + 1).toLowerCase()) } /** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */ const stateFile = (): string => join(app.getPath('userData'), 'window-state.json') /** 文件夹扫描增量缓存的跨会话持久化文件,位于 userData 目录 */ const scanCacheFile = (): string => join(app.getPath('userData'), 'scan-cache.json') /** 读取持久化扫描缓存存储:文件不存在/损坏回退空存储,不影响扫描 */ function loadScanCacheStore(): ScanCacheStoreData { try { return parseScanCacheStore(fs.readFileSync(scanCacheFile(), 'utf-8')) } catch { return emptyScanCacheStore() } } /** * 落盘扫描缓存(读-改-写,异步 fire-and-forget): * 失败静默(目录只读/磁盘满等,跨会话增量记忆失效不影响本次结果)。 * 写操作经内存 promise 链串行执行(每次追加到链尾):两次扫描近乎同时 * 完成时各自的读-改-写按序落盘,避免后写整体覆盖先写丢失 LRU 目录对。 */ let scanCacheWrites: Promise = Promise.resolve() function persistScanCache(leftDir: string, rightDir: string, cache: ScanCacheEntry[]): void { scanCacheWrites = scanCacheWrites.then(() => writeScanCache(leftDir, rightDir, cache)) } async function writeScanCache( leftDir: string, rightDir: string, cache: ScanCacheEntry[] ): Promise { try { const next = putScanCache(loadScanCacheStore(), leftDir, rightDir, cache, Date.now()) await fs.promises.writeFile(scanCacheFile(), JSON.stringify(next), 'utf-8') } catch { // 忽略写入失败 } } /** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */ let appState: AppState = {} /** 读取持久化状态:文件不存在/损坏一律回退空状态,不影响启动 */ function loadState(): AppState { try { return parseState(fs.readFileSync(stateFile(), 'utf-8')) } catch { return {} } } /** 写回持久化状态:失败静默忽略(目录只读/磁盘满等,偏好记忆失效但不影响本次会话) */ function saveState(): void { try { fs.writeFileSync(stateFile(), JSON.stringify(appState), 'utf-8') } catch { // 忽略写入失败 } } function createWindow(): void { // 恢复上次窗口尺寸/位置:越界 bounds 钳制回当前屏幕工作区,无记录时用默认尺寸 const wa = screen.getPrimaryDisplay().workArea const bounds = appState.bounds ? clampBounds(appState.bounds, wa) : null // 主窗口 const mainWindow = new BrowserWindow({ width: bounds?.width ?? 1280, height: bounds?.height ?? 820, x: bounds?.x, y: bounds?.y, minWidth: 900, minHeight: 600, show: false, autoHideMenuBar: false, title: 'DiffLens', icon: join(__dirname, '../../resources/icon.png'), backgroundColor: '#0a0e17', webPreferences: { preload: join(__dirname, '../preload/index.js'), sandbox: false, contextIsolation: true, nodeIntegration: false } }) // 关闭时记忆窗口普通状态 bounds:最大化/全屏时 getNormalBounds 仍返回普通状态的 // 尺寸位置(本次会话调整过的窗口位置不因最大化关闭而丢失);最小化时不记录 mainWindow.on('close', () => { if (mainWindow.isMinimized()) return const b = mainWindow.getNormalBounds() appState.bounds = { x: b.x, y: b.y, width: b.width, height: b.height } saveState() }) mainWindow.on('ready-to-show', () => { mainWindow.show() }) mainWindow.webContents.setWindowOpenHandler((details) => { shell.openExternal(details.url) return { action: 'deny' } }) // 窗口内导航白名单:仅放行回到当前应用地址(dev 首页 / 打包产物首页,等价于刷新), // 阻断其余一切页面内跳转(新窗口已在上方拦截,此处补齐页面内导航防线) mainWindow.webContents.on('will-navigate', (e, url) => { if (url !== mainWindow.webContents.getURL()) e.preventDefault() }) // 开发模式加载 dev server,生产加载打包后的 html if (is.dev && process.env['ELECTRON_RENDERER_URL']) { mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) } else { mainWindow.loadFile(join(__dirname, '../renderer/index.html')) } } /** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */ const MAX_FILE_SIZE = 10 * 1024 * 1024 /** 解码快路径阈值(256KB):小缓冲同步解码(毫秒级),大缓冲派发 worker 后台解码 */ const DECODE_FAST_PATH_BYTES = 256 * 1024 /** * 解码 worker(worker_threads)单例管理。 * 大文件解码(GBK 纯 JS 解码可达数百毫秒)移入后台线程,避免阻塞主进程事件循环; * 崩溃时拒绝在途任务并销毁实例(下次请求重建),调用方回退主进程同步解码。 */ let decodeWorker: Worker | null = null let decodeJobSeq = 0 const pendingDecode = new Map< number, { resolve: (r: DecodeResult) => void; reject: (e: Error) => void } >() /** 解码 worker 脚本路径:打包后位于 asar.unpacked(Electron 的 asar 补丁不覆盖 worker 线程) */ function decodeWorkerPath(): string { const p = join(__dirname, 'decodeWorker.js') return p.includes(`app.asar${sep}`) ? p.replace(`app.asar${sep}`, `app.asar.unpacked${sep}`) : p } function getDecodeWorker(): Worker { if (decodeWorker) return decodeWorker const w = new Worker(decodeWorkerPath()) w.on('message', (msg: { jobId: number; result: DecodeResult }) => { pendingDecode.get(msg.jobId)?.resolve(msg.result) pendingDecode.delete(msg.jobId) }) w.on('error', (err: Error) => { for (const { reject } of pendingDecode.values()) reject(err) pendingDecode.clear() const dead = decodeWorker decodeWorker = null dead?.terminate().catch(() => {}) }) // 意外退出兜底:拒绝全部在途任务(error 分支已处理时此处为 no-op),下次请求重建 w.on('exit', () => { for (const { reject } of pendingDecode.values()) reject(new Error('decode worker exited')) pendingDecode.clear() if (decodeWorker === w) decodeWorker = null }) decodeWorker = w return w } /** 大缓冲派发 worker 解码;worker 创建/运行失败时回退主进程同步解码(行为不降级) */ async function decodeMaybeWorker(buf: Buffer): Promise { if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf) const jobId = ++decodeJobSeq return new Promise((resolve, reject) => { let w: Worker try { w = getDecodeWorker() } catch (e) { reject(e instanceof Error ? e : new Error('decode worker unavailable')) return } pendingDecode.set(jobId, { resolve, reject }) w.postMessage({ jobId, buffer: new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) }) }).catch(() => decodeText(buf)) } /** 按路径读取并解码文本文件(file:open 与 file:read-by-path 共用;10MB 上限) */ async function readTextFile( filePath: string ): Promise< | { path: string; name: string; text: string; encoding: string; binary: boolean } | { error: 'too-large' | 'read-failed'; name: string; size?: number } > { const name = filePath.split(/[\\/]/).pop() ?? filePath let buf: Buffer try { const st = await fs.promises.stat(filePath) if (st.size > MAX_FILE_SIZE) { return { error: 'too-large', name, size: st.size } } buf = await fs.promises.readFile(filePath) } catch { return { error: 'read-failed', name } } const { text, encoding, binary } = await decodeMaybeWorker(buf) return { path: filePath, name, text, encoding, binary } } /** IPC: 打开文件对话框并读取内容 */ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => { const result = await dialog.showOpenDialog({ title: `选择${side === 'left' ? '左侧' : side === 'right' ? '右侧' : ''}文本文件`, // 记忆上次成功打开文件所在目录;无记录时传 undefined 走系统默认(最近使用位置) defaultPath: appState.lastDir, properties: ['openFile'], filters: [ { name: '文本文件', extensions: TEXT_FILE_EXTENSIONS }, // 兜底入口:允许打开任意扩展名文件 { name: '所有文件', extensions: ['*'] } ] }) if (result.canceled || result.filePaths.length === 0) return null const filePath = result.filePaths[0] // 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件) appState.lastDir = dirname(filePath) saveState() return readTextFile(filePath) }) /** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑,大缓冲走 worker) */ ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => { return decodeMaybeWorker(Buffer.from(buffer)) }) /** IPC: 保存差异报告(系统保存对话框 + 写入内容) */ ipcMain.handle('file:save-report', async (_event, content: string, defaultName: string) => { const ext = defaultName.split('.').pop() ?? 'txt' const result = await dialog.showSaveDialog({ title: '保存差异报告', defaultPath: join(app.getPath('documents'), defaultName), filters: [ { name: '差异报告', extensions: [ext] }, { name: '所有文件', extensions: ['*'] } ] }) if (result.canceled || !result.filePath) return { ok: false, path: null } // 异步写盘,避免大报告(MB 级 HTML)同步写入时阻塞主进程事件循环 await fs.promises.writeFile(result.filePath, content, 'utf-8') return { ok: true, path: result.filePath } }) /** IPC: 打开系统文件管理器定位文件 */ ipcMain.handle('file:show-in-folder', (_event, filePath: string) => { shell.showItemInFolder(filePath) return true }) /** IPC: 复制文本到剪贴板 */ ipcMain.handle('clipboard:write', (_event, text: string) => { clipboard.writeText(text) return true }) /** IPC: 选择文件夹(文件夹对比入口;记忆上次目录) */ ipcMain.handle('folder:pick', async () => { const result = await dialog.showOpenDialog({ title: '选择要对比的文件夹', defaultPath: appState.lastDir, properties: ['openDirectory'] }) if (result.canceled || result.filePaths.length === 0) return null const dirPath = result.filePaths[0] appState.lastDir = dirPath saveState() return dirPath }) /** IPC: 扫描对比两个文件夹(递归对齐 + 字节级判定 + 可选语义判等 + 忽略规则 + 增量缓存跨会话持久化;异步 fs 与解码 worker 不阻塞主进程) */ ipcMain.handle( 'folder:scan', async ( _event, leftDir: string, rightDir: string, semantic: boolean, previous?: unknown, ignoreRules?: unknown ) => { try { // 入参防御性校验与增量缓存源选择(内存缓存优先,未携带/非法回退 userData 落盘缓存,惰性读盘) const args = resolveScanArgs(previous, ignoreRules, () => getScanCache(loadScanCacheStore(), leftDir, rightDir) ) const result = await scanFolders(leftDir, rightDir, { ...args, ...(semantic ? { // 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线) semantic: { maxBytes: SEMANTIC_MAX_BYTES, isTextFile: isTextRel, decode: async (buf) => (await decodeMaybeWorker(buf)).text } } : {}) }) // 扫描成功且未截断:缓存异步落盘(LRU,失败静默;下次启动同目录对可增量重扫) if (!result.truncated) persistScanCache(leftDir, rightDir, result.cache) return result } catch { // 目录不存在/不可读等:转为错误标记,渲染端提示 return { error: 'scan-failed' as const } } } ) /** IPC: 按绝对路径读取文本文件(文件夹对比双击进入单文件对比;与 file:open 同一读取管线) */ ipcMain.handle('file:read-by-path', async (_event, filePath: string) => { return readTextFile(filePath) }) app.whenReady().then(() => { electronApp.setAppUserModelId('com.metonateam.difflens') // 强制暗色系统主题:Windows 标题栏/滚动条等系统控件跟随应用暗色科幻风格 nativeTheme.themeSource = 'dark' // 读取持久化应用状态(窗口 bounds + 上次文件目录;损坏数据回退空状态) appState = loadState() app.on('browser-window-created', (_, window) => { optimizer.watchWindowShortcuts(window) }) // 隐藏窗口自带的应用菜单栏(功能入口由界面按钮提供) Menu.setApplicationMenu(null) createWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit() } })