From 26903950f771f68ddcf4e1e0200a706ebfd62a4b Mon Sep 17 00:00:00 2001 From: Ednunp <29843396+Ednunp@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:32:17 +0100 Subject: [PATCH] Add "auto save non-read only profiles" preference (silent) New global setting in Preferences -> General, right after "Browse for profiles folder": a list "Auto-save non-read-only profiles" with Never / Every 2 / 5 / 10 / 15 / 20 / 30 minutes. When enabled, RemSound periodically saves the current profile, but ONLY if it is a real saved profile, is NOT read-only, and has unsaved changes -- and it saves SILENTLY (no save cue, no confirmation), so it never interrupts the user. - AppConfig.AutoSaveNonReadOnlyMinutes (machine-wide, 0 = Never = default). - SaveProfileTo gains a playCue flag; auto-save passes playCue: false. - MainForm autoSaveTimer + ApplyAutoSaveTimer + ShouldAutoSave guard, re-applied live when the setting changes in Preferences. - New self-test "Auto-save non-read-only profiles": options list, AppConfig persistence, the guard (read-only / blank / unchanged all skipped), and the timer turning on/off. Gate 28/28. Co-Authored-By: Claude Opus 4.8 --- src/RemSound.App/MainForm.cs | 57 ++++++++++++++++++++++++-- src/RemSound.App/PreferencesDialog.cs | 58 ++++++++++++++++++++++++++- src/RemSound.App/SelfTest.cs | 55 ++++++++++++++++++++++++- src/RemSound.Core/AppConfig.cs | 5 +++ 4 files changed, 170 insertions(+), 5 deletions(-) diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index dc726dc..f9535ec 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -481,6 +481,11 @@ public sealed class MainForm : Form private readonly Dictionary lastFocusedListIndices = []; private readonly System.Windows.Forms.Timer statusTimer = new() { Interval = 1000 }; + // Periodic silent auto-save of the current profile (Preferences → General → "auto save non-read only + // profiles"). Off by default; when enabled it fires every N minutes and saves the active profile only + // if it's a real saved profile, NOT read-only, and has unsaved changes — WITHOUT the save cue. Interval + // and enable/disable come from AppConfig.AutoSaveNonReadOnlyMinutes via ApplyAutoSaveTimer(). + private readonly System.Windows.Forms.Timer autoSaveTimer = new(); // Device-list refresh. As of v3.4 this is EVENT-DRIVEN, not polled: an // AudioDeviceChangeNotifier registers for Windows audio endpoint-change notifications and // pokes this timer when the device set actually changes (USB hot-plug / unplug, default-device @@ -1398,6 +1403,7 @@ public sealed class MainForm : Form // message-only window handle to be freed at GC finalization. MainForm is rebuilt on every // profile switch, so disposing here releases those handles deterministically each time. statusTimer.Stop(); statusTimer.Dispose(); + autoSaveTimer.Stop(); autoSaveTimer.Dispose(); deviceRefreshTimer.Stop(); deviceRefreshTimer.Dispose(); continuousTuneTimer.Stop(); continuousTuneTimer.Dispose(); updateCheckTimer.Stop(); updateCheckTimer.Dispose(); @@ -1584,6 +1590,12 @@ public sealed class MainForm : Form statusTimer.Start(); + // Silent periodic auto-save (opt-in, off by default). Event wiring here; the interval and whether + // it runs at all are set by ApplyAutoSaveTimer() from the saved preference, and re-applied live + // when the user changes it in Preferences. + autoSaveTimer.Tick += (_, _) => AutoSaveCurrentProfileIfDue(); + ApplyAutoSaveTimer(); + // Hot-plug detection is event-driven (see the deviceRefreshTimer comment): register for // Windows audio endpoint-change notifications and refresh the device lists only when the // device set actually changes. If that registration fails, fall back to the pre-v3.4 @@ -2835,6 +2847,7 @@ public sealed class MainForm : Form }, checkForUpdatesNow: () => CheckForUpdatesManually(), onUpdateFrequencyChanged: ApplyUpdateCheckTimer, + onAutoSaveIntervalChanged: ApplyAutoSaveTimer, applyUpnpEnabled: enabled => { // The persist already happened in the dialog; this callback only flips the @@ -8228,7 +8241,9 @@ public sealed class MainForm : Form /// title, refreshes button visibility, and shows a confirmation popup). private void SaveProfileTo(string title) => SaveProfileTo(title, showConfirmation: true); - private void SaveProfileTo(string title, bool showConfirmation) + private void SaveProfileTo(string title, bool showConfirmation) => SaveProfileTo(title, showConfirmation, playCue: true); + + private void SaveProfileTo(string title, bool showConfirmation, bool playCue) { if (profileStore is null) return; try @@ -8238,8 +8253,9 @@ public sealed class MainForm : Form // Save cue (2026-05-28): fires after any successful save — Save AND Save As, since // both routes funnel through this single method. Honours the EnableSaveCue per- // profile flag; the cue is silent if the user has unticked it in Preferences or if - // sounds\save.wav doesn't exist and no custom override has been set. - if (settings.LoadEnableSaveCue()) saveSound?.Play(); + // sounds\save.wav doesn't exist and no custom override has been set. Auto-save passes + // playCue: false so it never interrupts the user with a save sound. + if (playCue && settings.LoadEnableSaveCue()) saveSound?.Play(); unsavedChanges = false; if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed) { @@ -8260,6 +8276,41 @@ public sealed class MainForm : Form } } + /// Applies the "auto save non-read only profiles" preference to the timer: stops it when the + /// setting is Never (0 minutes), otherwise sets the interval and starts it. Called at launch and again + /// whenever the user changes the setting in Preferences, so the change takes effect immediately. + internal void ApplyAutoSaveTimer() => ApplyAutoSaveTimer(AppConfig.Load().AutoSaveNonReadOnlyMinutes); + + /// Overload taking the interval directly, so a self-test can drive it without touching the + /// real config. + internal void ApplyAutoSaveTimer(int minutes) + { + autoSaveTimer.Stop(); + if (minutes <= 0) return; // Never + autoSaveTimer.Interval = minutes * 60 * 1000; + autoSaveTimer.Start(); + } + + /// Timer tick: silently save the active profile, but ONLY if it's a real saved profile, is NOT + /// read-only, and actually has unsaved changes. Uses the shared save path with the cue and the + /// confirmation dialog suppressed, so it's completely unobtrusive — no sound, no popup. + private void AutoSaveCurrentProfileIfDue() + { + if (!ShouldAutoSave(profileStore is not null, currentProfileTitle, currentProfileReadOnly, unsavedChanges)) return; + SaveProfileTo(currentProfileTitle!, showConfirmation: false, playCue: false); + } + + /// Pure guard for the periodic auto-save (unit-testable). Only a real, saved profile that is + /// NOT read-only and has unsaved changes may be auto-saved — a blank template, a read-only profile, or + /// an unchanged one is left alone. + internal static bool ShouldAutoSave(bool hasStore, string? currentTitle, bool readOnly, bool dirty) + => hasStore && !string.IsNullOrEmpty(currentTitle) && !readOnly && dirty; + + // Test seams for the auto-save timer (headless): confirm ApplyAutoSaveTimer turns it on/off and sets + // the interval from AppConfig.AutoSaveNonReadOnlyMinutes. + internal bool AutoSaveTimerEnabledForTest => autoSaveTimer.Enabled; + internal int AutoSaveTimerIntervalForTest => autoSaveTimer.Interval; + /// Builds a Profile from the current control state and writes it via the store. /// Doesn't touch UI feedback — that's the caller's job. Throws on store failure. private void SaveCurrentStateToProfileFile(string title) diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index c234029..3017f19 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -30,6 +30,30 @@ internal sealed class PreferencesDialog : Form AutoSize = true, }; + // "Auto save non-read only profiles" (2026-07-13). How often RemSound silently saves the current + // profile if it's not read-only and has unsaved changes. A plain list (Ed asked for "a list") whose + // rows map to minute intervals; row 0 = Never = off (the default). The save is silent — no cue. + private readonly Label autoSaveLabel = new() + { + Text = "&Auto-save non-read-only profiles (Alt+A):", + AccessibleName = "Auto-save non-read-only profiles", + AutoSize = true, + Padding = new Padding(0, 6, 0, 4), + }; + private readonly ListBox autoSaveList = new() + { + IntegralHeight = false, + Width = 360, + Height = 132, + AccessibleName = "Auto-save non-read-only profiles", + }; + // Parallel to autoSaveList.Items: the minute interval each row means (0 = Never). + private static readonly int[] AutoSaveMinuteOptions = { 0, 2, 5, 10, 15, 20, 30 }; + + /// Test seam: the auto-save interval rows (minutes; 0 = Never), so a self-test can assert the + /// list Ed asked for stays intact. + internal static IReadOnlyList AutoSaveMinuteOptionsForTest => AutoSaveMinuteOptions; + // Audio cue UI (2026-05-28 revised after Ed's feedback that one-control-per-cue blew // out the tab order). Back to a single CheckedListBox — up/down arrows move between // cues, Space toggles enable, exactly as it always was. Two buttons sit BELOW the list: @@ -465,6 +489,7 @@ internal sealed class PreferencesDialog : Form Func deleteAllLogs, Action checkForUpdatesNow, Action onUpdateFrequencyChanged, + Action onAutoSaveIntervalChanged, Action applyUpnpEnabled, Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot, Action subscribeUpnpStatusChanged, @@ -611,6 +636,27 @@ internal sealed class PreferencesDialog : Form ChangedAnyProfileSetting = true; }; + // Auto-save interval — machine-local, saved on change. Rows map 1:1 to AutoSaveMinuteOptions; + // we select the row whose minutes match the saved value (falling back to Never). The owner's + // onAutoSaveIntervalChanged re-applies the live timer so a change takes effect immediately. + autoSaveList.Items.AddRange(new object[] + { + "Never", "Every 2 minutes", "Every 5 minutes", "Every 10 minutes", + "Every 15 minutes", "Every 20 minutes", "Every 30 minutes", + }); + var savedAutoSave = AppConfig.Load().AutoSaveNonReadOnlyMinutes; + var autoSaveRow = Array.IndexOf(AutoSaveMinuteOptions, savedAutoSave); + autoSaveList.SelectedIndex = autoSaveRow >= 0 ? autoSaveRow : 0; + autoSaveList.SelectedIndexChanged += (_, _) => + { + if (autoSaveList.SelectedIndex < 0) return; + var cfg = AppConfig.Load(); + cfg.AutoSaveNonReadOnlyMinutes = AutoSaveMinuteOptions[autoSaveList.SelectedIndex]; + try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ } + onAutoSaveIntervalChanged(); + }; + autoSaveLabel.Click += (_, _) => autoSaveList.Focus(); + // Update settings — wired against AppConfig directly since they're machine-local. // The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering // either side stays in lockstep. @@ -934,8 +980,18 @@ internal sealed class PreferencesDialog : Form // strip switch tabs; the active page's controls are the next tab stops. The control itself // is a field (declared above) so OnShown can focus it when the dialog opens. Logging is its // own tab (2026-06-19); the two logging controls moved off the General tab to lead it. + // Auto-save label + list stacked into one panel, so they read as a unit and sit as a single + // row of the General tab directly after the "Browse for profiles folder" button, as Ed asked. + var autoSavePanel = new FlowLayoutPanel + { + FlowDirection = FlowDirection.TopDown, + AutoSize = true, + WrapContents = false, + }; + autoSavePanel.Controls.Add(autoSaveLabel); + autoSavePanel.Controls.Add(autoSaveList); tabs.TabPages.Add(MakeTab("General", - browseProfilesFolderButton, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel)); + browseProfilesFolderButton, autoSavePanel, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel)); tabs.TabPages.Add(MakeTab("Appearance", themeRow, showPanEqTabBox, tabOrderLabel, tabOrderList, tabOrderButtons, enableDiscoveredPeersBox, enableRememberedPeersBox)); diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index 8ae50be..cfd4b25 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -78,6 +78,7 @@ internal static class SelfTest RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit); RunStep(results, "Main window coverage (all tabs + controls)", MainWindowCoverage); RunStep(results, "Main window profile round-trip (controls load + save)", MainWindowProfileRoundTrip); + RunStep(results, "Auto-save non-read-only profiles (options + guard + silent timer)", AutoSaveNonReadOnlyProfiles); var failed = results.Count(r => r.Status == "FAIL"); var skipped = results.Count(r => r.Status == "SKIP"); @@ -1105,7 +1106,7 @@ internal static class SelfTest ("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())), ("Preferences", () => new PreferencesDialog( new RemSoundSettingsStore("RemSound"), null, - () => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { }, + () => false, _ => { }, () => { }, () => 0, () => { }, () => { }, () => { }, _ => { }, () => (default(RouterMappingStatus), (IPEndPoint?)null, ""), _ => { }, _ => { })), ("Service profile", () => new ServiceProfileDialog(RemSound.Core.Profile.NewBlank(), false)), @@ -1209,6 +1210,58 @@ internal static class SelfTest } } + /// The "auto save non-read only profiles" preference (2026-07-13): the exact option list Ed + /// asked for, the guard that only auto-saves a real non-read-only dirty profile, the AppConfig + /// persistence, and that the timer turns on/off from the interval. The silence guarantee (no save cue) + /// is structural — the sole auto-save caller passes playCue: false — so we assert the guard, not audio. + private static string? AutoSaveNonReadOnlyProfiles() + { + // 1. The option rows are exactly Never / 2 / 5 / 10 / 15 / 20 / 30 minutes, in order. + var opts = PreferencesDialog.AutoSaveMinuteOptionsForTest; + var expected = new[] { 0, 2, 5, 10, 15, 20, 30 }; + Check(opts.Count == expected.Length, $"auto-save must offer {expected.Length} options (got {opts.Count})"); + for (var i = 0; i < expected.Length; i++) + Check(opts[i] == expected[i], $"auto-save option {i} must be {expected[i]} minutes (got {opts[i]})"); + + // 2. AppConfig persists the chosen interval across a save/load. Done in place (the gate runs + // against a throwaway --config-dir) and restored in a finally so we leave no trace. + var original = AppConfig.Load().AutoSaveNonReadOnlyMinutes; + try + { + var cfg = AppConfig.Load(); + cfg.AutoSaveNonReadOnlyMinutes = 15; + cfg.Save(); + Check(AppConfig.Load().AutoSaveNonReadOnlyMinutes == 15, "the auto-save interval must persist through AppConfig"); + } + finally + { + var restore = AppConfig.Load(); + restore.AutoSaveNonReadOnlyMinutes = original; + try { restore.Save(); } catch { /* best effort */ } + } + + // 3. The guard: only a real, saved, non-read-only, dirty profile may be auto-saved. + Check(MainForm.ShouldAutoSave(true, "mine", readOnly: false, dirty: true), "a dirty non-read-only profile must auto-save"); + Check(!MainForm.ShouldAutoSave(true, "mine", readOnly: true, dirty: true), "a read-only profile must never auto-save"); + Check(!MainForm.ShouldAutoSave(true, "mine", readOnly: false, dirty: false), "an unchanged profile must not auto-save"); + Check(!MainForm.ShouldAutoSave(true, "", readOnly: false, dirty: true), "a blank template (no title) must not auto-save"); + Check(!MainForm.ShouldAutoSave(false, "mine", readOnly: false, dirty: true), "with no store there is nothing to auto-save"); + + // 4. The timer turns on with the right interval, and off when set to Never. + MainForm mf; + try { mf = new MainForm(null, RemSound.Core.Profile.NewBlank(), null, null, headless: true); } + catch (Exception ex) { return Skip($"headless MainForm could not be constructed: {ex.GetType().Name}: {ex.Message}"); } + using (mf) + { + mf.ApplyAutoSaveTimer(5); + Check(mf.AutoSaveTimerEnabledForTest, "a 5-minute setting must start the auto-save timer"); + Check(mf.AutoSaveTimerIntervalForTest == 5 * 60 * 1000, $"5 minutes must be 300000 ms (got {mf.AutoSaveTimerIntervalForTest})"); + mf.ApplyAutoSaveTimer(0); + Check(!mf.AutoSaveTimerEnabledForTest, "Never (0) must stop the auto-save timer"); + } + return "options, persistence, guard (read-only/blank/unchanged skipped), and silent timer all verified"; + } + private static int CountControls(Control root, Func predicate) { var n = 0; diff --git a/src/RemSound.Core/AppConfig.cs b/src/RemSound.Core/AppConfig.cs index 77f91f7..1f3fc5e 100644 --- a/src/RemSound.Core/AppConfig.cs +++ b/src/RemSound.Core/AppConfig.cs @@ -244,6 +244,11 @@ public sealed class AppConfig /// Startup behaviour dialog. Null = always show the picker (legacy behaviour). public string? StartWithProfileTitle { get; set; } + /// How often (in minutes) RemSound auto-saves the current profile if it's NOT read-only and + /// has unsaved changes. 0 = never (the default). Set in Preferences → General. The auto-save is + /// SILENT — it never plays the save cue or shows the confirmation. Machine-wide. + public int AutoSaveNonReadOnlyMinutes { get; set; } + // The send-only service's profile + settings live in the machine-wide RemSound.Core.ServiceStore // (ProgramData), NOT here — AppConfig is per-user, but the service runs as SYSTEM and needs the same // file the user wrote. (ServiceProfileName / ServiceLoggingEnabled were moved there 2026-07-12.)