feat(notifications): add settings and migration

This commit is contained in:
Sucukdeluxe
2026-08-22 03:44:11 +02:00
parent 6a8a3828b8
commit 754231d15c
9 changed files with 140 additions and 18 deletions
+10 -3
View File
@@ -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,
+7
View File
@@ -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,
+21 -7
View File
@@ -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<AppSettings>).realDebridWebAccountIds;
}
if (!Object.prototype.hasOwnProperty.call(parsed, "notifyPackageSuccessMode")) {
delete (mergedInput as Partial<AppSettings>).notifyPackageSuccessMode;
}
const merged = normalizeSettings(mergedInput);
return { settings: merged, needsCredentialRewrite };
} catch (error) {
+2
View File
@@ -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"],
+21 -5
View File
@@ -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;
+18
View File
@@ -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 = {
+12
View File
@@ -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";
+39
View File
@@ -22,6 +22,14 @@ async function saveSettingsInMode(mode: SettingsSaveMode, paths: ReturnType<type
}
}
function loadSettingsFrom(raw: Record<string, unknown>): 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<AppSettings>;
delete legacy.animatePackageDisclosure;
+10 -3
View File
@@ -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,