Gate external window reveals on renderer readiness
Route second-instance activation, tray clicks, tray menu activation, and drop-target restores through one startup reveal gate. External focus and restore requests now remain queued until the active renderer generation reports Ready or the coordinator exposes the branded failure surface. Block the gate and reset close-handshake readiness synchronously before every renderer recovery navigation and failure-document load. This keeps early loadFile rejection from leaving the terminal failure surface subject to the normal renderer close-preparation handshake. Add production-like event binding regressions for all four external reveal paths, authorized Ready and failsafe reveals, and direct closing of the safe failure surface after an early recovery rejection.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user