fix: 修复 Z-Library 加载无限转圈
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, linux, ubuntu-24.04-arm) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (arm64, macos, macos-15) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, linux, ubuntu-24.04) (push) Blocked by required conditions
构建与发布 / 打包 ${{ matrix.platform }} ${{ matrix.arch }} (x64, windows, windows-2025) (push) Blocked by required conditions
构建与发布 / 发布 GitHub Release (push) Blocked by required conditions
构建与发布 / 单测与集成测试 (push) Waiting to run

登录时注入页面的 fetch 没有超时,executeJavaScriptInIsolatedWorld 在页面
卡死时也不会 reject,两处 Promise 永不落定会让 IPC 不回包,界面停在加载中
且没有任何错误。wrap() 只能捕获抛出,捕获不了不返回。

页内 fetch 加 AbortSignal.timeout,注入求值外挂超时,数据源 IPC 统一经
sourceCall() 加兜底时限。另外启动与保存代理时等待 Chromium 切换完成并关闭
旧连接,避免继续复用切换前的连接。
This commit is contained in:
lofyer
2026-08-09 15:02:39 +08:00
parent 763f2b02ac
commit 7f0eea7464
3 changed files with 169 additions and 19 deletions
+54 -19
View File
@@ -296,11 +296,17 @@ function notifyUiThemeChanged() {
library.setChangeListener(notifyLibraryChanged);
app.whenReady().then(() => {
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
const p = getProxy();
if (p) {
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
async function applySessionProxy(ses, proxy) {
await ses.setProxy({ proxyRules: proxy || 'direct://' });
await ses.closeAllConnections();
}
app.whenReady().then(async () => {
// 必须在建窗前等 Chromium 代理真正生效,否则启动后首批 net.fetch 会沿用直连。
try {
await applySessionProxy(session.defaultSession, getProxy());
} catch (e) {
console.warn('Chromium 代理应用失败:', e.message);
}
createWindow();
setTimeout(cleanupNoteAssets, 0);
@@ -328,15 +334,40 @@ function wrap(fn) {
.catch((err) => ({ ok: false, error: (err && err.message) || String(err) }));
}
// Promise 永不落定和同步抛出一样会把界面钉在"加载中",而且更难发现:
// executeJavaScript 在页面卡死或窗口销毁时不会 reject,页内 fetch 默认也没有超时。
function withTimeout(promise, ms, message) {
let timer = null;
return Promise.race([
promise,
new Promise((_resolve, reject) => {
timer = setTimeout(() => reject(new Error(message)), ms);
})
]).finally(() => { if (timer) clearTimeout(timer); });
}
// 兜底时限,不是精确预算:正常情况下各层自己的超时(fetchRaw 15s、登录页 30s
// 会先给出具体原因。串行走完 5 个镜像的登录最坏可达数分钟,这里会先截断并给出
// 通用提示,宁可让用户拿到可操作的错误,也不要让界面无限转圈。
const SOURCE_TIMEOUT_MS = 120000;
function sourceCall(fn) {
return wrap(() => withTimeout(
Promise.resolve().then(fn),
SOURCE_TIMEOUT_MS,
'数据源长时间无响应,请稍后重试或更换数据源'
));
}
// 数据源
ipcMain.handle('sources:list', () => wrap(() => sources.listSources()));
ipcMain.handle('source:list', (_e, sourceId, page) => 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)));
ipcMain.handle('source:list', (_e, sourceId, page) => sourceCall(() => sources.getSource(sourceId).list(page)));
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => sourceCall(() => sources.getSource(sourceId).search(keyword, page)));
ipcMain.handle('source:detail', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).detail(postId)));
ipcMain.handle('source:download', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).download(postId)));
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => wrap(() => {
ipcMain.handle('source:chapters', (_e, sourceId, postId, page, options) => sourceCall(() => {
const source = sources.getSource(sourceId);
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
return source.chapters(postId, page, options || {});
@@ -401,19 +432,20 @@ ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => (
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
ipcMain.handle('proxy:set', (_e, url) => {
ipcMain.handle('proxy:set', (_e, url) => wrap(async () => {
const previous = getProxy();
try {
const u = String(url || '').trim();
setProxy(u);
await applySessionProxy(session.defaultSession, u);
settings.set('proxy', u);
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
return { ok: true };
return true;
} catch (e) {
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
return { ok: false, error: e.message || String(e) };
try { await applySessionProxy(session.defaultSession, previous); } catch (rollbackError) { /* ignore */ }
throw e;
}
});
}));
function zlibOrigin(value) {
let url;
@@ -424,6 +456,8 @@ function zlibOrigin(value) {
return url.origin;
}
const ZLIB_LOGIN_FETCH_MS = 20000;
async function waitForZlibPage(win, origin) {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
@@ -476,6 +510,7 @@ async function browserZlibLogin(mirror, email, password) {
const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
method: 'POST',
credentials: 'include',
signal: AbortSignal.timeout(${ZLIB_LOGIN_FETCH_MS}),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest'
@@ -486,10 +521,10 @@ async function browserZlibLogin(mirror, email, password) {
contentType: response.headers.get('content-type') || '',
text: await response.text()
}))`;
const result = await win.webContents.executeJavaScriptInIsolatedWorld(
1001,
[{ code }],
true
const result = await withTimeout(
win.webContents.executeJavaScriptInIsolatedWorld(1001, [{ code }], true),
ZLIB_LOGIN_FETCH_MS + 5000,
'登录镜像无响应'
);
let data = null;
try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }