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 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-13 23:32:17 +01:00
co-authored by Claude Opus 4.8
parent 71fb065233
commit 26903950f7
4 changed files with 170 additions and 5 deletions
+54 -3
View File
@@ -481,6 +481,11 @@ public sealed class MainForm : Form
private readonly Dictionary<CheckedListBox, int> 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).</summary>
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
}
}
/// <summary>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.</summary>
internal void ApplyAutoSaveTimer() => ApplyAutoSaveTimer(AppConfig.Load().AutoSaveNonReadOnlyMinutes);
/// <summary>Overload taking the interval directly, so a self-test can drive it without touching the
/// real config.</summary>
internal void ApplyAutoSaveTimer(int minutes)
{
autoSaveTimer.Stop();
if (minutes <= 0) return; // Never
autoSaveTimer.Interval = minutes * 60 * 1000;
autoSaveTimer.Start();
}
/// <summary>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.</summary>
private void AutoSaveCurrentProfileIfDue()
{
if (!ShouldAutoSave(profileStore is not null, currentProfileTitle, currentProfileReadOnly, unsavedChanges)) return;
SaveProfileTo(currentProfileTitle!, showConfirmation: false, playCue: false);
}
/// <summary>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.</summary>
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;
/// <summary>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.</summary>
private void SaveCurrentStateToProfileFile(string title)
+57 -1
View File
@@ -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 };
/// <summary>Test seam: the auto-save interval rows (minutes; 0 = Never), so a self-test can assert the
/// list Ed asked for stays intact.</summary>
internal static IReadOnlyList<int> 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<int> deleteAllLogs,
Action checkForUpdatesNow,
Action onUpdateFrequencyChanged,
Action onAutoSaveIntervalChanged,
Action<bool> applyUpnpEnabled,
Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot,
Action<EventHandler> 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));
+54 -1
View File
@@ -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
}
}
/// <summary>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.</summary>
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<Control, bool> predicate)
{
var n = 0;
+5
View File
@@ -244,6 +244,11 @@ public sealed class AppConfig
/// Startup behaviour dialog. Null = always show the picker (legacy behaviour).</summary>
public string? StartWithProfileTitle { get; set; }
/// <summary>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.</summary>
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.)