// 阅读器独立窗口的生命周期管理。 // 全局只保留一个阅读器窗口,书籍通过窗口内标签切换,避免同一 PDF 出现两个并发编辑器。 const path = require('path'); const { pathToFileURL } = require('url'); const { BrowserWindow } = require('electron'); let readerWindow = null; let rendererReady = false; let pendingMessages = []; let closeAllowed = false; let closePending = false; let closeTimer = null; function alive(win) { return !!win && !win.isDestroyed(); } function get() { if (alive(readerWindow)) return readerWindow; readerWindow = null; return null; } function sendEntry(win, channel, payload) { if (!rendererReady) { pendingMessages.push([channel, payload]); return; } win.webContents.send(channel, payload); } function markReady(webContents) { const win = get(); if (!win || win.webContents.id !== webContents.id) return false; rendererReady = true; const messages = pendingMessages; pendingMessages = []; for (const [channel, payload] of messages) { if (alive(win)) win.webContents.send(channel, payload); } return true; } function isReady(win) { return alive(win) && win === get() && rendererReady; } function open(entryId, rootDir, fileIndex, locator, uiTheme = 'dark') { const existing = get(); if (existing) { if (existing.isMinimized()) existing.restore(); const payload = { entryId: String(entryId), fileIndex: Number.isInteger(fileIndex) ? fileIndex : null }; if (locator && typeof locator === 'object') payload.locator = locator; sendEntry(existing, 'reader:openEntry', payload); existing.focus(); return existing; } readerWindow = new BrowserWindow({ width: 1180, height: 900, minWidth: 760, minHeight: 540, frame: false, backgroundColor: '#141414', icon: path.join( rootDir, 'icons', 'dist', uiTheme === 'light' ? 'book-ai-light.ico' : 'book-ai-dark.ico' ), title: 'PeopleLib', webPreferences: { preload: path.join(rootDir, 'preload.js'), contextIsolation: true, nodeIntegration: false, // 重排图书正文来自不可信来源,即使已净化也不给它任何 Node 能力 sandbox: false, spellcheck: false } }); rendererReady = false; pendingMessages = []; closeAllowed = false; closePending = false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; readerWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); readerWindow.webContents.on('will-navigate', (event, url) => { const expected = pathToFileURL(path.join(rootDir, 'src', 'ui', 'reader.html')).href; if (!String(url).startsWith(expected)) event.preventDefault(); }); const query = { entryId: String(entryId) }; if (Number.isInteger(fileIndex)) query.fileIndex = String(fileIndex); if (locator && typeof locator === 'object') query.locator = JSON.stringify(locator); readerWindow.loadFile(path.join(rootDir, 'src', 'ui', 'reader.html'), { query }); readerWindow.on('close', (event) => { if (closeAllowed || !alive(readerWindow)) return; event.preventDefault(); if (closePending) return; closePending = true; sendEntry(readerWindow, 'reader:prepareClose', null); closeTimer = setTimeout(() => { const win = get(); if (win) win.destroy(); }, 10000); }); readerWindow.on('closed', () => { readerWindow = null; rendererReady = false; pendingMessages = []; closeAllowed = false; closePending = false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; }); return readerWindow; } function closeFor(entryId) { const win = get(); if (win) sendEntry(win, 'reader:closeEntry', String(entryId)); } function purgeFor(entryId, requestId) { const win = get(); if (win) { sendEntry(win, 'reader:purgeEntry', { entryId: String(entryId), requestId: String(requestId) }); } return !!win; } function shutdownReady(webContents) { const win = get(); if (!win || win.webContents.id !== webContents.id || !closePending) return false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; closeAllowed = true; closePending = false; win.close(); return true; } function fromWebContents(wc) { const win = get(); return win && win.webContents.id === wc.id ? 'reader' : null; } function all() { const win = get(); return win ? [win] : []; } module.exports = { open, get, closeFor, purgeFor, fromWebContents, all, markReady, isReady, shutdownReady };