feat(scheduler): persist recurring daily starts

Add a local-calendar daily start scheduler with pending receipts, per-day deduplication, DST-safe target calculation, missed-day recovery, and retryable account/start failure outcomes.

Persist and validate the daily rule, expose its next target to the renderer, preserve legacy one-time schedules until a daily rule is saved, and gate boot auto-resume on recorded active-run evidence.

Wire boot, settings, account, suspend, resume, interval, and shutdown lifecycle handling with focused RED/GREEN coverage for calendar, persistence, renderer, and controller boundaries.
This commit is contained in:
Sucukdeluxe
2026-08-22 09:36:45 +02:00
parent d7384c34d1
commit 4eb95c92b9
14 changed files with 843 additions and 71 deletions
+52 -13
View File
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, powerMonitor, safeStorage, shell, Tray, type IpcMainEvent, type IpcMainInvokeEvent } from "electron";
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, RendererSettingsUpdate, UpdateInstallProgress } from "../shared/types";
import { AppController } from "./app-controller";
import { IPC_CHANNELS } from "../shared/ipc";
@@ -24,6 +24,7 @@ import { assertTrustedIpcSender, type TrustedIpcOptions } from "./ipc-security";
import { validateRealDebridLoginRequest } from "../shared/preload-api";
import { migrateProductUserDataDirectory } from "./storage";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
import { DailyStartScheduler, hasDailyStartRulePatch } from "./daily-start-scheduler";
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
@@ -88,12 +89,27 @@ let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
let clipboardTimer: ReturnType<typeof setInterval> | null = null;
let updateQuitTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let scheduledStartTimer: ReturnType<typeof setTimeout> | null = null;
let dailyStartScheduler: DailyStartScheduler | null = null;
let lastClipboardText = "";
let controller: AppController;
let pendingBackupImport: Buffer | null = null;
const CLIPBOARD_MAX_TEXT_CHARS = 50_000;
function reconcileDailyStart(source: string): void {
void dailyStartScheduler?.reconcile().catch((error) => {
logger.warn(`Täglicher Start konnte nach ${source} nicht abgeglichen werden: ${String(error)}`);
});
}
function handlePowerSuspend(): void {
reconcileDailyStart("Suspend");
}
function handlePowerResume(): void {
reconcileDailyStart("Resume");
}
export interface BeforeQuitHandlerOptions {
cleanup: () => void;
shutdown: () => Promise<void>;
@@ -446,13 +462,18 @@ function registerIpcHandlers(): void {
handleTrusted(IPC_CHANNELS.OPEN_EXTERNAL, async (_event: IpcMainInvokeEvent, rawUrl: string) => {
return openAllowedExternalUrl(String(rawUrl || "").trim(), MAIN_WINDOW_EXTERNAL_HOSTS);
});
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
handleTrusted(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: RendererSettingsUpdate) => {
const validated = validateRendererSettingsUpdate(partial ?? {}, controller.getSettings());
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
armScheduledStart(result.scheduledStartEpochMs || 0, { startOnPast: true });
return createRendererSettings(result);
if (hasDailyStartRulePatch(validated)) {
await dailyStartScheduler?.reconcile();
} else {
reconcileDailyStart("Einstellungsänderung");
}
return createRendererSettings(controller.getSettings());
});
handleTrusted(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, (_event: IpcMainInvokeEvent, provider: string) => {
const validatedProvider = validateString(provider, "provider") as DebridProvider;
@@ -469,28 +490,36 @@ function registerIpcHandlers(): void {
return createRendererSettings(controller.resetDebridLinkApiKeyDailyUsage(validatedKeyId));
});
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.CREATE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "create") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.REPLACE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "replace") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.UPDATE_ACCOUNT_SECRET, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "update-secret") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
handleTrusted(IPC_CHANNELS.DELETE_ACCOUNT, async (_event: IpcMainInvokeEvent, rawCommand: unknown) => {
const command = validateAccountCommand(rawCommand);
if (command.action !== "delete") throw new Error("Account-Payload ist ungültig");
return controller.executeAccountCommand(command);
const result = await controller.executeAccountCommand(command);
reconcileDailyStart("Accountänderung");
return result;
});
handleTrusted(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, (_event: IpcMainInvokeEvent, rawRequest: unknown) => {
return controller.revealAccountSecret(validateAccountSecretRequest(rawRequest));
@@ -1043,6 +1072,7 @@ app.on("second-instance", () => {
app.whenReady().then(() => {
configureCredentialProtector(safeStorage);
controller = new AppController();
dailyStartScheduler = new DailyStartScheduler(controller);
cleanupStaleSubstDrives();
registerIpcHandlers();
mainWindow = createWindow();
@@ -1052,7 +1082,12 @@ app.whenReady().then(() => {
// 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 });
armScheduledStart(controller.getSettings().scheduledStartEpochMs || 0, { startOnPast: false });
dailyStartScheduler.begin((error) => {
logger.warn(`Täglicher Start konnte nicht abgeglichen werden: ${String(error)}`);
});
powerMonitor.on("suspend", handlePowerSuspend);
powerMonitor.on("resume", handlePowerResume);
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
@@ -1076,6 +1111,10 @@ app.on("window-all-closed", () => {
app.on("before-quit", createBeforeQuitHandler({
cleanup: () => {
if (updateQuitTimer) { clearTimeout(updateQuitTimer); updateQuitTimer = null; }
if (scheduledStartTimer) { clearTimeout(scheduledStartTimer); scheduledStartTimer = null; }
dailyStartScheduler?.end();
powerMonitor.removeListener("suspend", handlePowerSuspend);
powerMonitor.removeListener("resume", handlePowerResume);
stopClipboardWatcher();
destroyTray();
shutdownDaemon();