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:
co-authored by
Claude Opus 4.8
parent
71fb065233
commit
26903950f7
@@ -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)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user