diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index a3f404b..6f5d22e 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2912,6 +2912,18 @@ public sealed class MainForm : Form checkForUpdatesNow: () => CheckForUpdatesManually(), onUpdateFrequencyChanged: ApplyUpdateCheckTimer, onAutoSaveIntervalChanged: ApplyAutoSaveTimer, + onClearRememberedPeers: () => + { + settings.SaveRememberedPeers(Array.Empty()); + rememberedPeerInstanceIds.Clear(); + RefreshKnownPeers(); + logFile.Event("remembered peers list cleared (Preferences)"); + }, + onClearRememberedApplications: () => + { + settings.SaveRememberedApplications(Array.Empty()); + logFile.Event("remembered applications list cleared (Preferences)"); + }, applyUpnpEnabled: enabled => { // The persist already happened in the dialog; this callback only flips the @@ -3612,6 +3624,7 @@ public sealed class MainForm : Form if (suppressSendAppEvents) return; MarkProfileDirty(); ApplySendSources(); + RememberCheckedApps(); }); }; @@ -3802,6 +3815,21 @@ public sealed class MainForm : Form ApplySendModeVisibility(); if (sendModeList.SelectedIndex == SendModeApplicationsIndex) ReconcileSendAppsList(); + RememberCheckedApps(); + } + + /// 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. + 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); } /// The process names the user has ticked in the app list (lower-case, no extension). diff --git a/src/RemSound.App/PreferencesDialog.cs b/src/RemSound.App/PreferencesDialog.cs index 3017f19..04ddef5 100644 --- a/src/RemSound.App/PreferencesDialog.cs +++ b/src/RemSound.App/PreferencesDialog.cs @@ -50,6 +50,21 @@ internal sealed class PreferencesDialog : Form // Parallel to autoSaveList.Items: the minute interval each row means (0 = Never). 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, + }; + /// 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; @@ -490,6 +505,8 @@ internal sealed class PreferencesDialog : Form Action checkForUpdatesNow, Action onUpdateFrequencyChanged, Action onAutoSaveIntervalChanged, + Action onClearRememberedPeers, + Action onClearRememberedApplications, Action applyUpnpEnabled, Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot, Action subscribeUpnpStatusChanged, @@ -657,6 +674,21 @@ internal sealed class PreferencesDialog : Form }; 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. // The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering // either side stays in lockstep. @@ -990,8 +1022,12 @@ internal sealed class PreferencesDialog : Form }; autoSavePanel.Controls.Add(autoSaveLabel); 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", - browseProfilesFolderButton, autoSavePanel, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel)); + browseProfilesFolderButton, autoSavePanel, acceptRemoteVolumeBox, upnpEnabledBox, upnpStatusLabel, + clearListsHeader, clearRememberedPeersButton, clearRememberedAppsButton)); 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 c2c24f4..0a929b2 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -69,6 +69,7 @@ internal static class SelfTest RunStep(results, "Service network presence (reachable + shell teardown)", ServiceNetworkPresenceReachable); 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, "Remembered applications list is global + clearable", RememberedApplicationsGlobal); RunStep(results, "Service registration args", ServiceRegistrationArgs); RunStep(results, "Recording engine (all formats + source gate + mono)", RecordingEngine); RunStep(results, "Recording split tracks (per-peer + own)", RecordingSplitTracks); @@ -1116,7 +1117,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)), @@ -1551,6 +1552,27 @@ internal static class SelfTest return "signature stable when unchanged; changes when a ticked app opens/closes (drives instant capture)"; } + /// 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. + 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()); + 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() { using var s = new System.Net.Sockets.Socket(System.Net.Sockets.AddressFamily.InterNetwork, diff --git a/src/RemSound.Core/Profile.cs b/src/RemSound.Core/Profile.cs index cfb97a1..01239c3 100644 --- a/src/RemSound.Core/Profile.cs +++ b/src/RemSound.Core/Profile.cs @@ -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 /// list (remembered) and start being captured again the moment they reappear. public List SelectedSendApplications { get; set; } = []; - - /// The profile's REMEMBERED application names — apps the user has set this profile up to send, - /// whether or not they're running right now (the "Remembered applications" list, mirroring remembered - /// 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 ); the rest - /// stay remembered so they can be re-ticked later. Lower-case process names, no path/extension. - public List RememberedSendApplications { get; set; } = []; + // Remembered applications are GLOBAL, not per-profile (RemSoundSettingsStore.LoadRememberedApplications) — + // a single shared "apps I send" address book across all profiles, so it doesn't confuse the user. Only + // the ACTIVE subset (SelectedSendApplications above) is per-profile. // === Connectivity & transport === public int AudioPort { get; set; } = 47830; diff --git a/src/RemSound.Core/RemSoundSettingsStore.cs b/src/RemSound.Core/RemSoundSettingsStore.cs index 4990096..3a80501 100644 --- a/src/RemSound.Core/RemSoundSettingsStore.cs +++ b/src/RemSound.Core/RemSoundSettingsStore.cs @@ -222,6 +222,24 @@ public sealed class RemSoundSettingsStore Save(s); } + /// GLOBAL remembered application names (lower-case process names) — the shared "apps I send" + /// list, machine-wide like , not per-profile. + public IReadOnlyList LoadRememberedApplications() => + Try(() => Load()?.RememberedApplications? + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Distinct(StringComparer.OrdinalIgnoreCase).ToList()) + ?? []; + + public void SaveRememberedApplications(IEnumerable 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 // per-profile setting. LoadLoggingEnabled / SaveLoggingEnabled were retired here; // callers go to AppConfig.LoggingEnabled directly. @@ -639,6 +657,10 @@ public sealed class RemSoundSettingsStore public int? MaxLatencyMsAsio { get; set; } public bool? ContinuousAutoTuneAsioEnabled { get; set; } public List? 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? RememberedApplications { get; set; } public string? AsioDriverName { get; set; } // AudioMode and BothModeWarningSuppressed both retired from this cache. Mode is // derived from AsioDriverName via LoadAudioMode; the Both-mode warning popup is gone.