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:
co-authored by
Claude Opus 4.8
parent
fc451ba3e3
commit
dd70613017
@@ -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><exe>\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><exe>\remsound.config.json</c> → <c><exe>\config\global config.json</c>
|
||||
/// * <c><exe>\profiles\</c> → <c><exe>\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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user