1825 lines
70 KiB
JavaScript
1825 lines
70 KiB
JavaScript
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);
|
||
|
||
app.whenReady().then(() => {
|
||
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
|
||
const p = getProxy();
|
||
if (p) {
|
||
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
|
||
}
|
||
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) }));
|
||
}
|
||
|
||
// 数据源
|
||
ipcMain.handle('sources:list', () => wrap(() => sources.listSources()));
|
||
ipcMain.handle('source:list', (_e, sourceId, page) => wrap(() => sources.getSource(sourceId).list(page)));
|
||
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sources.getSource(sourceId).search(keyword, page)));
|
||
ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId)));
|
||
ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId)));
|
||
|
||
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
|
||
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => wrap(() => {
|
||
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) => {
|
||
const previous = getProxy();
|
||
try {
|
||
const u = String(url || '').trim();
|
||
setProxy(u);
|
||
settings.set('proxy', u);
|
||
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
|
||
return { ok: true };
|
||
} catch (e) {
|
||
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
|
||
return { ok: false, error: e.message || String(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;
|
||
}
|
||
|
||
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',
|
||
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 win.webContents.executeJavaScriptInIsolatedWorld(
|
||
1001,
|
||
[{ code }],
|
||
true
|
||
);
|
||
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(`登录镜像未返回 JSON(HTTP ${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()));
|
||
|
||
// 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(); });
|