feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)

This commit is contained in:
2026-08-18 14:40:44 +08:00
parent 2b43b5e676
commit be619c2377
24 changed files with 2030 additions and 151 deletions
+112 -53
View File
@@ -1,9 +1,11 @@
import { join, dirname } from 'path'
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 iconv from 'iconv-lite'
import fs from 'fs'
import { parseState, clampBounds, type AppState } from './windowState'
import { decodeText, type DecodeResult } from './decode'
import { scanFolders } from './folderScan'
/** 应用状态持久化文件(窗口 bounds + 上次文件目录),位于 userData 目录 */
const stateFile = (): string => join(app.getPath('userData'), 'window-state.json')
@@ -88,48 +90,88 @@ function createWindow(): void {
/** 单侧文件大小上限(10MB):超限直接拦截,避免超大文件卡死界面 */
const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 缓冲区二进制启发式判定:出现 NUL 字节立即判定;
* 否则统计前 8KB 内不可见控制字符占比(> 5% 视为二进制)。
*/
function looksBinary(buf: Buffer): boolean {
const len = Math.min(buf.length, 8000)
if (len === 0) return false
let suspicious = 0
for (let i = 0; i < len; i++) {
const b = buf[i]
if (b === 0) return true
if (b < 0x09 || (b > 0x0d && b < 0x20)) suspicious++
}
return suspicious / len > 0.05
}
/** 解码快路径阈值(256KB):小缓冲同步解码(毫秒级),大缓冲派发 worker 后台解码 */
const DECODE_FAST_PATH_BYTES = 256 * 1024
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding, binary }。
* 解码 workerworker_threads)单例管理。
* 大文件解码(GBK 纯 JS 解码可达数百毫秒)移入后台线程,避免阻塞主进程事件循环;
* 崩溃时拒绝在途任务并销毁实例(下次请求重建),调用方回退主进程同步解码。
*/
function decodeText(buf: Buffer): { text: string; encoding: string; binary: boolean } {
if (buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf) {
return { text: buf.subarray(3).toString('utf8'), encoding: 'UTF-8 (BOM)', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
return { text: iconv.decode(buf.subarray(2), 'utf16-be'), encoding: 'UTF-16 BE', binary: false }
}
if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe) {
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false }
}
// 严格 UTF-8 探测
let decodeWorker: Worker | null = null
let decodeJobSeq = 0
const pendingDecode = new Map<
number,
{ resolve: (r: DecodeResult) => void; reject: (e: Error) => void }
>()
/** 解码 worker 脚本路径:打包后位于 asar.unpackedElectron 的 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 decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8', binary: false }
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 {
const text = iconv.decode(buf, 'gbk')
// GBK 覆盖面广,二进制内容也能解出“文字”;用字节启发式 + 替换符占比双保险
const bad = (text.match(/\uFFFD/g) ?? []).length
const binary = looksBinary(buf) || (text.length > 0 && bad / text.length > 0.05)
return { text, encoding: 'GBK', binary }
return { error: 'read-failed', name }
}
const { text, encoding, binary } = await decodeMaybeWorker(buf)
return { path: filePath, name, text, encoding, binary }
}
/** IPC: 打开文件对话框并读取内容 */
@@ -151,24 +193,12 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
// 记录所在目录供下次对话框起始定位(与窗口 bounds 同存一个状态文件)
appState.lastDir = dirname(filePath)
saveState()
const name = filePath.split(/[\\/]/).pop() ?? filePath
let buf: Buffer
try {
const stat = await fs.promises.stat(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = await fs.promises.readFile(filePath)
} catch {
return { error: 'read-failed', name }
}
const { text, encoding, binary } = decodeText(buf)
return { path: filePath, name, text, encoding, binary }
return readTextFile(filePath)
})
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑,大缓冲走 worker */
ipcMain.handle('file:decode-buffer', async (_event, buffer: ArrayBuffer) => {
return decodeText(Buffer.from(buffer))
return decodeMaybeWorker(Buffer.from(buffer))
})
/** IPC: 保存差异报告(系统保存对话框 + 写入内容) */
@@ -200,6 +230,35 @@ ipcMain.handle('clipboard:write', (_event, text: string) => {
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 不阻塞主进程) */
ipcMain.handle('folder:scan', async (_event, leftDir: string, rightDir: string) => {
try {
return await scanFolders(leftDir, rightDir)
} 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')