diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index 1a921ca..f57ff68 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -18,13 +18,39 @@ function createStartupFailureDocument(language) { return `Multi Hoster Uploader

${title}

${detail}

`; } -function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) { +function createStartupRecoveryCoordinator({ + load, + reload, + reveal, + showFailure, + close, + readyTimeoutMs = 15000, + scheduleReadyDeadline = setTimeout, + cancelReadyDeadline = clearTimeout +}) { let initialLoad; let crashReloads = 0; let terminalFailure; + let recovery; + let readyDeadline; + let rendererDocument = 0; + let awaitingReady = false; + let stopped = false; + + function clearRendererDeadline() { + if (readyDeadline !== undefined) cancelReadyDeadline(readyDeadline); + readyDeadline = undefined; + } + + function abandonRendererDocument() { + awaitingReady = false; + clearRendererDeadline(); + } function endWithFailure(failure) { + if (stopped) return Promise.resolve(false); if (!terminalFailure) { + abandonRendererDocument(); terminalFailure = (async () => { if (typeof showFailure !== 'function') { await close(failure); @@ -41,28 +67,48 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c return terminalFailure; } - async function recoverRenderer(phase, details) { + function recoverRenderer(phase, details) { + if (stopped) return Promise.resolve(false); if (terminalFailure) return terminalFailure; - if (crashReloads >= 1) { - return endWithFailure({ phase, attempt: crashReloads + 1, details }); - } - crashReloads++; - try { - await reload(); - } catch (error) { - return endWithFailure({ phase: 'renderer-reload', attempt: crashReloads, details, error }); - } + if (recovery) return recovery; + abandonRendererDocument(); + const currentRecovery = (async () => { + if (crashReloads >= 1) { + return endWithFailure({ phase, attempt: crashReloads + 1, details }); + } + crashReloads++; + try { + await reload(); + return true; + } catch (error) { + if (stopped) return false; + return endWithFailure({ phase: 'renderer-reload', attempt: crashReloads, details, error }); + } + })(); + recovery = currentRecovery; + currentRecovery.then( + () => { + if (recovery === currentRecovery) recovery = undefined; + }, + () => { + if (recovery === currentRecovery) recovery = undefined; + } + ); + return currentRecovery; } return { loadInitial(...args) { + if (stopped) return Promise.resolve(false); if (terminalFailure) return terminalFailure; if (!initialLoad) { initialLoad = (async () => { for (let attempt = 1; attempt <= 2; attempt++) { + if (stopped) return false; try { return await load(...args); } catch (error) { + if (stopped) return false; if (attempt === 2) { await endWithFailure({ phase: 'initial-load', attempt, error }); } @@ -78,11 +124,76 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c rendererInitializationFailed(details) { return recoverRenderer('renderer-initialization', details); }, + rendererLoadStarted() { + if (stopped || terminalFailure) return false; + clearRendererDeadline(); + rendererDocument++; + awaitingReady = true; + return true; + }, + rendererLoaded() { + if (stopped || terminalFailure || !awaitingReady) return false; + clearRendererDeadline(); + const document = rendererDocument; + readyDeadline = scheduleReadyDeadline(() => { + if (stopped || terminalFailure || !awaitingReady || rendererDocument !== document) return false; + readyDeadline = undefined; + return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs }); + }, readyTimeoutMs); + if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref(); + return true; + }, rendererReady() { - if (terminalFailure) return false; + if (stopped || terminalFailure) return false; + abandonRendererDocument(); crashReloads = 0; reveal(); return true; + }, + dispose() { + if (stopped) return false; + stopped = true; + abandonRendererDocument(); + return true; + } + }; +} + +function createStartupRendererHandlers({ window, coordinator, onReady, onInitializationFailed }) { + let disposed = false; + + function accepts(event) { + return !disposed && window && !window.isDestroyed() && event && event.sender === window.webContents; + } + + return { + documentLoadStarted() { + if (disposed) return false; + return coordinator.rendererLoadStarted(); + }, + documentLoaded() { + if (disposed) return false; + return coordinator.rendererLoaded(); + }, + rendererCrashed(details) { + if (disposed) return false; + return coordinator.rendererCrashed(details); + }, + rendererInitializationFailed(event, details) { + if (!accepts(event)) return false; + if (typeof onInitializationFailed === 'function') onInitializationFailed(details); + return coordinator.rendererInitializationFailed(details); + }, + rendererReady(event) { + if (!accepts(event)) return false; + const ready = coordinator.rendererReady(); + if (ready && typeof onReady === 'function') onReady(); + return ready; + }, + dispose() { + if (disposed) return false; + disposed = true; + return coordinator.dispose(); } }; } @@ -105,6 +216,7 @@ module.exports = { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage }; diff --git a/main.js b/main.js index f1b8191..8baccde 100644 --- a/main.js +++ b/main.js @@ -9,6 +9,7 @@ const { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer'); @@ -135,7 +136,9 @@ const uploadBatchMutationGates = new WeakMap(); const uploadRecoveryStates = new WeakMap(); let lastSessionSummary = null; let startupRecoveryCoordinator = null; +let startupRendererHandlers = null; let sourceDeleteJournal = null; +const RENDERER_READY_TIMEOUT_MS = 15000; const pendingUploadFinalizations = new Map(); function requestUploadFinalization(summary, historyPersisted) { @@ -1462,6 +1465,11 @@ function createWindow() { if (isInPlace || !isMainFrame) return; closeHandshakeReady = false; restoreClosePreparation(closePreparationAttempt); + if (startupRendererHandlers) startupRendererHandlers.documentLoadStarted(); + }); + + mainWindow.webContents.on('did-finish-load', () => { + if (startupRendererHandlers) startupRendererHandlers.documentLoaded(); }); mainWindow.webContents.on('render-process-gone', (_event, details) => { @@ -1509,7 +1517,27 @@ function createWindow() { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) mainWindow.show(); }, showFailure: () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`), - close: () => app.exit(1) + close: () => app.exit(1), + readyTimeoutMs: RENDERER_READY_TIMEOUT_MS + }); + startupRendererHandlers = createStartupRendererHandlers({ + window: mainWindow, + coordinator: startupRecoveryCoordinator, + onReady: () => { + closeHandshakeReady = true; + }, + onInitializationFailed: (details) => { + const message = details && typeof details.message === 'string' ? details.message : 'Renderer initialization failed'; + _writeCrashLog('RENDERER INITIALIZATION FAILED', new Error(message), details); + debugLog(`RENDERER INITIALIZATION FAILED: ${message}`); + } + }); + const currentStartupRendererHandlers = startupRendererHandlers; + const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; + mainWindow.once('closed', () => { + currentStartupRendererHandlers.dispose(); + if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; + if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null; }); void startupRecoveryCoordinator.loadInitial(rendererTarget, rendererOptions); } @@ -2985,18 +3013,11 @@ ipcMain.handle('app:quit', () => { }); ipcMain.on('app:close-handshake-ready', (event) => { - if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { - closeHandshakeReady = true; - if (startupRecoveryCoordinator) startupRecoveryCoordinator.rendererReady(); - } + if (startupRendererHandlers) startupRendererHandlers.rendererReady(event); }); ipcMain.on('app:renderer-initialization-failed', (event, details) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return; - const message = details && typeof details.message === 'string' ? details.message : 'Renderer initialization failed'; - _writeCrashLog('RENDERER INITIALIZATION FAILED', new Error(message), details); - debugLog(`RENDERER INITIALIZATION FAILED: ${message}`); - if (startupRecoveryCoordinator) void startupRecoveryCoordinator.rendererInitializationFailed(details); + if (startupRendererHandlers) void startupRendererHandlers.rendererInitializationFailed(event, details); }); ipcMain.on('app:close-preparation-started', (event, attempt) => { @@ -3481,11 +3502,13 @@ ipcMain.on('remote:capture-log', (_event, msg) => { // IPC: Input events from capture window ipcMain.on('remote:input-event', (_event, data) => { if (!mainWindow || mainWindow.isDestroyed()) return; + if (!data || typeof data !== 'object' || Array.isArray(data)) return; const config = configStore.load(); const remote = config.globalSettings && config.globalSettings.remote; if (!remote || !remote.allowInput) return; if (data.role !== 'admin') return; + if ((data.type === 'keydown' || data.type === 'keyup') && (typeof data.key !== 'string' || data.key.length === 0)) return; // Capture includes window frame (title bar) but NOT invisible DWM borders // sendInputEvent coordinates are relative to web content area diff --git a/tests/main-remote-input.test.js b/tests/main-remote-input.test.js new file mode 100644 index 0000000..bde121f --- /dev/null +++ b/tests/main-remote-input.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function loadRemoteInputHandler(sendInputEvent, debugLog = () => {}) { + const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const handlerStart = source.indexOf("ipcMain.on('remote:input-event'"); + const handlerEnd = source.indexOf('\nfunction buildModifiers', handlerStart); + const modifiersEnd = source.indexOf('\n// IPC: Get capture source ID', handlerEnd); + assert.notEqual(handlerStart, -1); + assert.notEqual(handlerEnd, -1); + assert.notEqual(modifiersEnd, -1); + + let inputHandler; + const mainWindow = { + isDestroyed: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1100, height: 750 }), + getContentBounds: () => ({ x: 7, y: 30, width: 1086, height: 713 }), + webContents: { sendInputEvent } + }; + const context = vm.createContext({ + ipcMain: { + on(channel, handler) { + if (channel === 'remote:input-event') inputHandler = handler; + } + }, + mainWindow, + configStore: { + load: () => ({ globalSettings: { remote: { allowInput: true } } }) + }, + debugLog, + process: { platform: 'win32' }, + isFinite + }); + + vm.runInContext(source.slice(handlerStart, handlerEnd) + source.slice(handlerEnd, modifiersEnd), context); + assert.equal(typeof inputHandler, 'function'); + return inputHandler; +} + +test('authenticated keyboard input without a string key is discarded without throwing', () => { + const sent = []; + const logs = []; + const handler = loadRemoteInputHandler(event => sent.push(event), (...args) => logs.push(args)); + const invalidPayloads = [ + { role: 'admin', type: 'keydown' }, + { role: 'admin', type: 'keydown', key: null }, + { role: 'admin', type: 'keydown', key: 1 }, + { role: 'admin', type: 'keydown', key: '' }, + { role: 'admin', type: 'keyup' }, + { role: 'admin', type: 'keyup', key: {} } + ]; + + for (const payload of invalidPayloads) { + assert.doesNotThrow(() => handler({}, payload)); + } + + assert.deepEqual(sent, []); + assert.deepEqual(logs, []); +}); + +test('authenticated keyboard input with a string key keeps normal keydown and keyup behavior', () => { + const sent = []; + const handler = loadRemoteInputHandler(event => sent.push(event)); + + handler({}, { role: 'admin', type: 'keydown', key: 'a', ctrl: true }); + handler({}, { role: 'admin', type: 'keyup', key: 'a', ctrl: true }); + + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [ + { type: 'keyDown', keyCode: 'a', modifiers: ['control'] }, + { type: 'char', keyCode: 'a', modifiers: ['control'] }, + { type: 'keyUp', keyCode: 'a', modifiers: ['control'] } + ]); +}); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 721bc16..4b406c2 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -7,6 +7,7 @@ const { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer'); @@ -28,8 +29,11 @@ test('main process wires bounded startup recovery into real load and crash paths assert.match(source, /createStartupRecoveryCoordinator/); assert.match(source, /startupRecoveryCoordinator\.loadInitial/); assert.match(source, /startupRecoveryCoordinator\.rendererCrashed/); - assert.match(source, /startupRecoveryCoordinator\.rendererInitializationFailed/); - assert.match(source, /startupRecoveryCoordinator\.rendererReady/); + assert.match(source, /createStartupRendererHandlers/); + assert.match(source, /startupRendererHandlers\.documentLoadStarted/); + assert.match(source, /startupRendererHandlers\.documentLoaded/); + assert.match(source, /startupRendererHandlers\.rendererInitializationFailed/); + assert.match(source, /startupRendererHandlers\.rendererReady/); assert.match(source, /createStartupFailureDocument/); }); @@ -59,6 +63,35 @@ class TestBrowserWindow extends EventEmitter { } } +function createManualScheduler() { + let nextId = 1; + const pending = new Map(); + + return { + schedule(callback, delay) { + const handle = { id: nextId++, unref() {} }; + pending.set(handle, { callback, delay }); + return handle; + }, + cancel(handle) { + pending.delete(handle); + }, + count() { + return pending.size; + }, + delays() { + return Array.from(pending.values(), entry => entry.delay); + }, + async fireNext() { + const entry = pending.entries().next().value; + assert.ok(entry); + const [handle, timer] = entry; + pending.delete(handle); + await timer.callback(); + } + }; +} + test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => { let calls = 0; configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32'); @@ -311,6 +344,174 @@ test('renderer initialization failures share the bounded recovery path', async ( }]); }); +test('production startup handlers route renderer initialization failure through bounded recovery', async () => { + const failures = []; + let reloadCalls = 0; + let reportedFailure; + const webContents = {}; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + async showFailure(failure) { + failures.push(failure); + }, + close() {} + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() {}, + onInitializationFailed(details) { + reportedFailure = details; + } + }); + const details = { message: 'top-level initialization failed' }; + + await handlers.rendererInitializationFailed({ sender: webContents }, details); + await handlers.rendererInitializationFailed({ sender: webContents }, details); + + assert.equal(reloadCalls, 1); + assert.equal(reportedFailure, details); + assert.deepEqual(failures, [{ + phase: 'renderer-initialization', + attempt: 2, + details + }]); +}); + +test('production startup handlers enforce the Ready deadline after every main document load', async () => { + const scheduler = createManualScheduler(); + const failures = []; + let reloadCalls = 0; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + async showFailure(failure) { + failures.push(failure); + }, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents: {} }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + + assert.deepEqual(scheduler.delays(), [25]); + await scheduler.fireNext(); + assert.equal(reloadCalls, 1); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + await scheduler.fireNext(); + + assert.equal(reloadCalls, 1); + assert.deepEqual(failures, [{ + phase: 'renderer-ready-timeout', + attempt: 2, + details: { timeoutMs: 25 } + }]); +}); + +test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => { + const scheduler = createManualScheduler(); + let reloadCalls = 0; + let readyCalls = 0; + const webContents = {}; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() { + readyCalls++; + }, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + const ready = handlers.rendererReady({ sender: webContents }); + + assert.equal(ready, true); + assert.equal(readyCalls, 1); + assert.equal(scheduler.count(), 0); + assert.equal(reloadCalls, 0); +}); + +test('Ready before did-finish-load prevents a stale deadline', () => { + const scheduler = createManualScheduler(); + const webContents = {}; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() {}, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.rendererReady({ sender: webContents }); + handlers.documentLoaded(); + + assert.equal(scheduler.count(), 0); +}); + +test('disposing startup handlers cancels a pending Ready deadline', () => { + const scheduler = createManualScheduler(); + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() {}, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents: {} }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + handlers.dispose(); + + assert.equal(scheduler.count(), 0); +}); + test('a successful renderer ready event reveals content and resets crash recovery', async () => { const crashes = [ { reason: 'crashed', exitCode: 21 },