317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
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 } from './folderScan'
|
||
|
||
/**
|
||
* 文本类扩展名清单(小写,不含点):
|
||
* 与渲染进程 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')
|
||
|
||
/** 当前生效的应用状态(启动时从磁盘读取,运行中增量更新) */
|
||
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<DecodeResult> {
|
||
if (buf.length < DECODE_FAST_PATH_BYTES) return decodeText(buf)
|
||
const jobId = ++decodeJobSeq
|
||
return new Promise<DecodeResult>((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) => {
|
||
try {
|
||
return await scanFolders(leftDir, rightDir, semantic
|
||
? {
|
||
// 语义判等解码复用解码 worker(大缓冲后台解码,与文件打开共用同一管线)
|
||
semantic: {
|
||
maxBytes: SEMANTIC_MAX_BYTES,
|
||
isTextFile: isTextRel,
|
||
decode: async (buf) => (await decodeMaybeWorker(buf)).text
|
||
}
|
||
}
|
||
: {})
|
||
} 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()
|
||
}
|
||
}) |