diff --git a/main.js b/main.js index d64df07..2af0b58 100644 --- a/main.js +++ b/main.js @@ -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 */ } diff --git a/src/_test/electron/startup.integration.js b/src/_test/electron/startup.integration.js index 404489f..fed7afc 100644 --- a/src/_test/electron/startup.integration.js +++ b/src/_test/electron/startup.integration.js @@ -84,6 +84,17 @@ app.whenReady().then(async () => { const configReadyMs = Date.now() - startedAt; 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 rows = Array.from(document.querySelectorAll('#sourceList input[data-id]')).map((input) => ({ id: input.dataset.id, diff --git a/src/_test/main.test.js b/src/_test/main.test.js index b4c1dfb..19eb1d8 100644 --- a/src/_test/main.test.js +++ b/src/_test/main.test.js @@ -11,6 +11,12 @@ const { compareVersion } = h.extractFns( mainFile, 'function parseVersion', 'async function checkUpdate', ['compareVersion'] ); 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 () => { const r = await wrap(() => { throw new Error('未知数据源: nope'); }); @@ -43,6 +49,104 @@ test('所有 IPC handler 都通过 thunk 调用 wrap', () => { 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 通过源能力下载并按请求隔离进度', () => { const start = mainSrc.indexOf("ipcMain.handle('source:chapters'"); const end = mainSrc.indexOf('// 代理配置', start);