Files
peoplelib/main.js
T
lofyer 488de94289 feat: 支持自定义 Z-Library 镜像并发布 v2.1.5
在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址,
自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理,
退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。

版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。
2026-08-09 15:25:32 +08:00

1862 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { pathToFileURL } = require('url');
const { Readable, Transform } = require('stream');
const { pipeline } = require('stream/promises');
const RELEASES_API = 'https://api.github.com/repos/lofyer/peoplelib/releases/latest';
const RELEASES_PAGE = 'https://github.com/lofyer/peoplelib/releases';
function parseVersion(v) {
const s = String(v || '').replace(/^v/i, '').trim();
const [core, pre = ''] = s.split(/[-+]/);
return {
nums: core.split('.').map((n) => parseInt(n, 10) || 0),
// 有预发布标记的版本低于同号正式版:1.1.0-beta < 1.1.0
pre: pre.toLowerCase()
};
}
function compareVersion(a, b) {
const pa = parseVersion(a);
const pb = parseVersion(b);
const len = Math.max(pa.nums.length, pb.nums.length);
for (let i = 0; i < len; i++) {
const x = pa.nums[i] || 0;
const y = pb.nums[i] || 0;
if (x !== y) return x > y ? 1 : -1;
}
if (pa.pre === pb.pre) return 0;
if (!pa.pre) return 1;
if (!pb.pre) return -1;
return pa.pre > pb.pre ? 1 : -1;
}
async function checkUpdate() {
const current = app.getVersion();
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 15000);
let res;
try {
res = await fetchWithProxy(RELEASES_API, {
headers: { 'User-Agent': DL_UA, 'Accept': 'application/vnd.github+json' },
signal: ac.signal
});
} catch (e) {
if (e && e.name === 'AbortError') throw new Error('检查更新超时,请检查网络或代理设置');
throw e;
} finally {
clearTimeout(timer);
}
if (!res.ok) throw new Error(`检查更新失败: ${res.status}`);
const json = await res.json();
const latest = json.tag_name || json.name || '';
return {
current,
latest: String(latest).replace(/^v/i, ''),
hasUpdate: compareVersion(latest, current) > 0,
url: json.html_url || RELEASES_PAGE,
notes: json.body || ''
};
}
function filenameFromResponse(res, fallback) {
const cd = res.headers.get('content-disposition') || '';
let m = cd.match(/filename\*=(?:UTF-8'')?([^;]+)/i) || cd.match(/filename="?([^";]+)"?/i);
if (m) { try { return decodeURIComponent(m[1].trim()); } catch (e) { return m[1].trim(); } }
try {
const u = new URL(res.url);
const base = path.basename(u.pathname);
if (base && /\.[a-z0-9]{2,5}$/i.test(base)) return decodeURIComponent(base);
} catch (e) { /* ignore */ }
return fallback || 'download.bin';
}
app.setName('PeopleLib');
app.setAppUserModelId('com.peoplelib.client');
const APP_ICON_DIR = path.join(__dirname, 'icons', 'dist');
function iconForTheme(theme) {
const name = theme === 'light' ? 'light' : 'dark';
// .ico 只有 Windows 认;macOS/Linux 用 PNG,窗口图标不需要 icns
return process.platform === 'win32'
? path.join(APP_ICON_DIR, `book-ai-${name}.ico`)
: path.join(APP_ICON_DIR, name, 'icon-256.png');
}
// macOS 的 .app 内部不可写(DMG 只读,且升级覆盖会连用户数据一起删),
// 只有 Windows 便携版才把 data/ 放在可执行文件旁边。
function resolveUserDataDir() {
if (!app.isPackaged) return path.join(app.getPath('appData'), 'PeopleLib');
if (process.platform === 'win32') return path.join(path.dirname(app.getPath('exe')), 'data');
return path.join(app.getPath('appData'), 'PeopleLib');
}
const userDataDir = resolveUserDataDir();
app.setPath('userData', userDataDir);
const sources = require('./src/sources');
const library = require('./src/library/store');
const localImport = require('./src/library/local-import');
const coverGenerator = require('./src/library/cover-generator');
const zlibAuth = require('./src/sources/zlib-auth');
const semanticKey = require('./src/sources/semantic-key');
const settings = require('./src/settings');
const readerStore = require('./src/reader/store');
const annotations = require('./src/reader/annotations');
const noteAssets = require('./src/reader/note-assets');
const readerWindow = require('./src/reader/window');
const noteWindow = require('./src/reader/note-window');
const mangaOnlineWindow = require('./src/manga-online/window');
const mangaOnlineSessions = require('./src/manga-online/session');
const rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
const aiSessions = require('./src/reader/ai-sessions');
const aiImages = require('./src/reader/ai-images');
const { normalizeVisualContexts } = require('./src/reader/visual-context');
const { UA: DL_UA, setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
zlibAuth.init(userDataDir, safeStorage);
semanticKey.init(userDataDir, safeStorage);
settings.init(userDataDir);
let currentUiTheme = settings.get(
'ui.theme',
settings.get('reader.uiTheme', 'dark')
) === 'light' ? 'light' : 'dark';
readerStore.init(userDataDir);
annotations.init(userDataDir);
noteAssets.init(userDataDir);
aiConfig.init(userDataDir, safeStorage);
aiSessions.init(userDataDir);
aiImages.init(userDataDir);
// 启动时从持久化设置恢复代理
try {
setProxy(settings.get('proxy', ''));
} catch (e) {
console.warn('代理配置无效,已改为直连:', e.message);
setProxy('');
}
// 书库目录:默认 <userData>/library,用户可在设置中更改
const DEFAULT_LIBRARY_DIR = path.join(userDataDir, 'library');
try {
library.init(settings.get('libraryDir', '') || DEFAULT_LIBRARY_DIR);
} catch (e) {
console.warn('自定义书库目录不可用,已回退到默认目录:', e.message);
library.init(DEFAULT_LIBRARY_DIR);
}
coverGenerator.init(__dirname, library);
const legacyImportPending = !settings.get('legacyImported', false);
let mainWindow;
let activeDownloads = 0;
const downloadSessions = new Map();
const downloadSessionSenders = new Set();
let readerPurgeSeq = 0;
let startupMaintenanceStarted = false;
const readerPurgeWaiters = new Map();
const purgedReaderEntries = new Set();
const pendingLocalImports = new Map();
function requestReaderPurge(entryId) {
const requestId = `purge_${Date.now().toString(36)}_${(++readerPurgeSeq).toString(36)}`;
if (!readerWindow.get()) return Promise.resolve();
return new Promise((resolve) => {
const timer = setTimeout(() => {
readerPurgeWaiters.delete(requestId);
resolve();
}, 5000);
readerPurgeWaiters.set(requestId, () => {
clearTimeout(timer);
readerPurgeWaiters.delete(requestId);
resolve();
});
readerWindow.purgeFor(entryId, requestId);
});
}
function ensureReaderWritable(entryId) {
if (purgedReaderEntries.has(String(entryId))) throw new Error('该条目的阅读资料已删除');
}
function downloadSessionKey(senderId, requestId) {
return `${senderId}:${requestId}`;
}
function removeDownloadPartial(session) {
if (!session || !session.partial) return;
try {
fs.unlinkSync(session.partial);
session.partial = '';
} catch (e) {
if (e.code === 'ENOENT') session.partial = '';
}
}
function discardDownloadSession(session) {
if (!session) return;
session.control = 'delete';
if (session.controller) session.controller.abort();
removeDownloadPartial(session);
if (session.state !== 'running') downloadSessions.delete(session.key);
}
function trackDownloadSender(webContents) {
const senderId = webContents.id;
if (downloadSessionSenders.has(senderId)) return;
downloadSessionSenders.add(senderId);
webContents.once('destroyed', () => {
downloadSessionSenders.delete(senderId);
for (const session of downloadSessions.values()) {
if (session.senderId === senderId) discardDownloadSession(session);
}
});
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1240,
height: 840,
minWidth: 940,
minHeight: 620,
frame: false,
backgroundColor: '#141414',
icon: iconForTheme(currentUiTheme),
title: 'PeopleLib',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false
}
});
mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html'));
mainWindow.webContents.once('did-finish-load', () => {
setTimeout(runStartupMaintenance, 1500);
});
}
function runStartupMaintenance() {
if (startupMaintenanceStarted) return;
startupMaintenanceStarted = true;
try {
if (legacyImportPending) {
library.importLegacy(userDataDir);
settings.set('legacyImported', true);
}
library.scan();
for (const job of coverGenerator.ensureAll()) job.catch(() => {});
} catch (e) {
console.warn('启动维护任务失败:', e.message);
}
}
function notifyLibraryChanged() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('library:changed');
}
}
function notifyNotesChanged(data) {
const payload = data && typeof data === 'object' ? data : {};
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
}
}
function notifyNoteWindowsChanged(noteIds) {
const payload = Array.isArray(noteIds) ? noteIds : noteWindow.openIds();
const windows = [mainWindow, ...readerWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('notes:windowsChanged', payload);
}
}
noteWindow.setChangeListener(notifyNoteWindowsChanged);
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
}
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.all(), ...noteWindow.all(), ...mangaOnlineWindow.all()];
for (const win of windows) {
if (win && !win.isDestroyed()) {
win.webContents.send('ui:themeChanged', currentUiTheme);
}
}
}
library.setChangeListener(notifyLibraryChanged);
async function applySessionProxy(ses, proxy) {
await ses.setProxy({ proxyRules: proxy || 'direct://' });
await ses.closeAllConnections();
}
app.whenReady().then(async () => {
// 必须在建窗前等 Chromium 代理真正生效,否则启动后首批 net.fetch 会沿用直连。
try {
await applySessionProxy(session.defaultSession, getProxy());
} catch (e) {
console.warn('Chromium 代理应用失败:', e.message);
}
createWindow();
setTimeout(cleanupNoteAssets, 0);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit();
});
app.on('before-quit', () => {
for (const download of downloadSessions.values()) discardDownloadSession(download);
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
mangaOnlineSessions.reset();
});
// fn 同步抛出时也必须变成 { ok:false },否则 invoke 直接 reject
// 渲染层的 await 没有 catch,界面会永远停在"加载中"。
function wrap(fn) {
return Promise.resolve()
.then(typeof fn === 'function' ? fn : () => fn)
.then((data) => ({ ok: true, data }))
.catch((err) => ({ ok: false, error: (err && err.message) || String(err) }));
}
// Promise 永不落定和同步抛出一样会把界面钉在"加载中",而且更难发现:
// executeJavaScript 在页面卡死或窗口销毁时不会 reject,页内 fetch 默认也没有超时。
function withTimeout(promise, ms, message) {
let timer = null;
return Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
})
]).finally(() => { if (timer) clearTimeout(timer); });
}
// 兜底时限,不是精确预算:正常情况下各层自己的超时(fetchRaw 15s、登录页 30s
// 会先给出具体原因。串行走完 5 个镜像的登录最坏可达数分钟,这里会先截断并给出
// 通用提示,宁可让用户拿到可操作的错误,也不要让界面无限转圈。
const SOURCE_TIMEOUT_MS = 120000;
function sourceCall(fn) {
return wrap(() => withTimeout(
Promise.resolve().then(fn),
SOURCE_TIMEOUT_MS,
'数据源长时间无响应,请稍后重试或更换数据源'
));
}
// 数据源
ipcMain.handle('sources:list', () => wrap(() => sources.listSources()));
ipcMain.handle('source:list', (_e, sourceId, page) => sourceCall(() => sources.getSource(sourceId).list(page)));
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => sourceCall(() => sources.getSource(sourceId).search(keyword, page)));
ipcMain.handle('source:detail', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).detail(postId)));
ipcMain.handle('source:download', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).download(postId)));
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => sourceCall(() => {
const source = sources.getSource(sourceId);
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
return source.chapters(postId, page, options || {});
}));
ipcMain.handle('source:readOnline', (event, sourceId, input) => wrap(() => {
if (!mainWindow || mainWindow.isDestroyed() || event.sender.id !== mainWindow.webContents.id) {
throw new Error('只有主窗口可以打开在线漫画');
}
const source = sources.getSource(sourceId);
const win = mangaOnlineWindow.create(__dirname, currentUiTheme);
const ownerId = win.webContents.id;
win.webContents.once('destroyed', () => mangaOnlineSessions.closeOwner(ownerId));
try {
const value = mangaOnlineSessions.create(ownerId, source, input || {});
mangaOnlineWindow.load(__dirname, value.sessionId);
return value;
} catch (error) {
win.destroy();
throw error;
}
}));
ipcMain.handle('source:downloadChapter', async (event, sourceId, payload, requestId) => {
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const sendProgress = (done, total) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('source:chapterProgress', { requestId: progressId, done, total });
};
return wrap(async () => {
const source = sources.getSource(sourceId);
if (typeof source.downloadChapter !== 'function') throw new Error('该数据源不支持章节下载');
// library.add()/attachFile() 内部已经在 commit() 里触发变更通知,这里不需要再发一次
const result = await source.downloadChapter(library, payload || {}, sendProgress);
return { entryId: result.entry ? result.entry.id : null, chapterLabel: result.chapterLabel, created: result.created };
});
});
function requireMangaOnlineSender(event) {
if (!mangaOnlineWindow.fromWebContents(event.sender)) throw new Error('只有在线漫画阅读器可以读取内容');
return event.sender.id;
}
ipcMain.handle('mangaOnline:meta', (event, sessionId) => wrap(() => (
mangaOnlineSessions.meta(requireMangaOnlineSender(event), sessionId)
)));
ipcMain.handle('mangaOnline:chapters', (event, sessionId, page) => wrap(() => (
mangaOnlineSessions.chapters(requireMangaOnlineSender(event), sessionId, page)
)));
ipcMain.handle('mangaOnline:chapterManifest', (event, sessionId, chapterId) => wrap(() => (
mangaOnlineSessions.chapterManifest(requireMangaOnlineSender(event), sessionId, chapterId)
)));
ipcMain.handle('mangaOnline:image', (event, sessionId, manifestId, index) => wrap(() => (
mangaOnlineSessions.image(
requireMangaOnlineSender(event),
sessionId,
manifestId,
index
)
)));
ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => (
mangaOnlineSessions.close(requireMangaOnlineSender(event), sessionId)
)));
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
ipcMain.handle('proxy:set', (_e, url) => wrap(async () => {
const previous = getProxy();
try {
const u = String(url || '').trim();
setProxy(u);
await applySessionProxy(session.defaultSession, u);
settings.set('proxy', u);
return true;
} catch (e) {
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
try { await applySessionProxy(session.defaultSession, previous); } catch (rollbackError) { /* ignore */ }
throw e;
}
}));
function zlibOrigin(value) {
let url;
try { url = new URL(String(value || '')); } catch (e) { throw new Error('Z-Library 镜像地址无效'); }
if (url.protocol !== 'https:' || url.username || url.password) {
throw new Error('Z-Library 镜像必须使用 HTTPS');
}
return url.origin;
}
const ZLIB_LOGIN_FETCH_MS = 20000;
async function waitForZlibPage(win, origin) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (win.isDestroyed()) throw new Error('Z-Library 登录页面已关闭');
const current = win.webContents.getURL();
const title = win.getTitle();
if (current.startsWith(`${origin}/`) && title && !/checking your browser/i.test(title)) return;
await new Promise((resolve) => setTimeout(resolve, 400));
}
throw new Error('Z-Library 浏览器验证超时');
}
async function browserZlibLogin(mirror, email, password) {
const origin = zlibOrigin(mirror);
const win = new BrowserWindow({
show: false,
width: 900,
height: 700,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
backgroundThrottling: false
}
});
win.webContents.setWindowOpenHandler(() => ({ action: 'deny' }));
win.webContents.on('will-attach-webview', (event) => event.preventDefault());
win.webContents.on('will-navigate', (event, target) => {
try {
if (new URL(target).origin !== origin) event.preventDefault();
} catch (e) {
event.preventDefault();
}
});
try {
win.loadURL(`${origin}/`).catch(() => {});
await waitForZlibPage(win, origin);
const body = new URLSearchParams({
isModal: 'true',
email: String(email),
password: String(password),
site_mode: 'books',
action: 'login',
isSingleLogin: '1',
redirectUrl: '',
gg_json_mode: '1'
}).toString();
const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
method: 'POST',
credentials: 'include',
signal: AbortSignal.timeout(${ZLIB_LOGIN_FETCH_MS}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
},
body: ${JSON.stringify(body)}
}).then(async (response) => ({
status: response.status,
contentType: response.headers.get('content-type') || '',
text: await response.text()
}))`;
const result = await withTimeout(
win.webContents.executeJavaScriptInIsolatedWorld(1001, [{ code }], true),
ZLIB_LOGIN_FETCH_MS + 5000,
'登录镜像无响应'
);
let data = null;
try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }
if (!data) {
if (/checking your browser|diamwall|cloudflare/i.test(String(result && result.text || ''))) {
throw new Error('登录镜像触发了浏览器验证');
}
throw new Error(`登录镜像未返回 JSONHTTP ${Number(result && result.status) || 0}`);
}
const response = data.response && typeof data.response === 'object' ? data.response : {};
if (response.validationError || response.error) {
return { error: String(response.message || response.error || '登录失败') };
}
const cookies = await session.defaultSession.cookies.get({ url: `${origin}/` });
const userId = cookies.find((cookie) => cookie.name === 'remix_userid');
const userKey = cookies.find((cookie) => cookie.name === 'remix_userkey');
if (!userId || !userKey || !userId.value || !userKey.value) {
throw new Error('登录响应缺少会话信息');
}
return { userId: userId.value, userKey: userKey.value };
} finally {
if (!win.isDestroyed()) win.destroy();
}
}
sources.getSource('zlib').setLoginTransport(browserZlibLogin);
// Z-Library 凭据
ipcMain.handle('zlib:hasCreds', () => wrap(() => zlibAuth.hasCreds()));
ipcMain.handle('zlib:login', (_e, email, password) => wrap(() => sources.getSource('zlib').login(email, password)));
ipcMain.handle('zlib:logout', () => wrap(() => sources.getSource('zlib').logout()));
ipcMain.handle('zlib:getMirrors', () => wrap(() => sources.getSource('zlib').getMirrorConfig()));
ipcMain.handle('zlib:setMirrors', (_e, list) => wrap(() => sources.getSource('zlib').setMirrors(list)));
// Semantic Scholar API Key
ipcMain.handle('semanticScholar:keyStatus', () => wrap(() => semanticKey.status()));
ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(() => semanticKey.write(key)));
ipcMain.handle('semanticScholar:clearKey', () => wrap(() => semanticKey.clear()));
// 书库目录管理
ipcMain.handle('library:getDir', () => wrap(() => ({ dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR })));
ipcMain.handle('library:pickDir', async () => {
const r = await dialog.showOpenDialog(liveWindow(), {
title: '选择书库目录',
defaultPath: library.getRoot(),
properties: ['openDirectory', 'createDirectory']
});
if (r.canceled || !r.filePaths.length) return { ok: true, data: null };
return { ok: true, data: r.filePaths[0] };
});
// migrate=true 时把现有数据搬到新目录,否则只切换(旧目录原样保留)
ipcMain.handle('library:setDir', (_e, dir, migrate) => wrap(() => {
const dest = String(dir || '').trim();
if (!dest) throw new Error('目录不能为空');
if (activeDownloads || downloadSessions.size) throw new Error('请等待当前下载完成或删除未完成任务后再切换书库目录');
const previousSetting = settings.get('libraryDir', '');
const previousRoot = library.getRoot();
try {
if (migrate) {
library.migrateTo(dest);
} else {
library.init(dest);
}
const r = library.scan();
settings.set('libraryDir', dest);
if (migrate) library.finalizeMigration();
notifyLibraryChanged();
for (const job of coverGenerator.ensureAll()) job.catch(() => {});
return { dir: library.getRoot(), ...r };
} catch (e) {
if (migrate) {
try { library.rollbackMigration(); } catch (rollbackError) { /* ignore */ }
} else {
try { library.init(previousRoot); } catch (rollbackError) { /* ignore */ }
}
try { settings.set('libraryDir', previousSetting); } catch (rollbackError) { /* ignore */ }
throw e;
}
}));
ipcMain.handle('library:scan', () => wrap(() => {
const r = library.scan();
notifyLibraryChanged();
for (const job of coverGenerator.ensureAll()) job.catch(() => {});
return r;
}));
ipcMain.handle('library:removeMissing', () => wrap(async () => {
const ids = library.list()
.filter((item) => item.missing)
.map((item) => String(item.id));
for (const id of ids) await requestReaderPurge(id);
return library.removeMissing();
}));
// 本地书库
ipcMain.handle('library:list', () => wrap(() => library.list().map((item) => ({
...item,
lastReadAt: readerStore.getLastReadAt(item.id)
}))));
ipcMain.handle('library:get', (_e, id) => wrap(() => library.get(id)));
ipcMain.handle('library:listShelves', () => wrap(() => library.listShelves()));
ipcMain.handle('library:listTags', () => wrap(() => library.listTags()));
ipcMain.handle('library:addShelf', (_e, input) => wrap(() => library.addShelf(input)));
ipcMain.handle('library:updateShelf', (_e, shelfId, patch) => wrap(() => library.updateShelf(shelfId, patch)));
ipcMain.handle('library:removeShelf', (_e, shelfId) => wrap(() => library.removeShelf(shelfId)));
ipcMain.handle('library:addTag', (_e, input) => wrap(() => library.addTag(input)));
ipcMain.handle('library:updateTag', (_e, tagId, patch) => wrap(() => library.updateTag(tagId, patch)));
ipcMain.handle('library:removeTag', (_e, tagId) => wrap(() => library.removeTag(tagId)));
ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(() => library.findBySource(sourceId, postId)));
ipcMain.handle('library:add', (_e, item) => wrap(() => {
const entry = library.add(item);
coverGenerator.ensure(entry.id).catch(() => {});
return entry;
}));
ipcMain.handle('library:update', (_e, id, patch) => wrap(() => {
const entry = library.update(id, patch);
coverGenerator.ensure(entry.id).catch(() => {});
return entry;
}));
ipcMain.handle('library:remove', (_e, id, options) => wrap(() => {
const deleteFiles = options && typeof options === 'object'
? options.deleteFiles === true
: options === true;
const deleteReadingData = !!(
options && typeof options === 'object' && options.deleteReadingData === true
);
return (async () => {
await requestReaderPurge(id);
if (deleteReadingData) {
const key = String(id);
purgedReaderEntries.add(key);
try {
readerStore.forget(id);
annotations.forget(id);
cleanupNoteAssets();
} catch (e) {
purgedReaderEntries.delete(key);
throw e;
}
notifyNotesChanged({ entryId: String(id), type: 'forget' });
}
try {
const removed = library.remove(id, deleteFiles);
if (!removed && deleteReadingData) purgedReaderEntries.delete(String(id));
return removed;
} catch (e) {
if (deleteReadingData) purgedReaderEntries.delete(String(id));
throw e;
}
})();
}));
// 批量整理走单次索引写入。逐条 update 会把索引重写 N 遍,
// 上千条的书库里批量改动会明显卡顿。
ipcMain.handle('library:updateMany', (_e, patches) => wrap(() => {
const list = Array.isArray(patches) ? patches : [];
if (list.length > 5000) throw new Error('单次批量更新条目过多');
const result = library.updateMany(list);
for (const entry of list) {
if (entry && entry.id != null) coverGenerator.ensure(entry.id).catch(() => {});
}
return result;
}));
ipcMain.handle('library:removeMany', (_e, ids, options) => wrap(() => {
const list = (Array.isArray(ids) ? ids : []).map((id) => String(id));
if (list.length > 5000) throw new Error('单次批量移除条目过多');
const deleteFiles = !!(options && options.deleteFiles === true);
const deleteReadingData = !!(options && options.deleteReadingData === true);
return (async () => {
for (const id of list) await requestReaderPurge(id);
const purged = [];
if (deleteReadingData) {
for (const id of list) {
purgedReaderEntries.add(id);
purged.push(id);
}
try {
noteWindow.closeForEntries(list);
readerStore.forgetMany(list);
annotations.forgetMany(list);
aiSessions.forgetMany(list);
cleanupNoteAssets();
collectAiImages();
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
for (const id of list) notifyNotesChanged({ entryId: id, type: 'forget' });
}
try {
return library.removeMany(list, deleteFiles);
} catch (e) {
for (const id of purged) purgedReaderEntries.delete(id);
throw e;
}
})();
}));
// 孤立阅读资料对账。笔记在「我的笔记」里仍可查看,属于有意保留,
// 因此只报告不自动删除;批注没有浏览入口,孤立后只会白占空间。
ipcMain.handle('reader:orphanReport', () => wrap(() => {
const knownIds = library.list().map((item) => String(item.id));
const notes = readerStore.orphanReport(knownIds);
const annotationOrphans = annotations.orphanReport(knownIds);
const chatOrphans = aiSessions.orphanReport(knownIds);
return {
notes,
annotations: annotationOrphans,
chats: chatOrphans,
totalBytes: annotationOrphans.reduce((sum, item) => sum + item.bytes, 0)
+ chatOrphans.reduce((sum, item) => sum + item.bytes, 0)
};
}));
ipcMain.handle('reader:purgeOrphans', (_e, options) => wrap(() => {
const scope = options && typeof options === 'object' ? options : {};
const knownIds = library.list().map((item) => String(item.id));
// 目标必须重新对账后确定,不接受渲染层直接传 ID,
// 否则一个过期的界面状态就能删掉仍在书库里的条目的阅读资料
const noteTargets = scope.notes === true
? readerStore.orphanReport(knownIds).map((item) => item.entryId)
: [];
const annotationTargets = scope.annotations === true
? annotations.orphanReport(knownIds).map((item) => item.entryId)
: [];
const chatTargets = scope.chats === true
? aiSessions.orphanReport(knownIds).map((item) => item.entryId)
: [];
if (noteTargets.length) noteWindow.closeForEntries(noteTargets);
const notesRemoved = noteTargets.length ? readerStore.forgetMany(noteTargets) : 0;
const annotationsRemoved = annotationTargets.length
? annotations.forgetMany(annotationTargets)
: 0;
const chatsRemoved = chatTargets.length ? aiSessions.forgetMany(chatTargets) : 0;
if (notesRemoved) {
cleanupNoteAssets();
for (const id of noteTargets) notifyNotesChanged({ entryId: id, type: 'forget' });
}
if (chatsRemoved) collectAiImages();
return { notesRemoved, annotationsRemoved, chatsRemoved };
}));
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
if (!/^[A-Za-z0-9_-]{1,100}$/.test(progressId)) return { ok: false, error: '下载任务 ID 无效' };
const key = downloadSessionKey(event.sender.id, progressId);
let download = downloadSessions.get(key);
if (download && download.state === 'running') return { ok: false, error: '该下载任务正在运行' };
if (download && download.control === 'delete') return { ok: false, error: '该下载任务已删除' };
trackDownloadSender(event.sender);
activeDownloads++;
let res = null;
let bodyHandled = false;
const sendProgress = (data) => {
if (!progressId || event.sender.isDestroyed()) return;
event.sender.send('download:progress', { requestId: progressId, ...data });
};
try {
const parsedUrl = new URL(String(url || ''));
if (!/^https?:$/.test(parsedUrl.protocol)) throw new Error('仅支持 HTTP 或 HTTPS 下载链接');
const askSavePath = settings.get('askSavePath', false);
const suggested = library.sanitize(suggestName || 'download.bin');
if (!download) {
download = {
key,
senderId: event.sender.id,
requestId: progressId,
state: 'preparing',
control: '',
controller: null,
partial: '',
target: '',
defaultName: '',
askSavePath,
receivedBytes: 0,
totalBytes: null,
validator: '',
url: parsedUrl.toString(),
suggestName: String(suggestName || ''),
entryId,
extraHeaders: { ...(extraHeaders || {}) },
meta
};
downloadSessions.set(key, download);
}
if (download.url !== parsedUrl.toString()) throw new Error('续传链接与原任务不一致');
if (download.askSavePath && !download.target) {
const save = await dialog.showSaveDialog(liveWindow(), {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
if (save.canceled || !save.filePath) {
downloadSessions.delete(key);
return { ok: true, data: { canceled: true } };
}
download.target = save.filePath;
}
if (download.control === 'delete') {
downloadSessions.delete(key);
return { ok: true, data: { deleted: true } };
}
const headers = { 'User-Agent': DL_UA, ...download.extraHeaders };
headers['Referer'] = parsedUrl.origin + '/';
const resumeBytes = download.partial && fs.existsSync(download.partial)
? fs.statSync(download.partial).size
: 0;
download.receivedBytes = resumeBytes;
if (resumeBytes > 0) {
headers['Range'] = `bytes=${resumeBytes}-`;
if (download.validator) headers['If-Range'] = download.validator;
}
const ac = new AbortController();
download.controller = ac;
download.control = '';
download.state = 'running';
const timer = setTimeout(() => ac.abort(), 30000);
try {
res = await fetchWithProxy(download.url, {
redirect: 'follow',
headers,
signal: ac.signal
});
} finally {
clearTimeout(timer);
}
if (download.control) throw new Error('下载已中止');
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
const resumed = resumeBytes > 0 && res.status === 206;
if (resumeBytes > 0 && !resumed) {
removeDownloadPartial(download);
download.receivedBytes = 0;
}
const declaredSize = Number(res.headers.get('content-length'));
let totalBytes = Number.isFinite(declaredSize) && declaredSize > 0
? declaredSize + (resumed ? resumeBytes : 0)
: null;
const contentRange = res.headers.get('content-range') || '';
const rangeMatch = contentRange.match(/^bytes\s+(\d+)-\d+\/(\d+|\*)$/i);
if (resumed && (!rangeMatch || Number(rangeMatch[1]) !== resumeBytes)) {
throw new Error('远端服务器返回了错误的断点位置,请删除任务后重新下载');
}
if (rangeMatch && rangeMatch[2] !== '*') totalBytes = Number(rangeMatch[2]);
if (resumed && download.totalBytes && totalBytes && download.totalBytes !== totalBytes) {
throw new Error('远端文件在暂停期间发生变化,请删除任务后重新下载');
}
if (!resumed) download.validator = res.headers.get('etag') || res.headers.get('last-modified') || '';
download.totalBytes = totalBytes;
let receivedBytes = resumed ? resumeBytes : 0;
let lastProgressAt = 0;
sendProgress({
receivedBytes,
totalBytes,
percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
});
const respName = filenameFromResponse(res, download.suggestName);
const hasExt = download.suggestName && /\.[a-z0-9]{2,5}$/i.test(download.suggestName);
if (!download.defaultName) {
download.defaultName = library.sanitize(hasExt ? download.suggestName : respName);
}
const contentType = res.headers.get('content-type') || '';
const expectedExt = path.extname(download.target || download.defaultName).toLowerCase();
if (/text\/html|application\/json/i.test(contentType)
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
throw new Error('下载地址返回了网页而不是文献文件');
}
if (!download.target) download.target = library.allocFilePath(download.defaultName);
fs.mkdirSync(path.dirname(download.target), { recursive: true });
if (!download.partial) {
download.partial = path.join(
path.dirname(download.target),
`.${path.basename(download.target)}.${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.part`
);
}
if (!res.body) throw new Error('下载响应没有文件内容');
let transferTimer;
const refreshTransferTimer = () => {
clearTimeout(transferTimer);
transferTimer = setTimeout(() => ac.abort(), 30000);
};
const activity = new Transform({
transform(chunk, encoding, callback) {
refreshTransferTimer();
receivedBytes += chunk.length;
download.receivedBytes = receivedBytes;
const now = Date.now();
if (now - lastProgressAt >= 100 || (totalBytes && receivedBytes >= totalBytes)) {
lastProgressAt = now;
sendProgress({
receivedBytes,
totalBytes,
percent: totalBytes ? Math.min(1, receivedBytes / totalBytes) : null
});
}
callback(null, chunk);
}
});
refreshTransferTimer();
bodyHandled = true;
try {
await pipeline(
Readable.fromWeb(res.body),
activity,
fs.createWriteStream(download.partial, { flags: resumed ? 'a' : 'wx' })
);
} finally {
clearTimeout(transferTimer);
}
if (download.control) throw new Error('下载已中止');
if (download.askSavePath) {
const backup = `${download.target}.${process.pid}-${Date.now()}.bak`;
let backedUp = false;
try {
if (fs.existsSync(download.target)) {
fs.renameSync(download.target, backup);
backedUp = true;
}
fs.renameSync(download.partial, download.target);
download.partial = '';
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
}
} catch (e) {
try {
if (backedUp && !fs.existsSync(download.target) && fs.existsSync(backup)) {
fs.renameSync(backup, download.target);
}
} catch (rollbackError) { /* ignore */ }
throw e;
}
} else {
for (;;) {
try {
fs.linkSync(download.partial, download.target);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
download.target = library.allocFilePath(download.defaultName);
}
}
try { fs.unlinkSync(download.partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
download.partial = '';
}
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
// 落库:优先挂到已有条目,否则用 meta 新建
let id = download.entryId;
if (!id && download.meta) {
const existing = download.meta.sourceId && download.meta.sourcePostId
? library.findBySource(download.meta.sourceId, download.meta.sourcePostId) : null;
id = existing ? existing.id : library.add(download.meta).id;
}
const entry = id ? library.attachFile(id, download.target) : null;
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
downloadSessions.delete(key);
return {
ok: true,
data: {
path: download.target,
name: path.basename(download.target),
entryId: id || null,
entry,
receivedBytes,
totalBytes
}
};
} catch (e) {
if (res && res.body && !bodyHandled) {
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
}
if (download && download.control === 'pause') {
download.state = 'paused';
download.controller = null;
if (download.partial && fs.existsSync(download.partial)) {
download.receivedBytes = fs.statSync(download.partial).size;
}
return {
ok: true,
data: {
paused: true,
receivedBytes: download.receivedBytes,
totalBytes: download.totalBytes
}
};
}
if (download && download.control === 'delete') {
removeDownloadPartial(download);
downloadSessions.delete(key);
return { ok: true, data: { deleted: true } };
}
removeDownloadPartial(download);
downloadSessions.delete(key);
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
return { ok: false, error: '下载超时,请检查网络或代理设置' };
}
return { ok: false, error: e.message || String(e) };
} finally {
if (download) download.controller = null;
activeDownloads--;
}
});
ipcMain.handle('download:pause', (event, requestId) => wrap(() => {
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
if (!download || download.state !== 'running' || !download.controller) return false;
download.control = 'pause';
download.controller.abort();
return true;
}));
ipcMain.handle('download:delete', (event, requestId) => wrap(() => {
const id = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
const download = downloadSessions.get(downloadSessionKey(event.sender.id, id));
if (!download) return false;
discardDownloadSession(download);
return true;
}));
// 打开文件。没有关联程序时(例如未装 epub 阅读器)退而求其次,
// 在资源管理器里定位该文件,而不是静默失败。
ipcMain.handle('shell:openPath', async (_e, p) => {
const target = String(p || '');
if (!target) return { ok: false, error: '路径为空' };
if (!fs.existsSync(target)) return { ok: false, error: '文件不存在,可能已被移动或删除' };
const err = await shell.openPath(target);
if (!err) return { ok: true };
shell.showItemInFolder(target);
return { ok: true, data: { revealed: true, reason: err } };
});
ipcMain.handle('shell:showItem', (_e, p) => { shell.showItemInFolder(p || ''); return { ok: true }; });
ipcMain.handle('shell:openExternal', async (_e, url) => {
try {
const target = new URL(String(url || ''));
if (!/^https?:$/.test(target.protocol)) throw new Error('仅允许打开 HTTP 或 HTTPS 链接');
await shell.openExternal(target.toString());
return { ok: true };
}
catch (e) { return { ok: false, error: e.message || String(e) }; }
});
ipcMain.handle('dialog:pickLocal', (event, kind) => wrap(async () => {
const sourceKind = kind === 'folder' ? 'folder' : 'files';
const r = await dialog.showOpenDialog(liveWindow(), {
title: sourceKind === 'folder' ? '选择本地图书文件夹' : '选择本地图书文件',
properties: sourceKind === 'folder'
? ['openDirectory']
: ['openFile', 'multiSelections'],
filters: sourceKind === 'folder'
? undefined
: [{ name: '图书', extensions: ['pdf', 'epub', 'mobi', 'azw', 'azw3', 'txt', 'md', 'djvu', 'fb2', 'cbz', 'cbr'] }]
});
if (r.canceled || !r.filePaths.length) return null;
const records = await localImport.discover(r.filePaths);
if (!records.length) {
throw new Error('所选位置中没有支持的图书文件');
}
const now = Date.now();
for (const [id, pending] of pendingLocalImports) {
if (now - pending.createdAt > 10 * 60 * 1000) pendingLocalImports.delete(id);
}
const selectionId = crypto.randomUUID();
pendingLocalImports.set(selectionId, {
senderId: event.sender.id,
records,
kind: sourceKind,
createdAt: now
});
return {
selectionId,
kind: sourceKind,
paths: r.filePaths.map((file) => path.resolve(file)),
count: records.length,
sample: records.slice(0, 5)
};
}));
ipcMain.handle('library:importLocal', (event, selectionId, options) => wrap(async () => {
const id = String(selectionId || '');
const pending = pendingLocalImports.get(id);
if (!pending) {
throw new Error('本地导入选择已失效,请重新选择');
}
if (pending.senderId !== event.sender.id) throw new Error('无权使用该本地导入选择');
pendingLocalImports.delete(id);
if (Date.now() - pending.createdAt > 10 * 60 * 1000) {
throw new Error('本地导入选择已过期,请重新选择');
}
const organization = options && ['none', 'shelf', 'tag'].includes(options.organization)
? options.organization
: 'none';
const records = pending.records.map((record) => ({ ...record }));
if (records.length === 1 && options && typeof options === 'object') {
records[0].title = String(options.title || '').trim();
const author = String(options.author || '').trim();
records[0].authors = author ? [author] : [];
}
const result = library.importLocal(records, organization);
for (const item of result.items) coverGenerator.ensure(item.id).catch(() => {});
return { ...result, discovered: records.length };
}));
// --- 阅读器 ---
const READABLE_EXT = new Set(['.pdf', '.epub', '.mobi', '.azw', '.azw3', '.txt', '.md']);
function isReaderSender(webContents) {
const expected = pathToFileURL(path.join(__dirname, 'src', 'ui', 'reader.html')).href;
return !!readerWindow.fromWebContents(webContents)
|| String(webContents.getURL() || '').startsWith(expected);
}
ipcMain.handle('reader:ready', (event) => wrap(() => readerWindow.markReady(event.sender)));
// 关闭书籍标签页或整个阅读窗口时,阅读进度与批注刚落盘,
// 书库卡片上的"最近阅读"排序和批注计数需要立刻跟上
ipcMain.handle('reader:entryClosed', (event) => wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以上报关闭');
notifyLibraryChanged();
return true;
}));
ipcMain.handle('reader:captureRect', (event, rect) => wrap(async () => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以截取文档内容');
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed()) throw new Error('阅读器窗口不可用');
const value = rect && typeof rect === 'object' ? rect : {};
const area = {
x: Math.floor(Number(value.x)),
y: Math.floor(Number(value.y)),
width: Math.floor(Number(value.width)),
height: Math.floor(Number(value.height))
};
const [contentWidth, contentHeight] = win.getContentSize();
if (
!Object.values(area).every(Number.isFinite)
|| area.x < 0 || area.y < 0
|| area.width < 2 || area.height < 2
|| area.width > 4096 || area.height > 4096
|| area.x + area.width > contentWidth
|| area.y + area.height > contentHeight
) {
throw new Error('截图区域无效或超出阅读器窗口');
}
let image = await event.sender.capturePage(area);
if (image.isEmpty()) throw new Error('没有截取到文档图像');
let size = image.getSize();
const longest = Math.max(size.width, size.height);
if (longest > 2048) {
const ratio = 2048 / longest;
image = image.resize({
width: Math.max(1, Math.round(size.width * ratio)),
height: Math.max(1, Math.round(size.height * ratio)),
quality: 'best'
});
size = image.getSize();
}
let data = null;
for (const quality of [90, 82, 72, 62]) {
const candidate = image.toJPEG(quality);
if (candidate.length <= 3 * 1024 * 1024) {
data = candidate;
break;
}
}
if (!data) throw new Error('截图数据超过 3 MB');
return {
mimeType: 'image/jpeg',
base64: data.toString('base64'),
width: size.width,
height: size.height,
bytes: data.length
};
}));
ipcMain.on('reader:purgeReady', (event, requestId) => {
if (!readerWindow.fromWebContents(event.sender)) return;
const resolve = readerPurgeWaiters.get(String(requestId));
if (resolve) resolve();
});
ipcMain.on('reader:shutdownReady', (event) => {
readerWindow.shutdownReady(event.sender);
});
// 只允许读取书库中真实登记过的文件,杜绝渲染层传任意路径读盘
function resolveReadable(entryId, fileIndex, documentKey) {
const item = library.get(entryId);
if (!item) throw new Error('条目不存在');
const files = (item.files || []).filter((f) => f && f.path);
if (!files.length) throw new Error('该条目还没有可阅读的文件');
let idx = Number.isInteger(fileIndex) ? fileIndex : files.findIndex((f) => READABLE_EXT.has(path.extname(f.path).toLowerCase()));
const expectedKey = /^[a-f0-9]{64}$/.test(String(documentKey || '')) ? String(documentKey) : '';
if (expectedKey) {
const matched = files.findIndex((candidate) => {
if (!candidate || !candidate.path || !READABLE_EXT.has(path.extname(candidate.path).toLowerCase())) return false;
if (!fs.existsSync(candidate.path)) return false;
try { return annotations.documentKey(candidate.path) === expectedKey; } catch (e) { return false; }
});
if (matched < 0) throw new Error('笔记关联的原始文件已变更或不存在');
idx = matched;
}
const resolvedIndex = idx >= 0 ? idx : 0;
const file = files[resolvedIndex];
if (!file) throw new Error('找不到指定文件');
const abs = path.resolve(file.path);
const ext = path.extname(abs).toLowerCase();
if (!READABLE_EXT.has(ext)) throw new Error(`暂不支持在阅读器中打开 ${ext || '该格式'} 文件`);
if (!fs.existsSync(abs)) throw new Error('文件不存在,可能已被移动或删除');
return { item, file, abs, format: ext.slice(1), fileIndex: resolvedIndex };
}
rangeSessions.init(resolveReadable);
const rangeSessionSenders = new Set();
function trackRangeSessionSender(webContents) {
const senderId = webContents.id;
if (rangeSessionSenders.has(senderId)) return;
rangeSessionSenders.add(senderId);
webContents.once('destroyed', () => {
rangeSessionSenders.delete(senderId);
rangeSessions.closeSender(senderId).catch(() => {});
});
}
ipcMain.handle('reader:open', (_e, entryId, fileIndex) => wrap(() => {
const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex);
readerWindow.open(entryId, __dirname, resolvedIndex, null, currentUiTheme);
return { entryId, title: item.title, format, path: abs, fileIndex: resolvedIndex };
}));
ipcMain.handle('reader:openAt', (_e, entryId, fileIndex, documentKey, locator) => wrap(() => {
const resolved = resolveReadable(entryId, fileIndex, documentKey);
const target = locator && typeof locator === 'object' ? locator : null;
readerWindow.open(entryId, __dirname, resolved.fileIndex, target, currentUiTheme);
return {
entryId,
title: resolved.item.title,
format: resolved.format,
path: resolved.abs,
fileIndex: resolved.fileIndex,
locator: target
};
}));
ipcMain.handle('reader:meta', (_e, entryId, fileIndex) => wrap(() => {
const { item, abs, format, fileIndex: resolvedIndex } = resolveReadable(entryId, fileIndex);
const fileSize = fs.statSync(abs).size;
const documentKey = annotations.documentKey(abs);
readerStore.setBookSnapshot(String(entryId), {
title: item.title || '',
authors: item.authors || []
});
readerStore.bindDocument(String(entryId), documentKey);
const files = (item.files || []).filter((f) => f && f.path).map((f, i) => ({
index: i,
name: f.name || path.basename(f.path),
format: path.extname(f.path).toLowerCase().slice(1),
readable: READABLE_EXT.has(path.extname(f.path).toLowerCase())
}));
return {
entryId,
title: item.title,
authors: item.authors || [],
format,
documentKey,
fileIndex: resolvedIndex,
size: fileSize,
files,
state: readerStore.getState(entryId, documentKey)
};
}));
const MAX_BUFFERED_READER_BYTES = 256 * 1024 * 1024;
function readBoundedFile(abs, maxBytes) {
const fd = fs.openSync(abs, 'r');
try {
const stat = fs.fstatSync(fd);
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size > maxBytes) {
throw new Error('该电子书超过 256 MB,暂不支持在内置阅读器中打开,请使用外部应用');
}
const buffer = Buffer.allocUnsafe(stat.size);
let offset = 0;
while (offset < buffer.length) {
const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, offset);
if (!bytesRead) break;
offset += bytesRead;
}
const after = fs.fstatSync(fd);
if (offset !== buffer.length || after.size !== stat.size
|| after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
throw new Error('电子书文件在读取期间发生变化,请重试');
}
return buffer;
} finally {
fs.closeSync(fd);
}
}
ipcMain.handle('reader:rangeOpen', (event, entryId, fileIndex) => wrap(async () => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以创建 PDF 分段读取会话');
trackRangeSessionSender(event.sender);
return rangeSessions.open(event.sender.id, entryId, fileIndex);
}));
ipcMain.handle('reader:rangeRead', (event, sessionId, begin, end) => wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以读取 PDF 分段数据');
return rangeSessions.read(event.sender.id, sessionId, begin, end);
}));
ipcMain.handle('reader:rangeClose', (event, sessionId) => wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以关闭 PDF 分段读取会话');
return rangeSessions.close(event.sender.id, sessionId);
}));
ipcMain.handle('reader:bytes', (_e, entryId, fileIndex) => wrap(() => {
const { abs, format } = resolveReadable(entryId, fileIndex);
if (format === 'pdf') throw new Error('PDF 必须使用分段读取');
return readBoundedFile(abs, MAX_BUFFERED_READER_BYTES);
}));
ipcMain.handle('reader:openExternal', (_e, entryId, fileIndex) => wrap(async () => {
const { abs } = resolveReadable(entryId, fileIndex);
const error = await shell.openPath(abs);
if (error) throw new Error(error);
return true;
}));
ipcMain.handle('reader:getState', (_e, entryId, documentKey) => wrap(() => (
readerStore.getState(String(entryId), documentKey)
)));
ipcMain.handle('reader:setProgress', (_e, entryId, documentKey, locator, percent) => wrap(() => {
ensureReaderWritable(entryId);
return readerStore.setProgress(String(entryId), documentKey, locator, percent);
}));
ipcMain.handle('reader:addBookmark', (_e, entryId, mark) => wrap(() => {
ensureReaderWritable(entryId);
return readerStore.addBookmark(String(entryId), mark);
}));
ipcMain.handle('reader:removeBookmark', (_e, entryId, markId) => wrap(() => {
ensureReaderWritable(entryId);
return readerStore.removeBookmark(String(entryId), markId);
}));
function notePayloadWithAssets(event, value) {
const note = value && typeof value === 'object' ? { ...value } : value;
if (!note || typeof note !== 'object' || !Object.prototype.hasOwnProperty.call(note, 'canvasContent')) {
return { note, tokens: [] };
}
const resolved = noteAssets.resolveDrafts(note.canvasContent, event.sender.id);
note.canvasContent = resolved.content;
return { note, tokens: resolved.tokens };
}
function cleanupNoteAssets() {
try { noteAssets.cleanup(readerStore.noteAssetIds()); } catch (error) { /* 后续保存时重试 */ }
}
ipcMain.handle('reader:addNote', (event, entryId, note) => wrap(() => {
const id = String(entryId);
ensureReaderWritable(id);
const item = library.get(id);
if (item) readerStore.setBookSnapshot(id, { title: item.title || '', authors: item.authors || [] });
const prepared = notePayloadWithAssets(event, note);
const result = readerStore.addNote(id, prepared.note);
noteAssets.commitTokens(prepared.tokens);
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: result.id, type: 'add' });
return result;
}));
ipcMain.handle('reader:addStandaloneNote', (event, note) => wrap(() => {
const prepared = notePayloadWithAssets(event, note);
const result = readerStore.addStandaloneNote(prepared.note);
noteAssets.commitTokens(prepared.tokens);
cleanupNoteAssets();
notifyNotesChanged({
entryId: readerStore.STANDALONE_ENTRY_ID,
noteId: result.id,
type: 'add'
});
return result;
}));
ipcMain.handle('reader:updateNote', (event, entryId, noteId, patch) => wrap(() => {
const id = String(entryId);
ensureReaderWritable(id);
const prepared = notePayloadWithAssets(event, patch);
const result = readerStore.updateNote(id, noteId, prepared.note);
if (result) {
noteAssets.commitTokens(prepared.tokens);
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: result.id, type: 'update' });
}
return result;
}));
ipcMain.handle('reader:removeNote', (_e, entryId, noteId) => wrap(() => {
const id = String(entryId);
ensureReaderWritable(id);
const result = readerStore.removeNote(id, noteId);
if (result) {
// 窗口必须先退场再清理资产:留着的话它下次保存会把已删的笔记整条写回去
noteWindow.closeFor(noteId);
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
}
return result;
}));
// 笔记独立窗口。目标必须由主进程重新对账后确定,
// 渲染层给的 ID 只是查询条件,不能当授权凭据。
function findNote(entryId, noteId) {
const id = String(noteId == null ? '' : noteId);
if (!id) throw new Error('笔记 ID 无效');
const filters = entryId == null || entryId === '' ? {} : { entryId: String(entryId) };
const note = readerStore.listNotes(filters).find((item) => String(item.id) === id);
if (!note) throw new Error('笔记不存在或已被删除');
return note;
}
ipcMain.handle('notes:openWindow', (_e, entryId, noteId) => wrap(() => {
const note = findNote(entryId, noteId);
noteWindow.open(note.entryId, note.id, __dirname, currentUiTheme);
return { entryId: note.entryId, noteId: note.id };
}));
ipcMain.handle('notes:getOne', (event, entryId, noteId) => wrap(() => {
// 笔记窗口只能读自己已打开的标签,避免这个通道变成遍历全部笔记的后门。
// 多标签之后授权从「等于某一条」变成「在标签集内」,放宽成「是笔记窗口就给」等于取消校验。
if (noteWindow.fromWebContents(event.sender)
&& !noteWindow.ownsNote(event.sender, noteId)) {
throw new Error('无权读取其它笔记');
}
return findNote(entryId, noteId);
}));
// 标签集由渲染层上报,但只用于广播与授权范围收窄,新增标签仍要过 findNote 对账
ipcMain.handle('notes:tabsChanged', (event, tabs) => wrap(() => {
noteWindow.setTabs(event.sender, tabs);
return noteWindow.openIds();
}));
ipcMain.handle('notes:shutdownReady', (event) => wrap(() => noteWindow.shutdownReady(event.sender)));
ipcMain.handle('notes:cancelClose', (event) => wrap(() => noteWindow.cancelClose(event.sender)));
ipcMain.handle('notes:openWindows', () => wrap(() => noteWindow.openIds()));
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
ipcMain.handle('reader:getAnnotationCounts', () => wrap(() => annotations.getCounts()));
ipcMain.handle('reader:listCollections', () => wrap(() => readerStore.listCollections()));
ipcMain.handle('reader:addCollection', (_e, input) => wrap(() => {
const result = readerStore.addCollection(input);
notifyNotesChanged({ collectionId: result.id, type: 'collection-add' });
return result;
}));
ipcMain.handle('reader:updateCollection', (_e, collectionId, patch) => wrap(() => {
const result = readerStore.updateCollection(collectionId, patch);
if (result) notifyNotesChanged({ collectionId: result.id, type: 'collection-update' });
return result;
}));
ipcMain.handle('reader:removeCollection', (_e, collectionId) => wrap(() => {
const result = readerStore.removeCollection(collectionId);
if (result) notifyNotesChanged({ collectionId: String(collectionId), type: 'collection-remove' });
return result;
}));
ipcMain.handle('reader:pickNotePdf', (event) => wrap(async () => {
const result = await dialog.showOpenDialog(senderWindow(event), {
title: '选择 PDF 笔记底版',
properties: ['openFile'],
filters: [{ name: 'PDF 文档', extensions: ['pdf'] }]
});
if (result.canceled || !result.filePaths.length) return null;
return noteAssets.stagePdf(result.filePaths[0], event.sender.id);
}));
ipcMain.handle('reader:notePdfBytes', (event, ref) => wrap(() => {
const value = ref && typeof ref === 'object' ? ref : {};
if (value.draftToken) return noteAssets.readDraft(value.draftToken, event.sender.id);
const assetId = noteAssets.safeAssetId(value.assetId);
if (!readerStore.noteAssetIds().includes(assetId)) throw new Error('PDF 笔记底版不存在');
return noteAssets.readAsset(assetId);
}));
ipcMain.handle('reader:saveNotePdf', (event, bytes, suggestedName) => wrap(async () => {
const data = Buffer.isBuffer(bytes)
? Buffer.from(bytes)
: ArrayBuffer.isView(bytes)
? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength)
: bytes instanceof ArrayBuffer
? Buffer.from(bytes)
: null;
if (!data || !data.length || data.length > 100 * 1024 * 1024
|| data.subarray(0, 5).toString('ascii') !== '%PDF-') {
throw new Error('导出的 PDF 数据无效或超过 100 MB');
}
const base = String(suggestedName || 'PeopleLib-笔记.pdf')
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
.slice(0, 180);
const result = await dialog.showSaveDialog(senderWindow(event), {
title: '导出画布笔记',
defaultPath: base.toLowerCase().endsWith('.pdf') ? base : `${base}.pdf`,
filters: [{ name: 'PDF 文档', extensions: ['pdf'] }]
});
if (result.canceled || !result.filePath) return { canceled: true };
fs.writeFileSync(result.filePath, data);
return { canceled: false };
}));
ipcMain.handle('reader:getAnnotations', (_e, entryId, fileIndex) => wrap(() => {
const resolved = resolveReadable(entryId, fileIndex);
if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注');
return annotations.get(String(entryId), annotations.documentKey(resolved.abs));
}));
ipcMain.handle('reader:setAnnotationPage', (_e, entryId, fileIndex, page, data) => wrap(() => {
ensureReaderWritable(entryId);
const resolved = resolveReadable(entryId, fileIndex);
if (resolved.format !== 'pdf') throw new Error('只有 PDF 支持页面批注');
return annotations.setPage(String(entryId), annotations.documentKey(resolved.abs), page, data);
}));
// --- AI ---
function notifyAiChanged(status) {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('ai:changed', status);
}
}
ipcMain.handle('ai:status', () => wrap(() => aiConfig.status()));
ipcMain.handle('ai:save', (_e, cfg) => wrap(() => {
const status = aiConfig.save(cfg || {});
notifyAiChanged(status);
return status;
}));
ipcMain.handle('ai:clear', () => wrap(() => {
const status = aiConfig.clear();
notifyAiChanged(status);
return status;
}));
// 会话归属由主进程按 entryId 对账,渲染层给的 entryId 只作过滤条件,
// 不能当授权凭据:否则任意窗口都能读别的书的对话。
function readerOnly(event, fn) {
return wrap(() => {
if (!isReaderSender(event.sender)) throw new Error('只有阅读器可以管理 AI 会话');
return fn();
});
}
function aiSessionEntryId(value) {
const id = String(value == null ? '' : value);
if (!id || id === aiSessions.GLOBAL_ENTRY_ID) return aiSessions.GLOBAL_ENTRY_ID;
if (!library.get(id)) throw new Error('条目不存在');
return id;
}
// 会话文件可能残留已删除条目的记录,取其 entryId 时不再校验书库,
// 否则孤立会话既列不出来也删不掉。
function requireAiSession(sessionId) {
const meta = aiSessions.messages(sessionId, { limit: 1 }).meta;
return meta;
}
ipcMain.handle('ai:sessionList', (event, filters) => readerOnly(event, () => {
const raw = filters && typeof filters === 'object' ? filters : {};
const entryId = raw.entryId == null || raw.entryId === ''
? null
: aiSessionEntryId(raw.entryId);
return aiSessions.list(entryId ? { entryId } : {});
}));
ipcMain.handle('ai:sessionCreate', (event, input) => readerOnly(event, () => {
const raw = input && typeof input === 'object' ? input : {};
return aiSessions.create({
entryId: aiSessionEntryId(raw.entryId),
title: raw.title,
documentKey: raw.documentKey
});
}));
ipcMain.handle('ai:sessionRename', (event, sessionId, title) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.rename(sessionId, title);
}));
ipcMain.handle('ai:sessionPin', (event, sessionId, pinned) => readerOnly(event, () => {
requireAiSession(sessionId);
return aiSessions.setPinned(sessionId, pinned === true);
}));
ipcMain.handle('ai:sessionRemove', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const removed = aiSessions.remove(sessionId);
collectAiImages();
return removed;
}));
ipcMain.handle('ai:sessionClear', (event, sessionId) => readerOnly(event, () => {
requireAiSession(sessionId);
const meta = aiSessions.clear(sessionId);
collectAiImages();
return meta;
}));
ipcMain.handle('ai:sessionMessages', (event, sessionId, options) => readerOnly(event, () => {
const raw = options && typeof options === 'object' ? options : {};
return aiSessions.messages(sessionId, { limit: raw.limit, before: raw.before });
}));
// 图像 GC 的 keep 集合必须来自扫全部会话文件的 imageIds(),不能只读索引
function collectAiImages() {
try {
return aiImages.cleanup(aiSessions.imageIds());
} catch (error) {
return 0;
}
}
const AI_HISTORY_BUDGET = { maxChars: 12000, maxMessages: 20 };
const AI_TASK_TITLES = {
summarize: '总结当前上下文',
translate: '翻译选中文本',
explain: '解释选中文本',
ask: '提问'
};
// 存进会话的是用户看到的那句话,不是整篇正文:
// 正文另有 contextRef 记录哈希与字数,把它当消息正文会让重开后的气泡变成几千字原文
function aiTurnTitle(task, question) {
const asked = String(question == null ? '' : question).trim();
if (asked) return asked;
return AI_TASK_TITLES[task] || '提问';
}
const aiRuns = new Map();
function aiRunKey(senderId, runId) {
return `${senderId}:${runId}`;
}
function canonicalVisualContexts(raw) {
return normalizeVisualContexts(raw).map((context) => {
if (!context.includeImage || !context.image) return context;
const source = Buffer.from(context.image.base64, 'base64');
const decoded = nativeImage.createFromBuffer(source);
if (decoded.isEmpty()) throw new Error('无法解码上下文图像');
const size = decoded.getSize();
if (size.width !== context.image.width || size.height !== context.image.height) {
throw new Error('图像解码尺寸不匹配');
}
const data = decoded.toJPEG(85);
if (!data.length || data.length > 3 * 1024 * 1024) throw new Error('图像编码后超过 3 MB');
return {
...context,
image: {
mimeType: 'image/jpeg',
base64: data.toString('base64'),
width: size.width,
height: size.height,
bytes: data.length
}
};
});
}
ipcMain.handle('ai:cancel', (event, runId) => wrap(() => {
if (!isReaderSender(event.sender)) return false;
const run = aiRuns.get(aiRunKey(event.sender.id, String(runId)));
if (!run) return false;
run.controller.abort();
return true;
}));
// 流式:增量通过 ai:delta 事件推给发起窗口,最终结果由 invoke 返回
ipcMain.handle('ai:run', async (e, payload) => {
const { runId, sessionId, task, text, question, visualContexts, scope, locator, documentKey, fileIndex } = payload || {};
const id = String(runId || '');
if (!isReaderSender(e.sender)) return { ok: false, error: '只有阅读器可以使用 AI 助手' };
if (!/^[A-Za-z0-9_-]{1,80}$/.test(id)) return { ok: false, error: 'runId 无效' };
const key = aiRunKey(e.sender.id, id);
if (aiRuns.has(key)) return { ok: false, error: '该请求已在进行中' };
const chatId = sessionId == null || sessionId === '' ? '' : String(sessionId);
let meta = null;
if (chatId) {
try {
meta = requireAiSession(chatId);
} catch (err) {
return { ok: false, error: (err && err.message) || String(err) };
}
// 同一会话内不允许并发:两轮同时写同一个文件,后完成的那轮会覆盖前一轮的消息
for (const run of aiRuns.values()) {
if (run.sessionId && run.sessionId === chatId) {
return { ok: false, error: '该会话正在生成中,请先等待或停止' };
}
}
}
const ctl = new AbortController();
const wc = e.sender;
const abortOnDestroy = () => ctl.abort();
wc.once('destroyed', abortOnDestroy);
aiRuns.set(key, { controller: ctl, senderId: wc.id, sessionId: chatId });
const body = String(text == null ? '' : text);
let userMessageId = '';
let assistantMessageId = '';
let history = [];
let streamed = '';
try {
const visuals = canonicalVisualContexts(visualContexts);
if (chatId) {
// 历史必须在写入本轮之前取,否则当前提问会被当成自己的历史重复发一遍
history = aiSessions.historyFor(chatId, AI_HISTORY_BUDGET).messages;
const userMessage = aiSessions.appendUser(chatId, {
text: aiTurnTitle(task, question),
task,
contextRef: body || visuals.length ? {
scope,
chars: body.length,
hash: aiSessions.hashContext(body),
locator,
documentKey: documentKey || meta.documentKey,
fileIndex
} : null,
images: persistAiImages(visuals)
});
userMessageId = userMessage.id;
assistantMessageId = aiSessions.appendAssistant(chatId, { task }).id;
}
const full = await aiClient.stream({
task,
text,
question,
visualContexts: visuals,
history,
signal: ctl.signal,
onDelta: (piece) => {
streamed += piece;
if (!wc.isDestroyed()) {
wc.send('ai:delta', {
runId: id,
delta: piece,
sessionId: chatId,
messageId: assistantMessageId
});
}
}
});
if (chatId) settleAiAssistant(chatId, assistantMessageId, { text: full });
return { ok: true, data: { text: full, sessionId: chatId, userMessageId, assistantMessageId } };
} catch (err) {
const cancelled = !!(err && err.name === 'AbortError');
const message = cancelled ? '已取消' : ((err && err.message) || String(err));
// 失败与取消都要落盘:用户的提问已经花掉了 token,
// 已经流出来的残片也要留住,否则界面上看到的半截回答一重开就消失
if (chatId && assistantMessageId) {
settleAiAssistant(chatId, assistantMessageId, {
text: streamed,
cancelled,
error: cancelled ? null : message
});
}
if (cancelled) {
return { ok: false, error: message, cancelled: true, data: { sessionId: chatId, userMessageId, assistantMessageId } };
}
return { ok: false, error: message, data: { sessionId: chatId, userMessageId, assistantMessageId } };
} finally {
wc.removeListener('destroyed', abortOnDestroy);
aiRuns.delete(key);
}
});
// 落盘失败不能把已经拿到的回答变成请求失败,最多是这一轮没存住
function settleAiAssistant(chatId, messageId, patch) {
try {
return aiSessions.finishAssistant(chatId, messageId, patch);
} catch (error) {
return null;
}
}
function persistAiImages(visuals) {
const stored = [];
for (const context of visuals) {
if (!context.includeImage || !context.image) continue;
try {
const put = aiImages.put(Buffer.from(context.image.base64, 'base64'), context.image.mimeType);
stored.push({
imageId: put.imageId,
mimeType: 'image/jpeg',
width: context.image.width,
height: context.image.height,
bytes: put.bytes,
ocrIncluded: !!(context.ocr && context.ocr.include)
});
} catch (error) { /* 存图失败不影响本轮提问 */ }
}
return stored;
}
// 通用设置读写(目前用于"下载前询问保存位置"开关)
ipcMain.handle('settings:get', (_e, key, def) => wrap(() => settings.get(key, def)));
ipcMain.handle('settings:set', (_e, key, value) => wrap(() => { settings.set(key, value); }));
ipcMain.handle('ui:getTheme', () => wrap(() => currentUiTheme));
ipcMain.handle('ui:setTheme', (_e, value) => wrap(() => {
const theme = value === 'light' ? 'light' : 'dark';
settings.set('ui.theme', theme);
settings.set('reader.uiTheme', theme);
applyWindowIcons(theme);
notifyUiThemeChanged();
return theme;
}));
ipcMain.handle('app:version', () => wrap(() => app.getVersion()));
ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate));
ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; });
function liveWindow() {
return mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
}
// 窗口按钮要作用于发出请求的那个窗口,否则阅读器窗口的最小化/关闭会误操作主窗口
function senderWindow(e) {
const w = BrowserWindow.fromWebContents(e.sender);
return w && !w.isDestroyed() ? w : liveWindow();
}
ipcMain.on('win:minimize', (e) => { const w = senderWindow(e); if (w) w.minimize(); });
ipcMain.on('win:maximize', (e) => {
const w = senderWindow(e);
if (!w) return;
if (w.isMaximized()) w.unmaximize(); else w.maximize();
});
ipcMain.on('win:close', (e) => { const w = senderWindow(e); if (w) w.close(); });