feat: 解码 worker 化、Playwright E2E 测试体系与文件夹对比(0.6.0)
This commit is contained in:
+112
-53
@@ -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 }。
|
||||
* 解码 worker(worker_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.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 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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user