From 754231d15c46947596432f66e961d889721dae84 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 03:44:11 +0200 Subject: [PATCH] feat(notifications): add settings and migration --- src/main/constants.ts | 13 ++++++++--- src/main/renderer-state.ts | 7 ++++++ src/main/storage.ts | 28 +++++++++++++++++------ src/renderer/App.tsx | 2 ++ src/shared/types.ts | 26 +++++++++++++++++----- tests/renderer-settings.test.ts | 18 +++++++++++++++ tests/renderer-state.test.ts | 12 ++++++++++ tests/storage.test.ts | 39 +++++++++++++++++++++++++++++++++ tests/visual/fixtures.ts | 13 ++++++++--- 9 files changed, 140 insertions(+), 18 deletions(-) diff --git a/src/main/constants.ts b/src/main/constants.ts index 9d4c71c..8d6f64d 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -125,9 +125,16 @@ export function defaultSettings(): AppSettings { backupIncludeRemoteDiagnostics: false, notifyUrl: "", notifyMention: "", - notifyOnPackageCompleted: false, - notifyOnPackageFailed: false, - notifyOnRunFinished: false, + notifyOnPackageCompleted: false, + notifyOnPackageFailed: false, + notifyOnRunFinished: false, + notifyPackageSuccessMode: "digest", + notifyOnRemainingBelow: false, + notifyRemainingThresholdGb: 50, + notifyOnDownloadStall: false, + notifyStallAfterSeconds: 90, + notifyStallCooldownMinutes: 10, + notifyOnDownloadRecovery: true, totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 2c3ce8c..bb781f1 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -220,6 +220,13 @@ export function createRendererSettings(settings: AppSettings): RendererSettings notifyOnPackageCompleted: settings.notifyOnPackageCompleted, notifyOnPackageFailed: settings.notifyOnPackageFailed, notifyOnRunFinished: settings.notifyOnRunFinished, + notifyPackageSuccessMode: settings.notifyPackageSuccessMode, + notifyOnRemainingBelow: settings.notifyOnRemainingBelow, + notifyRemainingThresholdGb: settings.notifyRemainingThresholdGb, + notifyOnDownloadStall: settings.notifyOnDownloadStall, + notifyStallAfterSeconds: settings.notifyStallAfterSeconds, + notifyStallCooldownMinutes: settings.notifyStallCooldownMinutes, + notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery, totalDownloadedAllTime: settings.totalDownloadedAllTime, totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime, totalRuntimeAllTimeMs: settings.totalRuntimeAllTimeMs, diff --git a/src/main/storage.ts b/src/main/storage.ts index 8b89cdf..716520f 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -413,10 +413,14 @@ function migrateUpdateRepo(raw: string, fallback: string): string { return trimmed; } -export function normalizeSettings(settings: AppSettings): AppSettings { - const defaults = defaultSettings(); - const directorySettings = migrateLegacyDefaultDirectories(settings, defaults); - const currentUsageDay = getProviderUsageDayKey(); +export function normalizeSettings(settings: AppSettings): AppSettings { + const defaults = defaultSettings(); + const directorySettings = migrateLegacyDefaultDirectories(settings, defaults); + const legacySuccessMode = settings.notifyOnPackageCompleted === true ? "individual" : "digest"; + const notifyPackageSuccessMode = settings.notifyPackageSuccessMode === "individual" || settings.notifyPackageSuccessMode === "digest" + ? settings.notifyPackageSuccessMode + : legacySuccessMode; + const currentUsageDay = getProviderUsageDayKey(); const legacyMegaLogin = asText(settings.megaLogin); const legacyMegaPassword = asText(settings.megaPassword); let legacyMegaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); @@ -604,9 +608,16 @@ export function normalizeSettings(settings: AppSettings): AppSettings { backupIncludeRemoteDiagnostics: settings.backupIncludeRemoteDiagnostics !== undefined ? Boolean(settings.backupIncludeRemoteDiagnostics) : defaults.backupIncludeRemoteDiagnostics, notifyUrl: asText(settings.notifyUrl) || defaults.notifyUrl, notifyMention: asText(settings.notifyMention) || defaults.notifyMention, - notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, - notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed, - notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished, + notifyOnPackageCompleted: settings.notifyOnPackageCompleted !== undefined ? Boolean(settings.notifyOnPackageCompleted) : defaults.notifyOnPackageCompleted, + notifyOnPackageFailed: settings.notifyOnPackageFailed !== undefined ? Boolean(settings.notifyOnPackageFailed) : defaults.notifyOnPackageFailed, + notifyOnRunFinished: settings.notifyOnRunFinished !== undefined ? Boolean(settings.notifyOnRunFinished) : defaults.notifyOnRunFinished, + notifyPackageSuccessMode, + notifyOnRemainingBelow: settings.notifyOnRemainingBelow !== undefined ? Boolean(settings.notifyOnRemainingBelow) : defaults.notifyOnRemainingBelow, + notifyRemainingThresholdGb: clampNumber(settings.notifyRemainingThresholdGb, 50, 1, 100000), + notifyOnDownloadStall: settings.notifyOnDownloadStall !== undefined ? Boolean(settings.notifyOnDownloadStall) : defaults.notifyOnDownloadStall, + notifyStallAfterSeconds: clampNumber(settings.notifyStallAfterSeconds, 90, 60, 3600), + notifyStallCooldownMinutes: clampNumber(settings.notifyStallCooldownMinutes, 10, 5, 1440), + notifyOnDownloadRecovery: settings.notifyOnDownloadRecovery !== undefined ? Boolean(settings.notifyOnDownloadRecovery) : defaults.notifyOnDownloadRecovery, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, @@ -801,6 +812,9 @@ function readSettingsFile(filePath: string): LoadedSettingsFile | null { if (!Object.prototype.hasOwnProperty.call(parsed, "realDebridWebAccountIds")) { delete (mergedInput as Partial).realDebridWebAccountIds; } + if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) { + delete (mergedInput as Partial).notifyPackageSuccessMode; + } const merged = normalizeSettings(mergedInput); return { settings: merged, needsCredentialRewrite }; } catch (error) { diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0d44ee8..98b0814 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -786,6 +786,8 @@ const emptySnapshot = (): UiSnapshot => ({ updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, + notifyPackageSuccessMode: "digest", notifyOnRemainingBelow: false, notifyRemainingThresholdGb: 50, + notifyOnDownloadStall: false, notifyStallAfterSeconds: 90, notifyStallCooldownMinutes: 10, notifyOnDownloadRecovery: true, accountListShowDetailedDebridLinkKeys: false, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed", "availability"], diff --git a/src/shared/types.ts b/src/shared/types.ts index 0073c74..8daad56 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -120,6 +120,8 @@ export interface DebridAccountStatus { checkedAt: number; } +export type NotifyPackageSuccessMode = "digest" | "individual"; + export interface AppSettings { language: AppLanguage; token: string; @@ -200,11 +202,18 @@ export interface AppSettings { confirmDeleteSelection: boolean; backupIncludeDownloads: boolean; backupIncludeRemoteDiagnostics: boolean; - notifyUrl: string; - notifyMention: string; - notifyOnPackageCompleted: boolean; - notifyOnPackageFailed: boolean; - notifyOnRunFinished: boolean; + notifyUrl: string; + notifyMention: string; + notifyOnPackageCompleted: boolean; + notifyOnPackageFailed: boolean; + notifyOnRunFinished: boolean; + notifyPackageSuccessMode: NotifyPackageSuccessMode; + notifyOnRemainingBelow: boolean; + notifyRemainingThresholdGb: number; + notifyOnDownloadStall: boolean; + notifyStallAfterSeconds: number; + notifyStallCooldownMinutes: number; + notifyOnDownloadRecovery: boolean; totalDownloadedAllTime: number; totalCompletedFilesAllTime: number; totalRuntimeAllTimeMs: number; @@ -332,6 +341,13 @@ export interface RendererSettings { notifyOnPackageCompleted: boolean; notifyOnPackageFailed: boolean; notifyOnRunFinished: boolean; + notifyPackageSuccessMode: NotifyPackageSuccessMode; + notifyOnRemainingBelow: boolean; + notifyRemainingThresholdGb: number; + notifyOnDownloadStall: boolean; + notifyStallAfterSeconds: number; + notifyStallCooldownMinutes: number; + notifyOnDownloadRecovery: boolean; totalDownloadedAllTime: number; totalCompletedFilesAllTime: number; totalRuntimeAllTimeMs: number; diff --git a/tests/renderer-settings.test.ts b/tests/renderer-settings.test.ts index d669258..9e4ed28 100644 --- a/tests/renderer-settings.test.ts +++ b/tests/renderer-settings.test.ts @@ -4,6 +4,24 @@ import { createRendererSettings } from "../src/main/renderer-state"; import { validateRendererSettingsUpdate } from "../src/main/renderer-settings"; describe("renderer settings validation", () => { + it("accepts every editable notification control while keeping the webhook write-only", () => { + const current = { ...defaultSettings(), notifyUrl: "https://notify.example.test/private-hook" }; + const update = { + notifyPackageSuccessMode: "individual", + notifyOnRemainingBelow: true, + notifyRemainingThresholdGb: 25, + notifyOnDownloadStall: true, + notifyStallAfterSeconds: 120, + notifyStallCooldownMinutes: 15, + notifyOnDownloadRecovery: false + }; + const projected = createRendererSettings(current); + + expect(projected).not.toHaveProperty("notifyUrl"); + expect(projected.notifyUrlConfigured).toBe(true); + expect(validateRendererSettingsUpdate(update, current)).toEqual(update); + }); + it("accepts a complete settings save when an account status has no optional email", () => { const current = defaultSettings(); current.debridAccountStatuses = { diff --git a/tests/renderer-state.test.ts b/tests/renderer-state.test.ts index 23d9aae..9eab3e7 100644 --- a/tests/renderer-state.test.ts +++ b/tests/renderer-state.test.ts @@ -40,6 +40,18 @@ const ACCOUNT_FIXTURES: Array<{ ]; describe("renderer state serialization", () => { + it("projects notification controls with their safe defaults", () => { + expect(createRendererState(defaultSettings()).settings).toEqual(expect.objectContaining({ + notifyPackageSuccessMode: "digest", + notifyOnRemainingBelow: false, + notifyRemainingThresholdGb: 50, + notifyOnDownloadStall: false, + notifyStallAfterSeconds: 90, + notifyStallCooldownMinutes: 10, + notifyOnDownloadRecovery: true + })); + }); + it("projects distinct Real-Debrid API and Web rows with per-account state", () => { const firstToken = "fixture-renderer-rd-first-1aB2"; const secondToken = "fixture-renderer-rd-second-3cD4"; diff --git a/tests/storage.test.ts b/tests/storage.test.ts index 96ce2da..64a67a4 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -22,6 +22,14 @@ async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType): AppSettings { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-store-")); + tempDirs.push(dir); + const paths = createStoragePaths(dir); + fs.writeFileSync(paths.configFile, JSON.stringify(raw), "utf8"); + return loadSettings(paths); +} + beforeEach(() => { configureCredentialProtector({ isEncryptionAvailable: () => true, @@ -38,6 +46,37 @@ afterEach(() => { }); describe("settings storage", () => { + it("migrates legacy package success notifications without changing their delivery frequency", () => { + expect(loadSettingsFrom({ notifyOnPackageCompleted: true }).notifyPackageSuccessMode).toBe("individual"); + expect(loadSettingsFrom({}).notifyPackageSuccessMode).toBe("digest"); + expect(loadSettingsFrom({ notifyOnPackageCompleted: true, notifyPackageSuccessMode: "digest" }).notifyPackageSuccessMode).toBe("digest"); + }); + + it("normalizes notification defaults and numeric limits", () => { + const defaults = loadSettingsFrom({}); + const normalized = normalizeSettings({ + ...defaultSettings(), + notifyRemainingThresholdGb: 0, + notifyStallAfterSeconds: 3_601, + notifyStallCooldownMinutes: 1 + }); + + expect(defaults).toEqual(expect.objectContaining({ + notifyPackageSuccessMode: "digest", + notifyOnRemainingBelow: false, + notifyRemainingThresholdGb: 50, + notifyOnDownloadStall: false, + notifyStallAfterSeconds: 90, + notifyStallCooldownMinutes: 10, + notifyOnDownloadRecovery: true + })); + expect(normalized).toEqual(expect.objectContaining({ + notifyRemainingThresholdGb: 1, + notifyStallAfterSeconds: 3_600, + notifyStallCooldownMinutes: 5 + })); + }); + it("enables package disclosure motion by default and preserves an explicit opt-out", () => { const legacy = { ...defaultSettings() } as Partial; delete legacy.animatePackageDisclosure; diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 43ddd8a..13e7654 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -160,9 +160,16 @@ function createSettings(): AppSettings { backupIncludeRemoteDiagnostics: false, notifyUrl: "https://example.test/visual-webhook", notifyMention: "@visual", - notifyOnPackageCompleted: true, - notifyOnPackageFailed: true, - notifyOnRunFinished: true, + notifyOnPackageCompleted: true, + notifyOnPackageFailed: true, + notifyOnRunFinished: true, + notifyPackageSuccessMode: "digest", + notifyOnRemainingBelow: false, + notifyRemainingThresholdGb: 50, + notifyOnDownloadStall: false, + notifyStallAfterSeconds: 90, + notifyStallCooldownMinutes: 10, + notifyOnDownloadRecovery: true, totalDownloadedAllTime: 987654321000, totalCompletedFilesAllTime: 842, totalRuntimeAllTimeMs: 172800000,