From 488de9428928936f3fae21f01956a9b667727736 Mon Sep 17 00:00:00 2001 From: lofyer Date: Sun, 9 Aug 2026 15:25:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=20Z-Library=20=E9=95=9C=E5=83=8F=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E5=B8=83=20v2.1.5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 Z-Library 账户设置内增加镜像站点编辑入口,一行一个 HTTPS 地址, 自定义地址经 origin 规范化后优先于内置列表。镜像配置与凭据分开处理, 退出登录后仍保留;移除当前会话依赖的镜像时清理旧会话和 Cookie。 版本更新到 2.1.5,并补充存储、校验、IPC、界面与真实 Electron 保存测试。 --- main.js | 2 + package-lock.json | 4 +- package.json | 2 +- preload.js | 4 +- src/_test/auth.test.js | 30 +++++++++++ src/_test/electron/startup.integration.js | 23 ++++++++ src/_test/sources.test.js | 64 +++++++++++++++++++++++ src/_test/ui.test.js | 22 ++++++++ src/sources/zlib-auth.js | 26 ++++++++- src/sources/zlib.js | 51 +++++++++++++++++- src/ui/app.js | 32 ++++++++++++ src/ui/index.html | 7 +++ src/ui/style.css | 5 ++ 13 files changed, 265 insertions(+), 7 deletions(-) diff --git a/main.js b/main.js index 2af0b58..82a20f0 100644 --- a/main.js +++ b/main.js @@ -556,6 +556,8 @@ sources.getSource('zlib').setLoginTransport(browserZlibLogin); ipcMain.handle('zlib:hasCreds', () => wrap(() => 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())); +ipcMain.handle('zlib:getMirrors', () => wrap(() => sources.getSource('zlib').getMirrorConfig())); +ipcMain.handle('zlib:setMirrors', (_e, list) => wrap(() => sources.getSource('zlib').setMirrors(list))); // Semantic Scholar API Key ipcMain.handle('semanticScholar:keyStatus', () => wrap(() => semanticKey.status())); diff --git a/package-lock.json b/package-lock.json index dc5e2c8..58f093a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "peoplelib", - "version": "2.1.4", + "version": "2.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "peoplelib", - "version": "2.1.4", + "version": "2.1.5", "license": "MIT", "dependencies": { "foliate-js": "1.0.1", diff --git a/package.json b/package.json index 6ee26ee..f311d91 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "peoplelib", - "version": "2.1.4", + "version": "2.1.5", "description": "多源开放文献、电子书与本地书库客户端", "main": "main.js", "author": "peoplelib", diff --git a/preload.js b/preload.js index 70b07a7..321614d 100644 --- a/preload.js +++ b/preload.js @@ -129,7 +129,9 @@ contextBridge.exposeInMainWorld('api', { zlib: { hasCreds: () => ipcRenderer.invoke('zlib:hasCreds'), login: (email, password) => ipcRenderer.invoke('zlib:login', email, password), - logout: () => ipcRenderer.invoke('zlib:logout') + logout: () => ipcRenderer.invoke('zlib:logout'), + getMirrors: () => ipcRenderer.invoke('zlib:getMirrors'), + setMirrors: (list) => ipcRenderer.invoke('zlib:setMirrors', list) }, semanticScholar: { keyStatus: () => ipcRenderer.invoke('semanticScholar:keyStatus'), diff --git a/src/_test/auth.test.js b/src/_test/auth.test.js index 27ce338..618c306 100644 --- a/src/_test/auth.test.js +++ b/src/_test/auth.test.js @@ -135,6 +135,36 @@ test('customMirrors 往返不丢失', () => { assert.deepStrictEqual(auth.read().customMirrors, ['https://m1', 'https://m2']); }); +test('setCustomMirrors 不重写密文,凭据与会话都保留', () => { + const d = tmp(); + const auth = freshAuth(); + auth.init(d, fakeStorage()); + auth.write({ email: 'a@b.c', password: 'Keep', userId: '1', userKey: 'k', mirror: 'https://m1' }); + + auth.setCustomMirrors(['https://m1', 'https://m2']); + assert.deepStrictEqual(auth.getCustomMirrors(), ['https://m1', 'https://m2']); + assert.strictEqual(auth.read().password, 'Keep', '改镜像不该动凭据'); + assert.deepStrictEqual(auth.getSession(), { userId: '1', userKey: 'k', mirror: 'https://m1' }); + + auth.setCustomMirrors([]); + assert.deepStrictEqual(auth.getCustomMirrors(), []); + assert.strictEqual(auth.hasCreds(), true); +}); + +test('退出登录清掉凭据但保留自定义镜像', () => { + const d = tmp(); + const auth = freshAuth(); + auth.init(d, fakeStorage()); + auth.write({ email: 'a@b.c', password: 'p', userId: '1', userKey: 'k' }); + auth.setCustomMirrors(['https://mine.example']); + + auth.clear(); + assert.strictEqual(auth.hasCreds(), false, '凭据未清除'); + assert.strictEqual(auth.getSession(), null, '会话未清除'); + // 默认域名可能已全部失效,退出登录顺带清掉镜像会让用户无从登录 + assert.deepStrictEqual(auth.getCustomMirrors(), ['https://mine.example']); +}); + test('损坏的密文不影响会话字段读取', () => { const d = tmp(); const auth = freshAuth(); diff --git a/src/_test/electron/startup.integration.js b/src/_test/electron/startup.integration.js index fed7afc..d71aa3b 100644 --- a/src/_test/electron/startup.integration.js +++ b/src/_test/electron/startup.integration.js @@ -95,6 +95,29 @@ app.whenReady().then(async () => { proxyRoute); await win.webContents.executeJavaScript(`window.api.proxy.set('')`); + await win.webContents.executeJavaScript(`document.getElementById('zlibMirrorBtn').click()`); + await waitUntil(() => win.webContents.executeJavaScript( + `!!document.getElementById('zlibMirrorList')` + )); + await win.webContents.executeJavaScript(`(() => { + document.getElementById('zlibMirrorList').value = + 'https://mirror.example/path\\nhttps://second.example/'; + document.getElementById('modalOk').click(); + })()`); + const mirrorConfig = await waitUntil(() => win.webContents.executeJavaScript(`(async () => { + const result = await window.api.zlib.getMirrors(); + const status = document.getElementById('zlibMirrorStatus').textContent; + return result.ok && result.data.custom.length === 2 && /已自定义 2 个镜像/.test(status) + ? { custom: result.data.custom, status } + : null; + })()`)); + check('Z-Library 账户设置可保存并优先使用自定义镜像', + JSON.stringify(mirrorConfig.custom) === JSON.stringify([ + 'https://mirror.example', + 'https://second.example' + ]), + JSON.stringify(mirrorConfig)); + 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/sources.test.js b/src/_test/sources.test.js index df6839a..747dd50 100644 --- a/src/_test/sources.test.js +++ b/src/_test/sources.test.js @@ -560,6 +560,70 @@ test('zlib: 完全无效的 id 仍然拒绝', async () => { await assert.rejects(zlib.detail('not-an-id'), /无效的 Z-Library ID/); }); +test('zlib: 自定义镜像规范化、去重并优先于内置列表', () => { + const zlib = h.freshRequire('sources/zlib.js'); + const auth = require('../sources/zlib-auth'); + const orig = { + getCustomMirrors: auth.getCustomMirrors, + setCustomMirrors: auth.setCustomMirrors, + getSession: auth.getSession, + clearSession: auth.clearSession + }; + let custom = []; + auth.getCustomMirrors = () => custom.slice(); + auth.setCustomMirrors = (list) => { custom = list.slice(); }; + auth.getSession = () => null; + auth.clearSession = () => {}; + try { + const result = zlib.setMirrors([ + 'https://mirror.example/path?ignored=1', + 'https://mirror.example/', + 'https://second.example' + ]); + assert.deepStrictEqual(result.custom, [ + 'https://mirror.example', + 'https://second.example' + ]); + assert.deepStrictEqual(zlib.getMirrorConfig().custom, result.custom); + assert.ok(result.defaults.length >= 1, '内置镜像列表丢失'); + } finally { + Object.assign(auth, orig); + } +}); + +test('zlib: 自定义镜像拒绝非 HTTPS、凭据与超量输入', () => { + const zlib = h.freshRequire('sources/zlib.js'); + assert.throws(() => zlib.setMirrors(['http://mirror.example']), /必须使用 HTTPS/); + assert.throws(() => zlib.setMirrors(['https://user:pass@mirror.example']), /不能包含账号信息/); + assert.throws( + () => zlib.setMirrors(Array.from({ length: 21 }, (_, i) => `https://m${i}.example`)), + /最多只能保存 20 个镜像/ + ); +}); + +test('zlib: 移除当前会话的非内置镜像会清除旧会话', () => { + const zlib = h.freshRequire('sources/zlib.js'); + const auth = require('../sources/zlib-auth'); + const orig = { + getCustomMirrors: auth.getCustomMirrors, + setCustomMirrors: auth.setCustomMirrors, + getSession: auth.getSession, + clearSession: auth.clearSession + }; + let custom = ['https://old.example']; + let cleared = 0; + auth.getCustomMirrors = () => custom.slice(); + auth.setCustomMirrors = (list) => { custom = list.slice(); }; + auth.getSession = () => ({ userId: '1', userKey: 'k', mirror: 'https://old.example' }); + auth.clearSession = () => { cleared += 1; }; + try { + zlib.setMirrors(['https://new.example']); + assert.strictEqual(cleared, 1, '被移除镜像上的会话仍被保留'); + } finally { + Object.assign(auth, orig); + } +}); + // 实测:会话过期时 /file 返回 400 + {"success":0,"error":"Please login"}。 // 若按 HTTP 状态码短路,真实原因会被吞掉,自动重登也不会触发。 test('zlib: 4xx+JSON 的会话过期能被识别并自动重新登录', async () => { diff --git a/src/_test/ui.test.js b/src/_test/ui.test.js index 9c641c3..0e322c2 100644 --- a/src/_test/ui.test.js +++ b/src/_test/ui.test.js @@ -240,6 +240,28 @@ test('Z-Library 进入详情不消耗下载额度,点击下载后才解析并 assert.match(onDemand, /每日免费下载额度有限,仅在点击后获取下载地址/); }); +test('Z-Library 账户设置提供可编辑的镜像站点列表', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8'); + const app = fs.readFileSync(path.join(__dirname, '..', 'ui', 'app.js'), 'utf8'); + const preload = fs.readFileSync(path.join(__dirname, '..', '..', 'preload.js'), 'utf8'); + const main = fs.readFileSync(path.join(__dirname, '..', '..', 'main.js'), 'utf8'); + const css = fs.readFileSync(path.join(__dirname, '..', 'ui', 'style.css'), 'utf8'); + + const accountAt = html.indexOf('Z-Library 账号'); + const mirrorsAt = html.indexOf('id="zlibMirrorBtn"'); + const nextGroupAt = html.indexOf('
', accountAt + 30); + assert.ok(accountAt >= 0 && mirrorsAt > accountAt && mirrorsAt < nextGroupAt, + '镜像设置没有放在 Z-Library 账户设置组内'); + assert.match(app, /window\.api\.zlib\.getMirrors\(\)/); + assert.match(app, /window\.api\.zlib\.setMirrors\(lines\)/); + assert.match(app, /一行一个地址,必须是 HTTPS/); + assert.match(preload, /getMirrors:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('zlib:getMirrors'\)/); + assert.match(preload, /setMirrors:\s*\(list\)\s*=>\s*ipcRenderer\.invoke\('zlib:setMirrors', list\)/); + assert.match(main, /ipcMain\.handle\('zlib:getMirrors',[^\n]*wrap\(/); + assert.match(main, /ipcMain\.handle\('zlib:setMirrors',[^\n]*wrap\(/); + assert.match(css, /textarea\.modal-input\s*\{[^}]*height:\s*auto/); +}); + test('漫画源按分类显示,MangaDex 可筛选中文章节并设置图片画质', () => { const html = fs.readFileSync(path.join(__dirname, '..', 'ui', 'index.html'), 'utf8'); const browse = fs.readFileSync(path.join(__dirname, '..', 'ui', 'views', 'browse.js'), 'utf8'); diff --git a/src/sources/zlib-auth.js b/src/sources/zlib-auth.js index 7412f01..0607d06 100644 --- a/src/sources/zlib-auth.js +++ b/src/sources/zlib-auth.js @@ -123,11 +123,32 @@ function write(creds) { } // 清除全部(含凭据)——用于"退出登录" +// 自定义镜像是站点配置不是凭据,退出登录后要保留,否则用户每次重新登录 +// 都得重新填一遍地址,而默认域名可能全都已经失效。 function clear() { sessionCreds = null; + const mirrors = getCustomMirrors(); for (const fp of [getFilePath(), getCredPath(), `${getFilePath()}.tmp`, `${getCredPath()}.tmp`]) { try { fs.unlinkSync(fp); } catch (e) { /* ignore */ } } + if (mirrors.length) { + try { atomicWrite(getFilePath(), JSON.stringify({ customMirrors: mirrors }, null, 2)); } + catch (e) { /* 保留失败不影响退出登录 */ } + } +} + +function getCustomMirrors() { + const meta = readMeta(); + return (meta && Array.isArray(meta.customMirrors)) ? meta.customMirrors.slice() : []; +} + +// 只动镜像字段:与 setSession 同理,不重写密文,避免一次读取失败清空凭据 +function setCustomMirrors(list) { + const meta = readMeta() || {}; + const next = { ...meta }; + if (Array.isArray(list) && list.length) next.customMirrors = list.slice(); + else delete next.customMirrors; + atomicWrite(getFilePath(), JSON.stringify(next, null, 2)); } // 只清除会话令牌,保留邮箱密码以便自动重新登录 @@ -158,4 +179,7 @@ function setSession(userId, userKey, mirror) { atomicWrite(getFilePath(), JSON.stringify(next, null, 2)); } -module.exports = { init, read, write, clear, clearSession, hasCreds, getSession, setSession }; +module.exports = { + init, read, write, clear, clearSession, hasCreds, getSession, setSession, + getCustomMirrors, setCustomMirrors +}; diff --git a/src/sources/zlib.js b/src/sources/zlib.js index 8c6e60f..dfad890 100644 --- a/src/sources/zlib.js +++ b/src/sources/zlib.js @@ -43,13 +43,40 @@ function requestHeaders(mirror, includeForm = false) { } function getMirrors() { - const custom = (auth.read() || {}).customMirrors; - if (Array.isArray(custom) && custom.length) { + const custom = auth.getCustomMirrors(); + if (custom.length) { return custom.concat(DEFAULT_MIRRORS.filter((m) => !custom.includes(m))); } return DEFAULT_MIRRORS.slice(); } +const MAX_CUSTOM_MIRRORS = 20; + +// 镜像地址来自用户输入,会被直接拼进请求 URL,因此必须按 origin 归一: +// 带路径、查询串或凭据的地址会让后续 apiUrl() 拼出错误甚至泄露凭据的 URL。 +function normalizeMirror(value) { + const raw = String(value || '').trim(); + if (!raw) return ''; + let url; + try { url = new URL(raw); } catch (e) { throw new Error(`镜像地址无效:${raw}`); } + if (url.protocol !== 'https:') throw new Error(`镜像必须使用 HTTPS:${raw}`); + if (url.username || url.password) throw new Error(`镜像地址不能包含账号信息:${raw}`); + return url.origin; +} + +function normalizeMirrors(list) { + if (!Array.isArray(list)) throw new Error('镜像列表格式无效'); + if (list.length > MAX_CUSTOM_MIRRORS) { + throw new Error(`最多只能保存 ${MAX_CUSTOM_MIRRORS} 个镜像`); + } + const out = []; + for (const item of list) { + const origin = normalizeMirror(item); + if (origin && !out.includes(origin)) out.push(origin); + } + return out; +} + function apiUrl(base, path, params = {}) { const u = new URL(base + path); for (const [k, v] of Object.entries(params)) { @@ -379,5 +406,25 @@ module.exports = { return auth.hasCreds(); }, + // 默认域名变动频繁且会整批失效,用户需要能自己补上可用地址而不必等新版本 + getMirrorConfig() { + return { custom: auth.getCustomMirrors(), defaults: DEFAULT_MIRRORS.slice() }; + }, + + setMirrors(list) { + const next = normalizeMirrors(list); + const previous = auth.getCustomMirrors(); + auth.setCustomMirrors(next); + // 换镜像后旧会话绑定的域名可能已不在列表里,留着会一直优先命中失效地址 + const session = auth.getSession(); + if (session && session.mirror && !getMirrors().includes(session.mirror)) { + auth.clearSession(); + } + for (const mirror of previous) { + if (!next.includes(mirror)) clearCookies(mirror); + } + return { custom: next, defaults: DEFAULT_MIRRORS.slice() }; + }, + setLoginTransport }; diff --git a/src/ui/app.js b/src/ui/app.js index d914e43..e019d6a 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -124,6 +124,38 @@ $('zlibLogoutBtn').onclick = async () => { refreshZlibStatus(); +async function refreshZlibMirrors() { + const r = await window.api.zlib.getMirrors(); + const custom = (r.ok && r.data && r.data.custom) || []; + $('zlibMirrorStatus').textContent = custom.length + ? `已自定义 ${custom.length} 个镜像,优先于内置列表` + : '使用内置镜像列表'; +} + +$('zlibMirrorBtn').onclick = async () => { + const current = await window.api.zlib.getMirrors(); + if (!current.ok) return; + const custom = (current.data && current.data.custom) || []; + const defaults = (current.data && current.data.defaults) || []; + const r = await openModal('Z-Library 镜像站点', ` +

