feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0
新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件, 批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、 图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文, 页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。 便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化, 避免升级后 data/ 被遗留在旧目录。 Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
@@ -0,0 +1,225 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const h = require('./helpers');
|
||||
|
||||
const mainFile = path.join(__dirname, '..', '..', 'main.js');
|
||||
const mainSrc = fs.readFileSync(mainFile, 'utf8');
|
||||
|
||||
const { compareVersion } = h.extractFns(
|
||||
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
|
||||
);
|
||||
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
|
||||
|
||||
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
|
||||
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
|
||||
assert.deepStrictEqual(r, { ok: false, error: '未知数据源: nope' });
|
||||
});
|
||||
|
||||
test('wrap 捕获异步拒绝', async () => {
|
||||
const r = await wrap(() => Promise.reject(new Error('boom')));
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.error, 'boom');
|
||||
});
|
||||
|
||||
test('wrap 正常返回包成 { ok:true, data }', async () => {
|
||||
assert.deepStrictEqual(await wrap(() => 42), { ok: true, data: 42 });
|
||||
assert.deepStrictEqual(await wrap(() => Promise.resolve('x')), { ok: true, data: 'x' });
|
||||
});
|
||||
|
||||
test('wrap 对非 Error 抛出也能给出字符串', async () => {
|
||||
const r = await wrap(() => { throw 'plain string'; });
|
||||
assert.strictEqual(r.ok, false);
|
||||
assert.strictEqual(r.error, 'plain string');
|
||||
});
|
||||
|
||||
test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
|
||||
assert.ok(/function wrap\(fn\)/.test(mainSrc), 'wrap 未改成接收函数');
|
||||
assert.ok(!/wrap\(sources\.getSource/.test(mainSrc), '仍有同步求值的 getSource 传进 wrap');
|
||||
assert.ok(!/wrap\(Promise\.resolve/.test(mainSrc), '仍有 Promise.resolve 被提前求值');
|
||||
// 直接返回 { ok: true, ... } 而不过 wrap 的 handler 会绕开错误处理
|
||||
const bare = mainSrc.match(/ipcMain\.handle\([^)]*=>\s*\(\{\s*ok:\s*true/g) || [];
|
||||
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
||||
});
|
||||
|
||||
test('版本比较:预发布版本低于同号正式版', () => {
|
||||
assert.strictEqual(compareVersion('1.1.0', '1.1.0-beta'), 1);
|
||||
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0'), -1);
|
||||
assert.strictEqual(compareVersion('1.1.0-beta', '1.1.0-beta'), 0);
|
||||
});
|
||||
|
||||
test('版本比较:常规大小与位数不等', () => {
|
||||
assert.strictEqual(compareVersion('1.2.0', '1.1.9'), 1);
|
||||
assert.strictEqual(compareVersion('1.1.0', '1.1.0'), 0);
|
||||
assert.strictEqual(compareVersion('2.0', '1.9.9'), 1);
|
||||
assert.strictEqual(compareVersion('1.10.0', '1.9.0'), 1, '按数值而非字典序比较');
|
||||
assert.strictEqual(compareVersion('v1.1.1', '1.1.0'), 1, '应容忍 v 前缀');
|
||||
});
|
||||
|
||||
test('窗口控制 handler 检查 isDestroyed', () => {
|
||||
assert.ok(/function liveWindow\(\)/.test(mainSrc), '缺少 liveWindow 守卫');
|
||||
assert.ok(/isDestroyed\(\)\s*\?\s*null\s*:\s*mainWindow/.test(mainSrc.replace(/\s+/g, ' ')) ||
|
||||
/!mainWindow\.isDestroyed\(\)/.test(mainSrc), 'liveWindow 未检查 isDestroyed');
|
||||
assert.ok(!/mainWindow && mainWindow\.minimize\(\)/.test(mainSrc), '仍有未加守卫的窗口调用');
|
||||
assert.ok(!/dialog\.show\w+\(mainWindow,/.test(mainSrc), '对话框仍直接引用可能已销毁的窗口');
|
||||
});
|
||||
|
||||
test('下载校验协议,拒绝 file:// 等非 http(s)', () => {
|
||||
assert.ok(/仅支持 HTTP 或 HTTPS 下载链接/.test(mainSrc));
|
||||
assert.ok(/仅允许打开 HTTP 或 HTTPS 链接/.test(mainSrc), 'openExternal 缺协议校验');
|
||||
});
|
||||
|
||||
test('移除书籍默认保留阅读资料,仅显式勾选时清理', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('library:remove'");
|
||||
const end = mainSrc.indexOf('// 下载文件', start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.match(segment, /options\.deleteReadingData\s*===\s*true/);
|
||||
const guard = segment.indexOf('if (deleteReadingData)');
|
||||
assert.ok(guard >= 0, '缺少显式清理守卫');
|
||||
assert.ok(segment.indexOf('readerStore.forget', guard) > guard);
|
||||
assert.ok(segment.indexOf('annotations.forget', guard) > guard);
|
||||
});
|
||||
|
||||
test('书库列表附带阅读记录中的最近阅读时间', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('library:list'");
|
||||
const end = mainSrc.indexOf("ipcMain.handle('library:get'", start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.match(segment, /lastReadAt:\s*readerStore\.getLastReadAt\(item\.id\)/);
|
||||
});
|
||||
|
||||
test('Z-Library 登录通过受限同源浏览器完成反机器人验证', () => {
|
||||
const start = mainSrc.indexOf('async function browserZlibLogin');
|
||||
const end = mainSrc.indexOf('// Z-Library 凭据', start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.ok(start > 0 && end > start);
|
||||
assert.match(segment, /show:\s*false/);
|
||||
assert.match(segment, /contextIsolation:\s*true/);
|
||||
assert.match(segment, /nodeIntegration:\s*false/);
|
||||
assert.match(segment, /sandbox:\s*true/);
|
||||
assert.match(segment, /setWindowOpenHandler\(\(\)\s*=>\s*\(\{\s*action:\s*'deny'/);
|
||||
assert.match(segment, /new URL\(target\)\.origin\s*!==\s*origin/);
|
||||
assert.match(segment, /executeJavaScriptInIsolatedWorld/);
|
||||
assert.match(segment, /session\.defaultSession\.cookies\.get/);
|
||||
assert.match(segment, /if \(!win\.isDestroyed\(\)\) win\.destroy\(\)/);
|
||||
});
|
||||
|
||||
test('下载处理发送隔离请求 ID 的字节进度和完成事件', () => {
|
||||
assert.match(mainSrc, /event\.sender\.send\('download:progress'/);
|
||||
assert.match(mainSrc, /receivedBytes\s*\+=\s*chunk\.length/);
|
||||
assert.match(mainSrc, /percent:\s*totalBytes\s*\?\s*Math\.min\(1,\s*receivedBytes\s*\/\s*totalBytes\)\s*:\s*null/);
|
||||
assert.match(mainSrc, /percent:\s*1,\s*complete:\s*true/);
|
||||
});
|
||||
|
||||
test('窗口使用 icons/dist 主题图标并同步界面主题', () => {
|
||||
const iconDir = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||||
for (const name of ['book-ai-dark.ico', 'book-ai-light.ico']) {
|
||||
const bytes = fs.readFileSync(path.join(iconDir, name));
|
||||
assert.deepStrictEqual([...bytes.subarray(0, 4)], [0, 0, 1, 0], `${name} 不是 ICO`);
|
||||
}
|
||||
assert.match(mainSrc, /function iconForTheme\(theme\)/);
|
||||
assert.match(mainSrc, /icon:\s*iconForTheme\(currentUiTheme\)/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('ui:setTheme'/);
|
||||
assert.match(mainSrc, /settings\.set\('ui\.theme', theme\)/);
|
||||
assert.match(mainSrc, /settings\.set\('reader\.uiTheme', theme\)/);
|
||||
const build = fs.readFileSync(path.join(__dirname, '..', '..', 'build-portable.js'), 'utf8');
|
||||
assert.match(build, /rcedit\(executable,[\s\S]*book-ai-dark\.ico/);
|
||||
assert.match(build, /book-ai-light\.ico/);
|
||||
});
|
||||
|
||||
test('标准构建入口固定输出目录并保留便携数据', () => {
|
||||
const root = path.join(__dirname, '..', '..');
|
||||
const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
||||
const build = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
|
||||
assert.strictEqual(pkg.scripts.build, 'node build-portable.js');
|
||||
assert.match(build, /const OUT = REQUESTED_OUT/);
|
||||
assert.match(build, /if \(entry\.name === 'data'\) continue/);
|
||||
assert.match(build, /clearOutput\(OUT\)/);
|
||||
assert.match(build, /请先关闭其中正在运行的/);
|
||||
assert.doesNotMatch(build, /nextAvailableOutput|-rebuild/);
|
||||
// 目录名固定为平台标识,升级版本不再产生新目录,data/ 也就不会被落在旧目录里
|
||||
assert.match(build, /const TARGET = `\$\{PRODUCT\}-windows-x64`/);
|
||||
assert.doesNotMatch(build, /\$\{PRODUCT\}-\$\{pkg\.version\}/);
|
||||
});
|
||||
|
||||
test('删除阅读资料先等待阅读器排空,后续迟到写入会被拒绝', () => {
|
||||
assert.match(mainSrc, /await requestReaderPurge\(id\)/);
|
||||
assert.match(mainSrc, /purgedReaderEntries\.add\(key\)/);
|
||||
assert.match(mainSrc, /function ensureReaderWritable\(entryId\)/);
|
||||
assert.match(mainSrc, /ipcMain\.on\('reader:purgeReady'/);
|
||||
const forgetAt = mainSrc.indexOf('readerStore.forget(id)');
|
||||
const removeAt = mainSrc.indexOf('library.remove(id, deleteFiles)');
|
||||
assert.ok(forgetAt > 0 && removeAt > forgetAt, '显式阅读资料清理必须在移除书库条目前成功');
|
||||
assert.doesNotMatch(mainSrc, /readerStore\.forget\(id\);\s*\}\s*catch\s*\(e\)\s*\{\s*\/\*[^*]*不该阻断移除/);
|
||||
});
|
||||
|
||||
test('笔记文档指纹失配时拒绝回退到其它文件', () => {
|
||||
assert.match(mainSrc, /if \(matched < 0\) throw new Error\('笔记关联的原始文件已变更或不存在'\)/);
|
||||
});
|
||||
|
||||
test('启动扫描和旧库导入在首屏配置加载后延迟执行', () => {
|
||||
assert.match(mainSrc, /webContents\.once\('did-finish-load'/);
|
||||
assert.match(mainSrc, /setTimeout\(runStartupMaintenance,\s*1500\)/);
|
||||
const maintenanceAt = mainSrc.indexOf('function runStartupMaintenance()');
|
||||
const legacyAt = mainSrc.indexOf('library.importLegacy(userDataDir)', maintenanceAt);
|
||||
const scanAt = mainSrc.indexOf('library.scan()', maintenanceAt);
|
||||
assert.ok(maintenanceAt > 0 && legacyAt > maintenanceAt && scanAt > legacyAt);
|
||||
});
|
||||
|
||||
test('本地文件夹导入仅接受当前渲染进程的一次性选择令牌', () => {
|
||||
const start = mainSrc.indexOf("ipcMain.handle('dialog:pickLocal'");
|
||||
const end = mainSrc.indexOf('// --- 阅读器 ---', start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.ok(start > 0 && end > start);
|
||||
assert.match(segment, /localImport\.discover\(r\.filePaths\)/);
|
||||
assert.match(segment, /senderId:\s*event\.sender\.id/);
|
||||
assert.match(segment, /pending\.senderId\s*!==\s*event\.sender\.id/);
|
||||
assert.match(segment, /pendingLocalImports\.delete\(id\)/);
|
||||
assert.match(segment, /10\s*\*\s*60\s*\*\s*1000/);
|
||||
assert.match(segment, /library\.importLocal\(records,\s*organization\)/);
|
||||
});
|
||||
|
||||
test('内置阅读器允许 PDF、EPUB 和无 DRM Kindle 容器并保留外部回退', () => {
|
||||
assert.match(
|
||||
mainSrc,
|
||||
/READABLE_EXT = new Set\(\['\.pdf', '\.epub', '\.mobi', '\.azw', '\.azw3'\]\)/
|
||||
);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:openExternal'/);
|
||||
assert.match(mainSrc, /const error = await shell\.openPath\(abs\)/);
|
||||
assert.match(mainSrc, /if \(resolved\.format !== 'pdf'\) throw new Error\('只有 PDF 支持页面批注'\)/);
|
||||
});
|
||||
|
||||
test('PDF 使用发送者隔离的分段读取且不再整文件经过 IPC', () => {
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeOpen'/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeRead'/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:rangeClose'/);
|
||||
assert.match(mainSrc, /isReaderSender\(event\.sender\)/);
|
||||
assert.match(mainSrc, /rangeSessions\.closeSender\(senderId\)/);
|
||||
const start = mainSrc.indexOf("ipcMain.handle('reader:bytes'");
|
||||
const end = mainSrc.indexOf("ipcMain.handle('reader:openExternal'", start);
|
||||
const segment = mainSrc.slice(start, end);
|
||||
assert.match(segment, /format === 'pdf'/);
|
||||
assert.match(segment, /readBoundedFile\(abs, MAX_BUFFERED_READER_BYTES\)/);
|
||||
assert.doesNotMatch(segment, /fs\.readFileSync\(abs\)/);
|
||||
|
||||
const ranges = fs.readFileSync(path.join(__dirname, '..', 'reader', 'range-sessions.js'), 'utf8');
|
||||
assert.match(ranges, /MAX_RANGE_BYTES\s*=\s*4\s*\*\s*1024\s*\*\s*1024/);
|
||||
assert.match(ranges, /session\.senderId\s*!==\s*senderIdOf\(senderId\)/);
|
||||
assert.match(ranges, /session\.handle\.read\(buffer,\s*offset,\s*length - offset,\s*start \+ offset\)/);
|
||||
assert.match(ranges, /PDF 文件在阅读期间发生变化/);
|
||||
});
|
||||
|
||||
test('AI 图像与取消请求受阅读器发送者和资源边界保护', () => {
|
||||
assert.match(mainSrc, /function isReaderSender\(webContents\)/);
|
||||
assert.match(mainSrc, /ipcMain\.handle\('reader:captureRect'/);
|
||||
assert.match(mainSrc, /function canonicalVisualContexts\(raw\)/);
|
||||
assert.match(mainSrc, /nativeImage\.createFromBuffer/);
|
||||
assert.match(mainSrc, /decoded\.toJPEG\(85\)/);
|
||||
assert.match(mainSrc, /function aiRunKey\(senderId, runId\)/);
|
||||
assert.match(mainSrc, /aiRunKey\(event\.sender\.id/);
|
||||
assert.match(mainSrc, /aiRunKey\(e\.sender\.id/);
|
||||
assert.match(mainSrc, /wc\.once\('destroyed', abortOnDestroy\)/);
|
||||
assert.match(mainSrc, /只有阅读器可以使用 AI 助手/);
|
||||
assert.match(mainSrc, /function notifyAiChanged\(status\)/);
|
||||
assert.match(mainSrc, /webContents\.send\('ai:changed', status\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user