diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index 5d5c9db..ac901ae 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -32,6 +32,97 @@ function createStartupNavigationLoader(window, target, options = {}) { }; } +function createStartupRevealGate(window, { onBlock } = {}) { + let blocked = true; + let activationPending = false; + + function hasWindow() { + return !!window && (typeof window.isDestroyed !== 'function' || !window.isDestroyed()); + } + + function showAuthorizedSurface(activate) { + if (!hasWindow()) return false; + if (activate && window.isMinimized()) window.restore(); + if (!window.isVisible()) window.show(); + if (activate) window.focus(); + activationPending = false; + return true; + } + + const block = () => { + blocked = true; + if (typeof onBlock === 'function') onBlock(); + return true; + }; + + const request = () => { + if (!hasWindow()) return false; + activationPending = true; + if (!blocked) showAuthorizedSurface(true); + return true; + }; + + const reveal = () => { + blocked = false; + return showAuthorizedSurface(activationPending); + }; + + const navigate = (operation, ...args) => { + block(); + return operation(...args); + }; + + return { block, navigate, request, reveal }; +} + +function createStartupExternalRevealBindings({ getWindow, getRevealGate, sendDroppedFiles }) { + function getActiveWindow() { + const window = getWindow(); + if (!window || (typeof window.isDestroyed === 'function' && window.isDestroyed())) return null; + return window; + } + + function requestReveal() { + if (!getActiveWindow()) return false; + const revealGate = getRevealGate(); + if (!revealGate || typeof revealGate.request !== 'function') return false; + revealGate.request(); + return true; + } + + function handleDropTargetFiles(_event, paths) { + const window = getActiveWindow(); + if (!window) return false; + if (!window.isVisible() || window.isMinimized()) requestReveal(); + sendDroppedFiles(paths); + return true; + } + + return { + bindSecondInstance(app) { + app.on('second-instance', requestReveal); + }, + bindTrayClick(tray) { + tray.on('click', requestReveal); + }, + createTrayMenuItem(label) { + return { label, click: requestReveal }; + }, + bindDropTargetFiles(ipcMain) { + ipcMain.on('drop-target:files', handleDropTargetFiles); + } + }; +} + +function createStartupCloseHandler({ window, shouldPrepareClose, requestClosePreparation }) { + return function handleStartupClose(event) { + if (!shouldPrepareClose() || window.webContents.isDestroyed()) return false; + event.preventDefault(); + requestClosePreparation(); + return true; + }; +} + function createStartupRecoveryCoordinator({ load, reload, @@ -368,9 +459,12 @@ function createStartupWindow(BrowserWindow, options) { module.exports = { configureStartupRenderer, + createStartupCloseHandler, + createStartupExternalRevealBindings, createStartupFailureDocument, createStartupNavigationLoader, createStartupRecoveryCoordinator, + createStartupRevealGate, createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage diff --git a/main.js b/main.js index ac5ec93..69f0386 100644 --- a/main.js +++ b/main.js @@ -7,9 +7,12 @@ app.setName('Multi Hoster Uploader'); app.setAppUserModelId('com.multihoster.uploader'); const { configureStartupRenderer, + createStartupCloseHandler, + createStartupExternalRevealBindings, createStartupFailureDocument, createStartupNavigationLoader, createStartupRecoveryCoordinator, + createStartupRevealGate, createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage @@ -137,9 +140,15 @@ const uploadBatchMutationGates = new WeakMap(); const uploadRecoveryStates = new WeakMap(); let lastSessionSummary = null; let startupRecoveryCoordinator = null; +let startupRevealGate = null; let startupRendererHandlers = null; let sourceDeleteJournal = null; const RENDERER_READY_TIMEOUT_MS = 15000; +const startupExternalRevealBindings = createStartupExternalRevealBindings({ + getWindow: () => mainWindow, + getRevealGate: () => startupRevealGate, + sendDroppedFiles: paths => safeSend('drop-target:files', paths) +}); const pendingUploadFinalizations = new Map(); function requestUploadFinalization(summary, historyPersisted) { @@ -284,13 +293,7 @@ const _hasSingleInstanceLock = app.requestSingleInstanceLock(); if (!_hasSingleInstanceLock) { app.quit(); } else { - app.on('second-instance', () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - if (!mainWindow.isVisible()) mainWindow.show(); - mainWindow.focus(); - } - }); + startupExternalRevealBindings.bindSecondInstance(app); } // Rotation memory that survives batch-done → new UploadManager within the // same app session. Without this, clicking "Retry failed" after a batch @@ -1453,12 +1456,19 @@ function createWindow() { lastRestoredCloseAttempt = null; configStore.setWritesQuiesced(false); clearCloseFlushTimer(); - - mainWindow.on('close', (event) => { - if (closeFlushApproved || !closeHandshakeReady || mainWindow.webContents.isDestroyed()) return; - event.preventDefault(); - requestClosePreparation(); + const currentStartupRevealGate = createStartupRevealGate(mainWindow, { + onBlock: () => { + closeHandshakeReady = false; + restoreClosePreparation(closePreparationAttempt); + } }); + startupRevealGate = currentStartupRevealGate; + + mainWindow.on('close', createStartupCloseHandler({ + window: mainWindow, + shouldPrepareClose: () => !closeFlushApproved && closeHandshakeReady, + requestClosePreparation + })); mainWindow.webContents.setBackgroundThrottling(false); @@ -1488,7 +1498,7 @@ function createWindow() { const loadStartupDocument = createStartupNavigationLoader(mainWindow, rendererTarget, rendererOptions); const loadRendererSurface = async () => { try { - return await loadStartupDocument(); + return await currentStartupRevealGate.navigate(loadStartupDocument); } catch (error) { _writeCrashLog('LOAD FILE FAILED', error); debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`); @@ -1498,10 +1508,10 @@ function createWindow() { startupRecoveryCoordinator = createStartupRecoveryCoordinator({ load: loadRendererSurface, reload: loadRendererSurface, - reveal: () => { - if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) mainWindow.show(); - }, - showFailure: () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`), + reveal: currentStartupRevealGate.reveal, + showFailure: () => currentStartupRevealGate.navigate( + () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`) + ), close: () => app.exit(1), readyTimeoutMs: RENDERER_READY_TIMEOUT_MS }); @@ -1509,10 +1519,7 @@ function createWindow() { window: mainWindow, ipcMain, coordinator: startupRecoveryCoordinator, - onDocumentLoadStarted: () => { - closeHandshakeReady = false; - restoreClosePreparation(closePreparationAttempt); - }, + onDocumentLoadStarted: currentStartupRevealGate.block, onRendererCrashed: (details) => { _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); @@ -1530,6 +1537,7 @@ function createWindow() { const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; mainWindow.once('closed', () => { currentStartupRendererHandlers.dispose(); + if (startupRevealGate === currentStartupRevealGate) startupRevealGate = null; if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null; }); @@ -1553,9 +1561,7 @@ function createTray() { tray = new Tray(icon || nativeImage.createEmpty()); refreshTrayLanguage(); - tray.on('click', () => { - if (mainWindow) { mainWindow.show(); mainWindow.focus(); } - }); + startupExternalRevealBindings.bindTrayClick(tray); } catch (err) { tray = null; debugLog(`createTray failed (non-fatal): ${err && err.message ? err.message : err}`); @@ -1925,7 +1931,7 @@ function refreshTrayLanguage() { if (!tray || tray.isDestroyed()) return; tray.setToolTip('Multi Hoster Uploader'); tray.setContextMenu(Menu.buildFromTemplate([ - { label: shellText('Öffnen', 'Open'), click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, + startupExternalRevealBindings.createTrayMenuItem(shellText('Öffnen', 'Open')), { type: 'separator' }, { label: shellText('Beenden', 'Quit'), click: () => { app.quit(); } } ])); @@ -3701,15 +3707,7 @@ ipcMain.handle('hide-drop-target', () => { return true; }); -ipcMain.on('drop-target:files', (_event, paths) => { - if (mainWindow && !mainWindow.isDestroyed()) { - if (!mainWindow.isVisible() || mainWindow.isMinimized()) { - mainWindow.show(); - mainWindow.focus(); - } - safeSend('drop-target:files', paths); - } -}); +startupExternalRevealBindings.bindDropTargetFiles(ipcMain); // --- Shutdown after finish --- let shutdownMode = 'nothing'; diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index d6428f5..40cd3ad 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -5,9 +5,12 @@ const fs = require('node:fs'); const path = require('node:path'); const { configureStartupRenderer, + createStartupCloseHandler, + createStartupExternalRevealBindings, createStartupFailureDocument, createStartupNavigationLoader, createStartupRecoveryCoordinator, + createStartupRevealGate, createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage @@ -92,6 +95,38 @@ function createRendererFrame(frameToken, url = `file:///renderer/index.html?star }; } +function createRevealWindow({ minimized = true, visible = false } = {}) { + const events = []; + return { + events, + webContents: { + isDestroyed() { + return false; + } + }, + isDestroyed() { + return false; + }, + isMinimized() { + return minimized; + }, + isVisible() { + return visible; + }, + restore() { + events.push('restore'); + minimized = false; + }, + show() { + events.push('show'); + visible = true; + }, + focus() { + events.push('focus'); + } + }; +} + test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => { let calls = 0; configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32'); @@ -144,6 +179,136 @@ test('ready-to-show cannot reveal the main window before renderer Ready', async assert.equal(startup.window.showCalls, 0); }); +test('all production external reveal paths wait for an authorized startup surface', () => { + const paths = [ + ['second-instance', bindings => { + const app = new EventEmitter(); + bindings.bindSecondInstance(app); + app.emit('second-instance'); + }], + ['tray click', bindings => { + const tray = new EventEmitter(); + bindings.bindTrayClick(tray); + tray.emit('click'); + }], + ['tray menu', bindings => { + bindings.createTrayMenuItem('Open').click(); + }], + ['drop target files', bindings => { + const ipcMain = new EventEmitter(); + bindings.bindDropTargetFiles(ipcMain); + ipcMain.emit('drop-target:files', {}, ['queued.mkv']); + }] + ]; + + for (const [name, invoke] of paths) { + const window = createRevealWindow(); + const revealGate = createStartupRevealGate(window); + const droppedFiles = []; + const bindings = createStartupExternalRevealBindings({ + getWindow: () => window, + getRevealGate: () => revealGate, + sendDroppedFiles: paths => droppedFiles.push(paths) + }); + + invoke(bindings); + + assert.deepEqual(window.events, [], name); + assert.deepEqual(droppedFiles, name === 'drop target files' ? [['queued.mkv']] : [], name); + + revealGate.reveal(); + + assert.deepEqual(window.events, ['restore', 'show', 'focus'], name); + } +}); + +test('renderer Ready and the safe failure surface can authorize the gated window', async () => { + const readyWindow = createRevealWindow({ minimized: false }); + const readyGate = createStartupRevealGate(readyWindow); + const readyCoordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() {}, + reveal: readyGate.reveal, + close() {} + }); + const generation = readyCoordinator.rendererLoadStarted(); + readyCoordinator.rendererLoaded(generation); + + readyCoordinator.rendererReady(generation); + + assert.deepEqual(readyWindow.events, ['show']); + + const failureWindow = createRevealWindow({ minimized: false }); + const failureGate = createStartupRevealGate(failureWindow); + let failureSurfaceLoads = 0; + const failureCoordinator = createStartupRecoveryCoordinator({ + async load() { + throw new Error('navigation failed'); + }, + reload() {}, + reveal: failureGate.reveal, + async showFailure() { + failureSurfaceLoads++; + }, + close() {} + }); + + await failureCoordinator.loadInitial(); + + assert.equal(failureSurfaceLoads, 1); + assert.deepEqual(failureWindow.events, ['show']); +}); + +test('failed recovery navigation leaves the safe failure surface directly closable', async () => { + const window = createRevealWindow({ minimized: false }); + let closeHandshakeReady = true; + const readinessDuringNavigation = []; + const revealGate = createStartupRevealGate(window, { + onBlock() { + closeHandshakeReady = false; + } + }); + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() { + return revealGate.navigate(() => { + readinessDuringNavigation.push(closeHandshakeReady); + throw new Error('recovery load rejected before navigation'); + }); + }, + reveal: revealGate.reveal, + showFailure() { + return revealGate.navigate(() => { + readinessDuringNavigation.push(closeHandshakeReady); + }); + }, + close() {} + }); + + await coordinator.rendererCrashed({ reason: 'crashed' }); + + let closePreparationCalls = 0; + let preventDefaultCalls = 0; + const closeHandler = createStartupCloseHandler({ + window, + shouldPrepareClose: () => closeHandshakeReady, + requestClosePreparation() { + closePreparationCalls++; + } + }); + const intercepted = closeHandler({ + preventDefault() { + preventDefaultCalls++; + } + }); + + assert.deepEqual(readinessDuringNavigation, [false, false]); + assert.deepEqual(window.events, ['show']); + assert.equal(intercepted, false); + assert.equal(preventDefaultCalls, 0); + assert.equal(closePreparationCalls, 0); +}); + test('startup load forwards a rejected navigation to the error handler', async () => { const startup = createStartupWindow(TestBrowserWindow, {}); let handledError;