diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index a8283cd..54f774b 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -81,6 +81,7 @@ import { normalizeStatisticsLedger, saveStatisticsLedger } from "./statistics-le import { NotificationOutbox } from "./notification-outbox"; import { sendNotification } from "./notify"; import { DownloadHealthMonitor } from "./download-health-monitor"; +import { prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -254,7 +255,7 @@ export class AppController { }, 60_000); this.runtimeStatsTimer.unref?.(); - if (this.settings.autoResumeOnStart) { + if (this.settings.autoResumeOnStart && !shouldDeferAutoResumeToDailyStart(this.settings, loadResult.wasRunning)) { const snapshot = this.manager.getSnapshot(); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); if (hasPending && this.hasAnyProviderToken(this.settings)) { @@ -520,7 +521,7 @@ export class AppController { } public updateSettings(partial: Partial): AppSettings { - const sanitizedPatch = sanitizeSettingsPatch(partial); + const sanitizedPatch = prepareDailyStartSettingsPatch(sanitizeSettingsPatch(partial)) as Partial; const previousSettings = this.settings; let nextSettings = normalizeSettings({ ...previousSettings, diff --git a/src/main/constants.ts b/src/main/constants.ts index 8d6f64d..9641fbc 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -157,8 +157,14 @@ export function defaultSettings(): AppSettings { megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, - debridAccountStatuses: {}, - providerDailyUsageDay: getProviderUsageDayKey(), - scheduledStartEpochMs: 0 - }; -} + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + dailyStartEnabled: false, + dailyStartMinuteOfDay: 0, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + scheduledStartEpochMs: 0 + }; +} diff --git a/src/main/daily-start-scheduler.ts b/src/main/daily-start-scheduler.ts new file mode 100644 index 0000000..3597889 --- /dev/null +++ b/src/main/daily-start-scheduler.ts @@ -0,0 +1,215 @@ +import type { DailyStartOutcome, DailyStartSettings } from "../shared/types"; + +interface DailyStartSnapshot { + settings: DailyStartSettings; + session: { + running: boolean; + paused: boolean; + items: Record; + }; + canStart: boolean; +} + +interface DailyStartController { + getSnapshot(): DailyStartSnapshot; + updateSettings(partial: Partial): unknown; + start(): Promise; +} + +interface LocalDateParts { + year: number; + month: number; + day: number; +} + +function parseLocalDate(value: string): LocalDateParts | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) { + return null; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const date = new Date(year, month - 1, day, 12, 0, 0, 0); + if (year < 1000 || date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return null; + } + return { year, month, day }; +} + +function formatLocalDate(date: Date): string { + return `${date.getFullYear().toString().padStart(4, "0")}-${(date.getMonth() + 1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`; +} + +function addLocalDays(value: string, days: number): string { + const parts = parseLocalDate(value); + if (!parts) { + return ""; + } + return formatLocalDate(new Date(parts.year, parts.month - 1, parts.day + days, 12, 0, 0, 0)); +} + +function localTargetEpochMs(value: string, minuteOfDay: number): number { + const parts = parseLocalDate(value); + if (!parts) { + return 0; + } + const minute = Math.max(0, Math.min(1_439, Math.floor(minuteOfDay))); + return new Date(parts.year, parts.month - 1, parts.day, Math.floor(minute / 60), minute % 60, 0, 0).getTime(); +} + +export function isValidLocalDate(value: string): boolean { + return parseLocalDate(value) !== null; +} + +export function nextDailyStartEpochMs(settings: DailyStartSettings, nowEpochMs = Date.now()): number { + if (!settings.dailyStartEnabled || !Number.isFinite(settings.dailyStartMinuteOfDay)) { + return 0; + } + const firstDate = parseLocalDate(settings.dailyStartFirstLocalDate); + if (!firstDate) { + return 0; + } + const now = new Date(nowEpochMs); + const today = formatLocalDate(now); + let candidate = settings.dailyStartFirstLocalDate > today ? settings.dailyStartFirstLocalDate : today; + const handled = isValidLocalDate(settings.dailyStartLastHandledLocalDate) + ? settings.dailyStartLastHandledLocalDate + : ""; + if (handled && candidate <= handled) { + candidate = addLocalDays(handled, 1); + } + return localTargetEpochMs(candidate, settings.dailyStartMinuteOfDay); +} + +export function hasDailyStartRulePatch(partial: object): boolean { + const value = partial as Record; + return ["dailyStartEnabled", "dailyStartMinuteOfDay", "dailyStartFirstLocalDate"] + .some((key) => Object.prototype.hasOwnProperty.call(value, key)); +} + +export function prepareDailyStartSettingsPatch(partial: T): T & { scheduledStartEpochMs?: number } { + return hasDailyStartRulePatch(partial) + ? { ...partial, scheduledStartEpochMs: 0 } + : { ...partial }; +} + +export function shouldDeferAutoResumeToDailyStart( + settings: DailyStartSettings, + wasRunning: boolean, + nowEpochMs = Date.now() +): boolean { + return !wasRunning && nextDailyStartEpochMs(settings, nowEpochMs) > nowEpochMs; +} + +export class DailyStartScheduler { + private reconcileInFlight: Promise | null = null; + private reconcileTimer: ReturnType | null = null; + + public constructor( + private readonly controller: DailyStartController, + private readonly now: () => number = Date.now + ) {} + + public reconcile(): Promise { + if (this.reconcileInFlight) { + return this.reconcileInFlight; + } + const operation = this.reconcileOnce(); + this.reconcileInFlight = operation; + void operation.finally(() => { + if (this.reconcileInFlight === operation) { + this.reconcileInFlight = null; + } + }).catch(() => {}); + return operation; + } + + public begin(onError: (error: unknown) => void = () => {}): void { + this.end(); + void this.reconcile().catch(onError); + this.reconcileTimer = setInterval(() => { + void this.reconcile().catch(onError); + }, 60_000); + this.reconcileTimer.unref?.(); + } + + public end(): void { + if (this.reconcileTimer !== null) { + clearInterval(this.reconcileTimer); + this.reconcileTimer = null; + } + } + + private finish(localDate: string, outcome: DailyStartOutcome): DailyStartOutcome { + this.controller.updateSettings({ + dailyStartLastHandledLocalDate: localDate, + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: outcome + }); + return outcome; + } + + private async reconcileOnce(): Promise { + const nowEpochMs = this.now(); + let settings = this.controller.getSnapshot().settings; + if (!settings.dailyStartEnabled || !isValidLocalDate(settings.dailyStartFirstLocalDate)) { + return null; + } + + const today = formatLocalDate(new Date(nowEpochMs)); + let missed: DailyStartOutcome | null = null; + if (isValidLocalDate(settings.dailyStartPendingLocalDate) && settings.dailyStartPendingLocalDate < today) { + const pendingDate = settings.dailyStartPendingLocalDate; + this.controller.updateSettings({ + dailyStartLastHandledLocalDate: pendingDate, + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "missed" + }); + settings = { + ...settings, + dailyStartLastHandledLocalDate: pendingDate, + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "missed" + }; + missed = "missed"; + } + + if (today < settings.dailyStartFirstLocalDate || settings.dailyStartLastHandledLocalDate === today) { + return missed; + } + const targetEpochMs = localTargetEpochMs(today, settings.dailyStartMinuteOfDay); + if (!targetEpochMs || targetEpochMs > nowEpochMs) { + return missed; + } + + if (settings.dailyStartPendingLocalDate !== today) { + this.controller.updateSettings({ + dailyStartPendingLocalDate: today, + dailyStartLastOutcome: "" + }); + } + + const snapshot = this.controller.getSnapshot(); + if (snapshot.session.running || snapshot.session.paused) { + return this.finish(today, "already_active"); + } + const hasQueuedItems = Object.values(snapshot.session.items) + .some((item) => item.status === "queued" || item.status === "reconnect_wait"); + if (!hasQueuedItems) { + return this.finish(today, "empty_queue"); + } + if (!snapshot.canStart) { + this.controller.updateSettings({ dailyStartLastOutcome: "missing_account" }); + return "missing_account"; + } + + try { + await this.controller.start(); + } catch (error) { + this.controller.updateSettings({ dailyStartLastOutcome: "start_failed" }); + throw error; + } + return this.finish(today, "started"); + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 433a38e..4c2edc3 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -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 | null = null; let updateQuitTimer: ReturnType | null = null; -let scheduledStartTimer: ReturnType | null = null; +let scheduledStartTimer: ReturnType | 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; @@ -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); 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(); diff --git a/src/main/renderer-settings.ts b/src/main/renderer-settings.ts index 3760727..7c7374d 100644 --- a/src/main/renderer-settings.ts +++ b/src/main/renderer-settings.ts @@ -1,7 +1,16 @@ import type { AppSettings, RendererSettingsUpdate } from "../shared/types"; import { createRendererSettings } from "./renderer-state"; +import { isValidLocalDate } from "./daily-start-scheduler"; -const DERIVED_KEYS = new Set(["archivePasswordListConfigured", "notifyUrlConfigured", "configuredProviders"]); +const DERIVED_KEYS = new Set([ + "archivePasswordListConfigured", + "notifyUrlConfigured", + "configuredProviders", + "dailyStartLastHandledLocalDate", + "dailyStartPendingLocalDate", + "dailyStartLastOutcome", + "nextDailyStartEpochMs" +]); const WRITE_ONLY_KEYS = new Set(["archivePasswordList", "notifyUrl"]); const MAX_SETTINGS_PAYLOAD_BYTES = 1_000_000; @@ -85,6 +94,12 @@ export function validateRendererSettingsUpdate(value: unknown, current: AppSetti if (key === "notifyPackageSuccessMode" && entry !== "digest" && entry !== "individual") { invalid(); } + if (key === "dailyStartMinuteOfDay" && (!Number.isInteger(entry) || (entry as number) < 0 || (entry as number) > 1_439)) { + invalid(); + } + if (key === "dailyStartFirstLocalDate" && entry !== "" && (typeof entry !== "string" || !isValidLocalDate(entry))) { + invalid(); + } validateTopLevelType(entry, safe[key]); validateJsonValue(entry); output[key] = entry; diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index bb781f1..3983de1 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -3,6 +3,7 @@ import { getMegaDebridAccountsForMode, getMegaDebridDisabledAccountIdsForMode } import { getRealDebridAccounts } from "../shared/real-debrid-accounts"; import type { AppSettings, DebridAccountStatus, DebridProvider, RendererAccount, RendererAccountKind, RendererSettings } from "../shared/types"; import { collectAccountStatusRedactionValues, sanitizeDebridAccountStatus } from "./account-status-sanitizer"; +import { nextDailyStartEpochMs } from "./daily-start-scheduler"; function maskValue(value: string, keepStart = 3, keepEnd = 3): string { const trimmed = value.trim(); @@ -251,7 +252,14 @@ export function createRendererSettings(settings: AppSettings): RendererSettings megaDebridAccountTotalUsageBytes: { ...settings.megaDebridAccountTotalUsageBytes }, debridAccountStatuses: Object.fromEntries(Object.entries(settings.debridAccountStatuses).map(([id, status]) => [id, safeStatus(status, redactions)])), providerDailyUsageDay: settings.providerDailyUsageDay, - scheduledStartEpochMs: settings.scheduledStartEpochMs + dailyStartEnabled: settings.dailyStartEnabled, + dailyStartMinuteOfDay: settings.dailyStartMinuteOfDay, + dailyStartFirstLocalDate: settings.dailyStartFirstLocalDate, + dailyStartLastHandledLocalDate: settings.dailyStartLastHandledLocalDate, + dailyStartPendingLocalDate: settings.dailyStartPendingLocalDate, + dailyStartLastOutcome: settings.dailyStartLastOutcome, + scheduledStartEpochMs: settings.scheduledStartEpochMs, + nextDailyStartEpochMs: nextDailyStartEpochMs(settings) } as RendererSettings; } diff --git a/src/main/storage.ts b/src/main/storage.ts index 2dfd471..5984c95 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,12 +5,13 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getMegaDebridAccountIds, mergeMegaDebridCredentialPools, parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; -import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types"; +import { AppSettings, ArchiveOperationMetric, AudioStripSummary, BandwidthScheduleEntry, DailyStartOutcome, DebridAccountStatus, DebridFallbackProvider, DebridProvider, DownloadItem, DownloadStatus, FailurePhase, HistoryEntry, HistoryRetentionMode, LogStorageLocation, PackageEntry, PackagePriority, RemuxOperationMetric, SessionState } from "../shared/types"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getRealDebridAccountIds, normalizeRealDebridWebAccountIds, parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../shared/real-debrid-accounts"; import { defaultSettings } from "./constants"; import { needsPersistedSettingsRewrite, protectPersistedSettings, restorePersistedSettings } from "./credential-protection"; import { logger } from "./logger"; +import { isValidLocalDate } from "./daily-start-scheduler"; export function migrateProductUserDataDirectory(appDataPath: string): string { const legacyPath = path.join(appDataPath, "Real-Debrid-Downloader"); @@ -517,8 +518,9 @@ export function normalizeSettings(settings: AppSettings): AppSettings { settings.debridLinkApiKeyTotalUsageBytes, debridLinkApiKeyIds ); - const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); - const normalized: AppSettings = { + const debridLinkDisabledKeyIds = normalizeStringList(settings.debridLinkDisabledKeyIds, debridLinkApiKeyIds); + const validDailyStartOutcomes = new Set(["", "started", "already_active", "empty_queue", "missing_account", "start_failed", "missed"]); + const normalized: AppSettings = { language: settings.language === "de" ? "de" : "en", token: asText(settings.token), realDebridUseWebLogin: Boolean(settings.realDebridUseWebLogin), @@ -657,9 +659,15 @@ export function normalizeSettings(settings: AppSettings): AppSettings { realDebridAccountIds, legacyRealDebridTargetId ), - providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, - scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) - }; + providerDailyUsageDay: providerDailyUsageDay === currentUsageDay ? providerDailyUsageDay : currentUsageDay, + dailyStartEnabled: settings.dailyStartEnabled !== undefined ? Boolean(settings.dailyStartEnabled) : defaults.dailyStartEnabled, + dailyStartMinuteOfDay: clampNumber(settings.dailyStartMinuteOfDay, defaults.dailyStartMinuteOfDay, 0, 1_439), + dailyStartFirstLocalDate: isValidLocalDate(asText(settings.dailyStartFirstLocalDate)) ? asText(settings.dailyStartFirstLocalDate) : "", + dailyStartLastHandledLocalDate: isValidLocalDate(asText(settings.dailyStartLastHandledLocalDate)) ? asText(settings.dailyStartLastHandledLocalDate) : "", + dailyStartPendingLocalDate: isValidLocalDate(asText(settings.dailyStartPendingLocalDate)) ? asText(settings.dailyStartPendingLocalDate) : "", + dailyStartLastOutcome: validDailyStartOutcomes.has(settings.dailyStartLastOutcome) ? settings.dailyStartLastOutcome : "", + scheduledStartEpochMs: clampNumber(settings.scheduledStartEpochMs, defaults.scheduledStartEpochMs, 0, Number.MAX_SAFE_INTEGER) + }; if (!VALID_PRIMARY_PROVIDERS.has(normalized.providerPrimary)) { normalized.providerPrimary = defaults.providerPrimary; @@ -1211,7 +1219,12 @@ function sleepSyncMs(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } -function readSessionFile(filePath: string): SessionState | null { +interface LoadedSessionFile { + session: SessionState; + wasRunning: boolean; +} + +function readSessionFile(filePath: string): LoadedSessionFile | null { let raw: string | null = null; const maxAttempts = 5; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { @@ -1237,13 +1250,15 @@ function readSessionFile(filePath: string): SessionState | null { if (raw === null) { return null; } - try { - const parsed = JSON.parse(raw) as unknown; - const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); + try { + const parsed = JSON.parse(raw) as unknown; + const normalized = normalizeLoadedSession(parsed); + const wasRunning = normalized.running; + const session = normalizeLoadedSessionTransientFields(normalized); const pkgCount = Object.keys(session.packages).length; const itemCount = Object.keys(session.items).length; logger.info(`Session geladen: ${filePath} (${pkgCount} Pakete, ${itemCount} Items)`); - return session; + return { session, wasRunning }; } catch (error) { logger.error(`Session-Datei beschädigt (JSON ungültig): ${filePath}: ${String(error)}`); return null; @@ -1357,10 +1372,11 @@ export type SessionLoadStatus = | "empty-fresh" | "empty-unreadable"; -export interface SessionLoadResult { - session: SessionState; - status: SessionLoadStatus; -} +export interface SessionLoadResult { + session: SessionState; + status: SessionLoadStatus; + wasRunning: boolean; +} export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult { ensureBaseDir(paths.baseDir); @@ -1374,69 +1390,69 @@ export function loadSessionWithStatus(paths: StoragePaths): SessionLoadResult { if (!primaryExists) { if (!backupExists && !anyTempExists) { logger.info("Keine Session-Datei vorhanden, starte mit leerer Session"); - return { session: emptySession(), status: "empty-fresh" }; + return { session: emptySession(), status: "empty-fresh", wasRunning: false }; } logger.warn("Session-Primaerdatei fehlt, aber Backup/Temp vorhanden — Wiederherstellung wird versucht"); } - const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; - - if (primary) { - const primaryPkgCount = Object.keys(primary.packages).length; + const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; + + if (primary) { + const primaryPkgCount = Object.keys(primary.session.packages).length; if (primaryPkgCount === 0 && backupExists) { - const backup = readSessionFile(backupFile); - if (backup) { - const backupPkgCount = Object.keys(backup.packages).length; + const backup = readSessionFile(backupFile); + if (backup) { + const backupPkgCount = Object.keys(backup.session.packages).length; if (backupPkgCount > 0) { logger.warn(`Session-Datei ist leer (0 Pakete), aber Backup hat ${backupPkgCount} Pakete — verwende Backup`); try { - const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); + const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer); fs.writeFileSync(syncTempFile, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); } catch { } - return { session: backup, status: "recovered-backup" }; + return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning }; } } } - return { session: primary, status: "ok" }; + return { session: primary.session, status: "ok", wasRunning: primary.wasRunning }; } const backup = backupExists ? readSessionFile(backupFile) : null; if (backup) { logger.warn("Session defekt, Backup-Datei wird verwendet"); try { - const payload = JSON.stringify({ ...backup, updatedAt: Date.now() }, safeJsonReplacer); + const payload = JSON.stringify({ ...backup.session, updatedAt: Date.now() }, safeJsonReplacer); fs.writeFileSync(syncTempFile, payload, "utf8"); syncRenameWithExdevFallback(syncTempFile, paths.sessionFile); } catch { } - return { session: backup, status: "recovered-backup" }; + return { session: backup.session, status: "recovered-backup", wasRunning: backup.wasRunning }; } for (const kind of ["sync", "async"] as const) { const tmpPath = sessionTempPath(paths.sessionFile, kind); if (fs.existsSync(tmpPath)) { - const tmpSession = readSessionFile(tmpPath); - if (tmpSession && Object.keys(tmpSession.packages).length > 0) { - logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.packages).length} Pakete)`); - try { - const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer); + const tmpSession = readSessionFile(tmpPath); + if (tmpSession && Object.keys(tmpSession.session.packages).length > 0) { + logger.warn(`Session aus temporaerer Datei wiederhergestellt: ${tmpPath} (${Object.keys(tmpSession.session.packages).length} Pakete)`); + try { + const payload = JSON.stringify({ ...tmpSession.session, updatedAt: Date.now() }, safeJsonReplacer); fs.writeFileSync(paths.sessionFile, payload, "utf8"); } catch { } - return { session: tmpSession, status: "recovered-temp" }; + return { session: tmpSession.session, status: "recovered-temp", wasRunning: tmpSession.wasRunning }; } } } if (primaryExists || backupExists || anyTempExists) { logger.error("Session konnte nicht geladen werden (Primary, Backup und Temp-Dateien fehlgeschlagen) — Schutz gegen leeres Ueberschreiben aktiv"); - return { session: emptySession(), status: "empty-unreadable" }; + return { session: emptySession(), status: "empty-unreadable", wasRunning: false }; } - return { session: emptySession(), status: "empty-fresh" }; -} + return { session: emptySession(), status: "empty-fresh", wasRunning: false }; +} export function loadSession(paths: StoragePaths): SessionState { return loadSessionWithStatus(paths).session; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 81804aa..69b9b9c 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -808,9 +808,16 @@ const emptySnapshot = (): UiSnapshot => ({ megaDebridAccountDailyLimitBytes: {}, megaDebridAccountDailyUsageBytes: {}, megaDebridAccountTotalUsageBytes: {}, - debridAccountStatuses: {}, - providerDailyUsageDay: getProviderUsageDayKey(), - scheduledStartEpochMs: 0 + debridAccountStatuses: {}, + providerDailyUsageDay: getProviderUsageDayKey(), + dailyStartEnabled: false, + dailyStartMinuteOfDay: 0, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + scheduledStartEpochMs: 0, + nextDailyStartEpochMs: 0 }, accounts: [], session: { diff --git a/src/shared/types.ts b/src/shared/types.ts index 1c63b4a..d10734c 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -122,7 +122,18 @@ export interface DebridAccountStatus { export type NotifyPackageSuccessMode = "digest" | "individual"; -export interface AppSettings { +export type DailyStartOutcome = "" | "started" | "already_active" | "empty_queue" | "missing_account" | "start_failed" | "missed"; + +export interface DailyStartSettings { + dailyStartEnabled: boolean; + dailyStartMinuteOfDay: number; + dailyStartFirstLocalDate: string; + dailyStartLastHandledLocalDate: string; + dailyStartPendingLocalDate: string; + dailyStartLastOutcome: DailyStartOutcome; +} + +export interface AppSettings extends DailyStartSettings { language: AppLanguage; token: string; realDebridUseWebLogin: boolean; @@ -271,7 +282,7 @@ export interface RendererAccount { status: DebridAccountStatus | null; } -export interface RendererSettings { +export interface RendererSettings extends DailyStartSettings { language: AppLanguage; realDebridUseWebLogin: boolean; realDebridDisabledAccountIds: string[]; @@ -373,6 +384,7 @@ export interface RendererSettings { debridAccountStatuses: Record; providerDailyUsageDay: string; scheduledStartEpochMs: number; + nextDailyStartEpochMs: number; } export type RendererSettingsUpdate = Partial & { diff --git a/tests/app-controller.test.ts b/tests/app-controller.test.ts new file mode 100644 index 0000000..140cb57 --- /dev/null +++ b/tests/app-controller.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppController } from "../src/main/app-controller"; +import { defaultSettings } from "../src/main/constants"; +import { configureCredentialProtector } from "../src/main/credential-protection"; +import { createStoragePaths } from "../src/main/storage"; +import type { AppSettings } from "../src/shared/types"; + +vi.mock("electron", () => ({ + app: { getPath: () => "C:\\MDD\\Test" }, + BrowserWindow: class {}, + clipboard: {}, + dialog: {}, + ipcMain: { handle: vi.fn(), on: vi.fn() }, + Menu: { buildFromTemplate: vi.fn(), setApplicationMenu: vi.fn() }, + safeStorage: { isEncryptionAvailable: () => false, encryptString: vi.fn(), decryptString: vi.fn() }, + shell: {}, + Tray: class {} +})); + +const tempDirs: string[] = []; + +function createController(settings: AppSettings): AppController { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-controller-")); + tempDirs.push(dir); + const controller = Object.create(AppController.prototype) as any; + controller.settings = settings; + controller.storagePaths = createStoragePaths(dir); + controller.manager = { setSettings: vi.fn() }; + controller.audit = vi.fn(); + controller.overlayLiveUsageCounters = vi.fn(); + controller.pruneRealDebridWebFallbacks = vi.fn(); + controller.realDebridWebFallbacks = new Map(); + return controller as AppController; +} + +afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("AppController daily start settings", () => { + it("clears a legacy one-time schedule when a new daily rule is saved", () => { + configureCredentialProtector({ + isEncryptionAvailable: () => false, + encryptString: (value) => Buffer.from(value, "utf8"), + decryptString: (value) => Buffer.from(value).toString("utf8") + }); + const controller = createController({ + ...defaultSettings(), + scheduledStartEpochMs: 1_800_000_000_000 + }); + + const updated = controller.updateSettings({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 18 * 60 + 45, + dailyStartFirstLocalDate: "2026-08-23" + }); + + expect(updated.scheduledStartEpochMs).toBe(0); + expect(updated).toMatchObject({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 18 * 60 + 45, + dailyStartFirstLocalDate: "2026-08-23" + }); + }); +}); diff --git a/tests/daily-start-scheduler.test.ts b/tests/daily-start-scheduler.test.ts new file mode 100644 index 0000000..eab39d9 --- /dev/null +++ b/tests/daily-start-scheduler.test.ts @@ -0,0 +1,284 @@ +import { afterAll, describe, expect, it, vi } from "vitest"; +import type { DailyStartSettings } from "../src/shared/types"; +import { DailyStartScheduler, nextDailyStartEpochMs, prepareDailyStartSettingsPatch, shouldDeferAutoResumeToDailyStart } from "../src/main/daily-start-scheduler"; + +const originalTimezone = process.env.TZ; +process.env.TZ = "Europe/Berlin"; + +afterAll(() => { + if (originalTimezone === undefined) { + delete process.env.TZ; + } else { + process.env.TZ = originalTimezone; + } +}); + +function settings(overrides: Partial = {}): DailyStartSettings { + return { + dailyStartEnabled: true, + dailyStartMinuteOfDay: 10 * 60, + dailyStartFirstLocalDate: "2026-08-22", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + ...overrides + }; +} + +class FakeDailyStartController { + public settings: DailyStartSettings; + public running = false; + public paused = false; + public canStart = true; + public items: Record = { queued: { status: "queued" } }; + public events: string[] = []; + public start = vi.fn(async () => { + this.events.push("start"); + }); + + public constructor(value: DailyStartSettings) { + this.settings = value; + } + + public getSnapshot() { + return { + settings: this.settings, + session: { + running: this.running, + paused: this.paused, + items: this.items + }, + canStart: this.canStart + }; + } + + public updateSettings(partial: Partial): DailyStartSettings { + Object.assign(this.settings, partial); + if (partial.dailyStartPendingLocalDate) { + this.events.push(`pending:${partial.dailyStartPendingLocalDate}`); + } + if (partial.dailyStartLastHandledLocalDate) { + this.events.push(`handled:${partial.dailyStartLastHandledLocalDate}`); + } + return this.settings; + } +} + +describe("daily start scheduler", () => { + it("keeps today's future and past targets on the local calendar date", () => { + const futureNow = new Date(2026, 7, 22, 9, 15).getTime(); + const pastNow = new Date(2026, 7, 22, 12, 30).getTime(); + const expected = new Date(2026, 7, 22, 10, 0, 0, 0).getTime(); + + expect(nextDailyStartEpochMs(settings(), futureNow)).toBe(expected); + expect(nextDailyStartEpochMs(settings(), pastNow)).toBe(expected); + }); + + it("uses tomorrow as the first eligible local date", () => { + const now = new Date(2026, 7, 22, 8, 0).getTime(); + + expect(nextDailyStartEpochMs(settings({ dailyStartFirstLocalDate: "2026-08-23" }), now)) + .toBe(new Date(2026, 7, 23, 10, 0, 0, 0).getTime()); + }); + + it("dispatches once on each of five eligible local days", async () => { + let now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + const scheduler = new DailyStartScheduler(controller, () => now); + + for (let day = 22; day <= 26; day += 1) { + now = new Date(2026, 7, day, 10, 5).getTime(); + await scheduler.reconcile(); + } + + expect(controller.start).toHaveBeenCalledTimes(5); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-26"); + }); + + it("persists pending before dispatch and coalesces simultaneous reconciles", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + const scheduler = new DailyStartScheduler(controller, () => now); + + await Promise.all([scheduler.reconcile(), scheduler.reconcile()]); + + expect(controller.start).toHaveBeenCalledTimes(1); + expect(controller.events).toEqual([ + "pending:2026-08-22", + "start", + "handled:2026-08-22" + ]); + expect(controller.settings.dailyStartPendingLocalDate).toBe(""); + expect(controller.settings.dailyStartLastOutcome).toBe("started"); + }); + + it("catches an overdue target after a clock jump without dispatching twice", async () => { + let now = new Date(2026, 7, 22, 9, 55).getTime(); + const controller = new FakeDailyStartController(settings()); + const scheduler = new DailyStartScheduler(controller, () => now); + + expect(await scheduler.reconcile()).toBeNull(); + now = new Date(2026, 7, 22, 11, 10).getTime(); + expect(await scheduler.reconcile()).toBe("started"); + expect(await scheduler.reconcile()).toBeNull(); + + expect(controller.start).toHaveBeenCalledTimes(1); + }); + + it("treats repeated suspend and resume reconciles as one daily dispatch", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + const scheduler = new DailyStartScheduler(controller, () => now); + + await scheduler.reconcile(); + await scheduler.reconcile(); + await scheduler.reconcile(); + + expect(controller.start).toHaveBeenCalledTimes(1); + }); + + it("recovers a persisted pending receipt after restart and then deduplicates it", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" })); + + await new DailyStartScheduler(controller, () => now).reconcile(); + await new DailyStartScheduler(controller, () => now).reconcile(); + + expect(controller.start).toHaveBeenCalledTimes(1); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22"); + expect(controller.settings.dailyStartPendingLocalDate).toBe(""); + }); + + it("marks an empty queue handled without calling start", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + controller.items = {}; + + expect(await new DailyStartScheduler(controller, () => now).reconcile()).toBe("empty_queue"); + expect(controller.start).not.toHaveBeenCalled(); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22"); + expect(controller.settings.dailyStartLastOutcome).toBe("empty_queue"); + }); + + it("keeps a missing-account occurrence pending and retries after account recovery", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + controller.canStart = false; + const scheduler = new DailyStartScheduler(controller, () => now); + + expect(await scheduler.reconcile()).toBe("missing_account"); + expect(controller.start).not.toHaveBeenCalled(); + expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22"); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe(""); + + controller.canStart = true; + expect(await scheduler.reconcile()).toBe("started"); + expect(controller.start).toHaveBeenCalledTimes(1); + }); + + it("treats running and paused sessions as already active", async () => { + let now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + controller.running = true; + const scheduler = new DailyStartScheduler(controller, () => now); + + expect(await scheduler.reconcile()).toBe("already_active"); + controller.running = false; + controller.paused = true; + now = new Date(2026, 7, 23, 10, 5).getTime(); + expect(await scheduler.reconcile()).toBe("already_active"); + + expect(controller.start).not.toHaveBeenCalled(); + }); + + it("records a stale pending day as missed without disabling future days", async () => { + const now = new Date(2026, 7, 23, 9, 0).getTime(); + const controller = new FakeDailyStartController(settings({ dailyStartPendingLocalDate: "2026-08-22" })); + const scheduler = new DailyStartScheduler(controller, () => now); + + expect(await scheduler.reconcile()).toBe("missed"); + expect(controller.settings.dailyStartEnabled).toBe(true); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe("2026-08-22"); + expect(controller.settings.dailyStartLastOutcome).toBe("missed"); + expect(nextDailyStartEpochMs(controller.settings, now)).toBe(new Date(2026, 7, 23, 10, 0).getTime()); + }); + + it("keeps start failures pending for a later retry", async () => { + const now = new Date(2026, 7, 22, 10, 5).getTime(); + const controller = new FakeDailyStartController(settings()); + controller.start.mockRejectedValueOnce(new Error("start rejected")); + const scheduler = new DailyStartScheduler(controller, () => now); + + await expect(scheduler.reconcile()).rejects.toThrow("start rejected"); + expect(controller.settings.dailyStartPendingLocalDate).toBe("2026-08-22"); + expect(controller.settings.dailyStartLastHandledLocalDate).toBe(""); + expect(controller.settings.dailyStartLastOutcome).toBe("start_failed"); + + await scheduler.reconcile(); + expect(controller.start).toHaveBeenCalledTimes(2); + }); + + it("constructs each DST target from local calendar fields instead of adding 24 hours", () => { + const beforeDst = settings({ + dailyStartMinuteOfDay: 2 * 60 + 30, + dailyStartFirstLocalDate: "2026-03-29" + }); + const firstNow = new Date(2026, 2, 28, 12, 0).getTime(); + const firstTarget = nextDailyStartEpochMs(beforeDst, firstNow); + const nextTarget = nextDailyStartEpochMs({ + ...beforeDst, + dailyStartLastHandledLocalDate: "2026-03-29" + }, new Date(2026, 2, 29, 12, 0).getTime()); + + expect(firstTarget).toBe(new Date(2026, 2, 29, 2, 30, 0, 0).getTime()); + expect(nextTarget).toBe(new Date(2026, 2, 30, 2, 30, 0, 0).getTime()); + expect(nextTarget - firstTarget).toBe(23 * 60 * 60 * 1000); + }); + + it("clears a legacy one-time target only when a new daily rule is saved", () => { + expect(prepareDailyStartSettingsPatch({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 600, + dailyStartFirstLocalDate: "2026-08-22" + })).toEqual({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 600, + dailyStartFirstLocalDate: "2026-08-22", + scheduledStartEpochMs: 0 + }); + expect(prepareDailyStartSettingsPatch({ dailyStartPendingLocalDate: "2026-08-22" })) + .toEqual({ dailyStartPendingLocalDate: "2026-08-22" }); + }); + + it("defers boot auto-resume for a future daily target unless the saved run was active", () => { + const now = new Date(2026, 7, 22, 9, 0).getTime(); + const future = settings({ dailyStartMinuteOfDay: 10 * 60 }); + + expect(shouldDeferAutoResumeToDailyStart(future, false, now)).toBe(true); + expect(shouldDeferAutoResumeToDailyStart(future, true, now)).toBe(false); + expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartEnabled: false }, false, now)).toBe(false); + expect(shouldDeferAutoResumeToDailyStart({ ...future, dailyStartMinuteOfDay: 8 * 60 }, false, now)).toBe(false); + }); + + it("reconciles at boot and at most sixty seconds after a target becomes due", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 9, 59, 30)); + try { + const controller = new FakeDailyStartController(settings()); + const scheduler = new DailyStartScheduler(controller); + + scheduler.begin(); + await vi.advanceTimersByTimeAsync(0); + expect(controller.start).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(60_000); + expect(controller.start).toHaveBeenCalledTimes(1); + + scheduler.end(); + await vi.advanceTimersByTimeAsync(120_000); + expect(controller.start).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index 9450384..6dbdd35 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { defaultSettings } from "../src/main/constants"; import { createRendererSettings } from "../src/main/renderer-state"; import { validateRendererSettingsUpdate } from "../src/main/renderer-settings"; @@ -73,4 +73,54 @@ describe("renderer settings validation", () => { expect(projected.archivePasswordListConfigured).toBe(true); expect(JSON.stringify(projected)).not.toContain(password); }); + + it("projects daily start state and accepts only editable calendar controls", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 7, 22, 9, 0)); + try { + const current = { + ...defaultSettings(), + dailyStartEnabled: true, + dailyStartMinuteOfDay: 18 * 60 + 45, + dailyStartFirstLocalDate: "2026-08-23", + dailyStartLastHandledLocalDate: "2026-08-22", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "started" as const + }; + const projected = createRendererSettings(current); + + expect(projected).toMatchObject({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 18 * 60 + 45, + dailyStartFirstLocalDate: "2026-08-23", + dailyStartLastHandledLocalDate: "2026-08-22", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "started" + }); + expect(projected.nextDailyStartEpochMs).toBe(new Date(2026, 7, 23, 18, 45, 0, 0).getTime()); + expect(validateRendererSettingsUpdate({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 7 * 60 + 30, + dailyStartFirstLocalDate: "2026-08-24", + dailyStartLastHandledLocalDate: "2026-08-23", + dailyStartPendingLocalDate: "2026-08-24", + dailyStartLastOutcome: "missed", + nextDailyStartEpochMs: 123 + }, current)).toEqual({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 7 * 60 + 30, + dailyStartFirstLocalDate: "2026-08-24" + }); + } finally { + vi.useRealTimers(); + } + }); + + it("rejects invalid editable daily calendar values at the renderer boundary", () => { + const current = defaultSettings(); + + expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 1_440 }, current)).toThrow("Settings-Payload ist ungültig"); + expect(() => validateRendererSettingsUpdate({ dailyStartMinuteOfDay: 12.5 }, current)).toThrow("Settings-Payload ist ungültig"); + expect(() => validateRendererSettingsUpdate({ dailyStartFirstLocalDate: "2026-02-29" }, current)).toThrow("Settings-Payload ist ungültig"); + }); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 0893295..c64a58e 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -9,7 +9,7 @@ import { parseRealDebridApiAccounts, serializeRealDebridApiAccounts } from "../s import { AppSettings } from "../src/shared/types"; import { defaultSettings } from "../src/main/constants"; import { configureCredentialProtector } from "../src/main/credential-protection"; -import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; +import { addHistoryEntryForRetention, createStoragePaths, emptySession, loadHistory, loadHistoryForRetention, loadSession, loadSessionWithStatus, loadSettings, normalizeLoadedSession, normalizeSettings, removeHistoryEntries, resetHistoryForRetention, saveHistory, saveSession, saveSessionAsync, saveSettings, saveSettingsAsync } from "../src/main/storage"; const tempDirs: string[] = []; type SettingsSaveMode = "sync" | "async"; @@ -46,6 +46,48 @@ afterEach(() => { }); describe("settings storage", () => { + it("defaults and normalizes persistent daily start calendar fields", () => { + const defaults = defaultSettings(); + + expect(defaults.dailyStartEnabled).toBe(false); + expect(defaults.dailyStartMinuteOfDay).toBe(0); + expect(defaults.dailyStartFirstLocalDate).toBe(""); + expect(defaults.dailyStartLastHandledLocalDate).toBe(""); + expect(defaults.dailyStartPendingLocalDate).toBe(""); + expect(defaults.dailyStartLastOutcome).toBe(""); + + expect(normalizeSettings({ + ...defaults, + dailyStartEnabled: true, + dailyStartMinuteOfDay: 1_500, + dailyStartFirstLocalDate: "2026-02-29", + dailyStartLastHandledLocalDate: "2026-08-21", + dailyStartPendingLocalDate: "not-a-day", + dailyStartLastOutcome: "unsupported" + } as unknown as AppSettings)).toMatchObject({ + dailyStartEnabled: true, + dailyStartMinuteOfDay: 1_439, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "2026-08-21", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "" + }); + }); + + it("reports whether a loaded session was active before transient normalization", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + const session = emptySession(); + session.running = true; + saveSession(paths, session); + + const loaded = loadSessionWithStatus(paths); + + expect(loaded.wasRunning).toBe(true); + expect(loaded.session.running).toBe(false); + }); + it("migrates legacy package success notifications without changing their delivery frequency", () => { expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual"); expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest"); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 3f16aea..55eec65 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -262,11 +262,17 @@ function createSettings(): AppSettings { message: "API-Key aktiv", checkedAt: 1786312800000 } - }, - providerDailyUsageDay: "2026-08-10", - scheduledStartEpochMs: 0 - }; -} + }, + providerDailyUsageDay: "2026-08-10", + dailyStartEnabled: false, + dailyStartMinuteOfDay: 0, + dailyStartFirstLocalDate: "", + dailyStartLastHandledLocalDate: "", + dailyStartPendingLocalDate: "", + dailyStartLastOutcome: "", + scheduledStartEpochMs: 0 + }; +} function createEmptySnapshot(): UiSnapshot { const renderer = createRendererState(createSettings());