v3.4: handle-leak fix, Realtek ASIO block, device notifications, quick profile switch, hotkey announcements

Freezes the v3.4 feature set (everything since the v3.3 public release):

- Fix the receiver handle leak: the 3-second device-refresh timer reopened the
  configured ASIO driver every tick, and Realtek's ASIO driver leaks Event+Mutant
  handles on every open. Cache the ASIO probe per driver so it is opened once.
- Realtek ASIO block: detect a Realtek ASIO driver, offer once to disable it, and
  never touch it again if disabled; Options-menu toggle to reverse. Global config.
- Device hot-plug is event-driven (AudioDeviceChangeNotifier) instead of a 3s poll;
  debounced refresh, falls back to polling if registration fails.
- Quick profile switch: new global hotkey opens an NVDA-friendly popup of all
  profiles (current marked); Enter/click switches; plays a new "profile menu open"
  cue with Preferences mute + custom-sound.
- Announce assigned global hotkeys on the controls/menu items they drive (NVDA reads
  "press X anywhere"). File > Open already had Ctrl+O.
- Held-back changes folded in: config-folder migration, codec-column fix, Tailscale
  endpoint network-prune, empty-password guard, and the handle-leak diagnostics
  (ProcessSelfMeter, HandleTypeProbe).

Version bumped to 3.4.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-08 09:12:11 +01:00
co-authored by Claude Opus 4.8
parent fc451ba3e3
commit dd70613017
18 changed files with 1325 additions and 64 deletions
+99 -1
View File
@@ -160,7 +160,104 @@ public sealed class AppConfig
}
}
private static string ConfigPath => Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
/// <summary>Friendly names of ASIO drivers RemSound must never touch — it won't probe them,
/// won't list them in the driver picker, and won't open them for streaming. Global (not
/// per-profile) because "this driver is broken on this machine" is about the hardware/driver
/// install, not any one profile. Populated when the user answers "yes" to the Realtek-ASIO
/// compatibility warning, or toggles the Options-menu entry. Matched case-insensitively.</summary>
public List<string> DisabledAsioDrivers { get; set; } = new();
/// <summary>Friendly names of ASIO drivers RemSound has already shown its compatibility warning
/// for, so a user who answered "no, keep using it" isn't nagged on every launch. Independent of
/// <see cref="DisabledAsioDrivers"/>: a driver can be warned-about-but-still-enabled.</summary>
public List<string> AsioDriversWarnedAbout { get; set; } = new();
/// <summary>True if RemSound should refuse to interact with the named ASIO driver in any way.</summary>
public bool IsAsioDriverDisabled(string? driverName) =>
!string.IsNullOrWhiteSpace(driverName)
&& DisabledAsioDrivers.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
/// <summary>Disable or re-enable the named ASIO driver. Caller must <see cref="Save"/> after.</summary>
public void SetAsioDriverDisabled(string driverName, bool disabled)
{
if (string.IsNullOrWhiteSpace(driverName)) return;
DisabledAsioDrivers.RemoveAll(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
if (disabled) DisabledAsioDrivers.Add(driverName);
}
/// <summary>True once the compatibility warning has been shown for this driver. Case-insensitive.</summary>
public bool HasWarnedAboutAsioDriver(string driverName) =>
AsioDriversWarnedAbout.Exists(d => string.Equals(d, driverName, StringComparison.OrdinalIgnoreCase));
/// <summary>Record that the compatibility warning has been shown for this driver (so we don't
/// re-nag a user who chose to keep it). Caller must <see cref="Save"/> after.</summary>
public void MarkAsioDriverWarned(string driverName)
{
if (string.IsNullOrWhiteSpace(driverName)) return;
if (!HasWarnedAboutAsioDriver(driverName)) AsioDriversWarnedAbout.Add(driverName);
}
/// <summary>True if the named ASIO driver looks like a Realtek HD Audio ASIO driver (its name
/// or description contains "Realtek"). Realtek's bundled ASIO driver (rthdasio64.dll) leaks OS
/// handles on every open and is broadly known to misbehave with ASIO hosts; ASUS and other OEMs
/// ship the same Realtek driver under their own branding, so we match "Realtek" anywhere in the
/// name. RemSound uses this to proactively offer to disable the driver.</summary>
public static bool IsRealtekAsioDriver(string? driverName) =>
!string.IsNullOrWhiteSpace(driverName)
&& driverName.Contains("Realtek", StringComparison.OrdinalIgnoreCase);
/// <summary>The config folder next to the exe (<c>&lt;exe&gt;\config\</c>). Holds the global
/// config file and the <c>profiles\</c> subfolder. 2026-06-07: everything non-recording config
/// moved in here from loose files beside the exe, so the install root stays tidy.</summary>
public static string ConfigDirectory => Path.Combine(AppContext.BaseDirectory, "config");
private static string ConfigPath => Path.Combine(ConfigDirectory, "global config.json");
/// <summary>
/// One-time, idempotent relocation of the pre-2026-06-07 layout into <c>config\</c>:
/// * <c>&lt;exe&gt;\remsound.config.json</c> → <c>&lt;exe&gt;\config\global config.json</c>
/// * <c>&lt;exe&gt;\profiles\</c> → <c>&lt;exe&gt;\config\profiles\</c>
/// Run once at startup BEFORE anything reads config or profiles. Each move only happens when
/// the old item exists and the new one doesn't, so it's safe to call every launch and it
/// upgrades anyone coming from an older build without losing a profile or a setting. A custom
/// <see cref="ProfilesDirectory"/> is untouched — it isn't in the default location.
/// </summary>
/// <summary>What <see cref="MigrateLegacyLayoutIfNeeded"/> actually relocated this launch.
/// <see cref="MovedAnything"/> is true only on the one launch where an upgrade's old files
/// were found and moved — the caller uses it to show a one-time "your settings moved" notice.</summary>
public readonly record struct LayoutMigrationResult(bool MovedGlobalConfig, bool MovedProfiles)
{
public bool MovedAnything => MovedGlobalConfig || MovedProfiles;
}
public static LayoutMigrationResult MigrateLegacyLayoutIfNeeded()
{
var movedGlobal = false;
var movedProfiles = false;
try
{
Directory.CreateDirectory(ConfigDirectory);
var oldGlobal = Path.Combine(AppContext.BaseDirectory, "remsound.config.json");
if (File.Exists(oldGlobal) && !File.Exists(ConfigPath))
{
File.Move(oldGlobal, ConfigPath);
movedGlobal = true;
}
var oldProfiles = Path.Combine(AppContext.BaseDirectory, "profiles");
var newProfiles = Path.Combine(ConfigDirectory, "profiles");
if (Directory.Exists(oldProfiles) && !Directory.Exists(newProfiles))
{
Directory.Move(oldProfiles, newProfiles);
movedProfiles = true;
}
}
catch
{
// Best-effort: a failed move (permissions, file in use) just means the app falls
// back to defaults / an empty profiles list rather than crashing on launch.
}
return new LayoutMigrationResult(movedGlobal, movedProfiles);
}
/// <summary>Reads the app config from disk. Always returns a non-null instance — a missing
/// or malformed file becomes a defaults-only AppConfig rather than throwing.</summary>
@@ -185,6 +282,7 @@ public sealed class AppConfig
/// surface a MessageBox — failure to persist a directory choice is user-visible).</summary>
public void Save()
{
Directory.CreateDirectory(ConfigDirectory);
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json);
}
+6
View File
@@ -115,6 +115,9 @@ public sealed class Profile
public bool? EnableRecordStopCue { get; set; }
public bool? EnableSaveCue { get; set; }
public bool? EnableProfileSwitchCue { get; set; }
/// <summary>When true (the default), the "profile menu open" cue plays as the Quick profile
/// switch popup opens. Per-profile so each setup can mute it independently.</summary>
public bool? EnableProfileMenuOpenCue { get; set; }
/// <summary>Plays the update cue just before an update starts installing (manual or
/// silent). Null = unset → defaults to on, so a silent background update still gives an
/// audible heads-up. Added 2026-05-31.</summary>
@@ -206,6 +209,9 @@ public sealed class Profile
/// <summary>Hotkey that sends a "toggle Windows default-output-device mute" command to
/// every connected peer.</summary>
public HotkeyRecord? SystemMuteToggleHotkey { get; set; }
/// <summary>Global hotkey that opens the Quick profile switch popup — a list of all profiles you
/// can arrow through and press Enter to switch to, from anywhere in Windows. Unset by default.</summary>
public HotkeyRecord? QuickProfileSwitchHotkey { get; set; }
/// <summary>When true, this machine honours incoming Control packets from connected
/// peers — adjusts the local volume slider or toggles mute. Default false: receiving
/// remote control is opt-in even though the audio allow-list already gates who's
+3 -1
View File
@@ -24,7 +24,9 @@ public sealed class ProfileStore
public ProfileStore()
{
var machineFolder = SanitiseFsName(Environment.MachineName);
baseDir = Path.Combine(AppContext.BaseDirectory, "profiles", machineFolder);
// 2026-06-07: profiles live under config\profiles\<machine>\ (was <exe>\profiles\<machine>\).
// AppConfig.MigrateLegacyLayoutIfNeeded moves any pre-existing profiles here at startup.
baseDir = Path.Combine(AppContext.BaseDirectory, "config", "profiles", machineFolder);
try { Directory.CreateDirectory(baseDir); }
catch { /* permissions; List/Save will surface this when actually used */ }
}
@@ -134,6 +134,16 @@ public sealed class RemSoundSettingsStore
Save(s);
}
public HotkeyInfo LoadQuickProfileSwitchHotkey() =>
Try(() => Load()?.QuickProfileSwitchHotkey?.ToHotkeyInfo()) ?? HotkeyInfo.Unset;
public void SaveQuickProfileSwitchHotkey(HotkeyInfo hotkey)
{
var s = Load() ?? new Settings();
s.QuickProfileSwitchHotkey = HotkeySetting.From(hotkey);
Save(s);
}
public bool LoadAcceptRemoteVolumeCommands(bool defaultValue = false) =>
Try(() => Load()?.AcceptRemoteVolumeCommands) ?? defaultValue;
@@ -428,6 +438,16 @@ public sealed class RemSoundSettingsStore
Save(s);
}
public bool LoadEnableProfileMenuOpenCue() =>
Try(() => Load()?.EnableProfileMenuOpenCue) ?? true;
public void SaveEnableProfileMenuOpenCue(bool value)
{
var s = Load() ?? new Settings();
s.EnableProfileMenuOpenCue = value;
Save(s);
}
public bool LoadEnableUpdateCue() =>
Try(() => Load()?.EnableUpdateCue) ?? true;
@@ -572,6 +592,7 @@ public sealed class RemSoundSettingsStore
SystemVolumeUpHotkey = profile.SystemVolumeUpHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeUpHotkey),
SystemVolumeDownHotkey = profile.SystemVolumeDownHotkey is null ? null : HotkeySettingFromRecord(profile.SystemVolumeDownHotkey),
SystemMuteToggleHotkey = profile.SystemMuteToggleHotkey is null ? null : HotkeySettingFromRecord(profile.SystemMuteToggleHotkey),
QuickProfileSwitchHotkey = profile.QuickProfileSwitchHotkey is null ? null : HotkeySettingFromRecord(profile.QuickProfileSwitchHotkey),
AcceptRemoteVolumeCommands = profile.AcceptRemoteVolumeCommands,
MaxLatencyMs = profile.MaxLatencyMs,
Codec = profile.Codec,
@@ -597,6 +618,7 @@ public sealed class RemSoundSettingsStore
EnableRecordStopCue = profile.EnableRecordStopCue,
EnableSaveCue = profile.EnableSaveCue,
EnableProfileSwitchCue = profile.EnableProfileSwitchCue,
EnableProfileMenuOpenCue = profile.EnableProfileMenuOpenCue,
EnableUpdateCue = profile.EnableUpdateCue,
// Defensive copy so cache mutations don't leak into the in-memory Profile graph
// (and vice-versa). Profile is loaded once at startup; the cache evolves through
@@ -626,6 +648,7 @@ public sealed class RemSoundSettingsStore
profile.SystemVolumeUpHotkey = s.SystemVolumeUpHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeUpHotkey);
profile.SystemVolumeDownHotkey = s.SystemVolumeDownHotkey is null ? null : HotkeyRecordFromSetting(s.SystemVolumeDownHotkey);
profile.SystemMuteToggleHotkey = s.SystemMuteToggleHotkey is null ? null : HotkeyRecordFromSetting(s.SystemMuteToggleHotkey);
profile.QuickProfileSwitchHotkey = s.QuickProfileSwitchHotkey is null ? null : HotkeyRecordFromSetting(s.QuickProfileSwitchHotkey);
if (s.AcceptRemoteVolumeCommands is bool arvc) profile.AcceptRemoteVolumeCommands = arvc;
if (s.MaxLatencyMs is int ml) profile.MaxLatencyMs = ml;
if (s.Codec is AudioTransportCodec c) profile.Codec = c;
@@ -654,6 +677,7 @@ public sealed class RemSoundSettingsStore
profile.EnableRecordStopCue = s.EnableRecordStopCue;
profile.EnableSaveCue = s.EnableSaveCue;
profile.EnableProfileSwitchCue = s.EnableProfileSwitchCue;
profile.EnableProfileMenuOpenCue = s.EnableProfileMenuOpenCue;
profile.EnableUpdateCue = s.EnableUpdateCue;
profile.CustomCuePaths = s.CustomCuePaths is null
? new Dictionary<string, string>()
@@ -691,6 +715,7 @@ public sealed class RemSoundSettingsStore
public HotkeySetting? SystemVolumeUpHotkey { get; set; }
public HotkeySetting? SystemVolumeDownHotkey { get; set; }
public HotkeySetting? SystemMuteToggleHotkey { get; set; }
public HotkeySetting? QuickProfileSwitchHotkey { get; set; }
public bool? AcceptRemoteVolumeCommands { get; set; }
public int? MaxLatencyMs { get; set; }
public AudioTransportCodec? Codec { get; set; }
@@ -725,6 +750,7 @@ public sealed class RemSoundSettingsStore
public bool? EnableRecordStopCue { get; set; }
public bool? EnableSaveCue { get; set; }
public bool? EnableProfileSwitchCue { get; set; }
public bool? EnableProfileMenuOpenCue { get; set; }
public bool? EnableUpdateCue { get; set; }
public Dictionary<string, string>? CustomCuePaths { get; set; }
public RecordingSettings? RecordingSettings { get; set; }