Files
peoplelib/main.js
T
lofyer 381c07733a feat: 支持 macOS arm64 打包,修复图标未入库
新增 build-mac.js,产出 ad-hoc 签名的 .app 与 DMG。只能在 macOS 上
构建:DMG 依赖 hdiutil,且 Apple Silicon 拒绝执行未签名二进制,改名
与改 plist 后必须用 codesign 重签。

按官方 darwin-arm64 运行时的真实结构处理四处易错点:用 ditto 解压以
保留 framework 符号链接、删除重打包后失效的 ElectronAsarIntegrity、
为改名后的 helper 补上 CFBundleExecutable、签名由内向外且单独签
Squirrel 的 ShipIt。

打包版数据目录改为按平台决定:macOS 走 appData,避免写进 DMG 挂载后
只读的 .app 内部并在升级覆盖时丢失书库;Windows 便携版行为不变。
窗口图标在非 Windows 平台改用 PNG。

.gitignore 的 dist 规则补上前导斜杠。此前它同时匹配 icons/dist/,
导致构建与单测依赖的图标从未入库,新克隆的仓库无法构建。
2026-08-03 16:18:47 +08:00

1240 lines
48 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 DL_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
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 rangeSessions = require('./src/reader/range-sessions');
const aiConfig = require('./src/reader/ai-config');
const aiClient = require('./src/reader/ai-client');
const { normalizeVisualContexts } = require('./src/reader/visual-context');
const { 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);
// 启动时从持久化设置恢复代理
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;
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 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()];
for (const win of windows) {
if (win && !win.isDestroyed()) win.webContents.send('reader:notesChanged', payload);
}
}
function applyWindowIcons(theme) {
currentUiTheme = theme === 'light' ? 'light' : 'dark';
const icon = iconForTheme(currentUiTheme);
const windows = [mainWindow, ...readerWindow.all()];
for (const win of windows) {
if (!win || win.isDestroyed()) continue;
try { win.setIcon(icon); } catch (e) { /* 平台不支持动态图标时保留创建时图标 */ }
}
}
function notifyUiThemeChanged() {
const windows = [mainWindow, ...readerWindow.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', () => {
coverGenerator.close();
rangeSessions.closeAll().catch(() => {});
});
// 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)));
// 代理配置:全局生效,影响所有数据源的 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(`登录镜像未返回 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()));
// 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) 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: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;
}
})();
}));
// 下载文件:默认直接存入书库目录并挂到条目上;
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
ipcMain.handle('download:file', async (event, url, suggestName, entryId, extraHeaders, meta, requestId) => {
activeDownloads++;
let partial = '';
let res = null;
let bodyHandled = false;
const progressId = typeof requestId === 'string' ? requestId.slice(0, 100) : '';
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');
let target;
if (askSavePath) {
const save = await dialog.showSaveDialog(liveWindow(), {
title: '保存文件',
defaultPath: path.join(library.filesDir(), suggested)
});
if (save.canceled || !save.filePath) return { ok: true, data: { canceled: true } };
target = save.filePath;
}
const headers = { 'User-Agent': DL_UA, ...(extraHeaders || {}) };
headers['Referer'] = parsedUrl.origin + '/';
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), 30000);
try {
res = await fetchWithProxy(parsedUrl.toString(), {
redirect: 'follow',
headers,
signal: ac.signal
});
} finally {
clearTimeout(timer);
}
if (!res.ok) throw new Error(`下载失败: ${res.status}`);
const declaredSize = Number(res.headers.get('content-length'));
const totalBytes = Number.isFinite(declaredSize) && declaredSize > 0 ? declaredSize : null;
let receivedBytes = 0;
let lastProgressAt = 0;
sendProgress({ receivedBytes, totalBytes, percent: totalBytes ? 0 : null });
const respName = filenameFromResponse(res, suggestName);
const hasExt = suggestName && /\.[a-z0-9]{2,5}$/i.test(suggestName);
const defaultName = library.sanitize(hasExt ? suggestName : respName);
const contentType = res.headers.get('content-type') || '';
const expectedExt = path.extname(target || defaultName).toLowerCase();
if (/text\/html|application\/json/i.test(contentType)
&& !['.html', '.htm', '.json', '.txt'].includes(expectedExt)) {
throw new Error('下载地址返回了网页而不是文献文件');
}
if (!target) target = library.allocFilePath(defaultName);
fs.mkdirSync(path.dirname(target), { recursive: true });
partial = path.join(
path.dirname(target),
`.${path.basename(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;
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(partial, { flags: 'wx' }));
} finally {
clearTimeout(transferTimer);
}
if (askSavePath) {
const backup = `${target}.${process.pid}-${Date.now()}.bak`;
let backedUp = false;
try {
if (fs.existsSync(target)) {
fs.renameSync(target, backup);
backedUp = true;
}
fs.renameSync(partial, target);
partial = '';
if (backedUp) {
try { fs.unlinkSync(backup); } catch (e) { /* 保留备份不影响下载 */ }
}
} catch (e) {
try {
if (backedUp && !fs.existsSync(target) && fs.existsSync(backup)) fs.renameSync(backup, target);
} catch (rollbackError) { /* ignore */ }
throw e;
}
} else {
for (;;) {
try {
fs.linkSync(partial, target);
break;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
target = library.allocFilePath(defaultName);
}
}
try { fs.unlinkSync(partial); } catch (e) { /* 保留硬链接副本不影响文件 */ }
partial = '';
}
sendProgress({ receivedBytes, totalBytes, percent: 1, complete: true });
// 落库:优先挂到已有条目,否则用 meta 新建
let id = entryId;
if (!id && meta) {
const existing = meta.sourceId && meta.sourcePostId
? library.findBySource(meta.sourceId, meta.sourcePostId) : null;
id = existing ? existing.id : library.add(meta).id;
}
const entry = id ? library.attachFile(id, target) : null;
if (entry) coverGenerator.ensure(entry.id).catch(() => {});
return { ok: true, data: { path: target, name: path.basename(target), entryId: id || null, entry } };
} catch (e) {
if (res && res.body && !bodyHandled) {
try { await res.body.cancel(); } catch (cancelError) { /* ignore */ }
}
if (partial) {
try { fs.unlinkSync(partial); } catch (cleanupError) { /* ignore */ }
}
if (e && (e.name === 'AbortError' || /aborted/i.test(e.message || ''))) {
return { ok: false, error: '下载超时,请检查网络或代理设置' };
}
return { ok: false, error: e.message || String(e) };
} finally {
activeDownloads--;
}
});
// 打开文件。没有关联程序时(例如未装 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', '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']);
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: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) {
cleanupNoteAssets();
notifyNotesChanged({ entryId: id, noteId: String(noteId), type: 'remove' });
}
return result;
}));
ipcMain.handle('reader:listNotes', (_e, filters) => wrap(() => readerStore.listNotes(filters || {})));
ipcMain.handle('reader:getNoteCounts', () => wrap(() => readerStore.getNoteCounts()));
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;
}));
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, task, text, question, visualContexts } = 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 ctl = new AbortController();
const wc = e.sender;
const abortOnDestroy = () => ctl.abort();
wc.once('destroyed', abortOnDestroy);
aiRuns.set(key, { controller: ctl, senderId: wc.id });
try {
const visuals = canonicalVisualContexts(visualContexts);
const full = await aiClient.stream({
task,
text,
question,
visualContexts: visuals,
signal: ctl.signal,
onDelta: (piece) => {
if (!wc.isDestroyed()) wc.send('ai:delta', { runId: id, delta: piece });
}
});
return { ok: true, data: { text: full } };
} catch (err) {
if (err && err.name === 'AbortError') return { ok: false, error: '已取消', cancelled: true };
return { ok: false, error: (err && err.message) || String(err) };
} finally {
wc.removeListener('destroyed', abortOnDestroy);
aiRuns.delete(key);
}
});
// 通用设置读写(目前用于"下载前询问保存位置"开关)
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(); });