From e11c371bbe482cbf1de589c0720243b3532e9c79 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 11:20:09 +0800 Subject: [PATCH 1/9] feat: add multi-window editor support --- CHANGELOG.md | 1 + CLAUDE.md | 7 + README.md | 2 +- src/main/editorWindowManager.ts | 256 +++++++++++ src/main/index.ts | 531 ++++++++++++++++------- src/main/windowCloseController.ts | 43 +- src/preload/index.ts | 29 ++ src/renderer/src/App.svelte | 114 ++++- src/renderer/src/api.d.ts | 21 +- src/renderer/src/lib/i18n/en.json | 1 + src/renderer/src/lib/i18n/zh.json | 1 + tests/main/editorWindowManager.test.ts | 95 ++++ tests/main/windowCloseController.test.ts | 14 + 13 files changed, 914 insertions(+), 201 deletions(-) create mode 100644 src/main/editorWindowManager.ts create mode 100644 tests/main/editorWindowManager.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d387b84..f7a0b28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Multi-window editing with one independently autosaved presentation per editor window, per-editor presentation/debug windows, duplicate-file focusing, and cross-platform file-open routing - Math elements with TeX/LaTeX editing and MathJax-rendered SVG output - SVG image imports and animated GIF playback in presentation mode - Shape fill and stroke controls, including transparent fills and configurable borders diff --git a/CLAUDE.md b/CLAUDE.md index 4fbcf33..3fc67bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,13 @@ twig follows Electron's standard architecture: 2. **Preload Script** (`src/preload/index.ts`): Bridge layer that safely exposes IPC handlers to the renderer via `contextBridge` 3. **Renderer Process** (`src/renderer/`): Svelte application running in the browser context +### Multi-Window Ownership (`src/main/editorWindowManager.ts`) + +- Every editor renderer owns exactly one committed presentation and may temporarily reserve one replacement path while opening or saving. +- Canonical path identities prevent the same `.tb` file from being owned by multiple editor windows. +- Presentation and debug windows are scoped to an owning editor; their IPC must always route through that owner. +- Each editor runs in a separate renderer process, so the module-level Svelte state remains isolated per window. + ### Database Layer (`src/main/db.ts`) - Uses better-sqlite3 for synchronous SQLite operations diff --git a/README.md b/README.md index e839d16..efd7e21 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Presentations are stored as `.tb` files — plain SQLite databases. No cloud req ## Status -twig is in active development. It handles the essentials — editing, alignment guides, transitions, animations, custom fonts, backgrounds — and works well for day-to-day use. +twig is in active development. It handles the essentials — editing, multi-window workflows, alignment guides, transitions, animations, custom fonts, backgrounds — and works well for day-to-day use. On the roadmap: templates, element grouping, and more transitions and animation types. diff --git a/src/main/editorWindowManager.ts b/src/main/editorWindowManager.ts new file mode 100644 index 0000000..fe15bb0 --- /dev/null +++ b/src/main/editorWindowManager.ts @@ -0,0 +1,256 @@ +import fs from 'fs' +import { dirname, join, normalize, resolve } from 'path' + +export interface ManagedWebContents { + id: number +} + +export interface ManagedWindow { + id: number + webContents: ManagedWebContents + isDestroyed(): boolean + isMinimized(): boolean + restore(): void + show(): void + focus(): void +} + +export type DocumentReservationResult = 'reserved' | 'already-current' | 'focused-existing' +export type AuxiliaryWindowRole = 'debug' | 'presentation' + +export interface EditorWindowRecord { + window: W + launchFile: string | null + currentPath: string | null + currentIdentity: string | null + pendingPath: string | null + pendingIdentity: string | null + debugWindow: W | null + presentationWindow: W | null + lastFocusedAt: number +} + +function canonicalPathIdentity(filePath: string): string { + const absolutePath = normalize(resolve(filePath)) + try { + return fs.realpathSync.native(absolutePath) + } catch { + const parentPath = fs.realpathSync.native(dirname(absolutePath)) + return join(parentPath, absolutePath.slice(dirname(absolutePath).length + 1)) + } +} + +export class EditorWindowManager { + private readonly editors = new Map>() + private readonly editorByWebContentsId = new Map() + private readonly auxiliaryOwnerByWebContentsId = new Map< + number, + { editorId: number; role: AuxiliaryWindowRole } + >() + private focusSequence = 0 + + constructor( + private readonly identifyPath: (filePath: string) => string = canonicalPathIdentity + ) {} + + registerEditor(window: W, launchFile: string | null = null): EditorWindowRecord { + const record: EditorWindowRecord = { + window, + launchFile, + currentPath: null, + currentIdentity: null, + pendingPath: launchFile, + pendingIdentity: launchFile ? this.identifyPath(launchFile) : null, + debugWindow: null, + presentationWindow: null, + lastFocusedAt: ++this.focusSequence + } + this.editors.set(window.id, record) + this.editorByWebContentsId.set(window.webContents.id, window.id) + return record + } + + unregisterEditor(window: W): EditorWindowRecord | null { + const record = this.editors.get(window.id) ?? null + if (!record) return null + + this.editors.delete(window.id) + this.editorByWebContentsId.delete(window.webContents.id) + for (const auxiliary of [record.debugWindow, record.presentationWindow]) { + if (auxiliary) this.auxiliaryOwnerByWebContentsId.delete(auxiliary.webContents.id) + } + return record + } + + getEditors(): EditorWindowRecord[] { + return [...this.editors.values()].filter((record) => !record.window.isDestroyed()) + } + + getEditor(window: W | null): EditorWindowRecord | null { + return window ? (this.editors.get(window.id) ?? null) : null + } + + getEditorByWebContentsId(webContentsId: number): EditorWindowRecord | null { + const editorId = this.editorByWebContentsId.get(webContentsId) + return editorId === undefined ? null : (this.editors.get(editorId) ?? null) + } + + getOwnerByWebContentsId(webContentsId: number): EditorWindowRecord | null { + const editor = this.getEditorByWebContentsId(webContentsId) + if (editor) return editor + const owner = this.auxiliaryOwnerByWebContentsId.get(webContentsId) + return owner ? (this.editors.get(owner.editorId) ?? null) : null + } + + noteFocused(window: W): void { + const owner = this.getEditor(window) ?? this.getOwnerByWebContentsId(window.webContents.id) + if (owner) owner.lastFocusedAt = ++this.focusSequence + } + + getActiveEditor(focusedWindow: W | null = null): EditorWindowRecord | null { + const focusedOwner = focusedWindow + ? (this.getEditor(focusedWindow) ?? + this.getOwnerByWebContentsId(focusedWindow.webContents.id)) + : null + if (focusedOwner) return focusedOwner + + return ( + this.getEditors().sort((left, right) => right.lastFocusedAt - left.lastFocusedAt)[0] ?? null + ) + } + + consumeLaunchFile(window: W): string | null { + const record = this.getEditor(window) + if (!record) return null + const launchFile = record.launchFile + record.launchFile = null + return launchFile + } + + findDocumentOwner(filePath: string, excludingWindow?: W): EditorWindowRecord | null { + const identity = this.identifyPath(filePath) + return ( + this.getEditors().find( + (record) => + record.window.id !== excludingWindow?.id && + (record.currentIdentity === identity || record.pendingIdentity === identity) + ) ?? null + ) + } + + reserveDocument(window: W, filePath: string): DocumentReservationResult { + const record = this.getEditor(window) + if (!record) throw new Error('Document reservations require an editor window') + + const identity = this.identifyPath(filePath) + if (record.currentIdentity === identity) return 'already-current' + if (record.pendingIdentity === identity) return 'reserved' + + const existingOwner = this.findDocumentOwner(filePath, window) + if (existingOwner) { + this.focus(existingOwner.window) + return 'focused-existing' + } + + record.pendingPath = filePath + record.pendingIdentity = identity + return 'reserved' + } + + commitDocument(window: W, filePath: string): string | null { + const record = this.getEditor(window) + if (!record) throw new Error('Document commits require an editor window') + + const identity = this.identifyPath(filePath) + if (record.pendingIdentity !== identity && record.currentIdentity !== identity) { + throw new Error('Cannot commit an unreserved document') + } + + const previousPath = record.currentPath + record.currentPath = filePath + record.currentIdentity = identity + record.pendingPath = null + record.pendingIdentity = null + return previousPath + } + + cancelDocument(window: W, filePath: string): void { + const record = this.getEditor(window) + if (!record) return + const identity = this.identifyPath(filePath) + if (record.pendingIdentity === identity) { + record.pendingPath = null + record.pendingIdentity = null + } + } + + bindCreatedDocument(window: W, filePath: string): void { + const result = this.reserveDocument(window, filePath) + if (result !== 'reserved' && result !== 'already-current') { + throw new Error('Created document path is already owned by another window') + } + this.commitDocument(window, filePath) + } + + releaseDocument(window: W, filePath: string): void { + const record = this.getEditor(window) + if (!record) return + const identity = this.identifyPath(filePath) + if (record.currentIdentity === identity) { + record.currentPath = null + record.currentIdentity = null + } + if (record.pendingIdentity === identity) { + record.pendingPath = null + record.pendingIdentity = null + } + } + + ownsDocument(window: W, filePath: string, includePending = true): boolean { + const record = this.getEditor(window) + if (!record) return false + const identity = this.identifyPath(filePath) + return ( + record.currentIdentity === identity || (includePending && record.pendingIdentity === identity) + ) + } + + attachAuxiliary(owner: W, role: AuxiliaryWindowRole, auxiliary: W): void { + const record = this.getEditor(owner) + if (!record) throw new Error('Auxiliary windows require an editor owner') + if (role === 'debug') record.debugWindow = auxiliary + else record.presentationWindow = auxiliary + this.auxiliaryOwnerByWebContentsId.set(auxiliary.webContents.id, { + editorId: owner.id, + role + }) + } + + detachAuxiliary(auxiliary: W): void { + const owner = this.auxiliaryOwnerByWebContentsId.get(auxiliary.webContents.id) + if (!owner) return + const record = this.editors.get(owner.editorId) + if (record) { + if (owner.role === 'debug' && record.debugWindow === auxiliary) record.debugWindow = null + if (owner.role === 'presentation' && record.presentationWindow === auxiliary) { + record.presentationWindow = null + } + } + this.auxiliaryOwnerByWebContentsId.delete(auxiliary.webContents.id) + } + + getAuxiliary(owner: W, role: AuxiliaryWindowRole): W | null { + const record = this.getEditor(owner) + return role === 'debug' ? (record?.debugWindow ?? null) : (record?.presentationWindow ?? null) + } + + focus(window: W): void { + if (window.isDestroyed()) return + if (window.isMinimized()) window.restore() + window.show() + window.focus() + this.noteFocused(window) + } +} + +export { canonicalPathIdentity } diff --git a/src/main/index.ts b/src/main/index.ts index d878325..af75d6b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,14 +19,15 @@ import { Menu } from 'electron' import { autoUpdater } from 'electron-updater' -import { join, basename, extname, sep, resolve, relative, isAbsolute } from 'path' +import { join, basename, extname, sep, resolve, relative, isAbsolute, normalize } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import * as dbService from './db' import type { Slide, FontData } from './db' import { getPref, setPref } from './prefs' import * as bookmarksService from './bookmarks' -import { createWindowCloseController } from './windowCloseController' +import { closeWindowsSequentially, createWindowCloseController } from './windowCloseController' +import { EditorWindowManager } from './editorWindowManager' import { safeLog, formatError } from './logging' import { getTempDir, @@ -63,6 +64,9 @@ import os from 'os' import crypto from 'crypto' import fontkit from 'fontkit' +const hasSingleInstanceLock = app.requestSingleInstanceLock() +if (!hasSingleInstanceLock) app.quit() + // Suppress EIO errors on stdout/stderr that occur when the computer sleeps. // Node.js emits 'error' events asynchronously on these streams when the // underlying pipe is broken; without a listener, they crash the process. @@ -87,6 +91,8 @@ const exportFolderAllowlist = new Set() const exportFolderBookmarks = new Map() const allowedSystemFontPaths = new Set() +type SaveLocationResult = { status: 'saved'; filePath: string } | { status: 'focused-existing' } + function assertAllowedSystemFontPath(fontPath: unknown): string { if (typeof fontPath !== 'string' || !isAbsolute(fontPath)) { throw new Error('Font path must be absolute') @@ -404,77 +410,82 @@ function getSystemFonts(): SystemFont[] { // Window Management // ============================================================================ -/** - * Creates and configures the main application window. - * Sets up window properties, event handlers, and loads the renderer content. - */ -let mainWindow: BrowserWindow | null = null +const editorWindows = new EditorWindowManager() +const editorCloseControllers = new Map>() +const readyEditorIds = new Set() +const queuedEditorOpenFiles = new Map() -function getMainWindow(): BrowserWindow | null { - if (mainWindow && !mainWindow.isDestroyed()) { - return mainWindow - } +function getActiveEditorWindow(): BrowserWindow | null { + return editorWindows.getActiveEditor(BrowserWindow.getFocusedWindow())?.window ?? null +} - mainWindow = null - return null +function getEditorForSender(senderId: number): BrowserWindow | null { + return editorWindows.getEditorByWebContentsId(senderId)?.window ?? null } -function focusMainWindow(window: BrowserWindow): void { +function getOwnerForSender(senderId: number): BrowserWindow | null { + return editorWindows.getOwnerByWebContentsId(senderId)?.window ?? null +} + +function assertSenderOwnsDocument(senderId: number, filePath: string): BrowserWindow { + const editor = getEditorForSender(senderId) + const owner = editor ?? getOwnerForSender(senderId) + if (!owner || !editorWindows.ownsDocument(owner, filePath, Boolean(editor))) { + throw new Error('Presentation is owned by another editor window') + } + return owner +} + +function sendToEditor(window: BrowserWindow, channel: string, ...args: unknown[]): void { if (window.isDestroyed()) return - if (window.isMinimized()) { - window.restore() + const send = (): void => { + if (!window.isDestroyed()) window.webContents.send(channel, ...args) } - window.show() - window.focus() + if (window.webContents.isLoadingMainFrame()) window.webContents.once('did-finish-load', send) + else send() } -function showOrCreateMainWindow(): BrowserWindow { - const existingWindow = getMainWindow() +function showOrCreateEditorWindow(): BrowserWindow { + const existingWindow = getActiveEditorWindow() if (existingWindow) { - focusMainWindow(existingWindow) + editorWindows.focus(existingWindow) return existingWindow } - return createWindow() } function openSettingsInMainWindow(): void { - const existingWindow = getMainWindow() + const existingWindow = getActiveEditorWindow() if (existingWindow) { - focusMainWindow(existingWindow) - existingWindow.webContents.send('app:open-settings') + editorWindows.focus(existingWindow) + sendToEditor(existingWindow, 'app:open-settings') return } const window = createWindow() - window.webContents.once('did-finish-load', () => { - if (!window.isDestroyed()) { - window.webContents.send('app:open-settings') - } - }) + sendToEditor(window, 'app:open-settings') } function openExportImagesInMainWindow(): void { - const existingWindow = getMainWindow() + const existingWindow = getActiveEditorWindow() if (existingWindow) { - focusMainWindow(existingWindow) - existingWindow.webContents.send('menu:export-images') + editorWindows.focus(existingWindow) + sendToEditor(existingWindow, 'menu:export-images') return } - const window = showOrCreateMainWindow() - window.webContents.once('did-finish-load', () => { - if (!window.isDestroyed()) { - window.webContents.send('menu:export-images') - } - }) + const window = showOrCreateEditorWindow() + sendToEditor(window, 'menu:export-images') } -function createWindow(): BrowserWindow { - const existingWindow = getMainWindow() - if (existingWindow) { - focusMainWindow(existingWindow) - return existingWindow +function createWindow(launchFile: string | null = null): BrowserWindow { + if (launchFile) { + ensureMasFileAccess(launchFile) + const existingOwner = editorWindows.findDocumentOwner(launchFile) + if (existingOwner) { + editorWindows.focus(existingOwner.window) + return existingOwner.window + } } const window = new BrowserWindow({ @@ -493,7 +504,8 @@ function createWindow(): BrowserWindow { additionalArguments: ['--twig-window-role=editor'] } }) - mainWindow = window + editorWindows.registerEditor(window, launchFile) + window.on('focus', () => editorWindows.noteFocused(window)) let hasShownWindow = false let showFallbackTimeout: NodeJS.Timeout | null = null @@ -523,14 +535,11 @@ function createWindow(): BrowserWindow { window, ipcMain, timeoutMs: 30000, - getIsQuitting: () => isQuitting, - setIsQuitting: (value) => { - isQuitting = value - }, - quitApp: () => { - app.quit() - } + getIsQuitting: () => false, + setIsQuitting: () => {}, + quitApp: () => {} }) + editorCloseControllers.set(window.id, closeController) window.on('close', (event) => { closeController.handleClose(event) @@ -557,38 +566,32 @@ function createWindow(): BrowserWindow { } window.on('closed', () => { - if (mainWindow === window) { - mainWindow = null + const queuedOpenFiles = queuedEditorOpenFiles.get(window.id) ?? [] + const record = editorWindows.getEditor(window) + for (const auxiliary of [record?.debugWindow, record?.presentationWindow]) { + if (auxiliary && !auxiliary.isDestroyed()) auxiliary.destroy() + } + editorWindows.unregisterEditor(window) + editorCloseControllers.delete(window.id) + readyEditorIds.delete(window.id) + queuedEditorOpenFiles.delete(window.id) + setupAppMenu() + if (!isQuitting) { + for (const filePath of queuedOpenFiles) setImmediate(() => routeExternalOpen(filePath)) } }) return window } -/** - * Reference to the debug window (if open). - * Only one debug window can be open at a time. - */ -let debugWindow: BrowserWindow | null = null - -/** - * Reference to the presentation window (if open). - * Only one presentation window can be open at a time. - */ -let presentationWindow: BrowserWindow | null = null - -/** - * Creates and opens the debug window. - * If a debug window is already open, focuses it instead of creating a new one. - */ -function createDebugWindow(): void { - // If debug window already exists, focus it - if (debugWindow && !debugWindow.isDestroyed()) { - debugWindow.focus() +function createDebugWindow(owner: BrowserWindow): void { + const existingWindow = editorWindows.getAuxiliary(owner, 'debug') + if (existingWindow && !existingWindow.isDestroyed()) { + editorWindows.focus(existingWindow) return } - debugWindow = new BrowserWindow({ + const debugWindow = new BrowserWindow({ width: 800, height: 900, title: 'twig Debug Panel', @@ -603,14 +606,16 @@ function createDebugWindow(): void { additionalArguments: ['--twig-window-role=debug'] } }) + editorWindows.attachAuxiliary(owner, 'debug', debugWindow) + debugWindow.on('focus', () => editorWindows.noteFocused(debugWindow)) debugWindow.on('ready-to-show', () => { - debugWindow?.show() + if (!debugWindow.isDestroyed()) debugWindow.show() }) // Clean up reference when window is closed debugWindow.on('closed', () => { - debugWindow = null + editorWindows.detachAuxiliary(debugWindow) }) // Load the debug window content @@ -625,13 +630,14 @@ function createDebugWindow(): void { * Creates and opens the presentation window in fullscreen. * If already open, focuses it instead. */ -function createPresentationWindow(): void { - if (presentationWindow && !presentationWindow.isDestroyed()) { - presentationWindow.focus() +function createPresentationWindow(owner: BrowserWindow): void { + const existingWindow = editorWindows.getAuxiliary(owner, 'presentation') + if (existingWindow && !existingWindow.isDestroyed()) { + editorWindows.focus(existingWindow) return } - presentationWindow = new BrowserWindow({ + const presentationWindow = new BrowserWindow({ fullscreen: true, frame: false, title: 'twig Presentation', @@ -646,15 +652,16 @@ function createPresentationWindow(): void { additionalArguments: ['--twig-window-role=presentation'] } }) + editorWindows.attachAuxiliary(owner, 'presentation', presentationWindow) + presentationWindow.on('focus', () => editorWindows.noteFocused(presentationWindow)) presentationWindow.on('ready-to-show', () => { - presentationWindow?.show() + if (!presentationWindow.isDestroyed()) presentationWindow.show() }) presentationWindow.on('closed', () => { - // Notify main window that presentation was closed - getMainWindow()?.webContents.send('presentation:window-closed') - presentationWindow = null + sendToEditor(owner, 'presentation:window-closed') + editorWindows.detachAuxiliary(presentationWindow) }) if (is.dev && process.env['ELECTRON_RENDERER_URL']) { @@ -668,30 +675,53 @@ function createPresentationWindow(): void { // File Association Handling // ============================================================================ -// Path of a .tb file to open on launch (set by OS file association) -let fileToOpen: string | null = null +const pendingOpenFiles: string[] = [] + +function presentationPathsFromArgv(argv: string[]): string[] { + return argv + .filter((argument) => argument.toLowerCase().endsWith('.tb')) + .map((argument) => (isAbsolute(argument) ? normalize(argument) : resolve(argument))) +} + +function routeExternalOpen(filePath: string): void { + ensureMasFileAccess(filePath) + const existingOwner = editorWindows.findDocumentOwner(filePath) + if (existingOwner) { + editorWindows.focus(existingOwner.window) + return + } + + const activeEditor = getActiveEditorWindow() + if (!activeEditor) { + createWindow(filePath) + return + } + if (readyEditorIds.has(activeEditor.id)) { + sendToEditor(activeEditor, 'app:open-file', filePath) + return + } + const queued = queuedEditorOpenFiles.get(activeEditor.id) ?? [] + queued.push(filePath) + queuedEditorOpenFiles.set(activeEditor.id, queued) +} // macOS: open-file fires before and after app ready when double-clicking a .tb file app.on('open-file', (event, path) => { event.preventDefault() - if (path.endsWith('.tb')) { - fileToOpen = path - ensureMasFileAccess(path) - const window = getMainWindow() - if (window) { - focusMainWindow(window) - window.webContents.send('app:open-file', path) - } else if (app.isReady()) { - createWindow() - } + if (path.toLowerCase().endsWith('.tb')) { + if (app.isReady()) routeExternalOpen(path) + else pendingOpenFiles.push(path) } }) -// Windows / Linux: the file path is passed as a CLI argument -if (process.platform !== 'darwin') { - const argFile = process.argv.slice(1).find((a) => a.endsWith('.tb')) - if (argFile) fileToOpen = argFile -} +if (process.platform !== 'darwin') + pendingOpenFiles.push(...presentationPathsFromArgv(process.argv.slice(1))) + +app.on('second-instance', (_event, argv) => { + const paths = presentationPathsFromArgv(argv) + for (const filePath of paths) routeExternalOpen(filePath) + if (paths.length === 0) showOrCreateEditorWindow() +}) // ============================================================================ // Application Menu @@ -707,6 +737,24 @@ function setupAppMenu(): void { const fileMenu: Electron.MenuItemConstructorOptions = { label: 'File', submenu: [ + { + label: 'New Presentation', + accelerator: 'CmdOrCtrl+N', + click: () => { + const window = getActiveEditorWindow() + if (window) sendToEditor(window, 'menu:new-presentation') + else createWindow() + } + }, + { + label: 'Open Presentation…', + accelerator: 'CmdOrCtrl+O', + click: () => { + const window = getActiveEditorWindow() ?? createWindow() + sendToEditor(window, 'menu:open-presentation') + } + }, + { type: 'separator' as const }, ...(process.platform === 'darwin' ? [] : [ @@ -781,7 +829,9 @@ function setupAppMenu(): void { click: () => { const next = !getPref('snapToGuides') setPref('snapToGuides', next) - getMainWindow()?.webContents.send('snap:changed', next) + for (const record of editorWindows.getEditors()) { + sendToEditor(record.window, 'snap:changed', next) + } setupAppMenu() } }, @@ -795,9 +845,9 @@ function setupAppMenu(): void { role: 'window', submenu: [ { - label: 'Show Main Window', + label: 'Show Editor Window', click: () => { - showOrCreateMainWindow() + showOrCreateEditorWindow() } }, { type: 'separator' }, @@ -817,6 +867,7 @@ function setupAppMenu(): void { // ============================================================================ app.whenReady().then(() => { + if (!hasSingleInstanceLock) return // Ensure the temp directory exists and clean up stale temp files from previous sessions. ensureTempDir() @@ -879,8 +930,9 @@ app.whenReady().then(() => { } } else if (key === 'snapToGuides' && typeof value === 'boolean') { setPref('snapToGuides', value) - // Only the main editor window owns the interactive canvas - getMainWindow()?.webContents.send('snap:changed', value) + for (const record of editorWindows.getEditors()) { + sendToEditor(record.window, 'snap:changed', value) + } // Rebuild the menu so the checkbox state stays in sync setupAppMenu() } @@ -1120,9 +1172,10 @@ app.whenReady().then(() => { /** * Retrieves all slide IDs from a presentation file. */ - ipcMain.handle('db:get-slide-ids', (_event, filePath: string): string[] => { + ipcMain.handle('db:get-slide-ids', (event, filePath: string): string[] => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.getSlideIds(db)) } catch (error) { console.error('Error in db:get-slide-ids:', error) @@ -1133,9 +1186,10 @@ app.whenReady().then(() => { /** * Loads a specific slide with all its elements from the database. */ - ipcMain.handle('db:get-slide', (_event, filePath: string, slideId: string): Slide | null => { + ipcMain.handle('db:get-slide', (event, filePath: string, slideId: string): Slide | null => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) validateSlideId(slideId) return withDbConnection(filePath, (db) => dbService.getSlide(db, slideId)) } catch (error) { @@ -1147,9 +1201,10 @@ app.whenReady().then(() => { /** * Creates a new blank slide in the database. */ - ipcMain.handle('db:create-slide', (_event, filePath: string): Slide => { + ipcMain.handle('db:create-slide', (event, filePath: string): Slide => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.createSlide(db), { syncShadowBack: true, write: true @@ -1163,9 +1218,10 @@ app.whenReady().then(() => { /** * Duplicates a slide and inserts the copy immediately after the source. */ - ipcMain.handle('db:duplicate-slide', (_event, filePath: string, slideId: string): Slide => { + ipcMain.handle('db:duplicate-slide', (event, filePath: string, slideId: string): Slide => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) validateSlideId(slideId) return withDbConnection(filePath, (db) => dbService.duplicateSlide(db, slideId), { syncShadowBack: true, @@ -1180,9 +1236,10 @@ app.whenReady().then(() => { /** * Saves a slide and all its elements to the database. */ - ipcMain.handle('db:save-slide', (_event, filePath: string, slide: Slide): void => { + ipcMain.handle('db:save-slide', (event, filePath: string, slide: Slide): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) validateSlideId(slide.id) withDbConnection(filePath, (db) => dbService.saveSlide(db, slide), { syncShadowBack: true, @@ -1199,9 +1256,10 @@ app.whenReady().then(() => { */ ipcMain.handle( 'db:save-thumbnail', - (_event, filePath: string, slideId: string, thumbnail: string): void => { + (event, filePath: string, slideId: string, thumbnail: string): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) validateSlideId(slideId) withDbConnection(filePath, (db) => dbService.saveThumbnail(db, slideId, thumbnail), { syncShadowBack: true, @@ -1217,9 +1275,10 @@ app.whenReady().then(() => { /** * Retrieves all stored thumbnails for a presentation. */ - ipcMain.handle('db:get-thumbnails', (_event, filePath: string): Record => { + ipcMain.handle('db:get-thumbnails', (event, filePath: string): Record => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.getThumbnails(db)) } catch (error) { console.error('Error in db:get-thumbnails:', error) @@ -1227,9 +1286,10 @@ app.whenReady().then(() => { } }) - ipcMain.handle('db:get-setting', (_event, filePath: string, key: string): string | null => { + ipcMain.handle('db:get-setting', (event, filePath: string, key: string): string | null => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.getSetting(db, key)) } catch (error) { console.error('Error in db:get-setting:', error) @@ -1239,9 +1299,10 @@ app.whenReady().then(() => { ipcMain.handle( 'db:set-setting', - (_event, filePath: string, key: string, value: string | null): void => { + (event, filePath: string, key: string, value: string | null): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) withDbConnection(filePath, (db) => dbService.setSetting(db, key, value), { syncShadowBack: true, write: true @@ -1255,9 +1316,10 @@ app.whenReady().then(() => { ipcMain.handle( 'db:apply-background-to-all', - (_event, filePath: string, background: dbService.SlideBackground | null): void => { + (event, filePath: string, background: dbService.SlideBackground | null): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) withDbConnection(filePath, (db) => dbService.applyBackgroundToAllSlides(db, background), { syncShadowBack: true, write: true @@ -1269,9 +1331,10 @@ app.whenReady().then(() => { } ) - ipcMain.handle('db:delete-slide', (_event, filePath: string, slideId: string): void => { + ipcMain.handle('db:delete-slide', (event, filePath: string, slideId: string): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) validateSlideId(slideId) withDbConnection(filePath, (db) => dbService.deleteSlide(db, slideId), { syncShadowBack: true, @@ -1283,9 +1346,10 @@ app.whenReady().then(() => { } }) - ipcMain.handle('db:reorder-slides', (_event, filePath: string, orderedIds: string[]): void => { + ipcMain.handle('db:reorder-slides', (event, filePath: string, orderedIds: string[]): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) for (const id of orderedIds) validateSlideId(id) withDbConnection(filePath, (db) => dbService.reorderSlides(db, orderedIds), { syncShadowBack: true, @@ -1302,8 +1366,12 @@ app.whenReady().then(() => { * Used before overwriting or deleting a file. * Uses PASSIVE checkpoint mode for non-blocking WAL flush. */ - ipcMain.handle('db:close-connection', (_event, filePath: string): void => { + ipcMain.handle('db:close-connection', (event, filePath: string): void => { validateFilePath(filePath) + const window = getEditorForSender(event.sender.id) + if (!window || !editorWindows.ownsDocument(window, filePath)) { + throw new Error('Cannot close a presentation owned by another editor window') + } closeDbConnection(filePath, 'passive', { forgetReadOnly: true }) }) @@ -1312,9 +1380,10 @@ app.whenReady().then(() => { * caching a connection. Used by the renderer's open flow to distinguish * fresh/legacy/current/tooNew/notTwig before committing to an open mode. */ - ipcMain.handle('db:probe-format', (_event, filePath: string): dbService.FormatProbeResult => { + ipcMain.handle('db:probe-format', (event, filePath: string): dbService.FormatProbeResult => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return probeDatabaseFormat(filePath) } catch (error) { console.error('Error in db:probe-format:', error) @@ -1330,9 +1399,13 @@ app.whenReady().then(() => { */ ipcMain.handle( 'db:open-for-edit', - (_event, filePath: string, options?: { readOnly?: boolean }): string[] => { + (event, filePath: string, options?: { readOnly?: boolean }): string[] => { try { validateFilePath(filePath) + const window = getEditorForSender(event.sender.id) + if (!window || !editorWindows.ownsDocument(window, filePath)) { + throw new Error('Presentation must be reserved by this editor before opening') + } const readOnly = options?.readOnly === true if (readOnly) { // getReadOnlyConnection validates format via detectFormat and only @@ -1354,7 +1427,7 @@ app.whenReady().then(() => { * Creates a new temporary database for an unsaved presentation. * Returns the path to the temp database file. */ - ipcMain.handle('db:create-temp', (): string => { + ipcMain.handle('db:create-temp', (event): string => { try { ensureTempDir() const tempPath = createTempDbPath() @@ -1365,6 +1438,10 @@ app.whenReady().then(() => { // Track this as a temp file for cleanup — only after successful init. registerTempFile(tempPath) + const window = getEditorForSender(event.sender.id) + if (!window) throw new Error('Temporary presentations require an editor window') + editorWindows.bindCreatedDocument(window, tempPath) + safeLog(`Created temp database: ${tempPath}`) return tempPath } catch (error) { @@ -1379,9 +1456,10 @@ app.whenReady().then(() => { * so recovered temp files from crashes are still recognized as temporary. * Resolves symlinks to prevent path traversal attacks. */ - ipcMain.handle('db:is-temp-file', (_event, filePath: string): boolean => { + ipcMain.handle('db:is-temp-file', (event, filePath: string): boolean => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) // Resolve symlinks and normalize paths const realPath = fs.realpathSync(filePath) @@ -1398,9 +1476,10 @@ app.whenReady().then(() => { } }) - ipcMain.handle('db:is-bootstrap-presentation', (_event, filePath: string): boolean => { + ipcMain.handle('db:is-bootstrap-presentation', (event, filePath: string): boolean => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.isBootstrapPresentation(db)) } catch (error) { console.error('Error in db:is-bootstrap-presentation:', error) @@ -1412,8 +1491,14 @@ app.whenReady().then(() => { * Deletes a temporary database file. * Used for cleanup when temp file creation succeeds but initialization fails. */ - ipcMain.handle('db:delete-temp', (_event, filePath: string): void => { + ipcMain.handle('db:delete-temp', (event, filePath: string): void => { try { + const window = getEditorForSender(event.sender.id) + if (!window) throw new Error('Only editor windows can delete temporary presentations') + const owner = editorWindows.findDocumentOwner(filePath) + if (owner && owner.window.id !== window.id) { + throw new Error('Cannot delete a temporary presentation owned by another editor window') + } // Validate that this is actually a tracked temp file to prevent arbitrary deletion if (!isTempFile(filePath)) { throw new Error('Cannot delete: path is not a tracked temporary file') @@ -1436,6 +1521,7 @@ app.whenReady().then(() => { // Remove from temp files tracking unregisterTempFile(filePath) + editorWindows.releaseDocument(window, filePath) } catch (error) { console.error('Error deleting temp file:', error) throw error @@ -1448,11 +1534,15 @@ app.whenReady().then(() => { */ ipcMain.handle( 'db:save-to-location', - async (_event, sourcePath: string, destPath: string): Promise => { + async (event, sourcePath: string, destPath: string): Promise => { let stagingPath: string | null = null + const window = getEditorForSender(event.sender.id) try { validateFilePath(sourcePath) validateFilePath(destPath) + if (!window || !editorWindows.ownsDocument(window, sourcePath, false)) { + throw new Error('Cannot save a presentation owned by another editor window') + } ensureMasFileAccess(sourcePath) ensureMasFileAccess(destPath) @@ -1462,6 +1552,8 @@ app.whenReady().then(() => { if (pathsReferToSameFile(sourcePath, destPath)) { throw new Error('Cannot save a temporary presentation onto itself') } + const reservation = editorWindows.reserveDocument(window, destPath) + if (reservation === 'focused-existing') return { status: 'focused-existing' } if (isOpenedReadOnly(sourcePath)) { throw new Error( 'Cannot save a file that was opened read-only. Close and reopen it after upgrading twig, or save a copy through your file manager.' @@ -1495,8 +1587,10 @@ app.whenReady().then(() => { } safeLog(`Saved temp database from ${sourcePath} to ${destPath}`) - return destPath + editorWindows.commitDocument(window, destPath) + return { status: 'saved', filePath: destPath } } catch (error) { + if (window) editorWindows.cancelDocument(window, destPath) console.error('Error in db:save-to-location:', error) throw error } finally { @@ -1516,11 +1610,15 @@ app.whenReady().then(() => { */ ipcMain.handle( 'db:copy-to-location', - async (_event, sourcePath: string, destPath: string): Promise => { + async (event, sourcePath: string, destPath: string): Promise => { let stagingPath: string | null = null + const window = getEditorForSender(event.sender.id) try { validateFilePath(sourcePath) validateFilePath(destPath) + if (!window || !editorWindows.ownsDocument(window, sourcePath, false)) { + throw new Error('Cannot copy a presentation owned by another editor window') + } ensureMasFileAccess(sourcePath) ensureMasFileAccess(destPath) @@ -1529,6 +1627,8 @@ app.whenReady().then(() => { 'Cannot save to the same file. Please choose a different filename or location.' ) } + const reservation = editorWindows.reserveDocument(window, destPath) + if (reservation === 'focused-existing') return { status: 'focused-existing' } if (!fs.existsSync(sourcePath)) { throw new Error(`Source file does not exist: ${sourcePath}`) @@ -1548,8 +1648,10 @@ app.whenReady().then(() => { getWritableConnection(destPath) safeLog(`Copied database from ${sourcePath} to ${destPath}`) - return destPath + editorWindows.commitDocument(window, destPath) + return { status: 'saved', filePath: destPath } } catch (error) { + if (window) editorWindows.cancelDocument(window, destPath) console.error('Error in db:copy-to-location:', error) throw error } finally { @@ -1596,7 +1698,7 @@ app.whenReady().then(() => { ipcMain.handle( 'fonts:embed-font', ( - _event, + event, filePath: string, fontPath: string, fontFamily: string, @@ -1604,6 +1706,7 @@ app.whenReady().then(() => { ): void => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) const allowedFontPath = assertAllowedSystemFontPath(fontPath) if (typeof fontFamily !== 'string' || fontFamily.length === 0 || fontFamily.length > 256) { @@ -1652,9 +1755,10 @@ app.whenReady().then(() => { /** * Retrieves all embedded fonts from the database. */ - ipcMain.handle('fonts:get-embedded-fonts', (_event, filePath: string): FontData[] => { + ipcMain.handle('fonts:get-embedded-fonts', (event, filePath: string): FontData[] => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.getFonts(db)) } catch (error) { console.error('Error in fonts:get-embedded-fonts:', error) @@ -1668,13 +1772,14 @@ app.whenReady().then(() => { ipcMain.handle( 'fonts:get-font-data', ( - _event, + event, filePath: string, fontFamily: string, variant: string = 'normal-normal' ): FontData | null => { try { validateFilePath(filePath) + assertSenderOwnsDocument(event.sender.id, filePath) return withDbConnection(filePath, (db) => dbService.getFontData(db, fontFamily, variant)) } catch (error) { console.error('Error in fonts:get-font-data:', error) @@ -1696,12 +1801,16 @@ app.whenReady().then(() => { } }) - // Create the main window - createWindow() + if (pendingOpenFiles.length > 0) { + const launchFiles = pendingOpenFiles.splice(0) + for (const filePath of launchFiles) createWindow(filePath) + } else { + createWindow() + } // On macOS, restore the primary editor window when the dock icon is clicked. app.on('activate', () => { - showOrCreateMainWindow() + showOrCreateEditorWindow() }) // -------------------------------------------------------------------------- @@ -1712,10 +1821,76 @@ app.whenReady().then(() => { * Returns the file path to open on launch (from OS file association or argv). * Clears the pending value after returning it so it is consumed only once. */ - ipcMain.handle('app:get-file-to-open', (): string | null => { - const path = fileToOpen - fileToOpen = null - return path + ipcMain.handle('app:get-file-to-open', (event): string | null => { + const window = getEditorForSender(event.sender.id) + return window ? editorWindows.consumeLaunchFile(window) : null + }) + + ipcMain.handle('windows:create-editor', () => { + createWindow() + }) + + ipcMain.on('windows:ready', (event) => { + const window = getEditorForSender(event.sender.id) + if (!window) return + readyEditorIds.add(window.id) + const queued = queuedEditorOpenFiles.get(window.id) ?? [] + queuedEditorOpenFiles.delete(window.id) + for (const filePath of queued) sendToEditor(window, 'app:open-file', filePath) + }) + + ipcMain.handle('windows:close-if-empty', (event): boolean => { + const window = getEditorForSender(event.sender.id) + const record = window ? editorWindows.getEditor(window) : null + if ( + !window || + !record || + record.currentPath || + record.pendingPath || + editorWindows.getEditors().length <= 1 + ) { + return false + } + setImmediate(() => { + if (!window.isDestroyed()) window.destroy() + }) + return true + }) + + ipcMain.handle('windows:open-file', (event, filePath: string): 'created' | 'focused-existing' => { + validateFilePath(filePath) + const existingOwner = editorWindows.findDocumentOwner(filePath) + if (existingOwner) { + editorWindows.focus(existingOwner.window) + return 'focused-existing' + } + const sourceWindow = getEditorForSender(event.sender.id) + if (!sourceWindow) throw new Error('Only editor windows can open presentations') + createWindow(filePath) + return 'created' + }) + + ipcMain.handle( + 'documents:reserve', + (event, filePath: string): 'reserved' | 'already-current' | 'focused-existing' => { + validateFilePath(filePath) + const window = getEditorForSender(event.sender.id) + if (!window) throw new Error('Only editor windows can reserve presentations') + return editorWindows.reserveDocument(window, filePath) + } + ) + + ipcMain.handle('documents:commit', (event, filePath: string): void => { + validateFilePath(filePath) + const window = getEditorForSender(event.sender.id) + if (!window) throw new Error('Only editor windows can commit presentations') + editorWindows.commitDocument(window, filePath) + }) + + ipcMain.handle('documents:cancel', (event, filePath: string): void => { + validateFilePath(filePath) + const window = getEditorForSender(event.sender.id) + if (window) editorWindows.cancelDocument(window, filePath) }) /** @@ -1733,15 +1908,19 @@ app.whenReady().then(() => { * Opens the debug window. * If already open, focuses it instead of creating a new one. */ - ipcMain.handle('debug:open-window', () => { - createDebugWindow() + ipcMain.handle('debug:open-window', (event) => { + const owner = getEditorForSender(event.sender.id) + if (!owner) throw new Error('Debug windows require an editor owner') + createDebugWindow(owner) }) /** * Broadcasts state updates to the debug window (if open). * Called from the main renderer window whenever state changes. */ - ipcMain.on('debug:state-update', (_event, state) => { + ipcMain.on('debug:state-update', (event, state) => { + const owner = getEditorForSender(event.sender.id) + const debugWindow = owner ? editorWindows.getAuxiliary(owner, 'debug') : null if (debugWindow && !debugWindow.isDestroyed()) { debugWindow.webContents.send('debug:state-changed', state) } @@ -1751,12 +1930,9 @@ app.whenReady().then(() => { * Handles request from debug window to get initial state. * Forwards the request to the main window. */ - ipcMain.on('debug:request-state', () => { - const window = getMainWindow() - if (window) { - // Ask main window to send its state - window.webContents.send('debug:request-state-from-main') - } + ipcMain.on('debug:request-state', (event) => { + const owner = getOwnerForSender(event.sender.id) + if (owner) sendToEditor(owner, 'debug:request-state-from-main') }) // -------------------------------------------------------------------------- @@ -1764,38 +1940,47 @@ app.whenReady().then(() => { // -------------------------------------------------------------------------- // Fire-and-forget: renderer does not await this, so we use ipcMain.on - ipcMain.on('presentation:open-window', () => { - createPresentationWindow() + ipcMain.on('presentation:open-window', (event) => { + const owner = getEditorForSender(event.sender.id) + if (owner) createPresentationWindow(owner) }) - ipcMain.handle('presentation:close-window', () => { + ipcMain.handle('presentation:close-window', (event) => { + const owner = getEditorForSender(event.sender.id) + const presentationWindow = owner ? editorWindows.getAuxiliary(owner, 'presentation') : null if (presentationWindow && !presentationWindow.isDestroyed()) { presentationWindow.close() } }) /** Forward slide state from main window to presentation window. */ - ipcMain.on('presentation:state-update', (_event, state) => { + ipcMain.on('presentation:state-update', (event, state) => { + const owner = getEditorForSender(event.sender.id) + const presentationWindow = owner ? editorWindows.getAuxiliary(owner, 'presentation') : null if (presentationWindow && !presentationWindow.isDestroyed()) { presentationWindow.webContents.send('presentation:state-changed', state) } }) /** Forward navigation requests from presentation window to main window. */ - ipcMain.on('presentation:navigate', (_event, direction: string) => { - getMainWindow()?.webContents.send('presentation:navigate-request', direction) + ipcMain.on('presentation:navigate', (event, direction: string) => { + const owner = getOwnerForSender(event.sender.id) + if (owner) sendToEditor(owner, 'presentation:navigate-request', direction) }) /** Forward exit request from presentation window to main window. */ - ipcMain.on('presentation:exit', () => { + ipcMain.on('presentation:exit', (event) => { + const owner = getOwnerForSender(event.sender.id) + const presentationWindow = owner ? editorWindows.getAuxiliary(owner, 'presentation') : null if (presentationWindow && !presentationWindow.isDestroyed()) { presentationWindow.close() } }) /** Presentation window signals it's ready — forward to main window so it sends initial state. */ - ipcMain.on('presentation:ready', () => { - getMainWindow()?.webContents.send('presentation:window-ready') + ipcMain.on('presentation:ready', (event) => { + const owner = getOwnerForSender(event.sender.id) + if (owner) sendToEditor(owner, 'presentation:window-ready') }) // -------------------------------------------------------------------------- @@ -1818,7 +2003,9 @@ app.whenReady().then(() => { /** Notify the main window that a new version is downloaded and ready. */ function notifyUpdateReady(version: string): void { - getMainWindow()?.webContents.send('app:update-downloaded', version) + for (const record of editorWindows.getEditors()) { + sendToEditor(record.window, 'app:update-downloaded', version) + } } autoUpdater.on('update-downloaded', (info) => { @@ -1905,6 +2092,8 @@ let cleanupCompleted = false * This helps distinguish between "close all windows" and "quit app" on macOS. */ let isQuitting = false +let allowNativeQuit = false +let quitSequence: Promise | null = null /** * Cleans up all database connections and temp files during app shutdown. @@ -1955,8 +2144,30 @@ async function cleanupResources(): Promise { * Track when the user explicitly tries to quit the app. * This fires before windows start closing. */ -app.on('before-quit', () => { +app.on('before-quit', (event) => { + if (allowNativeQuit) return + event.preventDefault() + if (quitSequence) return + isQuitting = true + quitSequence = (async () => { + const controllers = editorWindows + .getEditors() + .map((record) => editorCloseControllers.get(record.window.id)) + .filter((controller): controller is ReturnType => + Boolean(controller) + ) + if (!(await closeWindowsSequentially(controllers))) { + isQuitting = false + return + } + + await cleanupResources() + allowNativeQuit = true + app.quit() + })().finally(() => { + quitSequence = null + }) }) /** diff --git a/src/main/windowCloseController.ts b/src/main/windowCloseController.ts index bf68949..cd40844 100644 --- a/src/main/windowCloseController.ts +++ b/src/main/windowCloseController.ts @@ -5,6 +5,15 @@ export const CLOSE_RESPONSE_CHANNEL = 'lifecycle:close-response' export const CLOSE_READY_CHANNEL = 'lifecycle:close-ready' export type CloseDecision = 'proceed' | 'cancel' +export async function closeWindowsSequentially( + controllers: Array<{ requestClose: () => Promise }> +): Promise { + for (const controller of controllers) { + if ((await controller.requestClose()) === 'cancel') return false + } + return true +} + interface CloseEventLike { preventDefault(): void } @@ -28,6 +37,7 @@ interface WindowCloseControllerOptions { export function createWindowCloseController(options: WindowCloseControllerOptions): { handleClose: (event: CloseEventLike) => void + requestClose: () => Promise } { const { window, @@ -39,7 +49,7 @@ export function createWindowCloseController(options: WindowCloseControllerOption logger = console } = options - let closePromise: Promise | null = null + let closePromise: Promise | null = null let nextRequestId = 0 let isRendererReadyForCloseRequests = false @@ -142,7 +152,7 @@ export function createWindowCloseController(options: WindowCloseControllerOption }) } - async function runClose(): Promise { + async function runClose(): Promise { const decision = await requestCloseDecision() if (decision === 'proceed') { @@ -152,32 +162,37 @@ export function createWindowCloseController(options: WindowCloseControllerOption if (getIsQuitting()) { quitApp() } - return + return decision } setIsQuitting(false) + return decision } - function handleClose(event: CloseEventLike): void { - event.preventDefault() - - if (window.isDestroyed() || closePromise) { - return - } + function requestClose(): Promise { + if (window.isDestroyed()) return Promise.resolve('proceed') + if (closePromise) return closePromise let shouldKeepPromise = false closePromise = runClose() - .then(() => { + .then((decision) => { shouldKeepPromise = window.isDestroyed() + return decision }) .finally(() => { - if (!shouldKeepPromise) { - closePromise = null - } + if (!shouldKeepPromise) closePromise = null }) + return closePromise + } + + function handleClose(event: CloseEventLike): void { + event.preventDefault() + + void requestClose() } return { - handleClose + handleClose, + requestClose } } diff --git a/src/preload/index.ts b/src/preload/index.ts index 05c766d..6df58cc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,19 @@ const isStoreBuild = * These wrap IPC calls to the main process for file dialogs and database operations. */ const api = { + windows: { + createEditor: () => ipcRenderer.invoke('windows:create-editor'), + openFile: (filePath: string) => ipcRenderer.invoke('windows:open-file', filePath), + closeIfEmpty: () => ipcRenderer.invoke('windows:close-if-empty'), + signalReady: () => ipcRenderer.send('windows:ready') + }, + + documents: { + reserve: (filePath: string) => ipcRenderer.invoke('documents:reserve', filePath), + commit: (filePath: string) => ipcRenderer.invoke('documents:commit', filePath), + cancel: (filePath: string) => ipcRenderer.invoke('documents:cancel', filePath) + }, + // File dialog operations dialog: { /** Show a file open dialog and return the selected path */ @@ -268,6 +281,22 @@ const api = { } }, + onMenuNewPresentation: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('menu:new-presentation', handler) + return (): void => { + ipcRenderer.removeListener('menu:new-presentation', handler) + } + }, + + onMenuOpenPresentation: (callback: () => void) => { + const handler = (): void => callback() + ipcRenderer.on('menu:open-presentation', handler) + return (): void => { + ipcRenderer.removeListener('menu:open-presentation', handler) + } + }, + /** Trigger a manual update check. Returns 'checking' | 'up-to-date' | 'error'. */ checkForUpdates: () => ipcRenderer.invoke('app:check-for-updates'), diff --git a/src/renderer/src/App.svelte b/src/renderer/src/App.svelte index 800d768..73a88f4 100644 --- a/src/renderer/src/App.svelte +++ b/src/renderer/src/App.svelte @@ -278,6 +278,7 @@ let tooNewModalCompatNotesRaw = $state('') let tooNewModalResolver: ((choice: 'readonly' | 'cancel') => void) | null = null let activePresentationTransitionPromise: Promise | null = null + let activeOpenRoutingPromise: Promise | null = null // Active side panel — only one can be open at a time type SidePanel = 'properties' | 'layers' | 'animate' @@ -1809,6 +1810,8 @@ let unsubscribeUpdateDownloaded: (() => void) | undefined let unsubscribeOpenSettings: (() => void) | undefined let unsubscribeMenuExportImages: (() => void) | undefined + let unsubscribeMenuNewPresentation: (() => void) | undefined + let unsubscribeMenuOpenPresentation: (() => void) | undefined let unsubscribeSnapChanged: (() => void) | undefined // Reset background and transition checkpoint gates on pointer release so the next drag @@ -1838,7 +1841,17 @@ // Check if the app was launched by double-clicking a .tb file const launchFile = await window.api?.app?.getFileToOpen() - const opened = launchFile ? await openPresentationAtPath(launchFile) : false + let opened = false + if (launchFile) { + try { + opened = await openPresentationAtPath(launchFile) + } catch (error) { + console.error('Failed to open launch presentation:', error) + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + alert(`Failed to open presentation: ${errorMessage}`) + } + if (!opened && (await window.api.windows.closeIfEmpty())) return + } if (!opened) { await createNewPresentationInternal() } @@ -1847,10 +1860,7 @@ // Handle .tb files opened while the app is already running unsubscribeOpenFile = window.api?.app?.onOpenFile(async (filePath) => { try { - await runGuardedPresentationTransition(async () => { - const opened = await openPresentationAtPath(filePath) - return { completed: opened, mutatedState: opened } - }) + await openPresentationInAppropriateWindow(filePath) } catch (error) { console.error('Failed to open presentation from OS event:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' @@ -1912,6 +1922,12 @@ unsubscribeMenuExportImages = window.api?.app?.onMenuExportImages(() => { exportModalOpen = true }) + unsubscribeMenuNewPresentation = window.api?.app?.onMenuNewPresentation(() => { + void handleNewPresentation() + }) + unsubscribeMenuOpenPresentation = window.api?.app?.onMenuOpenPresentation(() => { + void handleOpen() + }) // Initialize alignment-guide snap toggle from the main-owned pref and // subscribe to changes pushed from the View menu. @@ -1931,6 +1947,7 @@ window.api?.lifecycle?.respondToCloseRequest(requestId, decision) }) window.api?.lifecycle?.signalCloseReady() + window.api?.windows?.signalReady() }) /** @@ -1966,6 +1983,8 @@ unsubscribeUpdateDownloaded?.() unsubscribeOpenSettings?.() unsubscribeMenuExportImages?.() + unsubscribeMenuNewPresentation?.() + unsubscribeMenuOpenPresentation?.() unsubscribeSnapChanged?.() // Unregister flush save callback @@ -3512,11 +3531,16 @@ } async function openPresentationAtPath(filePath: string): Promise { + const reservation = await window.api.documents.reserve(filePath) + if (reservation === 'focused-existing') return false + if (reservation === 'already-current') return true + loadingScreenPhase = 'opening' const previousFilePath = appState.currentFilePath try { await loadPresentation(filePath, { onTooNewFile: handleTooNewFile }) } catch (error) { + await window.api.documents.cancel(filePath) if (isReadOnlyOpenAbort(error)) { return false } @@ -3531,6 +3555,7 @@ console.warn('Failed to close previous presentation connection:', error) } } + await window.api.documents.commit(filePath) // Clear history only after a replacement file actually loads; canceling a // too-new read-only open keeps the current presentation and undo stack. clearAllHistory() @@ -3622,8 +3647,8 @@ } async function handleCloseRequest(): Promise { - return withPresentationTransitionLock(() => - closePresentationWithTempGuard({ + return withPresentationTransitionLock(async () => { + const approved = await closePresentationWithTempGuard({ currentFilePath: appState.currentFilePath, isTempFile: appState.isTempFile, flushPendingSave, @@ -3640,7 +3665,21 @@ console.error(`Failed to delete temp file during close for ${filePath}:`, error) } }) - ) + if (!approved) return false + + if (appState.currentFilePath && !appState.isTempFile) { + try { + await window.api.db.closeConnection(appState.currentFilePath) + } catch (error) { + console.error('Failed to close presentation database:', error) + const shouldForceClose = await promptToForceCloseAfterFailure(error) + if (!shouldForceClose) return false + cancelPendingPersistence() + } + } + + return true + }) } /** @@ -3751,12 +3790,40 @@ * Creates a new, unsaved presentation with one blank slide in a temp database. * Checks for temp presentation destruction before proceeding. */ + async function canReuseCurrentWindow(): Promise { + if (!appState.currentFilePath || !appState.isTempFile) return false + await flushPendingSave() + return window.api.db.isBootstrapPresentation(appState.currentFilePath) + } + + async function openPresentationInAppropriateWindow(filePath: string): Promise { + const previous = activeOpenRoutingPromise + let release!: () => void + const current = new Promise((resolve) => { + release = resolve + }) + activeOpenRoutingPromise = current + + try { + if (previous) await previous + if (!(await canReuseCurrentWindow())) { + return (await window.api.windows.openFile(filePath)) === 'created' + } + + return runGuardedPresentationTransition(async () => { + const opened = await openPresentationAtPath(filePath) + return { completed: opened, mutatedState: opened } + }) + } finally { + release() + if (activeOpenRoutingPromise === current) activeOpenRoutingPromise = null + } + } + async function handleNewPresentation(): Promise { try { - await runGuardedPresentationTransition(async () => ({ - completed: await createNewPresentationInternal(), - mutatedState: true - })) + if (await canReuseCurrentWindow()) return + await window.api.windows.createEditor() } catch (error) { console.error('Failed to create new presentation:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' @@ -3770,15 +3837,9 @@ */ async function handleOpen(): Promise { try { - await runGuardedPresentationTransition(async () => { - const filePath = await window.api.dialog.showOpenDialog() - if (!filePath) { - return { completed: false, mutatedState: false } - } - - const opened = await openPresentationAtPath(filePath) - return { completed: opened, mutatedState: opened } - }) + const filePath = await window.api.dialog.showOpenDialog() + if (!filePath) return + await openPresentationInAppropriateWindow(filePath) } catch (error) { console.error('Failed to open presentation:', error) const errorMessage = error instanceof Error ? error.message : 'Unknown error' @@ -3835,14 +3896,19 @@ const currentSlideId = appState.currentSlide?.id // Move or copy the database depending on whether it's a temp file - let resultPath: string + let saveResult: Awaited> if (appState.isTempFile) { // For temp files, move the database to the new location - resultPath = await window.api.db.saveToLocation(appState.currentFilePath, newPath) + saveResult = await window.api.db.saveToLocation(appState.currentFilePath, newPath) } else { // For saved files, copy the database to the new location - resultPath = await window.api.db.copyToLocation(appState.currentFilePath, newPath) + saveResult = await window.api.db.copyToLocation(appState.currentFilePath, newPath) + } + if (saveResult.status === 'focused-existing') { + alert($_('open.already_open')) + return false } + const resultPath = saveResult.filePath // Update state appState.currentFilePath = resultPath diff --git a/src/renderer/src/api.d.ts b/src/renderer/src/api.d.ts index 08ab24e..251184e 100644 --- a/src/renderer/src/api.d.ts +++ b/src/renderer/src/api.d.ts @@ -55,6 +55,10 @@ export interface WriteImageFileResult { error?: string } +export type SaveLocationResult = + | { status: 'saved'; filePath: string } + | { status: 'focused-existing' } + /** * Result of probing a candidate `.tb` file for its format identity. */ @@ -112,6 +116,17 @@ export interface DebugState { declare global { interface Window { api: { + windows: { + createEditor: () => Promise + openFile: (filePath: string) => Promise<'created' | 'focused-existing'> + closeIfEmpty: () => Promise + signalReady: () => void + } + documents: { + reserve: (filePath: string) => Promise<'reserved' | 'already-current' | 'focused-existing'> + commit: (filePath: string) => Promise + cancel: (filePath: string) => Promise + } dialog: { showOpenDialog: () => Promise showSaveDialog: () => Promise @@ -137,8 +152,8 @@ declare global { createTemp: () => Promise isTempFile: (filePath: string) => Promise isBootstrapPresentation: (filePath: string) => Promise - saveToLocation: (sourcePath: string, destPath: string) => Promise - copyToLocation: (sourcePath: string, destPath: string) => Promise + saveToLocation: (sourcePath: string, destPath: string) => Promise + copyToLocation: (sourcePath: string, destPath: string) => Promise deleteTemp: (filePath: string) => Promise saveThumbnail: (filePath: string, slideId: string, thumbnail: string) => Promise getThumbnails: (filePath: string) => Promise> @@ -203,6 +218,8 @@ declare global { onOpenSettings: (callback: () => void) => () => void onOpenFile: (callback: (filePath: string) => void) => () => void onMenuExportImages: (callback: () => void) => () => void + onMenuNewPresentation: (callback: () => void) => () => void + onMenuOpenPresentation: (callback: () => void) => () => void checkForUpdates: () => Promise<'checking' | 'up-to-date' | 'error'> installUpdate: () => Promise checkForUpdateManual: () => Promise<{ diff --git a/src/renderer/src/lib/i18n/en.json b/src/renderer/src/lib/i18n/en.json index 6da006d..563ecf6 100644 --- a/src/renderer/src/lib/i18n/en.json +++ b/src/renderer/src/lib/i18n/en.json @@ -226,6 +226,7 @@ "debug.copy_clipboard.title": "Copy state JSON to clipboard", "open.not_twig": "This file isn't a twig presentation.", "open.empty_readonly": "This newer twig file has no slides to view.", + "open.already_open": "This presentation is already open in another twig window.", "open.too_new_title": "This file was made with a newer version of twig", "open.too_new_body": "Your copy of twig may not render every element correctly. You can open it read-only to view it, or cancel and upgrade twig first.", "open.too_new_open_readonly": "Open read-only", diff --git a/src/renderer/src/lib/i18n/zh.json b/src/renderer/src/lib/i18n/zh.json index d6f59c3..764a823 100644 --- a/src/renderer/src/lib/i18n/zh.json +++ b/src/renderer/src/lib/i18n/zh.json @@ -226,6 +226,7 @@ "debug.copy_clipboard.title": "将状态 JSON 复制到剪贴板", "open.not_twig": "此文件不是 twig 演示文稿。", "open.empty_readonly": "这个较新版本的 twig 文件没有可查看的幻灯片。", + "open.already_open": "此演示文稿已在另一个 twig 窗口中打开。", "open.too_new_title": "此文件由较新版本的 twig 创建", "open.too_new_body": "你当前的 twig 可能无法正确渲染其中的所有元素。你可以以只读方式打开查看,或先升级 twig 后再打开。", "open.too_new_open_readonly": "以只读方式打开", diff --git a/tests/main/editorWindowManager.test.ts b/tests/main/editorWindowManager.test.ts new file mode 100644 index 0000000..36fee96 --- /dev/null +++ b/tests/main/editorWindowManager.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import { join } from 'node:path' +import { canonicalPathIdentity, EditorWindowManager } from '../../src/main/editorWindowManager' + +class FakeWindow { + static nextId = 1 + id = FakeWindow.nextId++ + webContents = { id: this.id * 10 } + destroyed = false + minimized = false + restore = vi.fn(() => { + this.minimized = false + }) + show = vi.fn() + focus = vi.fn() + + isDestroyed(): boolean { + return this.destroyed + } + + isMinimized(): boolean { + return this.minimized + } +} + +describe('EditorWindowManager', () => { + it('uses the same identity for a file and a symlink alias', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const filePath = join(directory, 'deck.tb') + const aliasPath = join(directory, 'alias.tb') + fs.writeFileSync(filePath, '') + fs.symlinkSync(filePath, aliasPath) + expect(canonicalPathIdentity(aliasPath)).toBe(canonicalPathIdentity(filePath)) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it('tracks independent documents and focuses an existing duplicate owner', () => { + const manager = new EditorWindowManager((path) => path.toLowerCase()) + const first = new FakeWindow() + const second = new FakeWindow() + manager.registerEditor(first) + manager.registerEditor(second) + + expect(manager.reserveDocument(first, '/Deck.tb')).toBe('reserved') + manager.commitDocument(first, '/Deck.tb') + expect(manager.reserveDocument(second, '/deck.tb')).toBe('focused-existing') + expect(first.focus).toHaveBeenCalledOnce() + }) + + it('keeps current ownership while a replacement is pending and rolls it back', () => { + const manager = new EditorWindowManager((path) => path) + const window = new FakeWindow() + manager.registerEditor(window) + manager.bindCreatedDocument(window, '/temp.tb') + + expect(manager.reserveDocument(window, '/next.tb')).toBe('reserved') + expect(manager.ownsDocument(window, '/temp.tb')).toBe(true) + expect(manager.ownsDocument(window, '/next.tb')).toBe(true) + manager.cancelDocument(window, '/next.tb') + expect(manager.ownsDocument(window, '/temp.tb')).toBe(true) + expect(manager.ownsDocument(window, '/next.tb')).toBe(false) + }) + + it('commits a replacement and releases the previous identity', () => { + const manager = new EditorWindowManager((path) => path) + const window = new FakeWindow() + manager.registerEditor(window) + manager.bindCreatedDocument(window, '/before.tb') + manager.reserveDocument(window, '/after.tb') + + expect(manager.commitDocument(window, '/after.tb')).toBe('/before.tb') + expect(manager.ownsDocument(window, '/before.tb')).toBe(false) + expect(manager.ownsDocument(window, '/after.tb')).toBe(true) + }) + + it('routes auxiliary windows back to their owning editor', () => { + const manager = new EditorWindowManager((path) => path) + const editor = new FakeWindow() + const debug = new FakeWindow() + const presentation = new FakeWindow() + manager.registerEditor(editor) + manager.attachAuxiliary(editor, 'debug', debug) + manager.attachAuxiliary(editor, 'presentation', presentation) + + expect(manager.getOwnerByWebContentsId(debug.webContents.id)?.window).toBe(editor) + expect(manager.getOwnerByWebContentsId(presentation.webContents.id)?.window).toBe(editor) + manager.detachAuxiliary(debug) + expect(manager.getOwnerByWebContentsId(debug.webContents.id)).toBeNull() + }) +}) diff --git a/tests/main/windowCloseController.test.ts b/tests/main/windowCloseController.test.ts index aab7d05..772eea8 100644 --- a/tests/main/windowCloseController.test.ts +++ b/tests/main/windowCloseController.test.ts @@ -4,6 +4,7 @@ import { CLOSE_READY_CHANNEL, CLOSE_REQUEST_CHANNEL, CLOSE_RESPONSE_CHANNEL, + closeWindowsSequentially, createWindowCloseController } from '../../src/main/windowCloseController' @@ -200,3 +201,16 @@ describe('windowCloseController', () => { expect(window.destroy).not.toHaveBeenCalled() }) }) + +describe('closeWindowsSequentially', () => { + it('stops requesting closes after the first cancellation', async () => { + const first = { requestClose: vi.fn().mockResolvedValue('proceed') } + const second = { requestClose: vi.fn().mockResolvedValue('cancel') } + const third = { requestClose: vi.fn().mockResolvedValue('proceed') } + + await expect(closeWindowsSequentially([first, second, third])).resolves.toBe(false) + expect(first.requestClose).toHaveBeenCalledOnce() + expect(second.requestClose).toHaveBeenCalledOnce() + expect(third.requestClose).not.toHaveBeenCalled() + }) +}) From f8cd4e1d534bb17591e9c0b9725a08a73a5f27d5 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 11:23:06 +0800 Subject: [PATCH 2/9] docs: keep released changelog immutable --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7a0b28..3f32ec3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,15 @@ # Changelog -## [1.2.0] - 2026-08-24 +## Unreleased ### Added - Multi-window editing with one independently autosaved presentation per editor window, per-editor presentation/debug windows, duplicate-file focusing, and cross-platform file-open routing + +## [1.2.0] - 2026-08-24 + +### Added + - Math elements with TeX/LaTeX editing and MathJax-rendered SVG output - SVG image imports and animated GIF playback in presentation mode - Shape fill and stroke controls, including transparent fills and configurable borders From 21557ff8ec67e193572b4e84526180d22949ec5b Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 11:32:40 +0800 Subject: [PATCH 3/9] fix: normalize document identities by filesystem case --- src/main/editorWindowManager.ts | 119 +++++++++++++++++++++++-- tests/main/editorWindowManager.test.ts | 62 +++++++++++++ 2 files changed, 175 insertions(+), 6 deletions(-) diff --git a/src/main/editorWindowManager.ts b/src/main/editorWindowManager.ts index fe15bb0..88ed8d9 100644 --- a/src/main/editorWindowManager.ts +++ b/src/main/editorWindowManager.ts @@ -1,4 +1,5 @@ import fs from 'fs' +import { randomUUID } from 'node:crypto' import { dirname, join, normalize, resolve } from 'path' export interface ManagedWebContents { @@ -30,14 +31,122 @@ export interface EditorWindowRecord { lastFocusedAt: number } -function canonicalPathIdentity(filePath: string): string { +const directoryCaseSensitivity = new Map() + +function toggledAsciiCase(value: string): string | null { + for (let index = 0; index < value.length; index += 1) { + const character = value[index] + if (character >= 'a' && character <= 'z') { + return `${value.slice(0, index)}${character.toUpperCase()}${value.slice(index + 1)}` + } + if (character >= 'A' && character <= 'Z') { + return `${value.slice(0, index)}${character.toLowerCase()}${value.slice(index + 1)}` + } + } + return null +} + +function existingEntryIsCaseSensitive(directoryPath: string, entryName: string): boolean | null { + const alternateName = toggledAsciiCase(entryName) + if (!alternateName) return null + + const entryPath = join(directoryPath, entryName) + const alternatePath = join(directoryPath, alternateName) + let entry: fs.Stats + try { + entry = fs.lstatSync(entryPath) + } catch { + return null + } + + try { + const alternate = fs.lstatSync(alternatePath) + return entry.dev !== alternate.dev || entry.ino !== alternate.ino + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + return code === 'ENOENT' ? true : null + } +} + +function detectDirectoryCaseSensitivity(directoryPath: string): boolean { + const cached = directoryCaseSensitivity.get(directoryPath) + if (cached !== undefined) return cached + + try { + const entryNames = fs.readdirSync(directoryPath) + const entryNameSet = new Set(entryNames) + for (const entryName of entryNames) { + const alternateName = toggledAsciiCase(entryName) + if (alternateName && entryNameSet.has(alternateName)) { + directoryCaseSensitivity.set(directoryPath, true) + return true + } + const result = existingEntryIsCaseSensitive(directoryPath, entryName) + if (result !== null) { + directoryCaseSensitivity.set(directoryPath, result) + return result + } + } + } catch { + // A Save As will report inaccessible destinations separately. + } + + const probeName = `.twig-path-case-${randomUUID()}-Aa` + const probePath = join(directoryPath, probeName) + let descriptor: number | null = null + let probeCreated = false + try { + descriptor = fs.openSync(probePath, 'wx', 0o600) + probeCreated = true + fs.closeSync(descriptor) + descriptor = null + const result = existingEntryIsCaseSensitive(directoryPath, probeName) + if (result !== null) { + directoryCaseSensitivity.set(directoryPath, result) + return result + } + } catch { + // Fall back when the directory cannot be probed, such as a read-only location. + } finally { + if (descriptor !== null) { + try { + fs.closeSync(descriptor) + } catch { + // Continue with probe cleanup. + } + } + if (probeCreated) { + try { + fs.unlinkSync(probePath) + } catch { + // The zero-byte probe is best-effort cleanup only. + } + } + } + + // macOS and Windows filesystems are case-insensitive by default; other supported + // Unix filesystems are case-sensitive by default. Writable destinations are + // detected above, including case-sensitive volumes and directories on either OS. + const fallback = process.platform !== 'darwin' && process.platform !== 'win32' + directoryCaseSensitivity.set(directoryPath, fallback) + return fallback +} + +export function canonicalPathIdentity( + filePath: string, + isCaseSensitive: (directoryPath: string) => boolean = detectDirectoryCaseSensitivity +): string { const absolutePath = normalize(resolve(filePath)) + let identity: string + let parentPath: string try { - return fs.realpathSync.native(absolutePath) + identity = fs.realpathSync.native(absolutePath) + parentPath = dirname(identity) } catch { - const parentPath = fs.realpathSync.native(dirname(absolutePath)) - return join(parentPath, absolutePath.slice(dirname(absolutePath).length + 1)) + parentPath = fs.realpathSync.native(dirname(absolutePath)) + identity = join(parentPath, absolutePath.slice(dirname(absolutePath).length + 1)) } + return isCaseSensitive(parentPath) ? identity : identity.toLowerCase() } export class EditorWindowManager { @@ -252,5 +361,3 @@ export class EditorWindowManager { this.noteFocused(window) } } - -export { canonicalPathIdentity } diff --git a/tests/main/editorWindowManager.test.ts b/tests/main/editorWindowManager.test.ts index 36fee96..627fed4 100644 --- a/tests/main/editorWindowManager.test.ts +++ b/tests/main/editorWindowManager.test.ts @@ -39,6 +39,68 @@ describe('EditorWindowManager', () => { } }) + it('normalizes pending paths on a case-insensitive filesystem', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const upperCasePath = join(directory, 'Deck.tb') + const lowerCasePath = join(directory, 'deck.tb') + const identifyPath = (filePath: string): string => + canonicalPathIdentity(filePath, () => false) + + expect(identifyPath(upperCasePath)).toBe(identifyPath(lowerCasePath)) + + const manager = new EditorWindowManager(identifyPath) + const first = new FakeWindow() + const second = new FakeWindow() + manager.registerEditor(first) + manager.registerEditor(second) + + expect(manager.reserveDocument(first, upperCasePath)).toBe('reserved') + expect(manager.reserveDocument(second, lowerCasePath)).toBe('focused-existing') + expect(first.focus).toHaveBeenCalledOnce() + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it('preserves distinct pending paths on a case-sensitive filesystem', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const upperCasePath = join(directory, 'Deck.tb') + const lowerCasePath = join(directory, 'deck.tb') + expect(canonicalPathIdentity(upperCasePath, () => true)).not.toBe( + canonicalPathIdentity(lowerCasePath, () => true) + ) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it('detects the case behavior of the destination filesystem', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const probePath = join(directory, 'CaseProbe') + const alternateProbePath = join(directory, 'caseProbe') + fs.writeFileSync(probePath, '') + const probe = fs.lstatSync(probePath) + let caseInsensitive = false + try { + const alternateProbe = fs.lstatSync(alternateProbePath) + caseInsensitive = probe.dev === alternateProbe.dev && probe.ino === alternateProbe.ino + } catch { + // The alternate spelling does not exist on a case-sensitive filesystem. + } + fs.unlinkSync(probePath) + + const identitiesMatch = + canonicalPathIdentity(join(directory, 'Deck.tb')) === + canonicalPathIdentity(join(directory, 'deck.tb')) + expect(identitiesMatch).toBe(caseInsensitive) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + it('tracks independent documents and focuses an existing duplicate owner', () => { const manager = new EditorWindowManager((path) => path.toLowerCase()) const first = new FakeWindow() From 7fd994b5c58a0ff685ac537f0e09ff6ef831a79b Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 11:47:13 +0800 Subject: [PATCH 4/9] fix: strengthen multi-window file identity --- src/main/editorWindowManager.ts | 52 +++++++++++++++++++---- src/main/index.ts | 13 ++---- src/main/launchPaths.ts | 12 ++++++ tests/main/editorWindowManager.test.ts | 58 ++++++++++++++++++++++++++ tests/main/launchPaths.test.ts | 22 ++++++++++ 5 files changed, 140 insertions(+), 17 deletions(-) create mode 100644 src/main/launchPaths.ts create mode 100644 tests/main/launchPaths.test.ts diff --git a/src/main/editorWindowManager.ts b/src/main/editorWindowManager.ts index 88ed8d9..d38df28 100644 --- a/src/main/editorWindowManager.ts +++ b/src/main/editorWindowManager.ts @@ -137,6 +137,13 @@ export function canonicalPathIdentity( isCaseSensitive: (directoryPath: string) => boolean = detectDirectoryCaseSensitivity ): string { const absolutePath = normalize(resolve(filePath)) + try { + const stats = fs.statSync(absolutePath, { bigint: true }) + return `inode:${stats.dev}:${stats.ino}` + } catch { + // Not-yet-created Save As destinations use a canonical path reservation. + } + let identity: string let parentPath: string try { @@ -162,6 +169,25 @@ export class EditorWindowManager { private readonly identifyPath: (filePath: string) => string = canonicalPathIdentity ) {} + private claimMatches( + claimPath: string | null, + claimIdentity: string | null, + filePath: string, + identity: string + ): boolean { + if (!claimPath || !claimIdentity) return false + if (claimPath === filePath || claimIdentity === identity) return true + + // A reserved Save As destination changes from a path identity to an inode + // identity after the staged file is installed. Re-identifying the claim also + // keeps ownership conservative if an owned path is replaced externally. + try { + return this.identifyPath(claimPath) === identity + } catch { + return false + } + } + registerEditor(window: W, launchFile: string | null = null): EditorWindowRecord { const record: EditorWindowRecord = { window, @@ -242,7 +268,8 @@ export class EditorWindowManager { this.getEditors().find( (record) => record.window.id !== excludingWindow?.id && - (record.currentIdentity === identity || record.pendingIdentity === identity) + (this.claimMatches(record.currentPath, record.currentIdentity, filePath, identity) || + this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity)) ) ?? null ) } @@ -252,8 +279,12 @@ export class EditorWindowManager { if (!record) throw new Error('Document reservations require an editor window') const identity = this.identifyPath(filePath) - if (record.currentIdentity === identity) return 'already-current' - if (record.pendingIdentity === identity) return 'reserved' + if (this.claimMatches(record.currentPath, record.currentIdentity, filePath, identity)) { + return 'already-current' + } + if (this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity)) { + return 'reserved' + } const existingOwner = this.findDocumentOwner(filePath, window) if (existingOwner) { @@ -271,7 +302,10 @@ export class EditorWindowManager { if (!record) throw new Error('Document commits require an editor window') const identity = this.identifyPath(filePath) - if (record.pendingIdentity !== identity && record.currentIdentity !== identity) { + if ( + !this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity) && + !this.claimMatches(record.currentPath, record.currentIdentity, filePath, identity) + ) { throw new Error('Cannot commit an unreserved document') } @@ -287,7 +321,7 @@ export class EditorWindowManager { const record = this.getEditor(window) if (!record) return const identity = this.identifyPath(filePath) - if (record.pendingIdentity === identity) { + if (this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity)) { record.pendingPath = null record.pendingIdentity = null } @@ -305,11 +339,11 @@ export class EditorWindowManager { const record = this.getEditor(window) if (!record) return const identity = this.identifyPath(filePath) - if (record.currentIdentity === identity) { + if (this.claimMatches(record.currentPath, record.currentIdentity, filePath, identity)) { record.currentPath = null record.currentIdentity = null } - if (record.pendingIdentity === identity) { + if (this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity)) { record.pendingPath = null record.pendingIdentity = null } @@ -320,7 +354,9 @@ export class EditorWindowManager { if (!record) return false const identity = this.identifyPath(filePath) return ( - record.currentIdentity === identity || (includePending && record.pendingIdentity === identity) + this.claimMatches(record.currentPath, record.currentIdentity, filePath, identity) || + (includePending && + this.claimMatches(record.pendingPath, record.pendingIdentity, filePath, identity)) ) } diff --git a/src/main/index.ts b/src/main/index.ts index af75d6b..8f21e73 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -19,7 +19,7 @@ import { Menu } from 'electron' import { autoUpdater } from 'electron-updater' -import { join, basename, extname, sep, resolve, relative, isAbsolute, normalize } from 'path' +import { join, basename, extname, sep, resolve, relative, isAbsolute } from 'path' import { electronApp, optimizer, is } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import * as dbService from './db' @@ -28,6 +28,7 @@ import { getPref, setPref } from './prefs' import * as bookmarksService from './bookmarks' import { closeWindowsSequentially, createWindowCloseController } from './windowCloseController' import { EditorWindowManager } from './editorWindowManager' +import { presentationPathsFromArgv } from './launchPaths' import { safeLog, formatError } from './logging' import { getTempDir, @@ -677,12 +678,6 @@ function createPresentationWindow(owner: BrowserWindow): void { const pendingOpenFiles: string[] = [] -function presentationPathsFromArgv(argv: string[]): string[] { - return argv - .filter((argument) => argument.toLowerCase().endsWith('.tb')) - .map((argument) => (isAbsolute(argument) ? normalize(argument) : resolve(argument))) -} - function routeExternalOpen(filePath: string): void { ensureMasFileAccess(filePath) const existingOwner = editorWindows.findDocumentOwner(filePath) @@ -717,8 +712,8 @@ app.on('open-file', (event, path) => { if (process.platform !== 'darwin') pendingOpenFiles.push(...presentationPathsFromArgv(process.argv.slice(1))) -app.on('second-instance', (_event, argv) => { - const paths = presentationPathsFromArgv(argv) +app.on('second-instance', (_event, argv, workingDirectory) => { + const paths = presentationPathsFromArgv(argv, workingDirectory) for (const filePath of paths) routeExternalOpen(filePath) if (paths.length === 0) showOrCreateEditorWindow() }) diff --git a/src/main/launchPaths.ts b/src/main/launchPaths.ts new file mode 100644 index 0000000..95f9678 --- /dev/null +++ b/src/main/launchPaths.ts @@ -0,0 +1,12 @@ +import { isAbsolute, normalize, resolve } from 'path' + +export function presentationPathsFromArgv( + argv: string[], + workingDirectory: string = process.cwd() +): string[] { + return argv + .filter((argument) => argument.toLowerCase().endsWith('.tb')) + .map((argument) => + isAbsolute(argument) ? normalize(argument) : resolve(workingDirectory, argument) + ) +} diff --git a/tests/main/editorWindowManager.test.ts b/tests/main/editorWindowManager.test.ts index 627fed4..941bcca 100644 --- a/tests/main/editorWindowManager.test.ts +++ b/tests/main/editorWindowManager.test.ts @@ -39,6 +39,30 @@ describe('EditorWindowManager', () => { } }) + it('uses the same identity for hard links to an existing file', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const filePath = join(directory, 'deck.tb') + const aliasPath = join(directory, 'hard-link.tb') + fs.writeFileSync(filePath, '') + fs.linkSync(filePath, aliasPath) + + expect(canonicalPathIdentity(aliasPath)).toBe(canonicalPathIdentity(filePath)) + + const manager = new EditorWindowManager() + const first = new FakeWindow() + const second = new FakeWindow() + manager.registerEditor(first) + manager.registerEditor(second) + expect(manager.reserveDocument(first, filePath)).toBe('reserved') + manager.commitDocument(first, filePath) + expect(manager.reserveDocument(second, aliasPath)).toBe('focused-existing') + expect(first.focus).toHaveBeenCalledOnce() + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + it('normalizes pending paths on a case-insensitive filesystem', () => { const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) try { @@ -140,6 +164,40 @@ describe('EditorWindowManager', () => { expect(manager.ownsDocument(window, '/after.tb')).toBe(true) }) + it('commits a pending destination after it gains an inode identity', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const filePath = join(directory, 'saved.tb') + const manager = new EditorWindowManager() + const window = new FakeWindow() + manager.registerEditor(window) + + expect(manager.reserveDocument(window, filePath)).toBe('reserved') + fs.writeFileSync(filePath, '') + expect(manager.commitDocument(window, filePath)).toBeNull() + expect(manager.ownsDocument(window, filePath)).toBe(true) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + + it('cancels a pending destination after it gains an inode identity', () => { + const directory = fs.mkdtempSync(join(os.tmpdir(), 'twig-window-manager-')) + try { + const filePath = join(directory, 'failed-save.tb') + const manager = new EditorWindowManager() + const window = new FakeWindow() + manager.registerEditor(window) + + expect(manager.reserveDocument(window, filePath)).toBe('reserved') + fs.writeFileSync(filePath, '') + manager.cancelDocument(window, filePath) + expect(manager.ownsDocument(window, filePath)).toBe(false) + } finally { + fs.rmSync(directory, { recursive: true, force: true }) + } + }) + it('routes auxiliary windows back to their owning editor', () => { const manager = new EditorWindowManager((path) => path) const editor = new FakeWindow() diff --git a/tests/main/launchPaths.test.ts b/tests/main/launchPaths.test.ts new file mode 100644 index 0000000..7490667 --- /dev/null +++ b/tests/main/launchPaths.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import os from 'node:os' +import { join, resolve } from 'node:path' +import { presentationPathsFromArgv } from '../../src/main/launchPaths' + +describe('presentationPathsFromArgv', () => { + it('resolves relative presentations against the launching instance working directory', () => { + const workingDirectory = join(os.tmpdir(), 'twig-second-instance') + const absolutePath = resolve(os.tmpdir(), 'absolute.tb') + + expect( + presentationPathsFromArgv( + ['twig', 'relative.tb', absolutePath, 'notes.txt', 'UPPER.TB'], + workingDirectory + ) + ).toEqual([ + resolve(workingDirectory, 'relative.tb'), + absolutePath, + resolve(workingDirectory, 'UPPER.TB') + ]) + }) +}) From 20b4865361024b4fcb917adf2fd9af66fabbbac4 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 12:54:39 +0800 Subject: [PATCH 5/9] fix: accept case-insensitive twig extensions --- src/main/db/connection.ts | 2 +- tests/main/connectionValidation.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 tests/main/connectionValidation.test.ts diff --git a/src/main/db/connection.ts b/src/main/db/connection.ts index 0293c98..080d6d7 100644 --- a/src/main/db/connection.ts +++ b/src/main/db/connection.ts @@ -45,7 +45,7 @@ export function validateFilePath(filePath: unknown): asserts filePath is string if (!isAbsolute(filePath)) { throw new Error('File path must be absolute') } - if (!filePath.endsWith('.tb')) { + if (!filePath.toLowerCase().endsWith('.tb')) { throw new Error('Invalid file extension. Expected .tb file') } const normalized = normalize(filePath) diff --git a/tests/main/connectionValidation.test.ts b/tests/main/connectionValidation.test.ts new file mode 100644 index 0000000..5291fe0 --- /dev/null +++ b/tests/main/connectionValidation.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolve } from 'node:path' + +vi.mock('electron', () => ({ + app: { + getPath: (): string => process.cwd() + } +})) + +import { validateFilePath } from '../../src/main/db/connection' + +describe('validateFilePath', () => { + it('accepts twig extensions with any letter casing', () => { + expect(() => validateFilePath(resolve('presentation.tb'))).not.toThrow() + expect(() => validateFilePath(resolve('presentation.TB'))).not.toThrow() + expect(() => validateFilePath(resolve('presentation.Tb'))).not.toThrow() + }) + + it('still rejects paths without a twig extension', () => { + expect(() => validateFilePath(resolve('presentation.tb.backup'))).toThrow( + 'Invalid file extension' + ) + }) +}) From cb4a4266ff927ad7f81d67ea7be20ef0badc98d3 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 14:34:38 +0800 Subject: [PATCH 6/9] fix: cleanly close editor windows --- src/main/editorWindowManager.ts | 47 +++++++++++++++++++------- src/main/index.ts | 32 ++++++------------ src/main/windowMenu.ts | 20 +++++++++++ tests/main/editorWindowManager.test.ts | 34 ++++++++++++++++++- tests/main/windowMenu.test.ts | 22 ++++++++++++ 5 files changed, 120 insertions(+), 35 deletions(-) create mode 100644 src/main/windowMenu.ts create mode 100644 tests/main/windowMenu.test.ts diff --git a/src/main/editorWindowManager.ts b/src/main/editorWindowManager.ts index d38df28..b91ee6e 100644 --- a/src/main/editorWindowManager.ts +++ b/src/main/editorWindowManager.ts @@ -21,6 +21,7 @@ export type AuxiliaryWindowRole = 'debug' | 'presentation' export interface EditorWindowRecord { window: W + webContentsId: number launchFile: string | null currentPath: string | null currentIdentity: string | null @@ -158,11 +159,16 @@ export function canonicalPathIdentity( export class EditorWindowManager { private readonly editors = new Map>() + private readonly editorIdByWindow = new Map() private readonly editorByWebContentsId = new Map() private readonly auxiliaryOwnerByWebContentsId = new Map< number, { editorId: number; role: AuxiliaryWindowRole } >() + private readonly auxiliaryOwnerByWindow = new Map< + W, + { editorId: number; role: AuxiliaryWindowRole; webContentsId: number } + >() private focusSequence = 0 constructor( @@ -189,8 +195,11 @@ export class EditorWindowManager { } registerEditor(window: W, launchFile: string | null = null): EditorWindowRecord { + const windowId = window.id + const webContentsId = window.webContents.id const record: EditorWindowRecord = { window, + webContentsId, launchFile, currentPath: null, currentIdentity: null, @@ -200,19 +209,26 @@ export class EditorWindowManager { presentationWindow: null, lastFocusedAt: ++this.focusSequence } - this.editors.set(window.id, record) - this.editorByWebContentsId.set(window.webContents.id, window.id) + this.editors.set(windowId, record) + this.editorIdByWindow.set(window, windowId) + this.editorByWebContentsId.set(webContentsId, windowId) return record } unregisterEditor(window: W): EditorWindowRecord | null { - const record = this.editors.get(window.id) ?? null + const windowId = this.editorIdByWindow.get(window) + if (windowId === undefined) return null + const record = this.editors.get(windowId) ?? null if (!record) return null - this.editors.delete(window.id) - this.editorByWebContentsId.delete(window.webContents.id) - for (const auxiliary of [record.debugWindow, record.presentationWindow]) { - if (auxiliary) this.auxiliaryOwnerByWebContentsId.delete(auxiliary.webContents.id) + this.editors.delete(windowId) + this.editorIdByWindow.delete(window) + this.editorByWebContentsId.delete(record.webContentsId) + for (const [auxiliary, owner] of this.auxiliaryOwnerByWindow) { + if (owner.editorId === windowId) { + this.auxiliaryOwnerByWindow.delete(auxiliary) + this.auxiliaryOwnerByWebContentsId.delete(owner.webContentsId) + } } return record } @@ -222,7 +238,9 @@ export class EditorWindowManager { } getEditor(window: W | null): EditorWindowRecord | null { - return window ? (this.editors.get(window.id) ?? null) : null + if (!window) return null + const editorId = this.editorIdByWindow.get(window) + return editorId === undefined ? null : (this.editors.get(editorId) ?? null) } getEditorByWebContentsId(webContentsId: number): EditorWindowRecord | null { @@ -363,16 +381,20 @@ export class EditorWindowManager { attachAuxiliary(owner: W, role: AuxiliaryWindowRole, auxiliary: W): void { const record = this.getEditor(owner) if (!record) throw new Error('Auxiliary windows require an editor owner') + const editorId = this.editorIdByWindow.get(owner) + if (editorId === undefined) throw new Error('Auxiliary windows require an editor owner') + const webContentsId = auxiliary.webContents.id if (role === 'debug') record.debugWindow = auxiliary else record.presentationWindow = auxiliary - this.auxiliaryOwnerByWebContentsId.set(auxiliary.webContents.id, { - editorId: owner.id, + this.auxiliaryOwnerByWebContentsId.set(webContentsId, { + editorId, role }) + this.auxiliaryOwnerByWindow.set(auxiliary, { editorId, role, webContentsId }) } detachAuxiliary(auxiliary: W): void { - const owner = this.auxiliaryOwnerByWebContentsId.get(auxiliary.webContents.id) + const owner = this.auxiliaryOwnerByWindow.get(auxiliary) if (!owner) return const record = this.editors.get(owner.editorId) if (record) { @@ -381,7 +403,8 @@ export class EditorWindowManager { record.presentationWindow = null } } - this.auxiliaryOwnerByWebContentsId.delete(auxiliary.webContents.id) + this.auxiliaryOwnerByWindow.delete(auxiliary) + this.auxiliaryOwnerByWebContentsId.delete(owner.webContentsId) } getAuxiliary(owner: W, role: AuxiliaryWindowRole): W | null { diff --git a/src/main/index.ts b/src/main/index.ts index 8f21e73..6e29161 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -29,6 +29,7 @@ import * as bookmarksService from './bookmarks' import { closeWindowsSequentially, createWindowCloseController } from './windowCloseController' import { EditorWindowManager } from './editorWindowManager' import { presentationPathsFromArgv } from './launchPaths' +import { createWindowMenu } from './windowMenu' import { safeLog, formatError } from './logging' import { getTempDir, @@ -505,6 +506,7 @@ function createWindow(launchFile: string | null = null): BrowserWindow { additionalArguments: ['--twig-window-role=editor'] } }) + const windowId = window.id editorWindows.registerEditor(window, launchFile) window.on('focus', () => editorWindows.noteFocused(window)) @@ -540,7 +542,7 @@ function createWindow(launchFile: string | null = null): BrowserWindow { setIsQuitting: () => {}, quitApp: () => {} }) - editorCloseControllers.set(window.id, closeController) + editorCloseControllers.set(windowId, closeController) window.on('close', (event) => { closeController.handleClose(event) @@ -567,15 +569,15 @@ function createWindow(launchFile: string | null = null): BrowserWindow { } window.on('closed', () => { - const queuedOpenFiles = queuedEditorOpenFiles.get(window.id) ?? [] + const queuedOpenFiles = queuedEditorOpenFiles.get(windowId) ?? [] const record = editorWindows.getEditor(window) for (const auxiliary of [record?.debugWindow, record?.presentationWindow]) { if (auxiliary && !auxiliary.isDestroyed()) auxiliary.destroy() } editorWindows.unregisterEditor(window) - editorCloseControllers.delete(window.id) - readyEditorIds.delete(window.id) - queuedEditorOpenFiles.delete(window.id) + editorCloseControllers.delete(windowId) + readyEditorIds.delete(windowId) + queuedEditorOpenFiles.delete(windowId) setupAppMenu() if (!isQuitting) { for (const filePath of queuedOpenFiles) setImmediate(() => routeExternalOpen(filePath)) @@ -835,23 +837,9 @@ function setupAppMenu(): void { { role: 'toggleDevTools' as const } ] }, - { - label: 'Window', - role: 'window', - submenu: [ - { - label: 'Show Editor Window', - click: () => { - showOrCreateEditorWindow() - } - }, - { type: 'separator' }, - { role: 'minimize' }, - { role: 'zoom' }, - { type: 'separator' }, - { role: 'front' } - ] - } + createWindowMenu(() => { + showOrCreateEditorWindow() + }) ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) diff --git a/src/main/windowMenu.ts b/src/main/windowMenu.ts new file mode 100644 index 0000000..bc39e46 --- /dev/null +++ b/src/main/windowMenu.ts @@ -0,0 +1,20 @@ +import type { MenuItemConstructorOptions } from 'electron' + +export function createWindowMenu(onShowEditorWindow: () => void): MenuItemConstructorOptions { + return { + label: 'Window', + role: 'windowMenu', + submenu: [ + { role: 'minimize' }, + { role: 'zoom' }, + { role: 'togglefullscreen' }, + { type: 'separator' }, + { + label: 'Show Editor Window', + click: onShowEditorWindow + }, + { type: 'separator' }, + { role: 'front' } + ] + } +} diff --git a/tests/main/editorWindowManager.test.ts b/tests/main/editorWindowManager.test.ts index 941bcca..d01b914 100644 --- a/tests/main/editorWindowManager.test.ts +++ b/tests/main/editorWindowManager.test.ts @@ -7,7 +7,8 @@ import { canonicalPathIdentity, EditorWindowManager } from '../../src/main/edito class FakeWindow { static nextId = 1 id = FakeWindow.nextId++ - webContents = { id: this.id * 10 } + private readonly managedWebContents = { id: this.id * 10 } + throwOnWebContentsAccess = false destroyed = false minimized = false restore = vi.fn(() => { @@ -16,6 +17,11 @@ class FakeWindow { show = vi.fn() focus = vi.fn() + get webContents(): { id: number } { + if (this.throwOnWebContentsAccess) throw new Error('Object has been destroyed') + return this.managedWebContents + } + isDestroyed(): boolean { return this.destroyed } @@ -212,4 +218,30 @@ describe('EditorWindowManager', () => { manager.detachAuxiliary(debug) expect(manager.getOwnerByWebContentsId(debug.webContents.id)).toBeNull() }) + + it('unregisters destroyed editor and auxiliary windows without reading webContents', () => { + const manager = new EditorWindowManager((path) => path) + const editor = new FakeWindow() + const debug = new FakeWindow() + const presentation = new FakeWindow() + const editorWebContentsId = editor.webContents.id + const debugWebContentsId = debug.webContents.id + const presentationWebContentsId = presentation.webContents.id + manager.registerEditor(editor) + manager.attachAuxiliary(editor, 'debug', debug) + manager.attachAuxiliary(editor, 'presentation', presentation) + + editor.destroyed = true + debug.destroyed = true + presentation.destroyed = true + editor.throwOnWebContentsAccess = true + debug.throwOnWebContentsAccess = true + presentation.throwOnWebContentsAccess = true + + expect(() => manager.detachAuxiliary(debug)).not.toThrow() + expect(() => manager.unregisterEditor(editor)).not.toThrow() + expect(manager.getEditorByWebContentsId(editorWebContentsId)).toBeNull() + expect(manager.getOwnerByWebContentsId(debugWebContentsId)).toBeNull() + expect(manager.getOwnerByWebContentsId(presentationWebContentsId)).toBeNull() + }) }) diff --git a/tests/main/windowMenu.test.ts b/tests/main/windowMenu.test.ts new file mode 100644 index 0000000..4dcd91e --- /dev/null +++ b/tests/main/windowMenu.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest' +import type { MenuItemConstructorOptions } from 'electron' +import { createWindowMenu } from '../../src/main/windowMenu' + +describe('createWindowMenu', () => { + it('uses the native window menu role and standard window controls', () => { + const showEditorWindow = vi.fn() + const menu = createWindowMenu(showEditorWindow) + const submenu = menu.submenu as MenuItemConstructorOptions[] + + expect(menu.role).toBe('windowMenu') + expect(submenu.map((item) => item.role).filter(Boolean)).toEqual([ + 'minimize', + 'zoom', + 'togglefullscreen', + 'front' + ]) + + const showEditorItem = submenu.find((item) => item.label === 'Show Editor Window') + expect(showEditorItem?.click).toBe(showEditorWindow) + }) +}) From a475bbdda62372a6ec235c3d7b18b1fd97906bd3 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 14:43:14 +0800 Subject: [PATCH 7/9] fix: restore debug panel locale API --- src/preload/index.ts | 36 +++++++++++---------------- src/preload/roleApi.ts | 47 +++++++++++++++++++++++++++++++++++ src/renderer/src/api.d.ts | 2 ++ src/renderer/src/debug.ts | 4 +-- tests/preload/roleApi.test.ts | 43 ++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 src/preload/roleApi.ts create mode 100644 tests/preload/roleApi.test.ts diff --git a/src/preload/index.ts b/src/preload/index.ts index 6df58cc..0204c7d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -12,6 +12,7 @@ */ import { contextBridge, ipcRenderer } from 'electron' +import { selectWindowRoleApi } from './roleApi' const isStoreBuild = process.mas === true || @@ -179,6 +180,18 @@ const api = { /** Request current state (for debug window) */ requestState: () => ipcRenderer.send('debug:request-state'), + /** Read the locale without exposing the complete preferences API. */ + getLocale: () => ipcRenderer.invoke('prefs:get', 'locale'), + + /** Keep translated debug UI in sync with global locale changes. */ + onLocaleChanged: (callback: (locale: string) => void) => { + const handler = (_event: Electron.IpcRendererEvent, locale: string): void => callback(locale) + ipcRenderer.on('locale:changed', handler) + return (): void => { + ipcRenderer.removeListener('locale:changed', handler) + } + }, + /** Listen for state requests from debug window (for main window) */ onStateRequest: (callback) => { const handler = (): void => callback() @@ -390,28 +403,7 @@ const api = { const windowRole = process.argv .find((argument) => argument.startsWith('--twig-window-role=')) ?.slice('--twig-window-role='.length) -const exposedApi = - windowRole === 'presentation' - ? { - db: { getSlide: api.db.getSlide }, - fonts: { getEmbeddedFonts: api.fonts.getEmbeddedFonts }, - presentation: { - navigate: api.presentation.navigate, - exit: api.presentation.exit, - onStateChanged: api.presentation.onStateChanged, - signalReady: api.presentation.signalReady - } - } - : windowRole === 'debug' - ? { - debug: { - onStateUpdate: api.debug.onStateUpdate, - requestState: api.debug.requestState - } - } - : windowRole === 'editor' - ? api - : {} +const exposedApi = selectWindowRoleApi(api, windowRole) if (process.contextIsolated) { try { diff --git a/src/preload/roleApi.ts b/src/preload/roleApi.ts new file mode 100644 index 0000000..a69c4fb --- /dev/null +++ b/src/preload/roleApi.ts @@ -0,0 +1,47 @@ +interface RoleApiSource { + db: { getSlide: unknown } + fonts: { getEmbeddedFonts: unknown } + presentation: { + navigate: unknown + exit: unknown + onStateChanged: unknown + signalReady: unknown + } + debug: { + onStateUpdate: unknown + requestState: unknown + getLocale: unknown + onLocaleChanged: unknown + } +} + +export function selectWindowRoleApi( + api: T, + windowRole: string | undefined +): object { + if (windowRole === 'presentation') { + return { + db: { getSlide: api.db.getSlide }, + fonts: { getEmbeddedFonts: api.fonts.getEmbeddedFonts }, + presentation: { + navigate: api.presentation.navigate, + exit: api.presentation.exit, + onStateChanged: api.presentation.onStateChanged, + signalReady: api.presentation.signalReady + } + } + } + + if (windowRole === 'debug') { + return { + debug: { + onStateUpdate: api.debug.onStateUpdate, + requestState: api.debug.requestState, + getLocale: api.debug.getLocale, + onLocaleChanged: api.debug.onLocaleChanged + } + } + } + + return windowRole === 'editor' ? api : {} +} diff --git a/src/renderer/src/api.d.ts b/src/renderer/src/api.d.ts index 251184e..b97583d 100644 --- a/src/renderer/src/api.d.ts +++ b/src/renderer/src/api.d.ts @@ -211,6 +211,8 @@ declare global { sendStateUpdate: (state: DebugState) => void onStateUpdate: (callback: (state: DebugState) => void) => () => void requestState: () => void + getLocale: () => Promise + onLocaleChanged: (callback: (locale: string) => void) => () => void onStateRequest: (callback: () => void) => () => void } app: { diff --git a/src/renderer/src/debug.ts b/src/renderer/src/debug.ts index 46970ff..54c0e1a 100644 --- a/src/renderer/src/debug.ts +++ b/src/renderer/src/debug.ts @@ -11,11 +11,11 @@ import Debug from './Debug.svelte' import { normalizeLocale, setupI18n } from './lib/i18n' import { locale } from 'svelte-i18n' -const savedLocale = (await window.api.prefs.get('locale')) as string | null +const savedLocale = await window.api.debug.getLocale() await setupI18n(savedLocale) // Keep in sync when the user changes language in the main window -window.api?.app?.onLocaleChanged((newLocale) => { +window.api.debug.onLocaleChanged((newLocale) => { locale.set(normalizeLocale(newLocale)) }) diff --git a/tests/preload/roleApi.test.ts b/tests/preload/roleApi.test.ts new file mode 100644 index 0000000..99a4f6f --- /dev/null +++ b/tests/preload/roleApi.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest' +import { selectWindowRoleApi } from '../../src/preload/roleApi' + +function createApi(): Parameters[0] { + return { + db: { getSlide: vi.fn() }, + fonts: { getEmbeddedFonts: vi.fn() }, + presentation: { + navigate: vi.fn(), + exit: vi.fn(), + onStateChanged: vi.fn(), + signalReady: vi.fn() + }, + debug: { + onStateUpdate: vi.fn(), + requestState: vi.fn(), + getLocale: vi.fn(), + onLocaleChanged: vi.fn() + } + } +} + +describe('selectWindowRoleApi', () => { + it('gives the debug renderer its state and locale APIs only', () => { + const exposed = selectWindowRoleApi(createApi(), 'debug') + + expect(exposed).toEqual({ + debug: { + onStateUpdate: expect.any(Function), + requestState: expect.any(Function), + getLocale: expect.any(Function), + onLocaleChanged: expect.any(Function) + } + }) + expect(exposed).not.toHaveProperty('prefs') + expect(exposed).not.toHaveProperty('app') + expect(exposed).not.toHaveProperty('db') + }) + + it('does not expose APIs to an unknown renderer role', () => { + expect(selectWindowRoleApi(createApi(), 'unknown')).toEqual({}) + }) +}) From b95348ed93dc14a80eae69ee71499db6d75afc5f Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 15:00:52 +0800 Subject: [PATCH 8/9] fix: copy debug state through trusted clipboard --- src/main/index.ts | 13 ++- src/preload/index.ts | 3 + src/preload/roleApi.ts | 2 + src/renderer/src/Debug.svelte | 54 +------------ src/renderer/src/api.d.ts | 1 + src/renderer/src/lib/debugStateWindow.ts | 87 +++++++++++++++++++++ tests/preload/roleApi.test.ts | 2 + tests/renderer/lib/debugStateWindow.test.ts | 34 ++++++++ 8 files changed, 144 insertions(+), 52 deletions(-) create mode 100644 src/renderer/src/lib/debugStateWindow.ts create mode 100644 tests/renderer/lib/debugStateWindow.test.ts diff --git a/src/main/index.ts b/src/main/index.ts index 6e29161..dea6dac 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,7 +16,8 @@ import { dialog, powerMonitor, webContents, - Menu + Menu, + clipboard } from 'electron' import { autoUpdater } from 'electron-updater' import { join, basename, extname, sep, resolve, relative, isAbsolute } from 'path' @@ -1918,6 +1919,16 @@ app.whenReady().then(() => { if (owner) sendToEditor(owner, 'debug:request-state-from-main') }) + ipcMain.handle('debug:copy-text', (event, text: unknown) => { + const owner = getOwnerForSender(event.sender.id) + const debugWindow = owner ? editorWindows.getAuxiliary(owner, 'debug') : null + if (!debugWindow || debugWindow.webContents.id !== event.sender.id) { + throw new Error('Clipboard writes require an owned debug window') + } + if (typeof text !== 'string') throw new Error('Clipboard text must be a string') + clipboard.writeText(text) + }) + // -------------------------------------------------------------------------- // Presentation Window Handlers // -------------------------------------------------------------------------- diff --git a/src/preload/index.ts b/src/preload/index.ts index 0204c7d..4c22bba 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -180,6 +180,9 @@ const api = { /** Request current state (for debug window) */ requestState: () => ipcRenderer.send('debug:request-state'), + /** Copy debug output through Electron's trusted clipboard implementation. */ + copyText: (text: string) => ipcRenderer.invoke('debug:copy-text', text), + /** Read the locale without exposing the complete preferences API. */ getLocale: () => ipcRenderer.invoke('prefs:get', 'locale'), diff --git a/src/preload/roleApi.ts b/src/preload/roleApi.ts index a69c4fb..6cae8d4 100644 --- a/src/preload/roleApi.ts +++ b/src/preload/roleApi.ts @@ -10,6 +10,7 @@ interface RoleApiSource { debug: { onStateUpdate: unknown requestState: unknown + copyText: unknown getLocale: unknown onLocaleChanged: unknown } @@ -37,6 +38,7 @@ export function selectWindowRoleApi( debug: { onStateUpdate: api.debug.onStateUpdate, requestState: api.debug.requestState, + copyText: api.debug.copyText, getLocale: api.debug.getLocale, onLocaleChanged: api.debug.onLocaleChanged } diff --git a/src/renderer/src/Debug.svelte b/src/renderer/src/Debug.svelte index 2b8b5fa..cb28b5a 100644 --- a/src/renderer/src/Debug.svelte +++ b/src/renderer/src/Debug.svelte @@ -7,6 +7,7 @@ "}' + renderDebugStateWindow( + dom.window as unknown as Window, + json, + vi.fn(async () => {}) + ) + + expect(dom.window.document.getElementById('state-json')?.textContent).toBe(json) + expect(dom.window.document.querySelector('script')).toBeNull() + }) +}) From 87847c0da6c92169e1c6c9f42b57387ff53c45f9 Mon Sep 17 00:00:00 2001 From: boomzero Date: Tue, 25 Aug 2026 15:49:51 +0800 Subject: [PATCH 9/9] fix: focus windows before close prompts --- src/main/windowCloseController.ts | 8 +++++- tests/main/windowCloseController.test.ts | 32 ++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/main/windowCloseController.ts b/src/main/windowCloseController.ts index cd40844..e884e16 100644 --- a/src/main/windowCloseController.ts +++ b/src/main/windowCloseController.ts @@ -19,7 +19,10 @@ interface CloseEventLike { } type WebContentsLike = Pick -type WindowLike = Pick & { +type WindowLike = Pick< + BrowserWindow, + 'destroy' | 'focus' | 'isDestroyed' | 'isMinimized' | 'restore' | 'show' +> & { webContents: WebContentsLike } type IpcMainLike = Pick @@ -144,6 +147,9 @@ export function createWindowCloseController(options: WindowCloseControllerOption webContents.once('destroyed', destroyedHandler) try { + if (window.isMinimized()) window.restore() + window.show() + window.focus() webContents.send(CLOSE_REQUEST_CHANNEL, requestId) } catch (error) { logger.warn(`Failed to request close confirmation: ${String(error)}`) diff --git a/tests/main/windowCloseController.test.ts b/tests/main/windowCloseController.test.ts index 772eea8..f45fe51 100644 --- a/tests/main/windowCloseController.test.ts +++ b/tests/main/windowCloseController.test.ts @@ -11,21 +11,31 @@ import { class FakeWebContents extends EventEmitter { sentMessages: Array<{ channel: string; args: unknown[] }> = [] - send(channel: string, ...args: unknown[]): void { + send = vi.fn((channel: string, ...args: unknown[]): void => { this.sentMessages.push({ channel, args }) - } + }) } class FakeWindow { destroyed = false + minimized = false destroy = vi.fn(() => { this.destroyed = true }) + restore = vi.fn(() => { + this.minimized = false + }) + show = vi.fn() + focus = vi.fn() webContents = new FakeWebContents() isDestroyed(): boolean { return this.destroyed } + + isMinimized(): boolean { + return this.minimized + } } class FakeIpcMain extends EventEmitter {} @@ -118,6 +128,24 @@ describe('windowCloseController', () => { expect(getIsQuitting()).toBe(false) }) + it('restores and focuses the window before requesting close confirmation', async () => { + const { controller, window, ipcMain, signalRendererReady } = createHarness() + window.minimized = true + signalRendererReady() + + const closePromise = controller.requestClose() + + expect(window.restore).toHaveBeenCalledOnce() + expect(window.show).toHaveBeenCalledOnce() + expect(window.focus).toHaveBeenCalledOnce() + expect(window.focus.mock.invocationCallOrder[0]).toBeLessThan( + window.webContents.send.mock.invocationCallOrder[0] + ) + + ipcMain.emit(CLOSE_RESPONSE_CHANNEL, { sender: window.webContents }, 1, 'cancel') + await expect(closePromise).resolves.toBe('cancel') + }) + it('falls back to a local close when the renderer never becomes close-ready', async () => { vi.useFakeTimers() const { controller, event, window, quitApp } = createHarness(true)