Release v3.5: USB-card recovery, per-card adaptive buffer, one user folder, audit fixes

- Recover an unplugged/replugged output sound card automatically (issue #5):
  detect the dead WASAPI device and remember the receive-output selection so it
  re-ticks and re-opens when the card returns.
- Adaptive per-card WASAPI buffer target sized to each card's pull chunk, held
  stable so it never flits about under CPU/network load.
- Consolidate all per-user data (config, profiles, logs, sounds) into one
  "user settings and logs" folder; migrate every older layout; exclude it from
  the updater so custom cue sounds now survive updates.
- Mic-privacy detector: warn once when a Windows-blocked mic is switched on, or a
  profile loads with one already on.
- All warning/notice dialogs now come to the foreground even when minimised.
- Apply volume + mute on profile load (were saved but not restored).
- Crash-safe (atomic) profile/config saves.
- Fix two resource leaks (push-mode capture MMDevice; UPnP DeviceFound handler).
- Remove dead code (baseline-diff machinery, dead ASIO probes, no-op stubs).
- Docs: readme.html, MANUAL.md, About-box changelog and release notes for v3.5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-10 11:34:17 +01:00
co-authored by Claude Opus 4.8
parent 946e6f4be4
commit 4dbe9a47a0
21 changed files with 681 additions and 305 deletions
+31
View File
@@ -20,6 +20,37 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v3.5
Recover a sound card you unplug: if a USB sound card
you're listening through is pulled out and plugged back
in, RemSound re-opens it on its own and audio resumes
no need to re-tick it.
Each sound card now gets the right audio cushion
automatically. RemSound sizes it to the card, so one that
needs a little more gets it without any fiddling, and a
fast card stays tight.
A microphone-privacy heads-up: if Windows is blocking
microphone access and you switch a mic on, RemSound warns
you once, so you're not left wondering why no one can
hear you.
Warnings always come to the front now, even when RemSound
is tucked away in the system tray so you never miss one.
Everything this machine keeps for you settings,
profiles, logs and your cue sounds now lives in one
tidy folder called "user settings and logs". It moves
there automatically the first time you run this version.
From now on, updates never touch that folder, so any
custom cue sounds you put there are safe.
Also: your volume and mute now come back correctly when
you load a profile, plus a batch of under-the-hood
reliability and tidy-up work.
RemSound v3.4
Quick profile switch: a new global hotkey pops up a
+76
View File
@@ -0,0 +1,76 @@
using System.Drawing;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace RemSound.App;
/// <summary>
/// Shows a dialog FRONT-AND-CENTRE with keyboard focus, WITHOUT disturbing the main window — it can
/// stay minimised in the tray the whole time. We give the dialog a momentary, top-most, off-taskbar
/// 1×1 owner window at screen centre and force THAT owner to the foreground (the AttachThreadInput
/// dance bypasses Windows' focus-stealing lock), so the modal dialog opens on top with focus and a
/// screen reader lands on it wherever RemSound happens to be sitting. Used for every warning/notice
/// the app raises (mic-privacy, Realtek, About-after-update, config-moved), so a minimised RemSound
/// never leaves a blind user with a dialog dinging away behind everything.
/// </summary>
internal static class ForegroundDialog
{
/// <summary>Run <paramref name="show"/> with a foreground 1×1 owner; returns its result.</summary>
public static T Show<T>(Func<IWin32Window, T> show)
{
var area = Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 800, 600);
using var owner = new Form
{
ShowInTaskbar = false,
FormBorderStyle = FormBorderStyle.None,
StartPosition = FormStartPosition.Manual,
Size = new Size(1, 1),
Location = new Point(area.X + area.Width / 2, area.Y + area.Height / 2),
TopMost = true,
};
owner.Show();
ForceForeground(owner.Handle);
try { return show(owner); }
finally { try { owner.Close(); } catch { /* ignore */ } }
}
/// <summary>Void convenience overload.</summary>
public static void Show(Action<IWin32Window> show) =>
Show<object?>(owner => { show(owner); return null; });
/// <summary>Force <paramref name="hWnd"/> to the foreground even when RemSound isn't the active
/// app. A plain SetForegroundWindow from a background process is refused by Windows; attaching
/// our input queue to the current foreground thread for the call lifts that restriction. The
/// owner is top-most regardless, so this is belt-and-braces for focus.</summary>
private static void ForceForeground(IntPtr hWnd)
{
try
{
var foreThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
var thisThread = GetCurrentThreadId();
if (foreThread != 0 && foreThread != thisThread)
{
AttachThreadInput(foreThread, thisThread, true);
try
{
BringWindowToTop(hWnd);
SetForegroundWindow(hWnd);
}
finally { AttachThreadInput(foreThread, thisThread, false); }
}
else
{
BringWindowToTop(hWnd);
SetForegroundWindow(hWnd);
}
}
catch { /* best-effort; the owner is top-most anyway */ }
}
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll")] private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr hWnd);
}
+151 -124
View File
@@ -383,6 +383,11 @@ public sealed class MainForm : Form
// Windows audio endpoint-change notifier — drives the debounced device-list refresh. Null until
// wired in the constructor; disposed in FormClosing (which unregisters the COM callback).
private AudioDeviceChangeNotifier? deviceChangeNotifier;
// Receive-output device IDs the user/profile selected — kept even while a device is unplugged,
// so a card that returns is silently re-ticked and re-opened (issue #5: recover after USB
// unplug). Receive-only: the send lists deliberately don't persist selection (AudioDeviceCatalog).
private readonly HashSet<string> rememberedReceiveOutputIds = new(StringComparer.OrdinalIgnoreCase);
// Debounce timer for ASIO driver listbox selection. See SelectedIndexChanged handler
// wiring for the full rationale. 300 ms is long enough to coalesce arrow-key bursts
// (NVDA users typically press a few keys in quick succession to scan through items),
@@ -537,13 +542,6 @@ public sealed class MainForm : Form
/// when non-null — it deserialises the JSON from this exact path, not from the active
/// store's base directory. Lets Open profile work for files saved outside that folder.</summary>
public string? NextProfilePathToLoad { get; private set; }
// Baseline JSON snapshot of "what the loaded profile was at open / after the last save".
// OnFormClosing compares the current state's JSON to this; if they differ, prompt the
// user. Captured ~3 s after profile-apply (or app start for blank template) so async
// peer-reconnects have settled into the baseline. Null until that timer fires; if it's
// null at close (e.g. user closed within 3 s of opening) we skip the prompt — treating
// very-fast-close as "user knew what they wanted".
private string? baselineProfileJson;
// Set true by MarkProfileDirty() when the user actively changes something. Used as a
// fast-path hint — we still do the JSON diff at close to be sure, but this lets us skip
// the diff entirely when no user action has happened. Cleared on save and on profile load.
@@ -993,11 +991,37 @@ public sealed class MainForm : Form
sendMyAudioCheckbox.CheckedChanged += (_, _) => OnStreamingCheckboxChanged(sendMyAudioCheckbox);
volumeBar.Scroll += (_, _) => { receiver.Volume = volumeBar.Value / 100f; MarkProfileDirty(); };
WireCheckedListAccessibility(receiveOutputDevicesList, receiveOutputDevicesStatusLabel, "receive output device");
receiveOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyReceiveDevices); MarkProfileDirty(); } };
receiveOutputDevicesList.ItemCheck += (_, e) =>
{
if (suppressDeviceCheckChange) return;
// Track the user's intent so a card that's later unplugged is re-ticked + re-opened when
// it returns (issue #5). See ReapplyRememberedReceiveOutputs.
if (receiveOutputDevicesList.Items[e.Index] is AudioDeviceChoice c && c.DeviceId is { } rid)
{
if (e.NewValue == CheckState.Checked) rememberedReceiveOutputIds.Add(rid);
else rememberedReceiveOutputIds.Remove(rid);
}
BeginInvoke(ApplyReceiveDevices);
MarkProfileDirty();
};
WireCheckedListAccessibility(sendOutputDevicesList, sendOutputDevicesStatusLabel, "output device");
WireCheckedListAccessibility(sendInputDevicesList, sendInputDevicesStatusLabel, "input device");
sendOutputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
sendInputDevicesList.ItemCheck += (_, _) => { if (!suppressDeviceCheckChange) { BeginInvoke(ApplyAudioRuntime); MarkProfileDirty(); } };
sendInputDevicesList.ItemCheck += (_, args) =>
{
if (suppressDeviceCheckChange) return;
BeginInvoke(ApplyAudioRuntime);
MarkProfileDirty();
// Heads-up when the user ticks a WASAPI mic ON but Windows is blocking desktop-app
// microphone access — capture would open but silently send nothing. Deferred so the
// tick commits first and the modal doesn't re-enter ItemCheck. Skipped while a profile
// is being applied: the startup check (MaybeWarnMicBlockedOnStartup) owns the warning
// then, so a launch into a blocked-mic profile doesn't pop it twice.
if (!applyingProfile && args.NewValue == CheckState.Checked && IsMicrophoneBlockedByWindowsPrivacy())
{
BeginInvoke(new Action(WarnMicrophoneBlockedByWindowsPrivacy));
}
};
// ASIO list accessibility + ItemCheck handlers — same patterns as the WASAPI ones.
WireCheckedListAccessibility(asioReceiveOutputDevicesList, asioReceiveOutputDevicesStatusLabel, "ASIO receive output channel");
WireCheckedListAccessibility(asioSendDevicesList, asioSendDevicesStatusLabel, "ASIO send channel");
@@ -1176,17 +1200,11 @@ public sealed class MainForm : Form
// checkboxes, audio port, volume, ticked peers). Done here AFTER device lists are
// populated by LoadAudioDevices(). Settings-shaped fields (codec, hotkeys, etc.)
// were already pushed into the in-memory settings cache in the constructor.
// ApplyPendingProfileToControls() schedules its own baseline capture; for the
// blank-template case (no pendingProfile) we schedule it here.
if (pendingProfile is null) ScheduleBaselineCapture();
ApplyPendingProfileToControls();
// The profile-switch cue is played ON CLICK by the switch entry points (Recent menu,
// quick switch, File open) — NOT here. A fresh launch into the first profile must stay
// silent: hearing the switch cue and then the connect cue at startup is confusing
// (Ed, 2026-06-08). So the rebuilt form never replays it.
// Show/hide the Update vs Save-as buttons based on whether we're on a loaded
// profile or the blank template.
UpdateProfileButtonsVisibility();
// Andre's app gets focus inside the active tab page for free because his form is
// a MODAL DIALOG (ShowDialog) — WinForms' modal-dialog focus semantics walk the
// chain TabControl → active TabPage → first child. Our form is the main window,
@@ -1300,6 +1318,8 @@ public sealed class MainForm : Form
MaybeShowWhatsNewAfterUpdate();
if (IsDisposed) return;
MaybeWarnAboutRealtekAsio();
if (IsDisposed) return;
MaybeWarnMicBlockedOnStartup();
}
/// <summary>If the user opted in (<see cref="AppConfig.ShowWhatsNewAfterUpdate"/>) and the
@@ -1325,7 +1345,7 @@ public sealed class MainForm : Form
{
logFile.Event($"what's new: opening About after update {cfg.LastWhatsNewVersion} -> {current}");
using var dlg = new AboutDialog();
dlg.ShowDialog(this);
ForegroundDialog.Show(owner => dlg.ShowDialog(owner));
}
catch (Exception ex)
{
@@ -2253,8 +2273,8 @@ public sealed class MainForm : Form
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var choice = MessageBox.Show(this, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
var choice = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
@@ -2310,7 +2330,7 @@ public sealed class MainForm : Form
// "silent" install. UpdateInstallNoticeDialog auto-dismisses after a short
// countdown but lets the user pick Install now / Skip / Postpone before then.
using var notice = new UpdateInstallNoticeDialog(info);
var choice = notice.ShowDialog(this);
var choice = ForegroundDialog.Show(owner => notice.ShowDialog(owner));
switch (choice)
{
case DialogResult.OK:
@@ -2337,8 +2357,8 @@ public sealed class MainForm : Form
var summary = string.IsNullOrWhiteSpace(info.ReleaseNotes)
? $"RemSound {info.Tag} is available. Install now?"
: $"RemSound {info.Tag} is available.\n\n{TruncateForDialog(info.ReleaseNotes)}\n\nInstall now?";
var pick = MessageBox.Show(this, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
var pick = ForegroundDialog.Show(owner => MessageBox.Show(owner, summary, "Update available",
MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1));
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
}
@@ -2375,9 +2395,9 @@ public sealed class MainForm : Form
// Nothing was staged — allow a later attempt rather than wedging the updater off
// for the rest of the session.
updateInstallStarted = false;
MessageBox.Show(this,
ForegroundDialog.Show(owner => MessageBox.Show(owner,
$"Could not download or stage the update. Try again later, or visit the release page in your browser:\n\n{info.ReleaseUrl}",
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning);
"Update failed", MessageBoxButtons.OK, MessageBoxIcon.Warning));
return;
}
// The helper is staged and launched. We MUST now exit cleanly so it can replace our
@@ -3754,6 +3774,7 @@ public sealed class MainForm : Form
{
ApplyAudioRuntime();
}
if (receiveOutputChanged) ReapplyRememberedReceiveOutputs();
if (receiveOutputChanged || asioReceiveChanged)
{
ApplyReceiveDevices();
@@ -3800,6 +3821,67 @@ public sealed class MainForm : Form
return null;
}
/// <summary>Reads Windows' microphone privacy setting and returns true when DESKTOP apps (which
/// RemSound is) are blocked from the mic. When blocked, WASAPI capture still opens but returns
/// pure silence, so the user "sends" but the peer hears nothing. Registry-based: the
/// CapabilityAccessManager ConsentStore "Value" is "Allow"/"Deny"; either the general per-user
/// gate or the NonPackaged (desktop-app) gate set to Deny blocks us. Best-effort — any failure
/// returns false so a registry hiccup never stops the user enabling their mic.</summary>
private static bool IsMicrophoneBlockedByWindowsPrivacy()
{
try
{
const string consent = @"Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone";
return IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent)
|| IsConsentDenied(Microsoft.Win32.Registry.CurrentUser, consent + @"\NonPackaged")
|| IsConsentDenied(Microsoft.Win32.Registry.LocalMachine, consent);
}
catch
{
return false;
}
}
private static bool IsConsentDenied(Microsoft.Win32.RegistryKey root, string subKey)
{
using var key = root.OpenSubKey(subKey);
return string.Equals(key?.GetValue("Value") as string, "Deny", StringComparison.OrdinalIgnoreCase);
}
/// <summary>One-shot, OK-only message telling the user Windows is blocking desktop-app mic
/// access (so their mic would send silence) and exactly which two toggles to turn on. Shown
/// when they tick a WASAPI mic on while the block is in place.</summary>
private void WarnMicrophoneBlockedByWindowsPrivacy()
{
ForegroundDialog.Show(owner => MessageBox.Show(owner,
"Windows is currently blocking desktop apps from using your microphone, so RemSound can "
+ "switch the mic on but will only send silence - the people you're connected to won't "
+ "hear you.\n\n"
+ "To fix it, open Windows Settings, go to Privacy & security, then Microphone, and turn "
+ "ON both of these:\n\n"
+ " - Microphone access\n"
+ " - Let desktop apps access your microphone\n\n"
+ "Then your mic will work. This doesn't affect sound you receive - only sending your "
+ "own microphone.",
"Windows is blocking your microphone",
MessageBoxButtons.OK, MessageBoxIcon.Warning));
}
/// <summary>On profile load, if a WASAPI microphone is already ticked but Windows is blocking
/// desktop-app mic access, show the warning once — the per-tick warning only fires on a fresh
/// tick, so this covers a profile that loads with the mic already on. Same OK-only message.</summary>
private void MaybeWarnMicBlockedOnStartup()
{
if (IsDisposed) return;
if (!IsMicrophoneBlockedByWindowsPrivacy()) return;
var anyWasapiMicChecked = false;
for (var i = 0; i < sendInputDevicesList.Items.Count; i++)
{
if (sendInputDevicesList.GetItemChecked(i)) { anyWasapiMicChecked = true; break; }
}
if (anyWasapiMicChecked) WarnMicrophoneBlockedByWindowsPrivacy();
}
/// <summary>
/// On startup, if a Realtek ASIO driver is installed and we haven't already disabled it or
/// shown the warning, offer (once) to disable it — Realtek's ASIO driver leaks OS handles on
@@ -3847,7 +3929,10 @@ public sealed class MainForm : Form
+ "This driver is known to cause compatibility issues with ASIO software, including "
+ "RemSound — it leaks system resources and can make audio unstable.\n\n"
+ "Would you like to disable it in RemSound? RemSound will then never touch this "
+ "driver. You can re-enable it any time from the Options menu.",
+ "driver.\n\n"
+ "Whichever you choose now, you can change it at any time: the Options menu has an "
+ "\"Enable / Disable Realtek ASIO driver\" item that turns this driver on or off for "
+ "RemSound whenever you like.",
Icon = TaskDialogIcon.Warning,
};
var yes = new TaskDialogButton("&Yes, disable it (recommended)");
@@ -3855,7 +3940,7 @@ public sealed class MainForm : Form
page.Buttons.Add(yes);
page.Buttons.Add(no);
page.DefaultButton = yes;
return TaskDialog.ShowDialog(this, page) == yes;
return ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)) == yes;
}
/// <summary>Options-menu handler: flip every installed Realtek ASIO driver between disabled and
@@ -4058,6 +4143,32 @@ public sealed class MainForm : Form
receiver.SetOutputDevices(ids);
}
/// <summary>A receive-output card that was unplugged drops out of the WASAPI list (and its tick
/// with it). When it returns, re-tick it from <see cref="rememberedReceiveOutputIds"/> so audio
/// resumes automatically (issue #5). Only RE-ticks present-but-unticked remembered devices; it
/// never unticks — that's a deliberate user action handled in the ItemCheck handler. Receive-only
/// on purpose: the send lists keep their "re-tick each session" behaviour (see AudioDeviceCatalog).</summary>
private void ReapplyRememberedReceiveOutputs()
{
if (rememberedReceiveOutputIds.Count == 0) return;
var changed = false;
suppressDeviceCheckChange = true;
try
{
for (var i = 0; i < receiveOutputDevicesList.Items.Count; i++)
{
if (receiveOutputDevicesList.Items[i] is not AudioDeviceChoice c || c.DeviceId is null) continue;
if (rememberedReceiveOutputIds.Contains(c.DeviceId) && !receiveOutputDevicesList.GetItemChecked(i))
{
receiveOutputDevicesList.SetItemChecked(i, true);
changed = true;
}
}
}
finally { suppressDeviceCheckChange = false; }
if (changed) logFile.Event("receive output: re-ticked a returning device from the remembered selection");
}
/// <summary>
/// Applies the audio-backend mode derived from the current ASIO driver choice. Two effective
/// modes after the 2026-05-11 cleanup:
@@ -4839,19 +4950,6 @@ public sealed class MainForm : Form
if (!list.Focused) list.Focus();
}
private void RemoveSelectedManualPeer(CheckedListBox list)
{
if (list.SelectedItem is not PeerListItem selected) return;
manualPeers.Remove(selected.Peer.InstanceId);
DeselectPeer(selected.Peer.InstanceId);
foreach (var pair in rememberedPeerInstanceIds.Where(kv => kv.Value == selected.Peer.InstanceId).ToList())
{
rememberedPeerInstanceIds.Remove(pair.Key);
}
RefreshKnownPeers();
ApplyAudioRuntime();
}
private void RemoveSelectedRememberedPeer(CheckedListBox list)
{
if (list.SelectedItem is not RememberedPeerItem selected) return;
@@ -5494,11 +5592,20 @@ public sealed class MainForm : Form
{
// Volume first — affects what's audible during the rest of this method.
volumeBar.Value = Math.Clamp(p.Volume, volumeBar.Minimum, volumeBar.Maximum);
// Push volume + mute to the engine. Assigning .Value does NOT fire the Scroll handler, so
// without this a profile saved at e.g. 50% would show 50% but play at full volume until
// the slider was nudged. Mute is restored from the saved state for the same reason.
receiver.Volume = volumeBar.Value / 100f;
receiver.IsMuted = p.Muted;
// Tick checkboxes. Order matters: setting Checked fires runtime apply paths
// (Connect/Disconnect) so the side-effect cascade has to happen here, not in
// the constructor where the engines aren't fully wired up yet.
ApplyTicksToList(receiveOutputDevicesList, p.SelectedWasapiReceiveOutputs);
// Seed remembered receive-output intent from the profile so a selected card that's
// absent now (or unplugged later) is re-ticked + re-opened when it appears (issue #5).
rememberedReceiveOutputIds.Clear();
foreach (var rid in p.SelectedWasapiReceiveOutputs) rememberedReceiveOutputIds.Add(rid);
ApplyTicksToList(asioReceiveOutputDevicesList, p.SelectedAsioReceiveOutputs);
ApplyTicksToList(sendOutputDevicesList, p.SelectedWasapiSendOutputs);
ApplyTicksToList(sendInputDevicesList, p.SelectedWasapiSendInputs);
@@ -5524,9 +5631,6 @@ public sealed class MainForm : Form
pendingProfile = null;
applyingProfile = false;
}
// Schedule baseline capture for the unsaved-changes-on-close check. Done as a
// delayed snapshot so async peer-reconnects have settled.
ScheduleBaselineCapture();
}
/// <summary>Tick the items in <paramref name="list"/> whose DeviceId appears in
@@ -5561,21 +5665,6 @@ public sealed class MainForm : Form
: $"{AppName} — Active profile: {loadedTitle}{readOnlySuffix}";
}
/// <summary>Show/hide the Update button based on whether a profile is currently loaded.
/// Update only makes sense when there's an existing profile to overwrite; Save-as is
/// always available (and the only way to save from a blank template). Both Visible and
/// Enabled are toggled — Visible to keep NVDA / sighted users from seeing it, Enabled
/// so the Alt+U hotkey is a no-op even if focus somehow lands on it.</summary>
private void UpdateProfileButtonsVisibility()
{
// Retained as a stub — multiple call sites still poke this on profile load /
// save-as / rename. With the Profiles tab retired (2026-05-08) there's no UI to
// refresh; the Save / Rename actions on the File menu work for both
// blank-template and loaded-profile states because the menu handlers branch on
// currentProfileTitle internally. The window title is updated where the profile
// title actually changes (SaveProfileTo, RenameCurrentProfile, profile-load).
}
/// <summary>Update existing profile button. Overwrites the active profile with current
/// state. No prompt — user explicitly chose this button to commit. Hidden when no
/// profile is loaded.</summary>
@@ -5647,11 +5736,7 @@ public sealed class MainForm : Form
}
Text = FormatWindowTitle(title);
AccessibleName = Text;
UpdateProfileButtonsVisibility();
AppendLogEntry($"profile saved: \"{title}\" → {path}");
// Refresh baseline so the diff against unsaved-changes uses the just-saved state.
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* baseline failure shouldn't block save */ }
unsavedChanges = false;
// A freshly created profile has no password yet, and encryption is always on — so
// ask for one now and write it straight into the file we just saved. OK requires a
@@ -5659,7 +5744,7 @@ public sealed class MainForm : Form
// streaming gate will ask again when needed.
if (string.IsNullOrEmpty(currentProfilePassword))
{
var pw = ProfilePasswordDialog.Show(this, title, "", requireNonEmpty: true);
var pw = ProfilePasswordDialog.Show(title, "", requireNonEmpty: true);
if (!string.IsNullOrEmpty(pw))
{
currentProfilePassword = pw;
@@ -5728,11 +5813,6 @@ public sealed class MainForm : Form
// 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();
// Refresh the unsaved-changes baseline so this saved state becomes the new
// "no changes" reference. The Title field changes on save-as, so the next
// diff comparison must use the new state as baseline, not the pre-save one.
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* baseline failure shouldn't block save */ }
unsavedChanges = false;
if (showConfirmation && !AppConfig.Load().SaveProfileConfirmationSuppressed)
{
@@ -5778,7 +5858,6 @@ public sealed class MainForm : Form
currentProfileTitle = title;
Text = FormatWindowTitle(title);
AccessibleName = Text;
UpdateProfileButtonsVisibility();
}
/// <summary>Mark the profile as having unsaved user changes. No-op while a profile is
@@ -5829,16 +5908,6 @@ public sealed class MainForm : Form
profile.ReadOnly = readOnly;
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson);
// Refresh the unsaved-changes baseline so any user edits made BEFORE the toggle
// remain "unsaved" (still pending a real Save) — the baseline tracks the saved
// profile JSON, and we just rewrote it on disk, so the diff has to be against
// the new file contents not the old ones. Without this, toggling lock on a
// dirty profile would suddenly "clean" the dirty flag from the close path's
// POV, even though the user's other edits still aren't persisted. The new
// baseline reflects the on-disk truth; the in-memory state still differs by
// those other edits, so unsavedChanges-style tracking still works.
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
catch { /* baseline refresh is best-effort */ }
}
catch (Exception ex)
{
@@ -5851,14 +5920,6 @@ public sealed class MainForm : Form
}
}
/// <summary>Serialise an arbitrary <see cref="Profile"/> in the same shape
/// <see cref="SerializeCurrentStateAsProfile"/> uses for the dirty-diff. Lives here so
/// the lock-flag persistence path can refresh the baseline against the rewritten file
/// contents (a partial overwrite of the profile file) without flushing the user's
/// in-session edits. 2026-05-22.</summary>
private static string SerializeProfileForDirtyDiff(Profile profile) =>
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
/// <summary>File → Change this profile's password. Shows the current password (plain text,
/// for the screen reader) in a dialog; on OK, updates the in-memory value and writes JUST
/// the password back to the profile file straight away — same immediate-persist approach as
@@ -5874,7 +5935,7 @@ public sealed class MainForm : Form
AppName, MessageBoxButtons.OK, MessageBoxIcon.Information);
return;
}
var entered = ProfilePasswordDialog.Show(this, currentProfileTitle, currentProfilePassword);
var entered = ProfilePasswordDialog.Show(currentProfileTitle, currentProfilePassword);
if (entered is null) return; // cancelled
currentProfilePassword = entered;
RecomputeAudioCrypto();
@@ -5898,10 +5959,6 @@ public sealed class MainForm : Form
profile.Password = RemSoundCrypto.Obfuscate(plaintextPassword);
var newJson = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(currentProfilePath, newJson);
// Refresh the dirty-diff baseline against the rewritten file so the password change
// we just persisted doesn't read back as an unsaved change on close.
try { baselineProfileJson = SerializeProfileForDirtyDiff(profile); }
catch { /* baseline refresh is best-effort */ }
}
catch (Exception ex)
{
@@ -5946,7 +6003,7 @@ public sealed class MainForm : Form
if (!string.IsNullOrEmpty(currentProfilePassword)) return true; // already have one
var label = string.IsNullOrEmpty(currentProfileTitle) ? "this session" : currentProfileTitle;
var entered = ProfilePasswordDialog.Show(this, label, "", requireNonEmpty: true);
var entered = ProfilePasswordDialog.Show(label, "", requireNonEmpty: true);
if (string.IsNullOrEmpty(entered))
{
// No password → can't stream. Put the box back without re-firing this gate.
@@ -5960,9 +6017,9 @@ public sealed class MainForm : Form
// Offer to remember it on the profile (if we're on a saved one).
if (!string.IsNullOrEmpty(currentProfileTitle) && !string.IsNullOrEmpty(currentProfilePath))
{
var save = MessageBox.Show(this,
var save = ForegroundDialog.Show(owner => MessageBox.Show(owner,
$"Save this password to profile \"{currentProfileTitle}\" so you don't have to type it next time?",
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question);
AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question));
if (save == DialogResult.Yes) PersistPasswordOnly(currentProfilePassword);
}
return true;
@@ -6032,31 +6089,6 @@ public sealed class MainForm : Form
}
}
/// <summary>Serializes the current control state as if the user had just clicked Save.
/// Used for the unsaved-changes-on-close diff. Mirrors <see cref="SaveCurrentStateToProfileFile"/>
/// but doesn't write anywhere.</summary>
private string SerializeCurrentStateAsProfile() =>
JsonSerializer.Serialize(BuildCurrentProfile(currentProfileTitle ?? ""));
/// <summary>Capture the "this is what no-changes-since-load looks like" baseline 3 seconds
/// after the profile has been applied (or the app has started, for blank template). The
/// delay lets async peer-reconnects finish so they're folded into the baseline rather
/// than seen as user-initiated changes. If the user closes within those 3 seconds the
/// baseline is null and we just close without prompting (treating fast-close as
/// confident-close).</summary>
private void ScheduleBaselineCapture()
{
var timer = new System.Windows.Forms.Timer { Interval = 3000 };
timer.Tick += (_, _) =>
{
timer.Stop();
timer.Dispose();
try { baselineProfileJson = SerializeCurrentStateAsProfile(); }
catch { /* ignore — baseline just stays null */ }
};
timer.Start();
}
private static List<string> ExtractCheckedDeviceIds(CheckedListBox list)
{
var result = new List<string>();
@@ -6297,7 +6329,7 @@ public sealed class MainForm : Form
}
else
{
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
if (File.Exists(defaultPath))
{
path = defaultPath;
@@ -6794,11 +6826,6 @@ public sealed class MainForm : Form
// can't make the ASIO lane's auto-tune defer (and vice versa).
if (settings.LoadAudioMode() == AudioMode.BothIndependent)
{
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps
// window is populated by every incoming packet regardless of lane, so without
// this gate a route with no audio would still react to the OTHER route's
// gap signal and silently inflate its target before any of its own audio has
// arrived.
// Skip ticking a lane that has no active sessions. The shared recentMaxGaps
// window is populated by every incoming packet regardless of lane, so without
// this gate a route with no audio would still react to the OTHER route's
+2 -2
View File
@@ -622,7 +622,7 @@ internal sealed class PreferencesDialog : Form
_ => null,
};
if (defaultFileName is null) return null;
var defaultPath = Path.Combine(AppContext.BaseDirectory, "sounds", defaultFileName);
var defaultPath = Path.Combine(AppConfig.SoundsDirectory, defaultFileName);
return File.Exists(defaultPath) ? defaultPath : null;
}
@@ -667,7 +667,7 @@ internal sealed class PreferencesDialog : Form
/// fires on the way out.</summary>
private void OnBrowseClicked(Button btn, CueRowDescriptor cue, RemSoundSettingsStore settings)
{
var soundsFolder = Path.Combine(AppContext.BaseDirectory, "sounds");
var soundsFolder = AppConfig.SoundsDirectory;
var existing = settings.LoadCustomCuePath(cue.CueId);
var initialDir = !string.IsNullOrWhiteSpace(existing) && File.Exists(existing)
? Path.GetDirectoryName(existing) ?? soundsFolder
+8 -2
View File
@@ -13,7 +13,7 @@ namespace RemSound.App;
/// </summary>
internal static class ProfilePasswordDialog
{
public static string? Show(IWin32Window owner, string profileTitle, string currentPassword, bool requireNonEmpty = false)
public static string? Show(string profileTitle, string currentPassword, bool requireNonEmpty = false)
{
using var dialog = new Form
{
@@ -99,6 +99,12 @@ internal static class ProfilePasswordDialog
dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton;
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
// Run with a foreground 1×1 owner so the prompt jumps to the front even when RemSound is
// sitting minimised in the tray — e.g. a quick profile switch to a passwordless-but-
// streaming profile trips the password gate mid-switch, and the user must be able to read
// and answer it there and then. Centres on screen, forces focus, then closes.
return ForegroundDialog.Show(owner => dialog.ShowDialog(owner)) == DialogResult.OK
? textBox.Text.Trim()
: null;
}
}
+92 -17
View File
@@ -24,10 +24,19 @@ internal static class Program
ApplicationConfiguration.Initialize();
// Relocate any pre-2026-06-07 config/profiles into config\ before anything reads them.
// Idempotent and best-effort; also upgrades users coming from an older build. The result
// is shown to the user once (after the single-instance guard) if files actually moved.
// Consolidate every older layout (loose files, or the interim config\ folder) into the single
// "user settings and logs" folder before anything reads config/profiles/logs. Idempotent +
// best-effort; upgrades users from any older build. Shown to the user once if files moved.
var layoutMigration = RemSound.Core.AppConfig.MigrateLegacyLayoutIfNeeded();
// Move the cue sounds into that folder too — seeded from the shipped defaults (see method).
ConsolidateSounds();
// Remove cue WAVs (and their .sfk peak files) left loose in the install ROOT by pre-
// 2026-05-28 builds, where the cues lived next to RemSound.exe before they moved into
// sounds\. A robocopy update copies the new sounds\ tree but uses /E (not /PURGE), so it
// never deletes these orphans — they just linger in the root. Best-effort + idempotent:
// a no-op once they're gone. 2026-06-08.
CleanUpLegacyRootSounds();
// Single-instance guard. RemSound must never run as two copies at once: with the
// auto-updater relaunching the app, a copy that didn't exit cleanly used to leave two
@@ -84,7 +93,7 @@ internal static class Program
// here, after the guard and before the profile picker, so the user reads it once up front.
if (layoutMigration.MovedAnything)
{
ShowLayoutMigrationNotice(layoutMigration);
ShowLayoutMigrationNotice();
}
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
@@ -248,28 +257,94 @@ internal static class Program
}
/// <summary>One-time, Windows-native notice telling the user their config/profiles were moved
/// into the new <c>config\</c> folder. Only called when a real migration happened. TaskDialog
/// into the new "user settings and logs" folder. Only called when a real migration happened. TaskDialog
/// (not a hand-rolled Form) so a screen reader reads the whole message automatically.</summary>
private static void ShowLayoutMigrationNotice(RemSound.Core.AppConfig.LayoutMigrationResult migration)
private static void ShowLayoutMigrationNotice()
{
var moved = new System.Collections.Generic.List<string>();
if (migration.MovedGlobalConfig) moved.Add("- Your settings are now in: config\\global config.json");
if (migration.MovedProfiles) moved.Add("- Your saved profiles are now in: config\\profiles\\");
var page = new TaskDialogPage
{
Caption = "RemSound settings location",
Heading = "Your settings now live in a \"config\" folder",
Text = "To keep the RemSound folder tidy, this update moved your existing settings into a new "
+ "\"config\" folder inside RemSound:\n\n"
+ string.Join("\n", moved)
+ "\n\nNothing was lost and RemSound works exactly as before. You will only see this message once.",
Caption = "RemSound files location",
Heading = "Your RemSound files have moved into one folder",
Text = "To keep the RemSound folder tidy and stop updates from ever touching your own files, "
+ "this update moved everything this machine owns into a single folder inside RemSound "
+ "called \"user settings and logs\":\n\n"
+ "- Your settings (global config)\n"
+ "- Your saved profiles\n"
+ "- Your logs\n"
+ "- Your cue sounds\n\n"
+ "Nothing was lost and RemSound works exactly as before. From now on, RemSound updates "
+ "leave that folder completely untouched. You will only see this message once.",
Icon = TaskDialogIcon.Information,
Buttons = { TaskDialogButton.OK },
DefaultButton = TaskDialogButton.OK,
AllowCancel = true,
};
try { TaskDialog.ShowDialog(page); }
// Give the notice a momentary top-most, foreground owner so it opens FRONT and CENTRE —
// RemSound may have launched straight into the tray (auto-start / start-minimised), and a
// parent-less TaskDialog can otherwise open behind everything where a screen-reader user
// can't read it.
try { ForegroundDialog.Show(owner => TaskDialog.ShowDialog(owner, page)); }
catch { /* a notice must never stop RemSound from starting */ }
}
/// <summary>Delete cue WAVs and their .sfk peak files left loose in the install ROOT by
/// pre-2026-05-28 builds (the cues moved into <c>sounds\</c> then; a robocopy update copies
/// the new tree but never removes the old root copies). Best-effort and idempotent — runs
/// every launch and no-ops once the orphans are gone. Only the known default cue names are
/// touched, never anything else in the folder.</summary>
private static void CleanUpLegacyRootSounds()
{
try
{
var root = AppContext.BaseDirectory;
string[] cueBaseNames =
{
"connect", "disconnect", "record start", "record stop",
"save", "profile", "profile menu open", "update",
};
foreach (var baseName in cueBaseNames)
{
foreach (var fileName in new[] { baseName + ".wav", baseName + ".sfk", baseName + ".wav.sfk" })
{
try
{
var path = Path.Combine(root, fileName);
if (File.Exists(path)) File.Delete(path);
}
catch { /* a locked / unremovable file must never stop startup */ }
}
}
}
catch { /* never let cleanup disturb startup */ }
}
/// <summary>Consolidate the cue WAVs into the per-user sounds folder. The release ships the
/// default cues in <c>&lt;exe&gt;\sounds\</c>; this copies any cue MISSING from the per-user
/// <c>...\user settings and logs\sounds\</c> across (so a fresh install, or a release that adds a
/// new cue, gets seeded) WITHOUT overwriting one already there (so the user's own cue files
/// survive), then removes the shipped folder to keep the install root tidy. The app reads cues
/// only from the per-user folder, which the updater leaves untouched — so a user's custom cue
/// WAVs are no longer clobbered by an update. Best-effort + idempotent. 2026-06-10.</summary>
private static void ConsolidateSounds()
{
try
{
var userSounds = AppConfig.SoundsDirectory;
Directory.CreateDirectory(userSounds);
var shippedSounds = Path.Combine(AppContext.BaseDirectory, "sounds");
if (!Directory.Exists(shippedSounds)) return;
foreach (var src in Directory.GetFiles(shippedSounds))
{
try
{
var dest = Path.Combine(userSounds, Path.GetFileName(src));
if (!File.Exists(dest)) File.Copy(src, dest);
}
catch { /* one unreadable cue mustn't stop the rest */ }
}
try { Directory.Delete(shippedSounds, recursive: true); }
catch { /* leave it if locked — the app reads the per-user copy anyway */ }
}
catch { /* never let cue consolidation disturb startup */ }
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
tag_name on the latest GitHub release; bump it on every public release. The
AssemblyVersion / FileVersion default to this value, and Assembly.GetName().Version
is what the About dialog and the updater both read. -->
<Version>3.4.0</Version>
<Version>3.5.0</Version>
</PropertyGroup>
<ItemGroup>
+1 -1
View File
@@ -68,7 +68,7 @@ internal sealed class RemSoundLog : IDisposable
if (fileCreationFailed) return false;
try
{
var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
var dir = RemSound.Core.AppConfig.LogsDirectory;
Directory.CreateDirectory(dir);
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
Path = System.IO.Path.Combine(dir, name);
+12 -6
View File
@@ -317,6 +317,7 @@ internal sealed class RemSoundUpdater : IDisposable
echo. >> "%LOG%"
echo === %DATE% %TIME% update helper started, parent PID=%PID% === >> "%LOG%"
echo %DATE% %TIME% install dir=[%~dp0] >> "%LOG%"
:wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
@@ -326,13 +327,18 @@ internal sealed class RemSoundUpdater : IDisposable
)
echo %DATE% %TIME% parent exited, starting robocopy (R:60 W:1) >> "%LOG%"
rem /XF + /XD keep the update from ever overwriting the USER's own state: their
rem machine-local config (remsound.config.json holds the profiles-folder choice and
rem startup settings) and their data folders (logs / profiles / recordings). An update
rem replaces APP files only. build-release.ps1 already keeps those out of the release
rem zip; this is the second line of defence so a bad zip still can't clobber them.
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update /LOG+:"%LOG%"
rem /XF + /XD keep the update from ever overwriting the USER's own state: everything under
rem "user settings and logs" (global config, profiles, logs, sounds including any custom cue
rem WAVs the user dropped in) plus the legacy loose config. An update replaces APP files only.
rem build-release.ps1 keeps those out of the release zip; this is the second line of defence so
rem a bad zip still can't clobber them. The bare logs/profiles/recordings excludes stay for any
rem older layout still mid-migration.
robocopy "{stagingArg}" "{installArg}" /E /IS /IT /NFL /NDL /NJH /NJS /R:60 /W:1 /XF _apply-update.cmd /XF _update-helper.log /XF update-failed.txt /XF remsound.config.json /XF {ResumeProfileSentinelName} /XD logs profiles recordings _update "user settings and logs" /LOG+:"%LOG%"
set "ROBO_EXIT=%ERRORLEVEL%"
rem Guard against an empty exit code (e.g. robocopy never ran / ERRORLEVEL was clobbered):
rem an empty %ROBO_EXIT% turns the GEQ test below into a parse error. Default it to a
rem clear non-zero so the failure path is taken cleanly and logged with a real number.
if not defined ROBO_EXIT set "ROBO_EXIT=99"
echo %DATE% %TIME% robocopy exit=%ROBO_EXIT% >> "%LOG%"
if %ROBO_EXIT% GEQ 8 (
+5
View File
@@ -130,6 +130,11 @@ internal sealed class RouterPortMapper : IDisposable
RaiseChanged();
try
{
// Subscribe-once: Refresh() (fired on every sleep/wake) calls Start() again without a
// prior unsubscribe, and DeviceFound is a STATIC Mono.Nat event — a bare += would stack
// a new handler every resume (duplicate port-maps + this object kept alive forever).
// Remove first so there's only ever one subscription.
NatUtility.DeviceFound -= OnDeviceFound;
NatUtility.DeviceFound += OnDeviceFound;
NatUtility.StartDiscovery();
log?.Invoke("UPnP discovery started");
+8 -9
View File
@@ -162,8 +162,10 @@ internal sealed class StartupBehaviourDialog : Form
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); }
};
var suppressStartWithUserHandler = false;
startWithUserBox.CheckedChanged += (_, _) =>
{
if (suppressStartWithUserHandler) return;
// Source of truth for the auto-start state is the registry — we don't keep a
// duplicate in AppConfig. So this just flips the registry entry directly.
var ok = startWithUserBox.Checked
@@ -176,21 +178,18 @@ internal sealed class StartupBehaviourDialog : Form
"Auto-start change failed",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
// Re-read truth and reflect it without re-firing this handler.
// Re-read truth and reflect it WITHOUT re-firing this handler. The suppress flag
// genuinely gates it; the old detach/re-attach targeted an empty handler that was
// never in the invocation list, so it did nothing and the corrective set re-fired.
var actual = StartupAutoStart.IsEnabled;
if (startWithUserBox.Checked != actual)
{
// Temporarily detach the handler to avoid a recursive call.
var savedChecked = actual;
startWithUserBox.CheckedChanged -= AutoStartReentryGuard;
startWithUserBox.Checked = savedChecked;
startWithUserBox.CheckedChanged += AutoStartReentryGuard;
suppressStartWithUserHandler = true;
try { startWithUserBox.Checked = actual; }
finally { suppressStartWithUserHandler = false; }
}
}
};
// Empty handler used as a target-for-removal in the re-entry-guard path above.
// Kept so the +=/-= pair is symmetrical even though it does nothing on its own.
void AutoStartReentryGuard(object? _, EventArgs __) { }
startWithProfileBox.CheckedChanged += (_, _) =>
{