Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
428 lines
16 KiB
JavaScript
428 lines
16 KiB
JavaScript
const { app, BrowserWindow, ipcMain, clipboard, dialog, shell, session, safeStorage } = require('electron');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
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) {
|
|
return String(v || '').replace(/^v/i, '').trim().split('.').map((n) => parseInt(n, 10) || 0);
|
|
}
|
|
|
|
function compareVersion(a, b) {
|
|
const pa = parseVersion(a);
|
|
const pb = parseVersion(b);
|
|
const len = Math.max(pa.length, pb.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const x = pa[i] || 0;
|
|
const y = pb[i] || 0;
|
|
if (x !== y) return x > y ? 1 : -1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
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');
|
|
|
|
const userDataDir = app.isPackaged
|
|
? path.join(path.dirname(app.getPath('exe')), 'data')
|
|
: path.join(app.getPath('appData'), 'PeopleLib');
|
|
app.setPath('userData', userDataDir);
|
|
|
|
const sources = require('./src/sources');
|
|
const library = require('./src/library/store');
|
|
const zlibAuth = require('./src/sources/zlib-auth');
|
|
const semanticKey = require('./src/sources/semantic-key');
|
|
const settings = require('./src/settings');
|
|
const { setProxy, getProxy, fetchWithProxy } = require('./src/sources/http');
|
|
zlibAuth.init(userDataDir);
|
|
semanticKey.init(userDataDir, safeStorage);
|
|
settings.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);
|
|
}
|
|
// 首次运行时把旧版本散落在 userData 根目录的书库数据导入一次
|
|
if (!settings.get('legacyImported', false)) {
|
|
try {
|
|
library.importLegacy(userDataDir);
|
|
settings.set('legacyImported', true);
|
|
} catch (e) {
|
|
console.warn('旧书库导入失败:', e.message);
|
|
}
|
|
}
|
|
|
|
let mainWindow;
|
|
let activeDownloads = 0;
|
|
|
|
function createWindow() {
|
|
mainWindow = new BrowserWindow({
|
|
width: 1240,
|
|
height: 840,
|
|
minWidth: 940,
|
|
minHeight: 620,
|
|
frame: false,
|
|
backgroundColor: '#141414',
|
|
title: 'PeopleLib 文献库',
|
|
webPreferences: {
|
|
preload: path.join(__dirname, 'preload.js'),
|
|
contextIsolation: true,
|
|
nodeIntegration: false
|
|
}
|
|
});
|
|
mainWindow.loadFile(path.join(__dirname, 'src', 'ui', 'index.html'));
|
|
}
|
|
|
|
function notifyLibraryChanged() {
|
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
mainWindow.webContents.send('library:changed');
|
|
}
|
|
}
|
|
|
|
library.setChangeListener(notifyLibraryChanged);
|
|
|
|
app.whenReady().then(() => {
|
|
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
|
|
const p = getProxy();
|
|
if (p) {
|
|
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
|
|
}
|
|
createWindow();
|
|
// 启动时扫描书库目录,用户手动放进 files/ 的书会自动入库
|
|
try { library.scan(); } catch (e) { /* ignore */ }
|
|
app.on('activate', () => {
|
|
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
});
|
|
});
|
|
|
|
app.on('window-all-closed', () => {
|
|
if (process.platform !== 'darwin') app.quit();
|
|
});
|
|
|
|
function wrap(promise) {
|
|
return promise
|
|
.then((data) => ({ ok: true, data }))
|
|
.catch((err) => ({ ok: false, error: err.message || String(err) }));
|
|
}
|
|
|
|
// 数据源
|
|
ipcMain.handle('sources:list', () => ({ ok: true, data: 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', () => ({ ok: true, data: 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) };
|
|
}
|
|
});
|
|
|
|
// Z-Library 凭据
|
|
ipcMain.handle('zlib:hasCreds', () => ({ ok: true, data: 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', () => ({ ok: true, data: semanticKey.status() }));
|
|
ipcMain.handle('semanticScholar:setKey', (_e, key) => wrap(Promise.resolve().then(() => semanticKey.write(key))));
|
|
ipcMain.handle('semanticScholar:clearKey', () => wrap(Promise.resolve().then(() => semanticKey.clear())));
|
|
|
|
// 书库目录管理
|
|
ipcMain.handle('library:getDir', () => ({ ok: true, data: { dir: library.getRoot(), isDefault: library.getRoot() === DEFAULT_LIBRARY_DIR } }));
|
|
ipcMain.handle('library:pickDir', async () => {
|
|
const r = await dialog.showOpenDialog(mainWindow, {
|
|
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(Promise.resolve().then(() => {
|
|
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();
|
|
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(Promise.resolve().then(() => {
|
|
const r = library.scan();
|
|
notifyLibraryChanged();
|
|
return r;
|
|
})));
|
|
|
|
// 本地书库
|
|
ipcMain.handle('library:list', () => wrap(Promise.resolve(library.list())));
|
|
ipcMain.handle('library:get', (_e, id) => wrap(Promise.resolve(library.get(id))));
|
|
ipcMain.handle('library:findBySource', (_e, sourceId, postId) => wrap(Promise.resolve(library.findBySource(sourceId, postId))));
|
|
ipcMain.handle('library:add', (_e, item) => wrap(Promise.resolve(library.add(item))));
|
|
ipcMain.handle('library:update', (_e, id, patch) => wrap(Promise.resolve(library.update(id, patch))));
|
|
ipcMain.handle('library:remove', (_e, id, deleteFiles) => wrap(Promise.resolve(library.remove(id, deleteFiles))));
|
|
|
|
// 下载文件:默认直接存入书库目录并挂到条目上;
|
|
// 开启"下载前询问保存位置"后改为弹保存框(此时文件在书库外,记绝对路径)。
|
|
// meta 用于文件不属于任何已有条目时自动建条目,避免"下载了但书库不知道"。
|
|
ipcMain.handle('download:file', async (_e, url, suggestName, entryId, extraHeaders, meta) => {
|
|
activeDownloads++;
|
|
let partial = '';
|
|
let res = null;
|
|
let bodyHandled = false;
|
|
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(mainWindow, {
|
|
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 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();
|
|
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 = '';
|
|
}
|
|
|
|
// 落库:优先挂到已有条目,否则用 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;
|
|
|
|
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:pickFile', async () => {
|
|
const r = await dialog.showOpenDialog(mainWindow, {
|
|
title: '选择本地文献文件',
|
|
properties: ['openFile'],
|
|
filters: [{ name: '文献', extensions: ['pdf', 'epub', 'mobi', 'txt', 'azw3'] }, { name: '所有文件', extensions: ['*'] }]
|
|
});
|
|
if (r.canceled || !r.filePaths.length) return { ok: true, data: null };
|
|
const p = r.filePaths[0];
|
|
return { ok: true, data: { path: p, name: path.basename(p, path.extname(p)) } };
|
|
});
|
|
|
|
// 通用设置读写(目前用于"下载前询问保存位置"开关)
|
|
ipcMain.handle('settings:get', (_e, key, def) => ({ ok: true, data: settings.get(key, def) }));
|
|
ipcMain.handle('settings:set', (_e, key, value) => { settings.set(key, value); return { ok: true }; });
|
|
|
|
ipcMain.handle('app:version', () => ({ ok: true, data: app.getVersion() }));
|
|
ipcMain.handle('app:checkUpdate', () => wrap(checkUpdate()));
|
|
ipcMain.handle('copy', (_e, text) => { clipboard.writeText(String(text || '')); return { ok: true }; });
|
|
|
|
ipcMain.on('win:minimize', () => mainWindow && mainWindow.minimize());
|
|
ipcMain.on('win:maximize', () => {
|
|
if (!mainWindow) return;
|
|
if (mainWindow.isMaximized()) mainWindow.unmaximize(); else mainWindow.maximize();
|
|
});
|
|
ipcMain.on('win:close', () => mainWindow && mainWindow.close());
|