Remembered lists are global; add Clear buttons in Preferences

Remembered peers were already machine-wide for the main app; remembered apps are
now global too (a shared address book across all profiles), so it doesn't confuse
the user with a different list per profile.

- RemSoundSettingsStore.Load/SaveRememberedApplications (global, NOT written into
  profile files) mirrors remembered peers. Removed the per-profile
  Profile.RememberedSendApplications. Ticking an app in any profile adds it to the
  global list (RememberCheckedApps); the per-profile SelectedSendApplications
  (the active/ticked subset) is unchanged.
- Preferences -> General now has two buttons at the end, under a 'Remembered
  lists (shared across all profiles)' header: 'Clear remembered peers list...'
  and 'Clear remembered applications list...', each with a Yes/No confirmation.
  Clearing peers refreshes the main window; clearing apps empties the global list.
- Self-test 'Remembered applications list is global + clearable' (round-trip,
  case-insensitive dedupe, clear); the dialog accessibility audit covers the two
  new buttons (names + mnemonics, no clash). Gate 37/37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-15 22:54:55 +01:00
co-authored by Claude Opus 4.8
parent 1e5030b08f
commit e95fc36926
5 changed files with 113 additions and 9 deletions
+28
View File
@@ -2912,6 +2912,18 @@ public sealed class MainForm : Form
checkForUpdatesNow: () => CheckForUpdatesManually(), checkForUpdatesNow: () => CheckForUpdatesManually(),
onUpdateFrequencyChanged: ApplyUpdateCheckTimer, onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
onAutoSaveIntervalChanged: ApplyAutoSaveTimer, onAutoSaveIntervalChanged: ApplyAutoSaveTimer,
onClearRememberedPeers: () =>
{
settings.SaveRememberedPeers(Array.Empty<string>());
rememberedPeerInstanceIds.Clear();
RefreshKnownPeers();
logFile.Event("remembered peers list cleared (Preferences)");
},
onClearRememberedApplications: () =>
{
settings.SaveRememberedApplications(Array.Empty<string>());
logFile.Event("remembered applications list cleared (Preferences)");
},
applyUpnpEnabled: enabled => applyUpnpEnabled: enabled =>
{ {
// The persist already happened in the dialog; this callback only flips the // The persist already happened in the dialog; this callback only flips the
@@ -3612,6 +3624,7 @@ public sealed class MainForm : Form
if (suppressSendAppEvents) return; if (suppressSendAppEvents) return;
MarkProfileDirty(); MarkProfileDirty();
ApplySendSources(); ApplySendSources();
RememberCheckedApps();
}); });
}; };
@@ -3802,6 +3815,21 @@ public sealed class MainForm : Form
ApplySendModeVisibility(); ApplySendModeVisibility();
if (sendModeList.SelectedIndex == SendModeApplicationsIndex) ReconcileSendAppsList(); if (sendModeList.SelectedIndex == SendModeApplicationsIndex) ReconcileSendAppsList();
RememberCheckedApps();
}
/// <summary>Add the currently-ticked app names to the GLOBAL remembered-applications list (the shared
/// "apps I send" address book) — so a tick in any profile remembers the app for all of them, mirroring
/// how remembered peers work. Cleared from Preferences → General.</summary>
private void RememberCheckedApps()
{
var names = CheckedSendApplicationNames();
if (names.Count == 0) return;
var remembered = settings.LoadRememberedApplications().ToList();
var added = false;
foreach (var n in names)
if (!remembered.Any(e => string.Equals(e, n, StringComparison.OrdinalIgnoreCase))) { remembered.Add(n); added = true; }
if (added) settings.SaveRememberedApplications(remembered);
} }
/// <summary>The process names the user has ticked in the app list (lower-case, no extension).</summary> /// <summary>The process names the user has ticked in the app list (lower-case, no extension).</summary>
+37 -1
View File
@@ -50,6 +50,21 @@ internal sealed class PreferencesDialog : Form
// Parallel to autoSaveList.Items: the minute interval each row means (0 = Never). // Parallel to autoSaveList.Items: the minute interval each row means (0 = Never).
private static readonly int[] AutoSaveMinuteOptions = { 0, 2, 5, 10, 15, 20, 30 }; private static readonly int[] AutoSaveMinuteOptions = { 0, 2, 5, 10, 15, 20, 30 };
// Clear the GLOBAL (machine-wide) remembered lists — peers and applications are a shared address book
// across all profiles, so these live in Preferences, not per-profile. Each asks for confirmation first.
private readonly Button clearRememberedPeersButton = new()
{
Text = "Clear remembered &peers list...",
AccessibleName = "Clear remembered peers list",
AutoSize = true,
};
private readonly Button clearRememberedAppsButton = new()
{
Text = "Clear remembered applications &list...",
AccessibleName = "Clear remembered applications list",
AutoSize = true,
};
/// <summary>Test seam: the auto-save interval rows (minutes; 0 = Never), so a self-test can assert the /// <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> /// list Ed asked for stays intact.</summary>
internal static IReadOnlyList<int> AutoSaveMinuteOptionsForTest => AutoSaveMinuteOptions; internal static IReadOnlyList<int> AutoSaveMinuteOptionsForTest => AutoSaveMinuteOptions;
@@ -490,6 +505,8 @@ internal sealed class PreferencesDialog : Form
Action checkForUpdatesNow, Action checkForUpdatesNow,
Action onUpdateFrequencyChanged, Action onUpdateFrequencyChanged,
Action onAutoSaveIntervalChanged, Action onAutoSaveIntervalChanged,
Action onClearRememberedPeers,
Action onClearRememberedApplications,
Action<bool> applyUpnpEnabled, Action<bool> applyUpnpEnabled,
Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot, Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot,
Action<EventHandler> subscribeUpnpStatusChanged, Action<EventHandler> subscribeUpnpStatusChanged,
@@ -657,6 +674,21 @@ internal sealed class PreferencesDialog : Form
}; };
autoSaveLabel.Click += (_, _) => autoSaveList.Focus(); autoSaveLabel.Click += (_, _) => autoSaveList.Focus();
clearRememberedPeersButton.Click += (_, _) =>
{
if (MessageBox.Show(this,
"Clear the whole remembered peers list?\n\nThis empties the shared list of peers RemSound has remembered, for every profile. Peers you're actively connected to aren't affected, and any peer will simply be remembered again next time you connect to it.",
"RemSound", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
onClearRememberedPeers();
};
clearRememberedAppsButton.Click += (_, _) =>
{
if (MessageBox.Show(this,
"Clear the whole remembered applications list?\n\nThis empties the shared list of applications RemSound has remembered to send, for every profile. Apps you're actively sending aren't affected, and an app is remembered again the next time you tick it.",
"RemSound", MessageBoxButtons.YesNo, MessageBoxIcon.Warning) == DialogResult.Yes)
onClearRememberedApplications();
};
// Update settings — wired against AppConfig directly since they're machine-local. // 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 // The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering
// either side stays in lockstep. // either side stays in lockstep.
@@ -990,8 +1022,12 @@ internal sealed class PreferencesDialog : Form
}; };
autoSavePanel.Controls.Add(autoSaveLabel); autoSavePanel.Controls.Add(autoSaveLabel);
autoSavePanel.Controls.Add(autoSaveList); autoSavePanel.Controls.Add(autoSaveList);
// The two "clear remembered list" actions sit at the END of the General tab (they're machine-wide
// maintenance, not a per-profile setting). A header separates them from the settings above.
var clearListsHeader = Theme.SectionHeader("Remembered lists (shared across all profiles)");
tabs.TabPages.Add(MakeTab("General", tabs.TabPages.Add(MakeTab("General",
browseProfilesFolderButton, autoSavePanel, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel)); browseProfilesFolderButton, autoSavePanel, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel,
clearListsHeader, clearRememberedPeersButton, clearRememberedAppsButton));
tabs.TabPages.Add(MakeTab("Appearance", tabs.TabPages.Add(MakeTab("Appearance",
themeRow, showPanEqTabBox, tabOrderLabel, tabOrderList, tabOrderButtons, themeRow, showPanEqTabBox, tabOrderLabel, tabOrderList, tabOrderButtons,
enableDiscoveredPeersBox, enableRememberedPeersBox)); enableDiscoveredPeersBox, enableRememberedPeersBox));
+23 -1
View File
@@ -69,6 +69,7 @@ internal static class SelfTest
RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable); RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable);
RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating); RunStep(results, "Service reachability-gated sending (drop dead peers, re-arm recovered)", ServiceReachabilityGating);
RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection); RunStep(results, "Send-app capture change-detection (catch an app the instant it opens)", SendAppCaptureChangeDetection);
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Service registration args", ServiceRegistrationArgs);
RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine);
RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks); RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks);
@@ -1116,7 +1117,7 @@ internal static class SelfTest
("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())), ("Recording settings", () => new RecordingSettingsDialog(new RecordingSettings())),
("Preferences", () => new PreferencesDialog( ("Preferences", () => new PreferencesDialog(
new RemSoundSettingsStore("RemSound"), null, new RemSoundSettingsStore("RemSound"), null,
() => false, _ => { }, () => { }, () => 0, () => { }, () => { }, () => { }, _ => { }, () => false, _ => { }, () => { }, () => 0, () => { }, () => { }, () => { }, () => { }, () => { }, _ => { },
() => (default(RouterMappingStatus), (IPEndPoint?)null, ""), () => (default(RouterMappingStatus), (IPEndPoint?)null, ""),
_ => { }, _ => { })), _ => { }, _ => { })),
("Service profile", () => new ServiceProfileDialog(RemSound.Core.Profile.NewBlank(), false)), ("Service profile", () => new ServiceProfileDialog(RemSound.Core.Profile.NewBlank(), false)),
@@ -1551,6 +1552,27 @@ internal static class SelfTest
return "signature stable when unchanged; changes when a ticked app opens/closes (drives instant capture)"; return "signature stable when unchanged; changes when a ticked app opens/closes (drives instant capture)";
} }
/// <summary>Remembered applications are a GLOBAL, machine-wide list (like remembered peers), not
/// per-profile — a shared "apps I send" address book — and can be cleared (the Preferences button).
/// Verifies the store round-trips, dedupes case-insensitively to lower-case, and clears.</summary>
private static string? RememberedApplicationsGlobal()
{
var store = new RemSoundSettingsStore("RemSound");
var original = store.LoadRememberedApplications().ToList();
try
{
store.SaveRememberedApplications(new[] { "VLC", "Firefox", "vlc" });
var loaded = store.LoadRememberedApplications();
Check(loaded.Count == 2, $"remembered apps must dedupe case-insensitively (got {loaded.Count})");
Check(loaded.All(a => a == a.ToLowerInvariant()), "remembered app names must be stored lower-case");
store.SaveRememberedApplications(Array.Empty<string>());
Check(store.LoadRememberedApplications().Count == 0, "clearing must empty the remembered applications list");
return "global remembered applications: round-trip, case-insensitive dedupe, and clear";
}
finally { store.SaveRememberedApplications(original); }
}
private static int FreeUdpPort() private static int FreeUdpPort()
{ {
using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork,
+3 -7
View File
@@ -86,13 +86,9 @@ public sealed class Profile
/// NAME not PID so the selection survives an app restarting. Apps not currently running stay in the /// NAME not PID so the selection survives an app restarting. Apps not currently running stay in the
/// list (remembered) and start being captured again the moment they reappear.</summary> /// list (remembered) and start being captured again the moment they reappear.</summary>
public List<string> SelectedSendApplications { get; set; } = []; public List<string> SelectedSendApplications { get; set; } = [];
// Remembered applications are GLOBAL, not per-profile (RemSoundSettingsStore.LoadRememberedApplications) —
/// <summary>The profile's REMEMBERED application names — apps the user has set this profile up to send, // a single shared "apps I send" address book across all profiles, so it doesn't confuse the user. Only
/// whether or not they're running right now (the "Remembered applications" list, mirroring remembered // the ACTIVE subset (SelectedSendApplications above) is per-profile.
/// peers but profile-scoped). Includes apps that were added by name while closed. A subset of these is
/// ticked/active at any time (that active subset is <see cref="SelectedSendApplications"/>); the rest
/// stay remembered so they can be re-ticked later. Lower-case process names, no path/extension.</summary>
public List<string> RememberedSendApplications { get; set; } = [];
// === Connectivity & transport === // === Connectivity & transport ===
public int AudioPort { get; set; } = 47830; public int AudioPort { get; set; } = 47830;
@@ -222,6 +222,24 @@ public sealed class RemSoundSettingsStore
Save(s); Save(s);
} }
/// <summary>GLOBAL remembered application names (lower-case process names) — the shared "apps I send"
/// list, machine-wide like <see cref="LoadRememberedPeers"/>, not per-profile.</summary>
public IReadOnlyList<string> LoadRememberedApplications() =>
Try(() => Load()?.RememberedApplications?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase).ToList())
?? [];
public void SaveRememberedApplications(IEnumerable<string> apps)
{
var s = Load() ?? new Settings();
s.RememberedApplications = apps
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
Save(s);
}
// LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a // LoggingEnabled lives in AppConfig now — it's a machine-local debug knob, not a
// per-profile setting. LoadLoggingEnabled / SaveLoggingEnabled were retired here; // per-profile setting. LoadLoggingEnabled / SaveLoggingEnabled were retired here;
// callers go to AppConfig.LoggingEnabled directly. // callers go to AppConfig.LoggingEnabled directly.
@@ -639,6 +657,10 @@ public sealed class RemSoundSettingsStore
public int? MaxLatencyMsAsio { get; set; } public int? MaxLatencyMsAsio { get; set; }
public bool? ContinuousAutoTuneAsioEnabled { get; set; } public bool? ContinuousAutoTuneAsioEnabled { get; set; }
public List<string>? RememberedPeers { get; set; } public List<string>? RememberedPeers { get; set; }
// GLOBAL (machine-wide) remembered application names, like RememberedPeers — deliberately NOT
// per-profile and NOT copied into profile files (see ExportProfile/ImportProfile, which don't
// touch it), so one shared "apps I send" address book serves every profile. 2026-07-15.
public List<string>? RememberedApplications { get; set; }
public string? AsioDriverName { get; set; } public string? AsioDriverName { get; set; }
// AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is // AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is
// derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone. // derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.