diff --git a/main.js b/main.js index a9e8a07..496630c 100644 --- a/main.js +++ b/main.js @@ -112,6 +112,9 @@ let closePreparationAttempt = 0; let closeQuiesceOwnerAttempt = null; let lastRestoredCloseAttempt = null; let closeFolderMonitorWasRunning = false; +let folderMonitorRendererReady = false; +let folderMonitorStartupReconcilePending = false; +let folderMonitorStartupReconcileStarted = false; let preparedUpdate = null; let updatePreparationPromise = null; let updateQuitPending = false; @@ -1518,6 +1521,9 @@ function createWindow() { closeFlushApproved = false; closeFlushRequested = false; closeHandshakeReady = false; + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; + folderMonitorStartupReconcileStarted = false; closeQuiesceOwnerAttempt = null; lastRestoredCloseAttempt = null; configStore.setWritesQuiesced(false); @@ -1529,16 +1535,23 @@ function createWindow() { requestClosePreparation(); }); + mainWindow.on('closed', () => { + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; + }); + mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => { if (isInPlace || !isMainFrame) return; closeHandshakeReady = false; + folderMonitorRendererReady = false; updateAnnouncementState.reset(); restoreClosePreparation(closePreparationAttempt); }); mainWindow.webContents.on('render-process-gone', (_event, details) => { + folderMonitorRendererReady = false; _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); if (mainWindow && !mainWindow.isDestroyed()) { @@ -1685,7 +1698,7 @@ app.whenReady().then(async () => { const launchConfig = configStore.load(); const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; if (fm && fm.enabled && fm.folderPath) { - await startFolderMonitor(fm); + await startFolderMonitor(fm, { deferStartupReconcile: true }); } } catch (err) { debugLog(`folder-monitor auto-start failed: ${err.message}`); @@ -1740,6 +1753,8 @@ app.on('before-quit', (event) => { app.on('will-quit', () => { if (quitTeardownStarted) return; quitTeardownStarted = true; + folderMonitorRendererReady = false; + folderMonitorStartupReconcilePending = false; if (updateCheckInterval) clearInterval(updateCheckInterval); updateCheckInterval = null; if (preparedUpdate && updateQuitPending && closeFlushApproved && !preparedUpdateLaunchStarted) { @@ -3103,6 +3118,13 @@ ipcMain.on('app:close-handshake-ready', (event) => { } }); +ipcMain.on('folder-monitor:renderer-ready', (event) => { + if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { + folderMonitorRendererReady = true; + releaseFolderMonitorStartupReconcile(); + } +}); + ipcMain.on('app:close-preparation-started', (event, attempt) => { if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { armCloseFlushTimer(attempt, 3000); @@ -3312,7 +3334,28 @@ function bindFolderMonitorEvents(settings) { }); } -async function startFolderMonitor(settings) { +function releaseFolderMonitorStartupReconcile() { + if (!folderMonitorRendererReady || !folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted || quitTeardownStarted || !folderMonitor.running) return false; + folderMonitorStartupReconcilePending = false; + folderMonitorStartupReconcileStarted = true; + try { + void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => { + debugLog(`folder-monitor startup scan failed: ${error.message}`); + }); + } catch (error) { + debugLog(`folder-monitor startup scan failed: ${error.message}`); + } + return true; +} + +function deferFolderMonitorStartupReconcile() { + if (folderMonitorStartupReconcilePending || folderMonitorStartupReconcileStarted) return false; + folderMonitorStartupReconcilePending = true; + releaseFolderMonitorStartupReconcile(); + return true; +} + +async function startFolderMonitor(settings, options = {}) { try { const persisted = configStore.load().globalSettings?.folderMonitor || {}; const normalized = normalizeAutomationSettings(settings); @@ -3330,7 +3373,8 @@ async function startFolderMonitor(settings) { return { includesExisting: false, paused: true }; } const result = folderMonitor.start(effectiveSettings); - await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); + if (options.deferStartupReconcile === true) deferFolderMonitorStartupReconcile(); + else await folderMonitor.scan({ emitFiles: true, trigger: 'startup' }); debugLog(`folder-monitor started: ${settings.folderPath}`); return result; } catch (err) { diff --git a/preload.js b/preload.js index 6fae9a3..71da4a6 100644 --- a/preload.js +++ b/preload.js @@ -1,5 +1,8 @@ const { contextBridge, ipcRenderer, webUtils } = require('electron'); +let folderMonitorCandidateListenerReady = false; +let folderMonitorReadySent = false; + contextBridge.exposeInMainWorld('api', { // Config getConfig: () => ipcRenderer.invoke('get-config'), @@ -97,6 +100,13 @@ contextBridge.exposeInMainWorld('api', { automationResume: () => ipcRenderer.invoke('automation:resume'), onFolderMonitorNewFiles: (callback) => { ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); + folderMonitorCandidateListenerReady = true; + }, + signalFolderMonitorReady: () => { + if (!folderMonitorCandidateListenerReady || folderMonitorReadySent) return false; + folderMonitorReadySent = true; + ipcRenderer.send('folder-monitor:renderer-ready'); + return true; }, onAutomationStatus: (callback) => { ipcRenderer.on('automation:status', (_event, data) => callback(data)); @@ -176,6 +186,8 @@ contextBridge.exposeInMainWorld('api', { // File path from drag & drop (Electron 33+ compatible) getPathForFile: (file) => webUtils.getPathForFile(file), removeAllListeners: () => { + folderMonitorCandidateListenerReady = false; + folderMonitorReadySent = false; ipcRenderer.removeAllListeners('upload-progress'); ipcRenderer.removeAllListeners('upload-batch-done'); ipcRenderer.removeAllListeners('upload-stats'); diff --git a/renderer/app.js b/renderer/app.js index c6d566c..c989cbb 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -940,7 +940,7 @@ function renderAutomationStatusSnapshot(snapshot) { queueTrack.removeAttribute('aria-valuenow'); queueTrack.removeAttribute('aria-valuemax'); } else { - queueTrack.setAttribute('aria-valuenow', String(snapshot.currentJobCount)); + queueTrack.setAttribute('aria-valuenow', String(Math.min(snapshot.currentJobCount, snapshot.queueLimitJobs))); queueTrack.setAttribute('aria-valuemax', String(snapshot.queueLimitJobs)); } queueTrack.setAttribute('aria-valuetext', queueText); @@ -1343,6 +1343,7 @@ async function init() { window.api.onFolderMonitorNewFiles(files => { handleFolderMonitorFiles(files).catch(error => window.api.debugLog(`folder-monitor renderer evaluation failed: ${error.message || String(error)}`)); }); + window.api.signalFolderMonitorReady(); if (typeof window.api.onAutomationStatus === 'function') { window.api.onAutomationStatus(status => { applyAutomationRuntimeStatus(status); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index b02cdff..a8630dc 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -345,6 +345,54 @@ test('close readiness is signaled only after the renderer explicitly finishes in ]); }); +test('folder monitor readiness is emitted once and only after the candidate listener exists', () => { + const order = []; + const listeners = new Map(); + let exposedApi = null; + const electronMock = { + contextBridge: { + exposeInMainWorld: (_name, api) => { exposedApi = api; } + }, + ipcRenderer: { + invoke: () => Promise.resolve(), + on: (channel, listener) => { + listeners.set(channel, listener); + order.push(`listen:${channel}`); + }, + send: channel => { order.push(`send:${channel}`); }, + removeAllListeners: () => {} + }, + webUtils: { + getPathForFile: () => '' + } + }; + const originalLoad = Module._load; + const preloadPath = require.resolve('../preload'); + delete require.cache[preloadPath]; + Module._load = function (request, parent, isMain) { + if (request === 'electron') return electronMock; + return originalLoad.call(this, request, parent, isMain); + }; + try { + require(preloadPath); + } finally { + Module._load = originalLoad; + } + + exposedApi.signalFolderMonitorReady(); + let received = null; + exposedApi.onFolderMonitorNewFiles(files => { received = files; }); + exposedApi.signalFolderMonitorReady(); + exposedApi.signalFolderMonitorReady(); + listeners.get('folder-monitor:new-files')({}, [{ path: 'C:\\watch\\ready.mkv' }]); + + assert.deepEqual(order, [ + 'listen:folder-monitor:new-files', + 'send:folder-monitor:renderer-ready' + ]); + assert.deepEqual(received, [{ path: 'C:\\watch\\ready.mkv' }]); +}); + test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => { const listeners = new Map(); const invocations = []; @@ -812,7 +860,7 @@ test('startup keeps a missing configured folder disconnected without disabling a assert.notEqual(startupEnd, -1); const startup = mainSource.slice(startupStart, startupEnd); - assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/u); + assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm,\s*\{\s*deferStartupReconcile:\s*true\s*\}\)/u); assert.doesNotMatch(startup, /fm\.paused\s*!==\s*true/u); assert.doesNotMatch(startup, /folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index c57b872..9105ed7 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -2008,6 +2008,26 @@ contextBridge.exposeInMainWorld('api', { max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') }; + const finiteQueueJobs = queueJobs; + queueJobs = Array.from({ length: 15001 }, (_, index) => ({ + id: 'manual-over-limit-' + index, + file: 'C:\\\\manual\\\\' + index + '.mkv', + fileName: index + '.mkv', + hoster: 'doodstream.com', + status: 'preview', + bytesTotal: 1 + })); + rebuildJobIndex(); + renderAutomationStatusSnapshot(originalSnapshotFactory()); + const overLimitQueueAria = { + meter: document.getElementById('automationQueueMeter')?.textContent.trim() || null, + now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'), + max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'), + text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext') + }; + queueJobs = finiteQueueJobs; + rebuildJobIndex(); + renderAutomationStatusSnapshot(finiteQueueSnapshot); setUiLanguage('en'); renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' })); const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || ''; @@ -2194,7 +2214,7 @@ contextBridge.exposeInMainWorld('api', { enabledAfterCancel, lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none' }; - return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; + return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, overLimitQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading }; })()`; const automationControlCenterLayoutScript = `(() => { const card = document.getElementById('automationStatusCard'); @@ -3082,6 +3102,7 @@ app.whenReady().then(async () => { ]); assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' }); assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' }); + assert.deepEqual(result.automationControlCenter.overLimitQueueAria, { meter: '15.001 / 15.000', now: '15000', max: '15000', text: '15.001 / 15.000' }); assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed'); assert.deepEqual(result.automationControlCenter.pausedControls, { snapshotCalls: 1, @@ -3847,6 +3868,201 @@ ${startupAutomation} } }); +test('real startup releases one productive reconcile only after the folder candidate listener is ready', { skip: process.platform !== 'win32' }, () => { + const projectRoot = path.join(__dirname, '..'); + const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-startup-ready-e2e-')); + try { + const appRoot = path.join(probeRoot, 'app'); + const userDataPath = path.join(probeRoot, 'user-data'); + const outputPath = path.join(probeRoot, 'result.json'); + const watchPath = path.join(probeRoot, 'watch'); + fs.mkdirSync(appRoot, { recursive: true }); + fs.mkdirSync(watchPath, { recursive: true }); + fs.writeFileSync(path.join(watchPath, 'startup-ready.mkv'), Buffer.alloc(19, 5)); + for (const relativePath of ['main.js', 'preload.js', 'preload-drop-target.js', 'package.json']) { + fs.copyFileSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath)); + } + for (const relativePath of ['lib', 'renderer', 'assets']) { + fs.cpSync(path.join(projectRoot, relativePath), path.join(appRoot, relativePath), { recursive: true }); + } + fs.symlinkSync(path.join(projectRoot, 'node_modules'), path.join(appRoot, 'node_modules'), 'junction'); + const probePath = path.join(appRoot, 'startup-ready-probe.cjs'); + const probeSource = ` +const { app, BrowserWindow, ipcMain } = require('electron'); +const fs = require('node:fs'); +const path = require('node:path'); +const ConfigStore = require('./lib/config-store'); +const FolderMonitor = require('./lib/folder-monitor'); +const outputPath = process.env.MHU_STARTUP_READY_OUTPUT; +const userDataPath = process.env.MHU_STARTUP_READY_USER_DATA; +const watchPath = process.env.MHU_STARTUP_READY_WATCH; +app.setPath('userData', userDataPath); +BrowserWindow.prototype.show = function () {}; +let monitorStartedResolve; +const monitorStarted = new Promise(resolve => { monitorStartedResolve = resolve; }); +let getConfigStartedResolve; +const getConfigStarted = new Promise(resolve => { getConfigStartedResolve = resolve; }); +let releaseConfig; +const configRelease = new Promise(resolve => { releaseConfig = resolve; }); +let startupScanSettledResolve; +const startupScanSettled = new Promise(resolve => { startupScanSettledResolve = resolve; }); +let startupScanCalls = 0; +let startupScanSettledCalls = 0; +const originalStart = FolderMonitor.prototype.start; +FolderMonitor.prototype.start = function (...args) { + const result = originalStart.apply(this, args); + monitorStartedResolve(); + return result; +}; +const originalScan = FolderMonitor.prototype.scan; +FolderMonitor.prototype.scan = function (options = {}) { + const result = originalScan.call(this, options); + if (options.trigger === 'startup' && options.emitFiles === true) { + startupScanCalls++; + Promise.resolve(result).finally(() => { + startupScanSettledCalls++; + startupScanSettledResolve(); + }); + } + return result; +}; +const readySignals = { folder: 0, close: 0 }; +const originalOn = ipcMain.on.bind(ipcMain); +ipcMain.on = (channel, handler) => originalOn(channel, function (event, ...args) { + if (channel === 'folder-monitor:renderer-ready') readySignals.folder++; + if (channel === 'app:close-handshake-ready') readySignals.close++; + return handler.call(this, event, ...args); +}); +const originalHandle = ipcMain.handle.bind(ipcMain); +ipcMain.handle = (channel, handler) => originalHandle(channel, async function (event, ...args) { + if (channel === 'get-config') { + getConfigStartedResolve(); + await configRelease; + } + return handler.call(this, event, ...args); +}); + +async function waitFor(read, timeoutMs = 20000) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const value = await read(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 25)); + } + throw new Error('startup ready probe timed out'); +} + +(async () => { + const store = new ConfigStore(app); + const current = store.load(); + await store.save({ + globalSettings: { + ...current.globalSettings, + language: 'en', + logVerbose: false, + logFilePath: path.join(userDataPath, 'fileuploader.log'), + folderMonitor: { + ...current.globalSettings.folderMonitor, + enabled: true, + folderPath: watchPath, + recursive: true, + extensions: 'mkv', + filterMode: 'include', + skipDuplicates: true, + includeExisting: false, + autoStart: false, + hosters: ['doodstream.com'], + queueLimitJobs: 15000, + reconcileIntervalMinutes: 60, + paused: false, + pausedAt: null + } + } + }); + require('./main.js'); + await app.whenReady(); + const window = await waitFor(async () => BrowserWindow.getAllWindows().find(candidate => !candidate.isDestroyed()) || null); + await Promise.all([monitorStarted, getConfigStarted]); + const beforeReady = { + startupScanCalls, + startupScanSettledCalls, + folderReadySignals: readySignals.folder + }; + if (startupScanCalls > 0) await startupScanSettled; + releaseConfig(); + await waitFor(async () => readySignals.close === 1); + await waitFor(async () => startupScanCalls === 1); + await startupScanSettled; + const rendererState = await waitFor(async () => { + try { + const state = await window.webContents.executeJavaScript("(async () => { if (typeof queueJobs === 'undefined' || typeof automationEventQueue === 'undefined') return null; const monitor = await window.api.folderMonitorStatus(); return { candidateJobs: queueJobs.filter(job => job.fileName === 'startup-ready.mkv'), draining: Boolean(automationEventDrainPromise), pendingCandidates: automationEventQueue.size, monitor }; })()"); + return state && !state.draining && state.pendingCandidates === 0 ? state : null; + } catch { + return null; + } + }); + fs.writeFileSync(outputPath, JSON.stringify({ + hidden: window.isVisible() === false, + beforeReady, + readySignals, + startupScanCalls, + startupScanSettledCalls, + rendererState + }), 'utf8'); + window.destroy(); + app.exit(0); +})().catch(error => { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error), readySignals, startupScanCalls, startupScanSettledCalls }), 'utf8'); + app.exit(1); +}); +`; + fs.writeFileSync(probePath, probeSource, 'utf8'); + const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); + const probeEnvironment = { + ...process.env, + MHU_PERF: '0', + MHU_STARTUP_READY_OUTPUT: outputPath, + MHU_STARTUP_READY_USER_DATA: userDataPath, + MHU_STARTUP_READY_WATCH: watchPath + }; + delete probeEnvironment.RUN_UI_SMOKE; + const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], { + cwd: appRoot, + env: probeEnvironment, + encoding: 'utf8', + windowsHide: true, + timeout: 60000 + }); + const probeOutput = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, 'utf8') : ''; + assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}\n${probeOutput}`); + const outcome = JSON.parse(probeOutput); + assert.equal(outcome.error, undefined); + assert.equal(outcome.hidden, true); + assert.deepEqual({ + beforeReady: outcome.beforeReady, + readySignals: outcome.readySignals, + startupScanCalls: outcome.startupScanCalls, + startupScanSettledCalls: outcome.startupScanSettledCalls, + lastScanTrigger: outcome.rendererState.monitor.lastScanTrigger, + candidateNames: outcome.rendererState.candidateJobs.map(job => job.fileName) + }, { + beforeReady: { + startupScanCalls: 0, + startupScanSettledCalls: 0, + folderReadySignals: 0 + }, + readySignals: { folder: 1, close: 1 }, + startupScanCalls: 1, + startupScanSettledCalls: 1, + lastScanTrigger: 'startup', + candidateNames: ['startup-ready.mkv'] + }); + } finally { + fs.rmSync(probeRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + assert.equal(fs.existsSync(probeRoot), false); + } +}); + test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => { const projectRoot = path.join(__dirname, '..'); const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));