// 笔记独立窗口的生命周期管理。 // 单窗口多标签,形态与阅读器一致:窗口只有一个,一条笔记占一个标签。 // // 「一标签一条」是数据安全约束,不是体验优化:`reader:updateNote` 是整条覆盖、 // 无版本校验,同一条笔记开两个编辑器时后保存者会把前者的内容整块吃掉。 // // openNotes 是主进程侧的标签集镜像,由渲染层通过 `notes:tabsChanged` 上报。 // 它同时承担授权职责(见 ownsNote),所以不能只信渲染层:新增标签一律先过 // main.js 的 findNote() 用 listNotes() 对账。 const path = require('path'); const { pathToFileURL } = require('url'); const { BrowserWindow } = require('electron'); const CLOSE_TIMEOUT = 10000; let win = null; // noteId -> entryId。删除对账要按 entryId 找标签,所以存的是映射不是集合。 const openNotes = new Map(); let onChanged = null; let closeAllowed = false; let closePending = false; let closeTimer = null; function alive(target) { return !!target && !target.isDestroyed(); } function keyOf(noteId) { return String(noteId == null ? '' : noteId); } function get() { if (alive(win)) return win; win = null; return null; } function openIds() { if (!get()) return []; return [...openNotes.keys()]; } function notifyChanged() { if (typeof onChanged === 'function') onChanged(openIds()); } function setChangeListener(fn) { onChanged = typeof fn === 'function' ? fn : null; } function pageUrl(rootDir) { return pathToFileURL(path.join(rootDir, 'src', 'ui', 'note.html')).href; } function isNoteSender(wc) { const target = get(); return !!target && !!wc && target.webContents.id === wc.id; } function fromWebContents(wc) { return isNoteSender(wc) ? 'note' : null; } // 笔记窗口只能读自己已经打开的那些标签。放宽成「只要是笔记窗口就给」 // 会让这个通道变成遍历全部笔记的后门。 function ownsNote(wc, noteId) { if (!isNoteSender(wc)) return false; return openNotes.has(keyOf(noteId)); } // 渲染层只能"收窄"标签集(上报自己关掉了哪些),不能新增。 // 允许新增等于让渲染层自己扩权:谎报持有某条笔记,随后 notes:getOne 就放行了。 // 新增只能走 open(),那条路径在 main.js 里过 findNote() 对账。 function setTabs(wc, noteIds) { if (!isNoteSender(wc)) return false; const claimed = new Set(); for (const item of Array.isArray(noteIds) ? noteIds : []) { const id = keyOf(item && item.noteId != null ? item.noteId : item); if (id) claimed.add(id); } let changed = false; for (const key of [...openNotes.keys()]) { if (claimed.has(key)) continue; openNotes.delete(key); changed = true; } if (changed) notifyChanged(); return true; } function sendToWindow(channel, payload) { const target = get(); if (!target) return false; target.webContents.send(channel, payload); return true; } function create(rootDir, uiTheme) { closeAllowed = false; closePending = false; win = new BrowserWindow({ width: 1080, height: 820, minWidth: 720, minHeight: 520, 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, sandbox: false, spellcheck: false } }); const created = win; created.webContents.setWindowOpenHandler(() => ({ action: 'deny' })); created.webContents.on('will-navigate', (event, url) => { if (!String(url).startsWith(pageUrl(rootDir))) event.preventDefault(); }); // 未保存的编辑要在窗口消失之前问用户,所以必须先拦下 close 交给渲染层。 // 渲染层卡住时靠看门狗兜底,否则窗口永远关不掉。 created.on('close', (event) => { if (closeAllowed || !alive(created)) return; event.preventDefault(); if (closePending) return; closePending = true; created.webContents.send('notes:prepareClose', null); closeTimer = setTimeout(() => { if (alive(created)) { closeAllowed = true; created.destroy(); } }, CLOSE_TIMEOUT); }); created.on('closed', () => { if (win === created) win = null; openNotes.clear(); closeAllowed = false; closePending = false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; notifyChanged(); }); return created; } function open(entryId, noteId, rootDir, uiTheme = 'dark') { const key = keyOf(noteId); const entry = String(entryId); const existing = get(); if (existing) { if (existing.isMinimized()) existing.restore(); existing.focus(); // 已经开着的标签由渲染层激活,不新建第二个编辑器 existing.webContents.send('notes:openTab', { entryId: entry, noteId: key }); if (!openNotes.has(key)) { openNotes.set(key, entry); notifyChanged(); } return existing; } const created = create(rootDir, uiTheme); openNotes.set(key, entry); created.loadFile(path.join(rootDir, 'src', 'ui', 'note.html'), { query: { entryId: entry, noteId: key } }); notifyChanged(); return created; } // 笔记在别处被删除后标签必须自己退场,否则它下一次保存会把已删条目整条写回去 function closeFor(noteId) { const key = keyOf(noteId); if (!get() || !openNotes.has(key)) return false; sendToWindow('notes:closeTab', { noteIds: [key] }); openNotes.delete(key); notifyChanged(); return true; } function closeMany(noteIds) { const keys = (Array.isArray(noteIds) ? noteIds : []) .map((id) => keyOf(id)) .filter((id) => openNotes.has(id)); if (!get() || !keys.length) return 0; sendToWindow('notes:closeTab', { noteIds: keys }); for (const key of keys) openNotes.delete(key); notifyChanged(); return keys.length; } function closeForEntries(entryIds) { const targets = new Set((Array.isArray(entryIds) ? entryIds : []).map((id) => String(id))); if (!get() || !targets.size) return 0; const keys = []; for (const [key, entryId] of openNotes) { if (targets.has(entryId)) keys.push(key); } if (!keys.length) return 0; sendToWindow('notes:closeTab', { noteIds: keys }); for (const key of keys) openNotes.delete(key); notifyChanged(); return keys.length; } // 渲染层处理完未保存提示后才真正放行关闭 function shutdownReady(wc) { const target = get(); if (!target || !isNoteSender(wc) || !closePending) return false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; closeAllowed = true; closePending = false; target.close(); return true; } // 用户在未保存提示里选了取消。必须复位 closePending,否则下一次点关闭会被 // 「已在处理中」挡掉,窗口再也关不上;也必须撤掉看门狗,否则十秒后它会把 // 带着未保存内容的窗口直接销毁。 function cancelClose(wc) { if (!isNoteSender(wc) || !closePending) return false; if (closeTimer) clearTimeout(closeTimer); closeTimer = null; closePending = false; return true; } function all() { const target = get(); return target ? [target] : []; } module.exports = { open, get, all, openIds, closeFor, closeMany, closeForEntries, fromWebContents, ownsNote, setTabs, shutdownReady, cancelClose, setChangeListener };