一行一个地址,必须是 HTTPS。自定义镜像会排在内置列表前面优先尝试;留空则只用内置列表。

+
+ +
内置镜像:${escapeHtml(defaults.join('、'))}
+
+
+ `, async () => { + const lines = $('zlibMirrorList').value.split('\n').map((s) => s.trim()).filter(Boolean); + const res = await window.api.zlib.setMirrors(lines); + if (!res.ok) { $('zlibMirrorErr').textContent = res.error || '保存失败'; return false; } + return true; + }); + if (r) { refreshZlibMirrors(); refreshZlibStatus(); } +}; + +refreshZlibMirrors(); + async function refreshLibraryDir() { const r = await window.api.library.getDir(); if (r.ok && r.data) $('libDirPath').textContent = r.data.dir + (r.data.isDefault ? '(默认)' : ''); diff --git a/src/ui/index.html b/src/ui/index.html index 1cc1296..97a9890 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -331,6 +331,13 @@
+
+
+
镜像站点
+
使用内置镜像列表
+
+ +
diff --git a/src/ui/style.css b/src/ui/style.css index 248067a..9875c32 100644 --- a/src/ui/style.css +++ b/src/ui/style.css @@ -1541,6 +1541,11 @@ body { } .modal-body input[type="text"]:focus, .modal-input:focus { border-color: var(--accent); } +/* .modal-input 写死了单行高度,多行输入必须覆盖,否则 textarea 被压成一行 */ +textarea.modal-input { + height: auto; min-height: 92px; padding: 8px 12px; + line-height: 1.6; resize: vertical; font-family: inherit; +} .modal-actions { display: flex; justify-content: flex-end; gap: 10px; } .modal-actions > .tb-btn { width: 72px; height: 32px; padding: 0; }