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 },