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:
+52
-13
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user