feat: 完善本地书库与发布更新流程

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-07-28 21:55:16 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent de3e1d8a44
commit b8c8d24107
22 changed files with 1579 additions and 359 deletions
+15 -101
View File
@@ -1,4 +1,5 @@
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
const { fetch: undiciFetch, ProxyAgent } = require('undici');
// 代理配置:默认直连(空字符串)。一旦设置,所有 HTTP 请求统一走该代理。
// 持久化存储在 userData/settings.json 的 "proxy" 字段。
@@ -6,112 +7,25 @@ let proxyUrl = '';
let dispatcher = null;
function setProxy(url) {
proxyUrl = String(url || '').trim();
dispatcher = null;
if (!proxyUrl) return;
// Electron 下代理主要由 session.setProxy 接管;这里仍然构建 ProxyAgent
// 供 net.fetch 不可用时的 undici 回退路径使用。
try {
const { ProxyAgent } = require('undici');
dispatcher = new ProxyAgent({
uri: proxyUrl,
requestTls: { rejectUnauthorized: false }
});
} catch (e) {
console.warn('代理初始化失败:', e.message);
const nextUrl = String(url || '').trim();
if (nextUrl === proxyUrl) return;
let nextDispatcher = null;
if (nextUrl) {
const parsed = new URL(nextUrl);
if (!/^https?:$/.test(parsed.protocol)) throw new Error('代理地址仅支持 http:// 或 https://');
nextDispatcher = new ProxyAgent({ uri: nextUrl });
}
if (dispatcher) {
dispatcher.close().catch(() => {});
}
proxyUrl = nextUrl;
dispatcher = nextDispatcher;
}
function getProxy() { return proxyUrl; }
// --- Electron net.fetch 响应头非 ASCII 崩溃的兜底 ---
// 部分站点(如 Memory of the World)会把搜索关键词原样回写进 ETag 等响应头。
// 关键词含中文时,Electron 的 net.fetch 在其内部 emit 回调里抛出
// "Cannot convert argument to a ByteString"。该异常无法被 await 捕获:
// 主进程直接崩溃,且对应的 fetch Promise 永远不会 settle。
//
// 兜底策略:拦截这一类 uncaughtException(其余异常原样交还默认处理),
// 记录 origin 后让仍在挂起中的同源请求转为可捕获的失败,再用 undici 重试。
// 加宽限期是因为异常与请求之间无法直接关联,宽限期内正常返回的请求不受影响。
const HEADER_BUG_GRACE = 1200;
// origin -> 该源已知会触发响应头崩溃(后续请求直接走 undici)
const headerBugOrigins = new Set();
// 正在进行中的 net.fetch: origin -> Set<abortFn>
const pendingByOrigin = new Map();
function originOf(url) {
try { return new URL(url).origin; } catch (e) { return ''; }
}
function isHeaderByteStringError(e) {
return !!e && (e instanceof TypeError || e.name === 'TypeError') && /ByteString/i.test(e.message || '');
}
let rethrowing = false;
function onUncaught(e) {
if (isHeaderByteStringError(e)) {
// 只有在宽限期后仍未返回的请求,才判定为被该异常卡死
const victims = [];
for (const set of pendingByOrigin.values()) victims.push(...set);
setTimeout(() => { for (const fail of victims) fail(); }, HEADER_BUG_GRACE);
return;
}
if (rethrowing) return;
rethrowing = true;
process.removeListener('uncaughtException', onUncaught);
setImmediate(() => { throw e; });
}
process.on('uncaughtException', onUncaught);
function undiciFetch(url, options) {
if (dispatcher) return fetch(url, { ...options, dispatcher });
return fetch(url, options);
}
function netFetch(url, options) {
const { net } = require('electron');
const origin = originOf(url);
return new Promise((resolve, reject) => {
let settled = false;
let set = pendingByOrigin.get(origin);
if (!set) { set = new Set(); pendingByOrigin.set(origin, set); }
const fail = () => {
if (settled) return;
settled = true;
cleanup();
headerBugOrigins.add(origin);
const err = new Error('响应头包含非 ASCII 字符,Electron 网络栈无法处理');
err.code = 'HEADER_BYTESTRING';
reject(err);
};
function cleanup() {
set.delete(fail);
if (!set.size) pendingByOrigin.delete(origin);
}
set.add(fail);
net.fetch(url, options).then(
(r) => { if (settled) return; settled = true; cleanup(); resolve(r); },
(e) => { if (settled) return; settled = true; cleanup(); reject(e); }
);
});
}
async function fetchWithProxy(url, options = {}) {
// Electron 主进程优先用 net.fetch(走 Chromium 网络栈,由 session.setProxy 控制代理)
if (process.versions.electron) {
// 已知有问题的源直接走 undici,避免每次都重新触发一遍崩溃
if (headerBugOrigins.has(originOf(url))) return undiciFetch(url, options);
try {
return await netFetch(url, options);
} catch (e) {
if (e && e.code === 'HEADER_BYTESTRING') return undiciFetch(url, options);
throw e;
}
}
return undiciFetch(url, options);
function fetchWithProxy(url, options = {}) {
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options);
}
// 简易 cookie jar: Map<domain, Map<name, value>>