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
构建与发布 / 打包 ${{ 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:
@@ -296,11 +296,17 @@ function notifyUiThemeChanged() {
|
|||||||
|
|
||||||
library.setChangeListener(notifyLibraryChanged);
|
library.setChangeListener(notifyLibraryChanged);
|
||||||
|
|
||||||
app.whenReady().then(() => {
|
async function applySessionProxy(ses, proxy) {
|
||||||
// 应用代理到 Chromium defaultSession(影响 net.fetch、窗口加载、所有请求)
|
await ses.setProxy({ proxyRules: proxy || 'direct://' });
|
||||||
const p = getProxy();
|
await ses.closeAllConnections();
|
||||||
if (p) {
|
}
|
||||||
session.defaultSession.setProxy({ proxyRules: p }).catch(() => {});
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// 必须在建窗前等 Chromium 代理真正生效,否则启动后首批 net.fetch 会沿用直连。
|
||||||
|
try {
|
||||||
|
await applySessionProxy(session.defaultSession, getProxy());
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Chromium 代理应用失败:', e.message);
|
||||||
}
|
}
|
||||||
createWindow();
|
createWindow();
|
||||||
setTimeout(cleanupNoteAssets, 0);
|
setTimeout(cleanupNoteAssets, 0);
|
||||||
@@ -328,15 +334,40 @@ function wrap(fn) {
|
|||||||
.catch((err) => ({ ok: false, error: (err && err.message) || String(err) }));
|
.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('sources:list', () => wrap(() => sources.listSources()));
|
||||||
ipcMain.handle('source:list', (_e, sourceId, page) => wrap(() => sources.getSource(sourceId).list(page)));
|
ipcMain.handle('source:list', (_e, sourceId, page) => sourceCall(() => sources.getSource(sourceId).list(page)));
|
||||||
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => wrap(() => sources.getSource(sourceId).search(keyword, page)));
|
ipcMain.handle('source:search', (_e, sourceId, keyword, page) => sourceCall(() => sources.getSource(sourceId).search(keyword, page)));
|
||||||
ipcMain.handle('source:detail', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).detail(postId)));
|
ipcMain.handle('source:detail', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).detail(postId)));
|
||||||
ipcMain.handle('source:download', (_e, sourceId, postId) => wrap(() => sources.getSource(sourceId).download(postId)));
|
ipcMain.handle('source:download', (_e, sourceId, postId) => sourceCall(() => sources.getSource(sourceId).download(postId)));
|
||||||
|
|
||||||
// 按章节下载的源不复用 download:file;后者只处理单个直链文件。
|
// 按章节下载的源不复用 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);
|
const source = sources.getSource(sourceId);
|
||||||
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
|
if (typeof source.chapters !== 'function') throw new Error('该数据源不支持章节列表');
|
||||||
return source.chapters(postId, page, options || {});
|
return source.chapters(postId, page, options || {});
|
||||||
@@ -401,19 +432,20 @@ ipcMain.handle('mangaOnline:close', (event, sessionId) => wrap(() => (
|
|||||||
|
|
||||||
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
|
// 代理配置:全局生效,影响所有数据源的 HTTP 请求与文件下载
|
||||||
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
|
ipcMain.handle('proxy:get', () => wrap(() => getProxy()));
|
||||||
ipcMain.handle('proxy:set', (_e, url) => {
|
ipcMain.handle('proxy:set', (_e, url) => wrap(async () => {
|
||||||
const previous = getProxy();
|
const previous = getProxy();
|
||||||
try {
|
try {
|
||||||
const u = String(url || '').trim();
|
const u = String(url || '').trim();
|
||||||
setProxy(u);
|
setProxy(u);
|
||||||
|
await applySessionProxy(session.defaultSession, u);
|
||||||
settings.set('proxy', u);
|
settings.set('proxy', u);
|
||||||
session.defaultSession.setProxy({ proxyRules: u || 'direct://' }).catch(() => {});
|
return true;
|
||||||
return { ok: true };
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { setProxy(previous); } catch (rollbackError) { /* ignore */ }
|
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) {
|
function zlibOrigin(value) {
|
||||||
let url;
|
let url;
|
||||||
@@ -424,6 +456,8 @@ function zlibOrigin(value) {
|
|||||||
return url.origin;
|
return url.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ZLIB_LOGIN_FETCH_MS = 20000;
|
||||||
|
|
||||||
async function waitForZlibPage(win, origin) {
|
async function waitForZlibPage(win, origin) {
|
||||||
const deadline = Date.now() + 30000;
|
const deadline = Date.now() + 30000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -476,6 +510,7 @@ async function browserZlibLogin(mirror, email, password) {
|
|||||||
const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
|
const code = `fetch(${JSON.stringify(`${origin}/rpc.php`)}, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
|
signal: AbortSignal.timeout(${ZLIB_LOGIN_FETCH_MS}),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
@@ -486,10 +521,10 @@ async function browserZlibLogin(mirror, email, password) {
|
|||||||
contentType: response.headers.get('content-type') || '',
|
contentType: response.headers.get('content-type') || '',
|
||||||
text: await response.text()
|
text: await response.text()
|
||||||
}))`;
|
}))`;
|
||||||
const result = await win.webContents.executeJavaScriptInIsolatedWorld(
|
const result = await withTimeout(
|
||||||
1001,
|
win.webContents.executeJavaScriptInIsolatedWorld(1001, [{ code }], true),
|
||||||
[{ code }],
|
ZLIB_LOGIN_FETCH_MS + 5000,
|
||||||
true
|
'登录镜像无响应'
|
||||||
);
|
);
|
||||||
let data = null;
|
let data = null;
|
||||||
try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }
|
try { data = JSON.parse(result && result.text); } catch (e) { /* 非 JSON */ }
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ app.whenReady().then(async () => {
|
|||||||
const configReadyMs = Date.now() - startedAt;
|
const configReadyMs = Date.now() - startedAt;
|
||||||
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
|
check('慢速书库扫描不会阻塞窗口配置加载', configReadyMs < 1200, `${configReadyMs}ms`);
|
||||||
|
|
||||||
|
const proxyResult = await win.webContents.executeJavaScript(
|
||||||
|
`window.api.proxy.set('http://127.0.0.1:9')`
|
||||||
|
);
|
||||||
|
const proxyRoute = await app.whenReady().then(() => (
|
||||||
|
require('electron').session.defaultSession.resolveProxy('https://example.com/')
|
||||||
|
));
|
||||||
|
check('保存代理后 Chromium 会话已切换到新代理',
|
||||||
|
proxyResult && proxyResult.ok && /PROXY 127\.0\.0\.1:9/i.test(proxyRoute),
|
||||||
|
proxyRoute);
|
||||||
|
await win.webContents.executeJavaScript(`window.api.proxy.set('')`);
|
||||||
|
|
||||||
const sourceRows = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
|
const sourceRows = await waitUntil(() => win.webContents.executeJavaScript(`(() => {
|
||||||
const rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({
|
const rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({
|
||||||
id: input.dataset.id,
|
id: input.dataset.id,
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ const { compareVersion } = h.extractFns(
|
|||||||
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
|
mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion']
|
||||||
);
|
);
|
||||||
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
|
const { wrap } = h.extractFns(mainFile, 'function wrap(', '// 数据源', ['wrap']);
|
||||||
|
const { applySessionProxy } = h.extractFns(
|
||||||
|
mainFile, 'async function applySessionProxy', 'app.whenReady()', ['applySessionProxy']
|
||||||
|
);
|
||||||
|
const { withTimeout } = h.extractFns(
|
||||||
|
mainFile, 'function withTimeout', 'const SOURCE_TIMEOUT_MS', ['withTimeout']
|
||||||
|
);
|
||||||
|
|
||||||
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
|
test('wrap 捕获同步抛出,不让 invoke reject', async () => {
|
||||||
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
|
const r = await wrap(() => { throw new Error('未知数据源: nope'); });
|
||||||
@@ -43,6 +49,104 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => {
|
|||||||
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
assert.deepStrictEqual(bare, [], '存在绕过 wrap 的 handler: ' + bare);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('withTimeout 让永不落定的等待变成可读错误', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
withTimeout(new Promise(() => {}), 30, '数据源长时间无响应'),
|
||||||
|
/数据源长时间无响应/
|
||||||
|
);
|
||||||
|
assert.strictEqual(await withTimeout(Promise.resolve('ok'), 1000, '不该超时'), 'ok');
|
||||||
|
await assert.rejects(
|
||||||
|
withTimeout(Promise.reject(new Error('镜像返回了非预期内容')), 1000, '不该超时'),
|
||||||
|
/镜像返回了非预期内容/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withTimeout 在落定后清掉定时器,不吊住进程', async () => {
|
||||||
|
const timers = [];
|
||||||
|
const originalSetTimeout = global.setTimeout;
|
||||||
|
const originalClearTimeout = global.clearTimeout;
|
||||||
|
global.setTimeout = (fn, ms) => {
|
||||||
|
const handle = originalSetTimeout(fn, ms);
|
||||||
|
timers.push(handle);
|
||||||
|
return handle;
|
||||||
|
};
|
||||||
|
let cleared = 0;
|
||||||
|
global.clearTimeout = (handle) => { cleared += 1; return originalClearTimeout(handle); };
|
||||||
|
try {
|
||||||
|
await withTimeout(Promise.resolve(1), 60000, '不该超时');
|
||||||
|
} finally {
|
||||||
|
global.setTimeout = originalSetTimeout;
|
||||||
|
global.clearTimeout = originalClearTimeout;
|
||||||
|
for (const handle of timers) originalClearTimeout(handle);
|
||||||
|
}
|
||||||
|
assert.strictEqual(cleared, 1, '超时定时器没有被清理');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源 IPC 带总时限,永不落定也会回包', () => {
|
||||||
|
const source = mainSrc.slice(
|
||||||
|
mainSrc.indexOf('function sourceCall'),
|
||||||
|
mainSrc.indexOf("ipcMain.handle('source:readOnline'")
|
||||||
|
);
|
||||||
|
assert.match(source, /withTimeout\(\s*Promise\.resolve\(\)\.then\(fn\),\s*SOURCE_TIMEOUT_MS/);
|
||||||
|
for (const channel of ['source:list', 'source:search', 'source:detail', 'source:download', 'source:chapters']) {
|
||||||
|
const line = new RegExp(`ipcMain\\.handle\\('${channel}'[^\\n]*`).exec(mainSrc);
|
||||||
|
assert.ok(line, `缺少 ${channel} handler`);
|
||||||
|
assert.match(line[0], /sourceCall\(/, `${channel} 没有总时限,界面会卡在加载中`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Z-Library 登录页内请求与注入求值都有超时', () => {
|
||||||
|
const start = mainSrc.indexOf('async function browserZlibLogin');
|
||||||
|
const end = mainSrc.indexOf('// Z-Library 凭据', start);
|
||||||
|
const segment = mainSrc.slice(start, end);
|
||||||
|
assert.match(segment, /signal: AbortSignal\.timeout\(\$\{ZLIB_LOGIN_FETCH_MS\}\)/,
|
||||||
|
'页内 fetch 没有超时,代理不通时会永远挂着');
|
||||||
|
assert.match(segment, /await withTimeout\(\s*win\.webContents\.executeJavaScriptInIsolatedWorld/,
|
||||||
|
'executeJavaScript 在页面卡死时不会 reject,必须外挂超时');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Chromium 代理切换完成并关闭旧连接后才继续请求', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const fakeSession = {
|
||||||
|
setProxy: async (config) => { calls.push(['setProxy', config.proxyRules]); },
|
||||||
|
closeAllConnections: async () => { calls.push(['closeAllConnections']); }
|
||||||
|
};
|
||||||
|
await applySessionProxy(fakeSession, 'http://127.0.0.1:7890');
|
||||||
|
assert.deepStrictEqual(calls, [
|
||||||
|
['setProxy', 'http://127.0.0.1:7890'],
|
||||||
|
['closeAllConnections']
|
||||||
|
]);
|
||||||
|
|
||||||
|
calls.length = 0;
|
||||||
|
await applySessionProxy(fakeSession, '');
|
||||||
|
assert.deepStrictEqual(calls[0], ['setProxy', 'direct://']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('启动与保存设置都会等待 Chromium 代理生效', () => {
|
||||||
|
const startup = mainSrc.slice(
|
||||||
|
mainSrc.indexOf('app.whenReady().then'),
|
||||||
|
mainSrc.indexOf("app.on('window-all-closed'")
|
||||||
|
);
|
||||||
|
assert.match(startup, /app\.whenReady\(\)\.then\(async \(\) =>/);
|
||||||
|
assert.ok(
|
||||||
|
startup.indexOf('await applySessionProxy(session.defaultSession, getProxy())')
|
||||||
|
< startup.indexOf('createWindow()'),
|
||||||
|
'主窗口在 Chromium 代理生效前创建'
|
||||||
|
);
|
||||||
|
|
||||||
|
const proxyHandler = mainSrc.slice(
|
||||||
|
mainSrc.indexOf("ipcMain.handle('proxy:set'"),
|
||||||
|
mainSrc.indexOf('function zlibOrigin')
|
||||||
|
);
|
||||||
|
assert.match(proxyHandler, /wrap\(async \(\) =>/);
|
||||||
|
assert.match(proxyHandler, /await applySessionProxy\(session\.defaultSession, u\)/);
|
||||||
|
assert.ok(
|
||||||
|
proxyHandler.indexOf('await applySessionProxy(session.defaultSession, u)')
|
||||||
|
< proxyHandler.indexOf("settings.set('proxy', u)"),
|
||||||
|
'代理尚未应用成功就提前持久化'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
|
test('章节制数据源 IPC 通过源能力下载并按请求隔离进度', () => {
|
||||||
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
|
const start = mainSrc.indexOf("ipcMain.handle('source:chapters'");
|
||||||
const end = mainSrc.indexOf('// 代理配置', start);
|
const end = mainSrc.indexOf('// 代理配置', start);
|
||||||
|
|||||||
Reference in New Issue
Block a user