feat: persist automation pause state

This commit is contained in:
Sucukdeluxe
2026-08-26 10:05:26 +02:00
parent 2063599611
commit 9adb38de08
7 changed files with 506 additions and 38 deletions
+13 -1
View File
@@ -99,7 +99,11 @@ const DEFAULTS = {
skipDuplicates: true, skipDuplicates: true,
delaySec: 3, delaySec: 3,
autoStart: true, autoStart: true,
hosters: [] // pre-selected hosters, empty = ask via modal hosters: [], // pre-selected hosters, empty = ask via modal
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5,
paused: false,
pausedAt: null
}, },
remote: { remote: {
enabled: false, enabled: false,
@@ -618,6 +622,8 @@ class ConfigStore {
const currentGlobalSettings = current.globalSettings || {}; const currentGlobalSettings = current.globalSettings || {};
const currentRemote = currentGlobalSettings.remote || {}; const currentRemote = currentGlobalSettings.remote || {};
const incomingRemote = snapshot.remote || {}; const incomingRemote = snapshot.remote || {};
const currentFolderMonitor = currentGlobalSettings.folderMonitor || {};
const incomingFolderMonitor = snapshot.folderMonitor || {};
current.globalSettings = { current.globalSettings = {
...snapshot, ...snapshot,
pendingQueue: currentGlobalSettings.pendingQueue ?? null, pendingQueue: currentGlobalSettings.pendingQueue ?? null,
@@ -625,6 +631,12 @@ class ConfigStore {
lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '', lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '',
diagnostics: this._clone(currentGlobalSettings.diagnostics || {}), diagnostics: this._clone(currentGlobalSettings.diagnostics || {}),
historyRetention: currentGlobalSettings.historyRetention || 'all', historyRetention: currentGlobalSettings.historyRetention || 'all',
folderMonitor: {
...currentFolderMonitor,
...incomingFolderMonitor,
paused: currentFolderMonitor.paused === true,
pausedAt: currentFolderMonitor.pausedAt ?? null
},
remote: { remote: {
...incomingRemote, ...incomingRemote,
token: incomingRemote.token || currentRemote.token || '' token: incomingRemote.token || currentRemote.token || ''
+152 -37
View File
@@ -473,10 +473,6 @@ function logInfo(a, b) {
const s = _split(a, b); const s = _split(a, b);
debugLog(`[INFO ] ${_ctxTag(s.ctx)}${s.msg}`); debugLog(`[INFO ] ${_ctxTag(s.ctx)}${s.msg}`);
} }
function logWarn(a, b) {
const s = _split(a, b);
debugLog(`[WARN ] ${_ctxTag(s.ctx)}${s.msg}`);
}
function logError(a, b, c) { function logError(a, b, c) {
let ctx, msg, err; let ctx, msg, err;
if (typeof a === 'string') { ctx = null; msg = a; err = b; } if (typeof a === 'string') { ctx = null; msg = a; err = b; }
@@ -1690,18 +1686,15 @@ app.whenReady().then(async () => {
mainWindow.hide(); mainWindow.hide();
}); });
// Auto-start folder monitor if enabled
try { try {
const launchConfig = configStore.load(); const launchConfig = configStore.load();
const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor;
if (fm && fm.enabled && fm.folderPath) { if (fm && fm.enabled && fm.folderPath && fm.paused !== true) {
if (fs.existsSync(fm.folderPath)) { startFolderMonitor(fm);
startFolderMonitor(fm); if (!fs.existsSync(fm.folderPath)) {
} else { void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => {
logWarn(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`); debugLog(`folder-monitor startup scan failed: ${error.message}`);
// Persist the disable so the user gets a clean state on next launch });
const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } };
configStore.save({ globalSettings: gs }).catch(() => {});
} }
} }
} catch (err) { } catch (err) {
@@ -2026,6 +2019,9 @@ ipcMain.handle('select-files', async () => {
// Debug self-test: runs a minimal upload in the main process to verify events work // Debug self-test: runs a minimal upload in the main process to verify events work
ipcMain.handle('debug-test-upload', async () => { ipcMain.handle('debug-test-upload', async () => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
const testFile = path.join(__dirname, 'test-self-check.txt'); const testFile = path.join(__dirname, 'test-self-check.txt');
try { try {
fs.writeFileSync(testFile, 'selftest ' + Date.now(), 'utf-8'); fs.writeFileSync(testFile, 'selftest ' + Date.now(), 'utf-8');
@@ -2118,6 +2114,9 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => {
}); });
ipcMain.handle('start-upload', async (_event, payload) => { ipcMain.handle('start-upload', async (_event, payload) => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' }; if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
if (uploadManager) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; if (uploadManager) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' };
@@ -2158,6 +2157,9 @@ ipcMain.handle('start-upload', async (_event, payload) => {
if (tasks.length === 0) { if (tasks.length === 0) {
await appendUploadPlanAudit(batchPlan, 'start'); await appendUploadPlanAudit(batchPlan, 'start');
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
const skippedSummary = stats.mergeSkippedIntoSummary({ const skippedSummary = stats.mergeSkippedIntoSummary({
id: `skipped-${Date.now()}`, id: `skipped-${Date.now()}`,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
@@ -2179,6 +2181,10 @@ ipcMain.handle('start-upload', async (_event, payload) => {
const _thisManager = uploadManager; const _thisManager = uploadManager;
await appendUploadPlanAudit(batchPlan, 'start'); await appendUploadPlanAudit(batchPlan, 'start');
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
return { error: 'Automatik ist pausiert' };
}
const recovery = { const recovery = {
id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
@@ -2186,6 +2192,11 @@ ipcMain.handle('start-upload', async (_event, payload) => {
jobIds: tasks.map(task => task.jobId).filter(Boolean) jobIds: tasks.map(task => task.jobId).filter(Boolean)
}; };
try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); } try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); }
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared after automation pause: ${error.message}`); }
return { error: 'Automatik ist pausiert' };
}
// Pre-resolve a fallback for every hoster that has one. Lets the upload // Pre-resolve a fallback for every hoster that has one. Lets the upload
// manager break out of the retry loop after a single generic failure and // manager break out of the retry loop after a single generic failure and
@@ -2230,6 +2241,11 @@ ipcMain.handle('start-upload', async (_event, payload) => {
} }
return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` };
} }
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared after automation pause: ${error.message}`); }
return { error: 'Automatik ist pausiert' };
}
for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId);
_thisManager.sourceFileCleanup = sourceCleanup; _thisManager.sourceFileCleanup = sourceCleanup;
const _producerTracker = trackUploadProducer(_thisManager); const _producerTracker = trackUploadProducer(_thisManager);
@@ -2424,6 +2440,12 @@ ipcMain.handle('start-upload', async (_event, payload) => {
_producerTracker.finish(); _producerTracker.finish();
return; return;
} }
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
try { _thisManager.cancel(); } catch {}
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
_producerTracker.finish();
return;
}
_accountCooldowns.releaseExpired(); _accountCooldowns.releaseExpired();
const pausedAccounts = _accountCooldowns.activeKeys(); const pausedAccounts = _accountCooldowns.activeKeys();
debugLog(`setImmediate: calling startBatch now (priming ${pausedAccounts.length} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); debugLog(`setImmediate: calling startBatch now (priming ${pausedAccounts.length} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`);
@@ -2480,6 +2502,9 @@ ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => {
}); });
ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
if (!uploadManager || !uploadManager.running) { if (!uploadManager || !uploadManager.running) {
return { error: 'Kein Upload aktiv' }; return { error: 'Kein Upload aktiv' };
@@ -2498,6 +2523,9 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
const sourceCleanupFingerprints = batchManager.sourceFileCleanup const sourceCleanupFingerprints = batchManager.sourceFileCleanup
? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
: {}; : {};
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
if (uploadManager !== batchManager || !batchManager.running) { if (uploadManager !== batchManager || !batchManager.running) {
return { error: 'Kein Upload aktiv' }; return { error: 'Kein Upload aktiv' };
} }
@@ -3162,33 +3190,58 @@ function _sweepOrphanConfigTmps() {
} catch {} } catch {}
} }
// --- Folder Monitor --- let suppressAutomationStatusEvents = false;
function automationStatusSnapshot() {
const settings = configStore.load().globalSettings?.folderMonitor || {};
return Object.freeze({
...folderMonitor.status(),
enabled: settings.enabled === true,
configured: String(settings.folderPath || '').trim().length > 0,
paused: settings.paused === true,
pausedAt: settings.pausedAt ?? null,
queueLimitJobs: settings.queueLimitJobs,
reconcileIntervalMinutes: settings.reconcileIntervalMinutes
});
}
function publishAutomationStatus() {
const snapshot = automationStatusSnapshot();
if (!suppressAutomationStatusEvents) safeSend('automation:status', snapshot);
return snapshot;
}
function bindFolderMonitorEvents(settings) {
folderMonitor.removeAllListeners();
folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`);
safeSend('folder-monitor:new-files', files);
});
folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`);
});
folderMonitor.on('status', publishAutomationStatus);
folderMonitor.on('initial-scan-complete', async () => {
try {
const latest = configStore.load();
const current = latest.globalSettings?.folderMonitor;
if (!current?.includeExisting || path.resolve(current.folderPath || '') !== path.resolve(settings.folderPath || '')) return;
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: { ...current, includeExisting: false }
}
});
} catch (error) {
debugLog(`folder-monitor initial scan state failed: ${error.message}`);
}
});
}
function startFolderMonitor(settings) { function startFolderMonitor(settings) {
try { try {
folderMonitor.stop(); folderMonitor.stop();
folderMonitor.removeAllListeners(); bindFolderMonitorEvents(settings);
folderMonitor.on('new-files', (files) => {
debugLog(`folder-monitor: ${files.length} new file(s)`);
safeSend('folder-monitor:new-files', files);
});
folderMonitor.on('error', (err) => {
debugLog(`folder-monitor error: ${err.message}`);
});
folderMonitor.on('initial-scan-complete', async () => {
try {
const latest = configStore.load();
const current = latest.globalSettings?.folderMonitor;
if (!current?.includeExisting || path.resolve(current.folderPath || '') !== path.resolve(settings.folderPath || '')) return;
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: { ...current, includeExisting: false }
}
});
} catch (error) {
debugLog(`folder-monitor initial scan state failed: ${error.message}`);
}
});
const result = folderMonitor.start(settings); const result = folderMonitor.start(settings);
debugLog(`folder-monitor started: ${settings.folderPath}`); debugLog(`folder-monitor started: ${settings.folderPath}`);
return result; return result;
@@ -3198,7 +3251,17 @@ function startFolderMonitor(settings) {
} }
} }
async function resumeFolderMonitor(settings) {
bindFolderMonitorEvents(settings);
const result = await folderMonitor.resume(settings);
debugLog(`folder-monitor resumed: ${settings.folderPath}`);
return result;
}
ipcMain.handle('folder-monitor:start', (_event, settings) => { ipcMain.handle('folder-monitor:start', (_event, settings) => {
if (configStore.load().globalSettings?.folderMonitor?.paused === true) {
return { error: 'Automatik ist pausiert' };
}
const result = startFolderMonitor(settings); const result = startFolderMonitor(settings);
return { ok: true, includesExisting: result?.includesExisting === true }; return { ok: true, includesExisting: result?.includesExisting === true };
}); });
@@ -3213,6 +3276,58 @@ ipcMain.handle('folder-monitor:status', () => {
return folderMonitor.status(); return folderMonitor.status();
}); });
ipcMain.handle('automation:get-status', () => {
return automationStatusSnapshot();
});
ipcMain.handle('automation:pause-after-active', async () => {
const latest = configStore.load();
const settings = latest.globalSettings?.folderMonitor || {};
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: { ...settings, paused: true, pausedAt: Date.now() }
}
});
suppressAutomationStatusEvents = true;
try {
await folderMonitor.pause();
} finally {
suppressAutomationStatusEvents = false;
}
if (uploadManager) uploadManager.finishAfterActive();
return publishAutomationStatus();
});
ipcMain.handle('automation:resume', async () => {
const latest = configStore.load();
const settings = latest.globalSettings?.folderMonitor || {};
const resumedSettings = { ...settings, paused: false, pausedAt: null };
await configStore.save({
globalSettings: {
...latest.globalSettings,
folderMonitor: resumedSettings
}
});
suppressAutomationStatusEvents = true;
try {
if (resumedSettings.enabled && resumedSettings.folderPath) {
await resumeFolderMonitor(resumedSettings);
}
} finally {
suppressAutomationStatusEvents = false;
}
return publishAutomationStatus();
});
ipcMain.handle('folder-monitor:test-scan', () => {
return folderMonitor.scan({ emitFiles: false, trigger: 'test' });
});
ipcMain.handle('folder-monitor:reconcile', () => {
return folderMonitor.scan({ emitFiles: true, trigger: 'manual' });
});
ipcMain.handle('folder-monitor:select-folder', async () => { ipcMain.handle('folder-monitor:select-folder', async () => {
const result = await dialog.showOpenDialog(mainWindow, { const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory'] properties: ['openDirectory']
+9
View File
@@ -90,9 +90,17 @@ contextBridge.exposeInMainWorld('api', {
folderMonitorStop: () => ipcRenderer.invoke('folder-monitor:stop'), folderMonitorStop: () => ipcRenderer.invoke('folder-monitor:stop'),
folderMonitorStatus: () => ipcRenderer.invoke('folder-monitor:status'), folderMonitorStatus: () => ipcRenderer.invoke('folder-monitor:status'),
folderMonitorSelectFolder: () => ipcRenderer.invoke('folder-monitor:select-folder'), folderMonitorSelectFolder: () => ipcRenderer.invoke('folder-monitor:select-folder'),
folderMonitorTestScan: () => ipcRenderer.invoke('folder-monitor:test-scan'),
folderMonitorReconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
automationGetStatus: () => ipcRenderer.invoke('automation:get-status'),
automationPauseAfterActive: () => ipcRenderer.invoke('automation:pause-after-active'),
automationResume: () => ipcRenderer.invoke('automation:resume'),
onFolderMonitorNewFiles: (callback) => { onFolderMonitorNewFiles: (callback) => {
ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data));
}, },
onAutomationStatus: (callback) => {
ipcRenderer.on('automation:status', (_event, data) => callback(data));
},
// Account switched event // Account switched event
onAccountSwitched: (callback) => { onAccountSwitched: (callback) => {
@@ -176,6 +184,7 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.removeAllListeners('app:prepare-close'); ipcRenderer.removeAllListeners('app:prepare-close');
ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('shutdown-countdown');
ipcRenderer.removeAllListeners('folder-monitor:new-files'); ipcRenderer.removeAllListeners('folder-monitor:new-files');
ipcRenderer.removeAllListeners('automation:status');
ipcRenderer.removeAllListeners('drop-target:files'); ipcRenderer.removeAllListeners('drop-target:files');
ipcRenderer.removeAllListeners('account-switched'); ipcRenderer.removeAllListeners('account-switched');
ipcRenderer.removeAllListeners('session-failed-accounts-changed'); ipcRenderer.removeAllListeners('session-failed-accounts-changed');
+55
View File
@@ -34,6 +34,16 @@ function createStore() {
return store; return store;
} }
function createStoreAt(filePath) {
const configuredStore = new ConfigStore({
isPackaged: false,
getPath: () => path.dirname(filePath)
});
configuredStore.filePath = filePath;
configuredStore.historyPath = path.join(path.dirname(filePath), 'electron-history.json');
return configuredStore;
}
describe('ConfigStore', () => { describe('ConfigStore', () => {
beforeEach(() => { beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-')); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-'));
@@ -97,6 +107,24 @@ describe('ConfigStore', () => {
assert.deepEqual(config.history, []); assert.deepEqual(config.history, []);
}); });
it('automation defaults persist a 15000 job limit and five minute interval', () => {
const settings = store.load().globalSettings.folderMonitor;
assert.equal(settings.queueLimitJobs, 15000);
assert.equal(settings.reconcileIntervalMinutes, 5);
assert.equal(settings.paused, false);
assert.equal(settings.pausedAt, null);
});
it('automation pause survives a save and reload', async () => {
await store.save({ globalSettings: { folderMonitor: { paused: true, pausedAt: 1787712000000 } } });
const reloaded = createStoreAt(store.filePath).load();
assert.equal(reloaded.globalSettings.folderMonitor.paused, true);
assert.equal(reloaded.globalSettings.folderMonitor.pausedAt, 1787712000000);
});
it('drops the retired plaintext credential setting from legacy configurations', () => { it('drops the retired plaintext credential setting from legacy configurations', () => {
fs.writeFileSync(store.filePath, JSON.stringify({ fs.writeFileSync(store.filePath, JSON.stringify({
hosters: {}, hosters: {},
@@ -501,6 +529,33 @@ describe('ConfigStore', () => {
assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false }); assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false });
}); });
it('renderer settings saves cannot clear the authoritative automation pause', async () => {
const current = store.load();
await store.save({
globalSettings: {
...current.globalSettings,
folderMonitor: {
...current.globalSettings.folderMonitor,
enabled: true,
folderPath: 'C:\\watch',
paused: true,
pausedAt: 1787712000000
}
}
});
await store.saveRendererGlobalSettings({
alwaysOnTop: true,
folderMonitor: { paused: false, pausedAt: null }
});
const saved = store.load().globalSettings.folderMonitor;
assert.equal(saved.paused, true);
assert.equal(saved.pausedAt, 1787712000000);
assert.equal(saved.enabled, true);
assert.equal(saved.folderPath, 'C:\\watch');
});
it('persists the last browse directory across stale renderer settings saves', async () => { it('persists the last browse directory across stale renderer settings saves', async () => {
const selectedDirectory = path.join(tmpDir, 'selected'); const selectedDirectory = path.join(tmpDir, 'selected');
+139
View File
@@ -253,3 +253,142 @@ test('preload exposes account cooldown snapshots and removes their listener duri
assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] }); assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] });
assert.equal(removed.includes('session-failed-accounts-changed'), true); assert.equal(removed.includes('session-failed-accounts-changed'), true);
}); });
test('exposes persistent automation controls and status through narrow IPC boundaries', () => {
const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
assert.match(mainSource, /ipcMain\.handle\('automation:get-status'/u);
assert.match(mainSource, /ipcMain\.handle\('automation:pause-after-active'/u);
assert.match(mainSource, /ipcMain\.handle\('automation:resume'/u);
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:test-scan'/u);
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:reconcile'/u);
assert.match(mainSource, /safeSend\('automation:status'/u);
assert.match(preloadSource, /automationGetStatus:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-status'\)/u);
assert.match(preloadSource, /automationPauseAfterActive:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:pause-after-active'\)/u);
assert.match(preloadSource, /automationResume:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:resume'\)/u);
assert.match(preloadSource, /folderMonitorTestScan:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:test-scan'\)/u);
assert.match(preloadSource, /folderMonitorReconcile:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:reconcile'\)/u);
assert.match(preloadSource, /onAutomationStatus:\s*\(callback\)\s*=>\s*\{[\s\S]*?ipcRenderer\.on\('automation:status'/u);
assert.match(preloadSource, /ipcRenderer\.removeAllListeners\('automation:status'\)/u);
});
test('every batch start and extension IPC fails closed before account and cleanup side effects', () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const gate = /configStore\.load\(\)\.globalSettings\?\.folderMonitor\?\.paused\s*===\s*true/u;
const cases = [
['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync', 1],
['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker', 6],
['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker', 2]
];
for (const [channel, startMarker, endMarker, sideEffectMarker, expectedGateCount] of cases) {
const start = mainSource.indexOf(startMarker);
const end = mainSource.indexOf(endMarker, start);
assert.notEqual(start, -1, `${channel} handler missing`);
assert.notEqual(end, -1, `${channel} handler boundary missing`);
const handler = mainSource.slice(start, end);
const gateIndex = handler.search(gate);
const sideEffectIndex = handler.indexOf(sideEffectMarker);
assert.notEqual(gateIndex, -1, `${channel} pause gate missing`);
assert.notEqual(sideEffectIndex, -1, `${channel} side-effect marker missing`);
assert.ok(gateIndex < sideEffectIndex, `${channel} pause gate runs after side effects`);
assert.equal([...handler.matchAll(new RegExp(gate.source, 'gu'))].length, expectedGateCount, `${channel} does not recheck every asynchronous race boundary`);
}
});
test('automation pause and resume commit state before lifecycle effects', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const blockStart = mainSource.indexOf('let suppressAutomationStatusEvents = false;');
const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart);
assert.notEqual(blockStart, -1, 'automation lifecycle block missing');
assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing');
const handlers = new Map();
const order = [];
const sent = [];
const folderMonitor = new (require('node:events').EventEmitter)();
folderMonitor.running = true;
folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true });
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; };
folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); };
folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; };
folderMonitor.scan = async options => { order.push(`scan:${options.trigger}:${options.emitFiles}`); return { reachable: true }; };
let state = {
globalSettings: {
folderMonitor: {
enabled: true,
folderPath: 'C:\\watch',
paused: false,
pausedAt: null,
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5
}
}
};
let rejectSave = false;
const configStore = {
load: () => structuredClone(state),
save: async config => {
const paused = config.globalSettings.folderMonitor.paused;
order.push(`save:${paused}`);
if (rejectSave) throw new Error('save failed');
state = structuredClone(config);
}
};
const uploadManager = {
finishAfterActive: () => order.push('finish'),
startBatch: () => order.push('startBatch')
};
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
configStore,
debugLog: () => {},
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
folderMonitor,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
path,
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
uploadManager
});
await handlers.get('automation:pause-after-active')();
assert.deepEqual(order, ['save:true', 'pause', 'finish']);
assert.equal(sent.length, 1);
assert.equal(sent[0][0], 'automation:status');
assert.equal(sent[0][1].paused, true);
order.length = 0;
sent.length = 0;
state.globalSettings.folderMonitor.paused = false;
state.globalSettings.folderMonitor.pausedAt = null;
folderMonitor.running = true;
rejectSave = true;
await assert.rejects(handlers.get('automation:pause-after-active')(), /save failed/u);
assert.deepEqual(order, ['save:true']);
assert.equal(folderMonitor.running, true);
assert.equal(sent.length, 0);
order.length = 0;
rejectSave = false;
state.globalSettings.folderMonitor.paused = true;
state.globalSettings.folderMonitor.pausedAt = 1;
folderMonitor.running = false;
await handlers.get('automation:resume')();
assert.deepEqual(order, ['save:false', 'resume']);
assert.equal(order.includes('startBatch'), false);
assert.equal(sent.length, 1);
assert.equal(sent[0][1].paused, false);
});
test('startup keeps a missing configured folder disconnected without disabling automation', () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const startupStart = mainSource.indexOf('app.whenReady().then(async () => {');
const startupEnd = mainSource.indexOf("\napp.on('window-all-closed'", startupStart);
assert.notEqual(startupStart, -1);
assert.notEqual(startupEnd, -1);
const startup = mainSource.slice(startupStart, startupEnd);
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u);
assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?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);
});
+103
View File
@@ -1054,6 +1054,109 @@ app.whenReady().then(async () => {
} }
}); });
test('persisted automation pause rejects batch starts through hidden real IPC', { skip: process.platform !== 'win32' }, () => {
const projectRoot = path.join(__dirname, '..');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'");
const startUploadEnd = mainSource.indexOf('\n// Logged at batch boundaries', startUploadStart);
const addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart);
assert.notEqual(startUploadStart, -1);
assert.notEqual(startUploadEnd, -1);
assert.notEqual(addJobsStart, -1);
assert.notEqual(addJobsEnd, -1);
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-pause-ipc-'));
const probePath = path.join(probeRoot, 'probe.cjs');
const preloadPath = path.join(probeRoot, 'preload.cjs');
const rendererPath = path.join(probeRoot, 'renderer.html');
const outputPath = path.join(probeRoot, 'result.json');
const userDataPath = path.join(probeRoot, 'user-data');
fs.writeFileSync(preloadPath, `
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('automationProbe', {
start: () => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [] }),
extend: () => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [], sourceCleanupGroups: [] })
});
`, 'utf8');
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
(async () => {
const start = await window.automationProbe.start();
const extend = await window.automationProbe.extend();
window.__automationPauseResult = { start, extend };
})().catch(error => { window.__automationPauseResult = { error: error.message || String(error) }; });
</script></body></html>`, 'utf8');
const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`;
const probeSource = `
const { app, BrowserWindow, ipcMain } = require('electron');
const fs = require('node:fs');
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
const configStore = new ConfigStore(app);
let closeFlushRequested = false;
const settingsImportGate = { canStartUpload: () => true };
let uploadManager = { running: false };
${productionHandlers}
app.whenReady().then(async () => {
const current = configStore.load();
await configStore.save({
globalSettings: {
...current.globalSettings,
folderMonitor: { ...current.globalSettings.folderMonitor, paused: true, pausedAt: Date.now() }
}
});
const window = new BrowserWindow({
show: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
});
await window.loadFile(rendererPath);
let result = null;
for (let attempt = 0; attempt < 200; attempt++) {
result = await window.webContents.executeJavaScript('window.__automationPauseResult || null');
if (result) break;
await new Promise(resolve => setTimeout(resolve, 10));
}
fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, result }), 'utf8');
window.destroy();
app.exit(0);
}).catch(error => {
fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8');
app.exit(1);
});
`;
fs.writeFileSync(probePath, probeSource, 'utf8');
try {
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
cwd: projectRoot,
env: {
...process.env,
MHU_AUTOMATION_OUTPUT: outputPath,
MHU_AUTOMATION_RENDERER: rendererPath,
MHU_AUTOMATION_PRELOAD: preloadPath,
MHU_AUTOMATION_USER_DATA: userDataPath
},
encoding: 'utf8',
windowsHide: true,
timeout: 30000
});
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
assert.equal(outcome.error, undefined);
assert.deepEqual(outcome, {
hidden: true,
result: {
start: { error: 'Automatik ist pausiert' },
extend: { error: 'Automatik ist pausiert' }
}
});
} finally {
fs.rmSync(probeRoot, { recursive: true, force: true });
}
});
test('resolveStartupLanguage accepts only the supported persisted language', () => { test('resolveStartupLanguage accepts only the supported persisted language', () => {
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de'); assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en'); assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
+35
View File
@@ -603,6 +603,41 @@ describe('UploadManager', () => {
assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done')); assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done'));
}); });
it('finishAfterActive completes active work without starting queued work', async () => {
let releaseActive;
const started = [];
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
started.push(filePath);
if (filePath.endsWith('/active.mp4')) {
await new Promise(resolve => { releaseActive = resolve; });
}
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
});
const mgr = new UploadManager({
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const settled = new Map();
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
const batch = mgr.startBatch([
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'queued', file: '/test/queued.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) {
await new Promise(resolve => setTimeout(resolve, 5));
}
assert.equal(typeof releaseActive, 'function');
mgr.finishAfterActive();
releaseActive();
await batch;
assert.deepEqual(started, ['/test/active.mp4']);
assert.equal(settled.get('active'), 'done');
assert.equal(settled.get('queued'), 'aborted');
});
it('_combineSignals propagates abort from either source', () => { it('_combineSignals propagates abort from either source', () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
const ac1 = new AbortController(); const ac1 = new AbortController();