diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 2dd9cad..7dc66ef 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -5640,6 +5640,7 @@ export class DownloadManager extends EventEmitter { const parkForRestart = options?.parkForRestart === true; const abortReason: "stop" | "shutdown" = parkForRestart ? "shutdown" : "stop"; const keepExtraction = this.settings.autoExtractWhenStopped; + const wasRunning = this.session.running; this.schedulerGeneration += 1; this.session.running = false; this.session.paused = false; @@ -5685,6 +5686,19 @@ export class DownloadManager extends EventEmitter { pkg.updatedAt = nowMs(); } } + // A manual stop ends the run without ever reaching finishRun (the scheduler + // loop exits at its while condition), so the run summary would be lost. + // Suppressed for the restart/shutdown path: the process is about to die. + if (wasRunning && !parkForRestart && this.settings.notifyOnRunFinished && this.runItemIds.size > 0) { + const outcomes = Array.from(this.runOutcomes.values()); + const success = outcomes.filter((s) => s === "completed").length; + const failed = outcomes.filter((s) => s === "failed").length; + void sendNotification(this.settings.notifyUrl, { + title: "⏹️ Durchlauf gestoppt", + message: `${success}/${this.runItemIds.size} erfolgreich, ${failed} fehlgeschlagen — Rest zurueck in der Warteschlange`, + mention: this.settings.notifyMention + }); + } this.persistSoon(); this.emitState(true); } @@ -8248,6 +8262,14 @@ export class DownloadManager extends EventEmitter { } finally { this.scheduleRunning = false; logger.info(`Scheduler beendet (gen=${myGeneration})`); + // Stop->Start race: a new run can begin while this loop sleeps (start()'s + // ensureScheduler early-returns on scheduleRunning, then this loop exits on + // the generation mismatch). Without a respawn the run sits leaderless + // forever: running=true, but nothing schedules and finishRun never comes. + if (this.session.running && this.schedulerGeneration !== myGeneration) { + logger.warn(`Scheduler-Respawn: Run aktiv, alte Generation ${myGeneration} beendet (Stop->Start-Race)`); + void this.ensureScheduler().catch((error) => logger.error(`Scheduler-Respawn fehlgeschlagen: ${compactErrorText(error)}`)); + } } } @@ -12181,9 +12203,14 @@ export class DownloadManager extends EventEmitter { }; this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${total}`; if (this.settings.notifyOnRunFinished && total > 0) { + // With autoExtractWhenStopped (default) the run ends as soon as downloads + // are done while extraction may still be running — say so instead of + // claiming the whole run is finished. + const postProcessPending = this.packagePostProcessTasks.size > 0 || this.hasAnyDeferredPostProcessPending(); + const scope = postProcessPending ? "Downloads beendet" : "Durchlauf beendet"; void sendNotification(this.settings.notifyUrl, { - title: failed > 0 ? "⚠️ Durchlauf beendet" : "🏁 Durchlauf beendet", - message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s`, + title: failed > 0 ? `⚠️ ${scope}` : `🏁 ${scope}`, + message: `${success}/${total} erfolgreich, ${failed} fehlgeschlagen, ${cancelled} abgebrochen\nDauer ${duration}s, Durchschnitt ${humanSize(avgSpeed)}/s${postProcessPending ? "\nEntpacken laeuft noch — Paket-Meldungen folgen." : ""}`, mention: this.settings.notifyMention }); } diff --git a/src/main/main.ts b/src/main/main.ts index 65aaf1f..4930521 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -77,6 +77,35 @@ function isDevMode(): boolean { return process.env.NODE_ENV === "development"; } +// Single owner of the scheduled-start timer. startOnPast: a past time entered +// interactively starts right away; at boot a stale past time is cleared instead +// (an unattended auto-start at boot would race autoResumeOnStart's conflict gate). +function armScheduledStart(schedMs: number, opts: { startOnPast: boolean }): void { + if (scheduledStartTimer !== null) { + clearTimeout(scheduledStartTimer); + scheduledStartTimer = null; + } + if (!schedMs || schedMs <= 0) { + return; + } + const delay = schedMs - Date.now(); + if (delay <= 0) { + if (opts.startOnPast) { + void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); + } else { + logger.warn(`Geplanter Start (${new Date(schedMs).toLocaleString()}) lag beim App-Start in der Vergangenheit — verworfen`); + } + controller.updateSettings({ scheduledStartEpochMs: 0 }); + return; + } + scheduledStartTimer = setTimeout(() => { + scheduledStartTimer = null; + void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); + controller.updateSettings({ scheduledStartEpochMs: 0 }); + }, delay); + logger.info(`Geplanter Start gearmt: ${new Date(schedMs).toLocaleString()}`); +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1920, @@ -328,24 +357,7 @@ function registerIpcHandlers(): void { const result = controller.updateSettings(validated as Partial); updateClipboardWatcher(); updateTray(); - if (scheduledStartTimer !== null) { - clearTimeout(scheduledStartTimer); - scheduledStartTimer = null; - } - const schedMs = result.scheduledStartEpochMs || 0; - if (schedMs > 0) { - const delay = schedMs - Date.now(); - if (delay <= 0) { - void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); - controller.updateSettings({ scheduledStartEpochMs: 0 }); - } else { - scheduledStartTimer = setTimeout(() => { - scheduledStartTimer = null; - void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); - controller.updateSettings({ scheduledStartEpochMs: 0 }); - }, delay); - } - } + armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true }); return result; }); ipcMain.handle(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => { @@ -807,6 +819,10 @@ app.whenReady().then(() => { bindMainWindowLifecycle(mainWindow); updateClipboardWatcher(); updateTray(); + // A scheduled start persists in the settings but its timer lived only in this + // process — without re-arming it here, any restart (auto-update, reboot, + // crash) silently swallowed the planned run. + armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) {