fix: v0.2.4 健壮性与体验打磨 - 读取容错、10MB 大文件防护、横向滚动同步、Esc/外点关闭、二进制预警

This commit is contained in:
2026-08-17 21:03:40 +08:00
parent e10ddea30b
commit 4ee5d62348
17 changed files with 380 additions and 39 deletions
+44 -12
View File
@@ -1,5 +1,5 @@
import { join } from 'path'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog } from 'electron'
import { app, shell, BrowserWindow, Menu, ipcMain, dialog, clipboard } from 'electron'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import iconv from 'iconv-lite'
import fs from 'fs'
@@ -41,27 +41,50 @@ 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
}
/**
* 探测文本编码:优先 BOM,其次严格 UTF-8,
* 失败则回退 GBK 解码,返回 { text, encoding }。
* 失败则回退 GBK 解码,返回 { text, encoding, binary }。
*/
function decodeText(buf: Buffer): { text: string; encoding: string } {
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)' }
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' }
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' }
return { text: buf.subarray(2).toString('utf16le'), encoding: 'UTF-16 LE', binary: false }
}
// 严格 UTF-8 探测
try {
const decoder = new TextDecoder('utf-8', { fatal: true })
const text = decoder.decode(buf)
return { text, encoding: 'UTF-8' }
return { text, encoding: 'UTF-8', binary: false }
} catch {
return { text: iconv.decode(buf, 'gbk'), encoding: 'GBK' }
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 }
}
}
@@ -76,10 +99,19 @@ ipcMain.handle('file:open', async (_event, side: 'left' | 'right' | null) => {
})
if (result.canceled || result.filePaths.length === 0) return null
const filePath = result.filePaths[0]
const buf = fs.readFileSync(filePath)
const { text, encoding } = decodeText(buf)
const name = filePath.split(/[\\/]/).pop() ?? filePath
return { path: filePath, name, text, encoding }
let buf: Buffer
try {
const stat = fs.statSync(filePath)
if (stat.size > MAX_FILE_SIZE) {
return { error: 'too-large', name, size: stat.size }
}
buf = fs.readFileSync(filePath)
} catch {
return { error: 'read-failed', name }
}
const { text, encoding, binary } = decodeText(buf)
return { path: filePath, name, text, encoding, binary }
})
/** IPC: 解码拖拽传入的原始字节(复用编码探测逻辑) */
@@ -111,7 +143,7 @@ ipcMain.handle('file:show-in-folder', (_event, filePath: string) => {
/** IPC: 复制文本到剪贴板 */
ipcMain.handle('clipboard:write', (_event, text: string) => {
require('electron').clipboard.writeText(text)
clipboard.writeText(text)
return true
})