新增 build-mac.js,产出 ad-hoc 签名的 .app 与 DMG。只能在 macOS 上 构建:DMG 依赖 hdiutil,且 Apple Silicon 拒绝执行未签名二进制,改名 与改 plist 后必须用 codesign 重签。 按官方 darwin-arm64 运行时的真实结构处理四处易错点:用 ditto 解压以 保留 framework 符号链接、删除重打包后失效的 ElectronAsarIntegrity、 为改名后的 helper 补上 CFBundleExecutable、签名由内向外且单独签 Squirrel 的 ShipIt。 打包版数据目录改为按平台决定:macOS 走 appData,避免写进 DMG 挂载后 只读的 .app 内部并在升级覆盖时丢失书库;Windows 便携版行为不变。 窗口图标在非 Windows 平台改用 PNG。 .gitignore 的 dist 规则补上前导斜杠。此前它同时匹配 icons/dist/, 导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
372 lines
18 KiB
JavaScript
372 lines
18 KiB
JavaScript
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\}/);
|
||
});
|
||
|
||
function loadPathResolvers(platform, isPackaged) {
|
||
return h.extractFns(
|
||
mainFile,
|
||
'const APP_ICON_DIR =',
|
||
'const userDataDir = resolveUserDataDir()',
|
||
['iconForTheme', 'resolveUserDataDir'],
|
||
`const path = require('path');
|
||
const __dirname = ${JSON.stringify(path.join('/opt', 'app'))};
|
||
const process = { platform: ${JSON.stringify(platform)} };
|
||
const app = {
|
||
isPackaged: ${isPackaged},
|
||
getPath: (key) => {
|
||
if (key === 'exe') return ${JSON.stringify(path.join('/opt', 'portable', 'PeopleLib.exe'))};
|
||
if (key === 'appData') return ${JSON.stringify(path.join('/home', 'u', 'AppData'))};
|
||
throw new Error('未预期的 getPath: ' + key);
|
||
}
|
||
};`
|
||
);
|
||
}
|
||
|
||
test('macOS 打包版把用户数据放到 appData,不写进只读的 .app 内部', () => {
|
||
const appData = path.join('/home', 'u', 'AppData', 'PeopleLib');
|
||
|
||
// macOS 上 exe 位于 PeopleLib.app/Contents/MacOS/,DMG 挂载只读,
|
||
// 且覆盖升级会连同用户书库一起删掉,所以绝不能落在可执行文件旁边
|
||
assert.strictEqual(loadPathResolvers('darwin', true).resolveUserDataDir(), appData);
|
||
assert.strictEqual(loadPathResolvers('linux', true).resolveUserDataDir(), appData);
|
||
|
||
// Windows 便携版仍旧放在程序同级 data/
|
||
assert.strictEqual(
|
||
loadPathResolvers('win32', true).resolveUserDataDir(),
|
||
path.join('/opt', 'portable', 'data')
|
||
);
|
||
|
||
for (const platform of ['darwin', 'win32']) {
|
||
assert.strictEqual(loadPathResolvers(platform, false).resolveUserDataDir(), appData);
|
||
}
|
||
});
|
||
|
||
test('窗口图标按平台取用,macOS 不会拿到 .ico', () => {
|
||
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||
|
||
for (const theme of ['dark', 'light']) {
|
||
const win = loadPathResolvers('win32', true).iconForTheme(theme);
|
||
assert.strictEqual(path.extname(win), '.ico');
|
||
assert.strictEqual(path.basename(win), `book-ai-${theme}.ico`);
|
||
|
||
const mac = loadPathResolvers('darwin', true).iconForTheme(theme);
|
||
assert.strictEqual(path.extname(mac), '.png');
|
||
// 打包脚本只复制 32 与 256 两个尺寸,取用的那个必须真实存在
|
||
assert.ok(
|
||
fs.existsSync(path.join(iconRoot, theme, path.basename(mac))),
|
||
`缺少 macOS 窗口图标 ${theme}/${path.basename(mac)}`
|
||
);
|
||
}
|
||
|
||
// 未知主题回落到 dark,不能拼出不存在的路径
|
||
assert.strictEqual(
|
||
path.basename(loadPathResolvers('darwin', true).iconForTheme('nope')),
|
||
'icon-256.png'
|
||
);
|
||
});
|
||
|
||
test('icns 图标容器结构合法且尺寸齐全', () => {
|
||
const iconRoot = path.join(__dirname, '..', '..', 'icons', 'dist');
|
||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||
|
||
for (const theme of ['dark', 'light']) {
|
||
const buf = fs.readFileSync(path.join(iconRoot, `book-ai-${theme}.icns`));
|
||
assert.strictEqual(buf.toString('latin1', 0, 4), 'icns', `${theme} 魔数错误`);
|
||
// 长度字段写错时 Finder 会静默显示默认图标,必须逐字节核对
|
||
assert.strictEqual(buf.readUInt32BE(4), buf.length, `${theme} 长度字段与文件不符`);
|
||
|
||
const slots = new Map();
|
||
for (let off = 8; off < buf.length;) {
|
||
const type = buf.toString('latin1', off, off + 4);
|
||
const len = buf.readUInt32BE(off + 4);
|
||
assert.ok(len >= 8 && off + len <= buf.length, `${theme} 槽 ${type} 长度非法`);
|
||
const payload = buf.subarray(off + 8, off + len);
|
||
assert.ok(payload.subarray(0, 8).equals(PNG), `${theme} 槽 ${type} 不是 PNG`);
|
||
slots.set(type, payload.readUInt32BE(16));
|
||
off += len;
|
||
}
|
||
|
||
for (const [type, size] of [['ic07', 128], ['ic08', 256], ['ic09', 512], ['ic10', 1024]]) {
|
||
assert.strictEqual(slots.get(type), size, `${theme} 缺少 ${type}/${size}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('图标产物不被 dist 规则忽略,构建脚本引用的文件都在仓库里', () => {
|
||
const root = path.join(__dirname, '..', '..');
|
||
const ignore = fs.readFileSync(path.join(root, '.gitignore'), 'utf8');
|
||
|
||
// 不加前导斜杠时 dist/ 会连 icons/dist/ 一起忽略,
|
||
// 新克隆的仓库缺少图标,构建直接失败
|
||
assert.match(ignore, /^\/dist\/$/m, '.gitignore 的 dist 规则必须锚定到仓库根');
|
||
assert.doesNotMatch(ignore, /^dist\/$/m);
|
||
|
||
const portable = fs.readFileSync(path.join(root, 'build-portable.js'), 'utf8');
|
||
const mac = fs.readFileSync(path.join(root, 'build-mac.js'), 'utf8');
|
||
const required = [
|
||
'book-ai-dark.ico', 'book-ai-light.ico',
|
||
'book-ai-dark.icns',
|
||
path.join('dark', 'icon-32.png'), path.join('light', 'icon-32.png'),
|
||
path.join('dark', 'icon-256.png'), path.join('light', 'icon-256.png')
|
||
];
|
||
for (const rel of required) {
|
||
assert.ok(
|
||
fs.existsSync(path.join(root, 'icons', 'dist', rel)),
|
||
`构建需要的图标缺失: icons/dist/${rel}`
|
||
);
|
||
}
|
||
assert.ok(portable.includes('book-ai-dark.ico'));
|
||
assert.ok(mac.includes('book-ai-dark.icns'));
|
||
});
|
||
|
||
test('macOS 打包脚本保留签名前提并产出 arm64 DMG', () => {
|
||
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-mac.js'), 'utf8');
|
||
|
||
assert.strictEqual(pkg.scripts['build:mac'], 'node build-mac.js');
|
||
assert.match(build, /const TARGET = `\$\{PRODUCT\}-macos-\$\{ARCH\}`/);
|
||
assert.match(build, /const ARCH = 'arm64'/);
|
||
|
||
// 交叉打包做不出可用产物:hdiutil 与 codesign 都只有 macOS 才有
|
||
assert.match(build, /process\.platform !== 'darwin'/);
|
||
for (const tool of ['hdiutil', 'codesign', 'ditto']) assert.ok(build.includes(tool), `缺少 ${tool} 检查`);
|
||
|
||
// Electron Framework 带符号链接,用 Node 解压会展开成副本并让签名失效
|
||
assert.match(build, /run\('ditto', \['-x', '-k', zip, cacheDir\]\)/);
|
||
|
||
// 重打包后 asar 哈希对不上,留着该字段会启动即报完整性错误
|
||
assert.match(build, /ElectronAsarIntegrity/);
|
||
// 官方 helper plist 没有 CFBundleExecutable,改名后必须补上
|
||
assert.match(build, /CFBundleExecutable/);
|
||
// 带版本的 framework 要签 Versions/A;Squirrel 里的 ShipIt 是独立可执行文件
|
||
assert.match(build, /Versions', 'A'/);
|
||
assert.match(build, /ShipIt/);
|
||
// 签名必须由内向外,外层 .app 最后签
|
||
assert.match(build, /targets\.push\(APP_BUNDLE\)/);
|
||
assert.match(build, /'--sign', '-'/);
|
||
assert.match(build, /'--verify', '--deep', '--strict'/);
|
||
});
|
||
|
||
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\)/);
|
||
});
|