diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index ac901ae..c4d95df 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -75,29 +75,95 @@ function createStartupRevealGate(window, { onBlock } = {}) { return { block, navigate, request, reveal }; } -function createStartupExternalRevealBindings({ getWindow, getRevealGate, sendDroppedFiles }) { +function createStartupExternalRevealBindings({ + getWindow, + getRevealGate, + sendDroppedFiles, + maxPendingDropPayloads = 32 +}) { + const pendingDropPayloads = []; + const pendingDropLimit = Number.isSafeInteger(maxPendingDropPayloads) && maxPendingDropPayloads > 0 + ? maxPendingDropPayloads + : 32; + let pendingWindow = null; + let readyWindow = null; + + function clearPendingDropPayloads(window) { + if (window && pendingWindow !== window) return false; + pendingDropPayloads.length = 0; + pendingWindow = null; + return true; + } + + function clearWindowState(window) { + clearPendingDropPayloads(window); + if (!window || readyWindow === window) readyWindow = null; + } + function getActiveWindow() { const window = getWindow(); - if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) return null; + if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) { + clearWindowState(); + return null; + } + if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads(); + if (readyWindow && readyWindow !== window) readyWindow = null; return window; } function requestReveal() { - if (!getActiveWindow()) return false; + const activeWindow = getActiveWindow(); + if (!activeWindow) return false; const revealGate = getRevealGate(); if (!revealGate || typeof revealGate.request !== 'function') return false; revealGate.request(); return true; } + function queueDropPayload(window, paths) { + if (pendingWindow && pendingWindow !== window) clearPendingDropPayloads(); + pendingWindow = window; + if (pendingDropPayloads.length >= pendingDropLimit) pendingDropPayloads.shift(); + pendingDropPayloads.push(paths); + } + function handleDropTargetFiles(_event, paths) { const window = getActiveWindow(); if (!window) return false; if (!window.isVisible() || window.isMinimized()) requestReveal(); - sendDroppedFiles(paths); + if (readyWindow === window) sendDroppedFiles(paths); + else queueDropPayload(window, paths); return true; } + function rendererBlocked(window) { + const activeWindow = getActiveWindow(); + if (!activeWindow || activeWindow !== window) { + clearWindowState(window); + return false; + } + readyWindow = null; + return true; + } + + function rendererReady(window) { + const activeWindow = getActiveWindow(); + if (!activeWindow || activeWindow !== window) { + clearWindowState(window); + return false; + } + readyWindow = window; + if (pendingWindow !== window) return true; + const payloads = pendingDropPayloads.splice(0); + pendingWindow = null; + for (const paths of payloads) sendDroppedFiles(paths); + return true; + } + + function windowClosed(window) { + clearWindowState(window); + } + return { bindSecondInstance(app) { app.on('second-instance', requestReveal); @@ -110,7 +176,10 @@ function createStartupExternalRevealBindings({ getWindow, getRevealGate, sendDro }, bindDropTargetFiles(ipcMain) { ipcMain.on('drop-target:files', handleDropTargetFiles); - } + }, + rendererBlocked, + rendererReady, + windowClosed }; } diff --git a/main.js b/main.js index 69f0386..60e95a4 100644 --- a/main.js +++ b/main.js @@ -1458,6 +1458,7 @@ function createWindow() { clearCloseFlushTimer(); const currentStartupRevealGate = createStartupRevealGate(mainWindow, { onBlock: () => { + startupExternalRevealBindings.rendererBlocked(startupWindow.window); closeHandshakeReady = false; restoreClosePreparation(closePreparationAttempt); } @@ -1525,6 +1526,7 @@ function createWindow() { debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); }, onReady: () => { + startupExternalRevealBindings.rendererReady(startupWindow.window); closeHandshakeReady = true; }, onInitializationFailed: (details) => { @@ -1536,6 +1538,7 @@ function createWindow() { const currentStartupRendererHandlers = startupRendererHandlers; const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; mainWindow.once('closed', () => { + startupExternalRevealBindings.windowClosed(startupWindow.window); currentStartupRendererHandlers.dispose(); if (startupRevealGate === currentStartupRevealGate) startupRevealGate = null; if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 40cd3ad..d8fc7b1 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -214,14 +214,141 @@ test('all production external reveal paths wait for an authorized startup surfac invoke(bindings); assert.deepEqual(window.events, [], name); - assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name); + assert.deepEqual(droppedFiles, [], name); revealGate.reveal(); assert.deepEqual(window.events, ['restore', 'show', 'focus'], name); + assert.deepEqual(droppedFiles, [], name); + + bindings.rendererReady(window); + + assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name); } }); +test('drop payloads wait through recovery and a safe failure reveal until renderer Ready', () => { + const window = createRevealWindow({ minimized: false, visible: true }); + const droppedFiles = []; + let bindings; + const revealGate = createStartupRevealGate(window, { + onBlock() { + bindings.rendererBlocked(window); + } + }); + bindings = createStartupExternalRevealBindings({ + getWindow: () => window, + getRevealGate: () => revealGate, + sendDroppedFiles: paths => droppedFiles.push(paths) + }); + const ipcMain = new EventEmitter(); + bindings.bindDropTargetFiles(ipcMain); + bindings.rendererReady(window); + + ipcMain.emit('drop-target:files', {}, ['ready.mkv']); + revealGate.navigate(() => {}); + ipcMain.emit('drop-target:files', {}, ['recovering.mkv']); + revealGate.reveal(); + + assert.deepEqual(droppedFiles, [['ready.mkv']]); + + bindings.rendererReady(window); + + assert.deepEqual(droppedFiles, [['ready.mkv'], ['recovering.mkv']]); +}); + +test('multiple pending drop payloads flush once in order and ready drops stay immediate', () => { + const window = createRevealWindow(); + const revealGate = createStartupRevealGate(window); + const droppedFiles = []; + const bindings = createStartupExternalRevealBindings({ + getWindow: () => window, + getRevealGate: () => revealGate, + sendDroppedFiles: paths => droppedFiles.push(paths) + }); + const ipcMain = new EventEmitter(); + bindings.bindDropTargetFiles(ipcMain); + + ipcMain.emit('drop-target:files', {}, ['first.mkv']); + ipcMain.emit('drop-target:files', {}, ['second.mkv']); + ipcMain.emit('drop-target:files', {}, ['third.mkv']); + + assert.deepEqual(droppedFiles, []); + + revealGate.reveal(); + bindings.rendererReady(window); + bindings.rendererReady(window); + + assert.deepEqual(droppedFiles, [ + ['first.mkv'], + ['second.mkv'], + ['third.mkv'] + ]); + + ipcMain.emit('drop-target:files', {}, ['fourth.mkv']); + + assert.deepEqual(droppedFiles, [ + ['first.mkv'], + ['second.mkv'], + ['third.mkv'], + ['fourth.mkv'] + ]); +}); + +test('pending drop payloads stay bounded while retaining delivery order', () => { + const window = createRevealWindow(); + const revealGate = createStartupRevealGate(window); + const droppedFiles = []; + const bindings = createStartupExternalRevealBindings({ + getWindow: () => window, + getRevealGate: () => revealGate, + sendDroppedFiles: paths => droppedFiles.push(paths), + maxPendingDropPayloads: 2 + }); + const ipcMain = new EventEmitter(); + bindings.bindDropTargetFiles(ipcMain); + + ipcMain.emit('drop-target:files', {}, ['first.mkv']); + ipcMain.emit('drop-target:files', {}, ['second.mkv']); + ipcMain.emit('drop-target:files', {}, ['third.mkv']); + bindings.rendererReady(window); + + assert.deepEqual(droppedFiles, [ + ['second.mkv'], + ['third.mkv'] + ]); +}); + +test('pending drop payloads are cleared for destroyed and replaced windows', () => { + const firstWindow = createRevealWindow(); + let activeWindow = firstWindow; + const droppedFiles = []; + const bindings = createStartupExternalRevealBindings({ + getWindow: () => activeWindow, + getRevealGate: () => ({ request() {} }), + sendDroppedFiles: paths => droppedFiles.push(paths) + }); + const ipcMain = new EventEmitter(); + bindings.bindDropTargetFiles(ipcMain); + + ipcMain.emit('drop-target:files', {}, ['replaced.mkv']); + activeWindow = createRevealWindow(); + bindings.rendererReady(firstWindow); + bindings.rendererReady(activeWindow); + + assert.deepEqual(droppedFiles, []); + + ipcMain.emit('drop-target:files', {}, ['ready.mkv']); + bindings.rendererBlocked(activeWindow); + ipcMain.emit('drop-target:files', {}, ['destroyed.mkv']); + activeWindow.isDestroyed = () => true; + bindings.rendererReady(activeWindow); + activeWindow = createRevealWindow(); + bindings.rendererReady(activeWindow); + + assert.deepEqual(droppedFiles, [['ready.mkv']]); +}); + test('renderer Ready and the safe failure surface can authorize the gated window', async () => { const readyWindow = createRevealWindow({ minimized: false }); const readyGate = createStartupRevealGate(readyWindow); @@ -894,6 +1021,94 @@ test('production event wiring rejects an old document Ready and exposes the fail handlers.dispose(); }); +test('production recovery wiring flushes pending drops only for the current document Ready', async () => { + const ipcMain = new EventEmitter(); + const webContents = new EventEmitter(); + const initialFrame = createRendererFrame('drop-initial'); + const recoveryFrame = createRendererFrame('drop-recovery'); + const window = createRevealWindow({ minimized: false, visible: true }); + window.webContents = webContents; + const droppedFiles = []; + let bindings; + const revealGate = createStartupRevealGate(window, { + onBlock() { + bindings.rendererBlocked(window); + } + }); + bindings = createStartupExternalRevealBindings({ + getWindow: () => window, + getRevealGate: () => revealGate, + sendDroppedFiles: paths => droppedFiles.push(paths) + }); + bindings.bindDropTargetFiles(ipcMain); + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + webContents.mainFrame = recoveryFrame; + webContents.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: recoveryFrame.url, + frame: recoveryFrame + }); + webContents.emit('did-finish-load'); + }, + reveal: revealGate.reveal, + close() {} + }); + const handlers = createStartupRendererHandlers({ + window, + ipcMain, + coordinator, + onDocumentLoadStarted: revealGate.block, + onReady() { + bindings.rendererReady(window); + }, + onInitializationFailed() {} + }); + + webContents.mainFrame = initialFrame; + webContents.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: initialFrame.url, + frame: initialFrame + }); + webContents.emit('did-finish-load'); + ipcMain.emit('app:close-handshake-ready', { + sender: webContents, + senderFrame: initialFrame + }); + ipcMain.emit('drop-target:files', {}, ['ready.mkv']); + webContents.emit('render-process-gone', {}, { reason: 'crashed' }); + await new Promise(resolve => setImmediate(resolve)); + + ipcMain.emit('drop-target:files', {}, ['first-recovery.mkv']); + ipcMain.emit('drop-target:files', {}, ['second-recovery.mkv']); + ipcMain.emit('app:close-handshake-ready', { + sender: webContents, + senderFrame: initialFrame + }); + + assert.deepEqual(droppedFiles, [['ready.mkv']]); + + ipcMain.emit('app:close-handshake-ready', { + sender: webContents, + senderFrame: recoveryFrame + }); + ipcMain.emit('app:close-handshake-ready', { + sender: webContents, + senderFrame: recoveryFrame + }); + + assert.deepEqual(droppedFiles, [ + ['ready.mkv'], + ['first-recovery.mkv'], + ['second-recovery.mkv'] + ]); + handlers.dispose(); +}); + test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => { const scheduler = createManualScheduler(); let reloadCalls = 0;