v5.2: stability and polish — deep-audit bug fixes + install-flow fixes

Verified findings from a multi-dimension code audit, plus the two install-flow bugs:
- Fix Opus encoder use-after-free on a codec/rate change while streaming (guard swap vs encode).
- Fix "both" single-file recording dropping audio + drifting (drain both directions in lockstep).
- Fix broken clip counter, UPnP teardown on exit, auto-update-restart foreground grant, and a
  malformed-Opus-format packet orphaning a playout session forever.
- Post-install relaunch now respects start-minimised; uninstall is path-aware so it won't clear a
  different copy's run-at-startup.
- Perf/hygiene: cache AppConfig off UI hot paths, fold per-peer EQ+gain into one pass, deterministic
  disposal (tray menu, timers, COM shortcut, Process handles, process meter), ring-buffer overflow
  guard, receiver session-lock fix, remote-control allow-list moved onto the UI thread.
- Remove dead code (two IsAsioBackend, SessionPlayout.Reset, IsSameEndpoint, RemSoundUpdater
  IDisposable); several stale-doc fixes.

Deferred (not in this release): drift-estimator tweak, peer-discovery pruning, uninstall retry-loop,
encryption nonce. Wire format unchanged (interops v3.3-v5.1). Version -> 5.2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-09 20:54:53 +01:00
co-authored by Claude Opus 4.8
parent 7038fef67c
commit 2fb9274a95
28 changed files with 408 additions and 198 deletions
+8
View File
@@ -20,6 +20,14 @@ internal sealed class AboutDialog : Form
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v5.2
A stability and polish release bug fixes and tidy-ups from a deep code review. No new features; it should just feel a bit more solid.
Fixed: changing the codec or send rate while streaming could, in rare cases, crash RemSound. Fixed: recording both your sent and received audio into a single file could lose a little audio and drift out of sync over a long recording it now stays accurate the whole way through. Fixed: after installing, RemSound now stays minimised if that's your setting (and still comes to the front otherwise), and uninstalling one copy no longer switches off another copy's run-at-startup.
Also: the clipping indicator works again, the router port opened for incoming connections is tidied up when you close, and the app is a little lighter on memory and disk. Nothing about how you connect changed, so it still talks to older versions.
RemSound v5.1
Install RemSound as a proper Windows app.
+25 -6
View File
@@ -103,6 +103,11 @@ internal static class AppInstaller
return;
}
// Degenerate case: the portable copy was extracted straight into the install location (no marker
// yet, so IsInstalledCopy is false). Copying the folder onto itself would throw an IOException
// (self-copy) and abort with a scary message. Detect it and skip the copy — we're already in
// place, so registration (marker + shortcuts) is all that's needed.
var sameLocation = string.Equals(source, target, StringComparison.OrdinalIgnoreCase);
var updating = InstallExistsAtTarget;
var options = ShowInstallOptionsDialog(owner, target, updating);
if (options is null) return; // cancelled
@@ -116,10 +121,14 @@ internal static class AppInstaller
$"recordings={options.CopyRecordings}, logs={options.CopyLogs})");
// The file copy is the part that must succeed — a failure here (disk full, permissions)
// aborts with the copy untouched. Directory.CreateDirectory + copy.
// aborts with the copy untouched. Skipped entirely when we're already running from the
// target (self-copy would throw); registration below still runs.
Directory.CreateDirectory(target);
CopyProgramFiles(source, target);
CopyUserData(source, target, options);
if (!sameLocation)
{
CopyProgramFiles(source, target);
CopyUserData(source, target, options);
}
// Drop the marker that tells the installed copy it IS installed (so the Options menu shows
// Uninstall). Part of the must-succeed path: without it the install wouldn't recognise
@@ -258,7 +267,9 @@ internal static class AppInstaller
{
try { SetDesktopShortcut(false, "", ""); } catch { }
try { SetStartMenuFolder(false, "", ""); } catch { }
try { StartupAutoStart.TryDisable(); } catch { }
// Only clear login-autostart if it points at THIS installed copy — never wipe a different
// copy's (e.g. a portable copy's) autostart entry that shares the "RemSound" value name.
try { StartupAutoStart.TryDisableIfPointsInto(AppContext.BaseDirectory); } catch { }
try { UnregisterInstalledApp(); } catch { }
}
@@ -383,10 +394,12 @@ internal static class AppInstaller
var shellType = Type.GetTypeFromProgID("WScript.Shell");
if (shellType is null) return;
dynamic? shell = null;
object? linkObj = null;
try
{
shell = Activator.CreateInstance(shellType);
dynamic link = shell!.CreateShortcut(linkPath);
linkObj = link; // keep a handle so we can release this SECOND COM object too, not just shell
link.TargetPath = targetPath;
link.Arguments = arguments;
link.WorkingDirectory = workingDir;
@@ -396,6 +409,12 @@ internal static class AppInstaller
}
finally
{
// Release both COM objects deterministically (the IWshShortcut from CreateShortcut AND the
// WScript.Shell), rather than leaving the shortcut object to the GC finalizer.
if (linkObj is not null)
{
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(linkObj); } catch { }
}
if (shell is not null)
{
try { System.Runtime.InteropServices.Marshal.FinalReleaseComObject(shell); } catch { }
@@ -459,7 +478,7 @@ internal static class AppInstaller
psi.ArgumentList.Add("--await-pid");
psi.ArgumentList.Add(Environment.ProcessId.ToString());
var child = Process.Start(psi);
using var child = Process.Start(psi);
// Grant the just-launched child the right to take the foreground. Given now, while we still
// hold it, it survives our imminent exit and lets the child's SetForegroundWindow succeed.
if (child is not null)
@@ -524,7 +543,7 @@ internal static class AppInstaller
{
var path = Path.Combine(Path.GetTempPath(), namePrefix + Guid.NewGuid().ToString("N") + ".cmd");
File.WriteAllText(path, script, new UTF8Encoding(false));
Process.Start(new ProcessStartInfo
using var proc = Process.Start(new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"{path}\"",
+63 -20
View File
@@ -372,6 +372,24 @@ internal sealed class AudioRecorder : IDisposable
return 0;
}
/// <summary>How many frames <see cref="DrainOneDirection"/> would produce for one direction right
/// now, WITHOUT consuming anything — the lane-merge min (or the single active lane), capped at
/// <paramref name="maxFrames"/>. Used by the Both path to drain the sent and received directions by
/// the SAME amount so neither ring is over-consumed and the two stay sample-aligned. Read heads are
/// owned by this (writer) thread so a plain read is fine; the caller passes Volatile.Read snapshots
/// of the write heads, which the audio threads advance.</summary>
private static int DirectionAvailFrames(long wasapiWrite, long wasapiRead, long asioWrite, long asioRead, int maxFrames)
{
var w = (int)((wasapiWrite - wasapiRead) / MixChannels);
var a = (int)((asioWrite - asioRead) / MixChannels);
int avail;
if (w > 0 && a > 0) avail = Math.Min(w, a);
else if (w > 0) avail = w;
else if (a > 0) avail = a;
else avail = 0;
return Math.Min(avail, maxFrames);
}
private void Process()
{
int framesThisCall;
@@ -410,18 +428,37 @@ internal sealed class AudioRecorder : IDisposable
// received scratch we'll grow as needed.
EnsureScratchSize(DrainChunkMaxFrames * MixChannels);
EnsureSecondaryScratchSize(DrainChunkMaxFrames * MixChannels);
var sentFrames = DrainOneDirection(
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
mixScratch, mixScratchAux, DrainChunkMaxFrames);
EnsureRecvDirectionScratchSize(DrainChunkMaxFrames * MixChannels);
var recvFrames = DrainOneDirection(
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
recvDirectionScratch, mixScratchAux, DrainChunkMaxFrames);
if (sentFrames > 0 && recvFrames > 0)
// Peek how much each direction can supply WITHOUT consuming, so when both carry audio we
// drain them by the SAME amount and keep them sample-aligned. Draining each independently
// (the old code) advanced the faster direction's ring head past frames we then never
// wrote — a silent, continuous loss + progressive drift on any real two-way session,
// which is exactly what Both mode exists to capture. Now the surplus genuinely stays in
// its ring for the next pass.
var sentAvail = DirectionAvailFrames(
Volatile.Read(ref sentWasapiWriteHead), sentWasapiReadHead,
Volatile.Read(ref sentAsioWriteHead), sentAsioReadHead, DrainChunkMaxFrames);
var recvAvail = DirectionAvailFrames(
Volatile.Read(ref receivedWasapiWriteHead), receivedWasapiReadHead,
Volatile.Read(ref receivedAsioWriteHead), receivedAsioReadHead, DrainChunkMaxFrames);
if (sentAvail > 0 && recvAvail > 0)
{
framesThisCall = Math.Min(sentFrames, recvFrames);
// Both directions have data — take the same count from each. Because write heads only
// advance (producers add) and this is the sole consumer, each drain returns exactly
// `take`, so the two stay aligned and nothing is over-consumed.
var take = Math.Min(sentAvail, recvAvail);
var got1 = DrainOneDirection(
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
mixScratch, mixScratchAux, take);
var got2 = DrainOneDirection(
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
recvDirectionScratch, mixScratchAux, take);
framesThisCall = Math.Min(got1, got2); // defensive; both equal `take` in practice
if (framesThisCall <= 0) return;
var dst = mixScratch.AsSpan(0, framesThisCall * MixChannels);
var aux = recvDirectionScratch.AsSpan(0, framesThisCall * MixChannels);
for (var i = 0; i < dst.Length; i++)
@@ -431,20 +468,26 @@ internal sealed class AudioRecorder : IDisposable
else if (s < -1f) s = -1f + MathF.Tanh(-1f - s);
dst[i] = s;
}
// Any leftover frames in the direction that produced MORE this tick stay
// in their rings for the next iteration — they're not lost, just deferred.
// We can't write them now without un-syncing the two directions.
}
else if (sentFrames > 0)
else if (sentAvail > 0)
{
framesThisCall = sentFrames;
// mixScratch already contains the sent direction's audio — emit as-is.
// Only the sent direction has audio right now — record it solo (no over-consume,
// nothing to align against). mixScratch already holds it.
framesThisCall = DrainOneDirection(
sentWasapiRing, ref sentWasapiWriteHead, ref sentWasapiReadHead,
sentAsioRing, ref sentAsioWriteHead, ref sentAsioReadHead,
mixScratch, mixScratchAux, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
}
else if (recvFrames > 0)
else if (recvAvail > 0)
{
framesThisCall = recvFrames;
// The recv-direction audio lives in recvDirectionScratch; copy into
// mixScratch so EmitMixBuffer (which reads from mixScratch) sees it.
framesThisCall = DrainOneDirection(
receivedWasapiRing, ref receivedWasapiWriteHead, ref receivedWasapiReadHead,
receivedAsioRing, ref receivedAsioWriteHead, ref receivedAsioReadHead,
recvDirectionScratch, mixScratchAux, DrainChunkMaxFrames);
if (framesThisCall <= 0) return;
// The recv-direction audio lives in recvDirectionScratch; copy into mixScratch so
// EmitMixBuffer (which reads from mixScratch) sees it.
var len = framesThisCall * MixChannels;
recvDirectionScratch.AsSpan(0, len).CopyTo(mixScratch.AsSpan(0, len));
}
+9 -3
View File
@@ -16,6 +16,11 @@ internal static class CheckSoundService
{
private static CuePlayer? checkSound;
private static CuePlayer? uncheckSound;
// Cached at Reload() so Play() (which fires on the UI thread for every genuine checkbox toggle)
// doesn't re-read + re-deserialize the whole config file from disk just to fetch one bool. Reload()
// is already the hook that runs whenever cue settings change, so these stay current.
private static bool enableCheckboxOn;
private static bool enableCheckboxOff;
/// <summary>When true, <see cref="Play"/> is a no-op. MainForm sets this around bulk programmatic
/// control updates (profile load, "uncheck all", device-list refresh). The per-call Focused gate
@@ -29,6 +34,8 @@ internal static class CheckSoundService
public static void Reload()
{
var cfg = AppConfig.Load();
enableCheckboxOn = cfg.EnableCheckboxOnCue;
enableCheckboxOff = cfg.EnableCheckboxOffCue;
checkSound = LoadCue(MainForm.CueId.CheckboxOn, "check.wav", cfg);
uncheckSound = LoadCue(MainForm.CueId.CheckboxOff, "uncheck.wav", cfg);
}
@@ -36,9 +43,8 @@ internal static class CheckSoundService
public static void Play(bool isChecked)
{
if (Suppressed) return;
var cfg = AppConfig.Load();
if (isChecked) { if (cfg.EnableCheckboxOnCue) checkSound?.Play(); }
else { if (cfg.EnableCheckboxOffCue) uncheckSound?.Play(); }
if (isChecked) { if (enableCheckboxOn) checkSound?.Play(); }
else { if (enableCheckboxOff) uncheckSound?.Play(); }
}
private static CuePlayer? LoadCue(string cueId, string defaultFile, AppConfig cfg)
+7 -1
View File
@@ -51,7 +51,13 @@ internal static class CueSounds
}
}
catch { return Array.Empty<string>(); }
return matches.OrderBy(m => m.Order).Select(m => m.Name).ToList();
var ordered = matches.OrderBy(m => m.Order).ToList();
// If numbered variants exist, drop the bare unnumbered file: it labels as "Sound 1" (below), the
// same as "<cue> 1.wav", so the two would show as indistinguishable "Sound 1" rows a screen-reader
// user can't tell apart. The bare name is a legacy single-file fallback; numbered is what ships.
if (ordered.Exists(m => m.Order >= 1) && ordered.Exists(m => m.Order == 0))
ordered.RemoveAll(m => m.Order == 0);
return ordered.Select(m => m.Name).ToList();
}
/// <summary>The "Sound N" label for a variant filename, for the Preferences listbox.
+68 -47
View File
@@ -1331,11 +1331,16 @@ public sealed class MainForm : Form
FormClosing += (_, _) =>
{
statusTimer.Stop();
deviceRefreshTimer.Stop();
continuousTuneTimer.Stop();
updateCheckTimer.Stop();
asioDriverChangeDebounce.Stop();
// Stop AND dispose each timer. A WinForms Timer is a Component, not a Control, so base
// Form.Dispose never reaches it; Stop() only kills the WM_TIMER, leaving the timer's
// message-only window handle to be freed at GC finalization. MainForm is rebuilt on every
// profile switch, so disposing here releases those handles deterministically each time.
statusTimer.Stop(); statusTimer.Dispose();
deviceRefreshTimer.Stop(); deviceRefreshTimer.Dispose();
continuousTuneTimer.Stop(); continuousTuneTimer.Dispose();
updateCheckTimer.Stop(); updateCheckTimer.Dispose();
asioDriverChangeDebounce.Stop(); asioDriverChangeDebounce.Dispose();
try { processSelfMeter.Dispose(); } catch { }
try { deviceChangeNotifier?.Dispose(); } catch { }
try { powerResumeHandler?.Dispose(); } catch { }
try { routerPortMapper?.Dispose(); } catch { }
@@ -1409,20 +1414,22 @@ public sealed class MainForm : Form
// wired up before we hide the window.
var coldStart = isFirstLaunch;
isFirstLaunch = false;
// A post-install relaunch (--foreground) overrides any start-minimised preference — the
// user just ran an interactive install and expects to see the installed copy come up.
var minimizeThisInstance = !forceForegroundOnStart
&& (startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised));
var minimizeThisInstance = startNextInstanceMinimized || (coldStart && AppConfig.Load().StartMinimised);
startNextInstanceMinimized = false;
// Consume the one-shot post-install foreground flag now, whichever branch we take below, so
// it can't leak into a later profile-switch relaunch. "Start minimised" WINS over it: a user
// who chose to boot into the tray wants the just-installed copy in the tray too — we only
// pull the window to the front when we're NOT minimising (else it can open behind others).
var forcePostInstallForeground = forceForegroundOnStart;
forceForegroundOnStart = false;
if (minimizeThisInstance)
{
// playCue:false — starting up in the tray (StartMinimised / --minimized) isn't the
// user choosing to minimise, so it must not sound the "minimise" cue.
BeginInvoke(() => trayController.Minimize(playCue: false));
}
else if (forceForegroundOnStart)
else if (forcePostInstallForeground)
{
forceForegroundOnStart = false;
logFile.Event("installer: post-install relaunch — bringing the window to the foreground");
// Deferred so it runs after Shown settles, then yanks the window to the front so the
// just-installed copy isn't left hiding behind other windows. Try again a moment later:
@@ -3470,7 +3477,11 @@ public sealed class MainForm : Form
loadingPanEqControls = true;
try
{
var s = GetOrCreateShaping(selectedShapingKey);
// Read-only for display: use the existing shaping if any, else a throwaway default. Do NOT
// GetOrCreateShaping here — merely selecting/scrolling a peer would then insert a no-op entry
// into the saved profile for every peer the user only glanced at. The actual edit handlers
// (pan/volume/mode/band) call GetOrCreateShaping, so an entry is created only on a real change.
var s = GetShaping(selectedShapingKey) ?? new PeerShaping();
volumeSlider.Value = Math.Clamp((int)Math.Round(s.Volume * 100f), 0, 100);
UpdateVolumeAccessibleName();
panSlider.Value = Math.Clamp((int)Math.Round(s.Pan * 50f) + 50, 0, 100);
@@ -8246,15 +8257,18 @@ public sealed class MainForm : Form
/// Custom paths are per-profile (changed from machine-wide in v3.0.3 development) so
/// each profile can carry its own cue palette. The settings cache mirrors the active
/// profile's CustomCuePaths dictionary and is the runtime source of truth.</summary>
private void TryLoadCueSound(string cueId, string defaultFileName, out CuePlayer? player)
private void TryLoadCueSound(string cueId, string defaultFileName, out CuePlayer? player, AppConfig? cfg = null)
{
player = null;
try
{
// Load the config once per call (or reuse the caller's — ReloadAllCueSounds passes one shared
// instance for all 14 cues instead of each cue re-reading + re-parsing the file from disk).
cfg ??= AppConfig.Load();
string? path = null;
var customPath = settings.LoadCustomCuePath(cueId);
if (string.IsNullOrWhiteSpace(customPath)
&& AppConfig.Load().MachineCueCustomPaths.TryGetValue(cueId, out var machinePath))
&& cfg.MachineCueCustomPaths.TryGetValue(cueId, out var machinePath))
{
// Machine-wide cues (send/receive/hide/show) keep their custom override in AppConfig.
customPath = machinePath;
@@ -8268,7 +8282,7 @@ public sealed class MainForm : Form
{
// The cue ships as numbered variants ("connect 1.wav", "connect 2.wav", ...);
// resolve the machine-wide chosen default (or the first variant) for this cue.
var defaultPath = CueSounds.ResolveDefaultPath(cueId, defaultFileName, AppConfig.Load());
var defaultPath = CueSounds.ResolveDefaultPath(cueId, defaultFileName, cfg);
if (defaultPath is not null && File.Exists(defaultPath))
{
path = defaultPath;
@@ -8295,20 +8309,23 @@ public sealed class MainForm : Form
/// </summary>
public void ReloadAllCueSounds()
{
TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound);
TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound);
TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound);
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound);
TryLoadCueSound(CueId.Save, "save.wav", out saveSound);
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound);
TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound);
TryLoadCueSound(CueId.Update, "update.wav", out updateSound);
TryLoadCueSound(CueId.SendOn, "send on.wav", out sendOnSound);
TryLoadCueSound(CueId.SendOff, "send off.wav", out sendOffSound);
TryLoadCueSound(CueId.ReceiveOn, "recieve on.wav", out receiveOnSound);
TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound);
TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound);
TryLoadCueSound(CueId.Show, "maximise.wav", out showSound);
// Load the machine config ONCE and pass it to all 14 cues, instead of each cue (twice) re-reading
// and re-deserializing the config file — this runs on the UI thread on every Preferences close.
var cfg = AppConfig.Load();
TryLoadCueSound(CueId.Connect, "connect.wav", out connectSound, cfg);
TryLoadCueSound(CueId.Disconnect, "disconnect.wav", out disconnectSound, cfg);
TryLoadCueSound(CueId.RecordStart, "record start.wav", out recordStartSound, cfg);
TryLoadCueSound(CueId.RecordStop, "record stop.wav", out recordStopSound, cfg);
TryLoadCueSound(CueId.Save, "save.wav", out saveSound, cfg);
TryLoadCueSound(CueId.ProfileSwitch, "profile.wav", out profileSwitchSound, cfg);
TryLoadCueSound(CueId.ProfileMenuOpen, "profile menu open.wav", out profileMenuOpenSound, cfg);
TryLoadCueSound(CueId.Update, "update.wav", out updateSound, cfg);
TryLoadCueSound(CueId.SendOn, "send on.wav", out sendOnSound, cfg);
TryLoadCueSound(CueId.SendOff, "send off.wav", out sendOffSound, cfg);
TryLoadCueSound(CueId.ReceiveOn, "recieve on.wav", out receiveOnSound, cfg);
TryLoadCueSound(CueId.ReceiveOff, "recieve off.wav", out receiveOffSound, cfg);
TryLoadCueSound(CueId.Hide, "minimise.wav", out hideSound, cfg);
TryLoadCueSound(CueId.Show, "maximise.wav", out showSound, cfg);
// The app-wide checkbox tick/untick and tab-switch sounds live in their own services; keep
// them in step.
CheckSoundService.Reload();
@@ -8553,26 +8570,30 @@ public sealed class MainForm : Form
/// </summary>
private void HandleRemoteControlPacket(RemoteControlKind kind, sbyte delta, IPEndPoint remote)
{
// Allow-list match by IP only — the sender's source port is their ephemeral outbound,
// not their announced audio port.
var allowed = false;
foreach (var ep in selectedPeerEndpoints.Values)
{
if (ep.Address.Equals(remote.Address)) { allowed = true; break; }
}
if (!allowed)
{
logFile.Event($"remote-control IGNORED (not in allow-list) kind={kind} delta={delta} from={remote}");
return;
}
if (!settings.LoadAcceptRemoteVolumeCommands())
{
logFile.Event($"remote-control IGNORED (Accept remote volume commands is off) kind={kind} delta={delta} from={remote}");
return;
}
// Marshal to the UI thread FIRST. The allow-list scan reads selectedPeerEndpoints — a plain
// Dictionary owned and mutated by the UI thread — so enumerating it here on the receiver's
// network thread races a concurrent peer tick/untick (a caught "collection was modified" that
// silently drops the remote command). Running the whole check on the UI thread removes the race.
BeginInvoke(() =>
{
// Allow-list match by IP only — the sender's source port is their ephemeral outbound,
// not their announced audio port.
var allowed = false;
foreach (var ep in selectedPeerEndpoints.Values)
{
if (ep.Address.Equals(remote.Address)) { allowed = true; break; }
}
if (!allowed)
{
logFile.Event($"remote-control IGNORED (not in allow-list) kind={kind} delta={delta} from={remote}");
return;
}
if (!settings.LoadAcceptRemoteVolumeCommands())
{
logFile.Event($"remote-control IGNORED (Accept remote volume commands is off) kind={kind} delta={delta} from={remote}");
return;
}
switch (kind)
{
case RemoteControlKind.VolumeUp:
+10 -2
View File
@@ -50,6 +50,10 @@ internal sealed class MainFormTrayController : IDisposable
private readonly ToolStripMenuItem sendingItem;
private readonly ToolStripMenuItem receivingItem;
private readonly ToolStripMenuItem profilesItem;
// Retained so Dispose can free it — NotifyIcon.Dispose does NOT dispose an externally-assigned
// ContextMenuStrip, and this menu (a native-window-handle-owning Control once shown) would otherwise
// leak per profile-switch rebuild. Disposing it cascades to its ToolStripItems.
private readonly ContextMenuStrip menu;
public MainFormTrayController(
Form owner,
@@ -87,7 +91,7 @@ internal sealed class MainFormTrayController : IDisposable
trayIcon.Visible = false;
trayIcon.DoubleClick += (_, _) => Restore();
var menu = new ContextMenuStrip();
menu = new ContextMenuStrip();
var showItem = new ToolStripMenuItem("Sho&w RemSound")
{
@@ -248,7 +252,11 @@ internal sealed class MainFormTrayController : IDisposable
trayIcon.Visible = true;
}
public void Dispose() => trayIcon.Dispose();
public void Dispose()
{
trayIcon.Dispose();
menu.Dispose(); // NotifyIcon.Dispose doesn't free the assigned ContextMenuStrip; do it ourselves.
}
private void RefreshMenuState()
{
+5 -1
View File
@@ -18,7 +18,7 @@ namespace RemSound.App;
/// Threading: <see cref="Take"/> is called from the App's status-tick handler on the UI
/// thread. Snapshot fields are mutated by that same single thread; no locks needed.
/// </summary>
internal sealed class ProcessSelfMeter
internal sealed class ProcessSelfMeter : IDisposable
{
private TimeSpan prevTotalCpu;
private long prevAllocBytes;
@@ -140,4 +140,8 @@ internal sealed class ProcessSelfMeter
HandleCount: handleCount,
ThreadCount: threadCount);
}
/// <summary>Release the cached Process handle deterministically rather than at GC finalization —
/// small, but consistent with the app's resource-lifecycle discipline. Called when MainForm closes.</summary>
public void Dispose() => selfProcess.Dispose();
}
+11
View File
@@ -149,6 +149,17 @@ internal sealed class RecordingController
private void StartMultiTrack(RecordingSettings s, DateTime now)
{
// Refuse a split received-only recording with no peers connected: it would make an empty dated
// folder and capture nothing while still announcing "recording started". Fail clearly instead
// (Start()'s catch surfaces this message). Both / sent-only still record your own send, so they
// never hit this. Checked BEFORE creating the folder so no orphan folder is left behind.
if (s.Source == RecordingSource.ReceivedOnly && (ConnectedPeersProvider?.Invoke() ?? []).Count == 0)
{
throw new InvalidOperationException(
"No peers are connected, so a split received-only recording would capture nothing. " +
"Connect to a peer first, or set the recording source to include your own sent audio.");
}
var folder = MultiTrackFolder(s, now);
Directory.CreateDirectory(folder);
var ext = AudioRecorder.ExtensionFor(s.FileFormat);
@@ -612,5 +612,9 @@ internal sealed class RecordingSettingsDialog : Form
&& a.OggOpusBitrateKbps == b.OggOpusBitrateKbps
&& a.FlacBitsPerSample == b.FlacBitsPerSample
&& a.FlacCompressionLevel == b.FlacCompressionLevel
// Include the two v5 toggles the dialog can change, so toggling only these still marks the
// profile dirty (before, changing just split-tracks / bypass-shaping reported "no change").
&& a.SplitTracks == b.SplitTracks
&& a.BypassShaping == b.BypassShaping
&& string.Equals(a.Folder ?? string.Empty, b.Folder ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
+1 -1
View File
@@ -18,7 +18,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>5.1</Version>
<Version>5.2</Version>
</PropertyGroup>
<ItemGroup>
+2 -7
View File
@@ -30,7 +30,7 @@ namespace RemSound.App;
/// single canonical release stream, not to be re-pointed at a fork. If you need to publish
/// from a different repo, change <see cref="RepoOwner"/> / <see cref="RepoName"/>.
/// </summary>
internal sealed class RemSoundUpdater : IDisposable
internal sealed class RemSoundUpdater
{
public const string RepoOwner = "Ednunp";
public const string RepoName = "RemSound";
@@ -51,11 +51,6 @@ internal sealed class RemSoundUpdater : IDisposable
public string CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
public void Dispose()
{
// HttpClient is static and shared across the process; nothing to dispose here.
}
/// <summary>Hit the GitHub Releases API, parse the latest release, return a struct
/// describing what was found. Returns null if the request fails (network down, rate
/// limited, repo not found) or if the latest version is not newer than the running
@@ -259,7 +254,7 @@ internal sealed class RemSoundUpdater : IDisposable
}
Log?.Invoke($"updater: launching in-app installer from {appRoot}, parent PID {pid}");
System.Diagnostics.Process.Start(psi);
using (System.Diagnostics.Process.Start(psi)) { }
return true;
}
catch (Exception ex)
+5 -2
View File
@@ -226,9 +226,12 @@ internal sealed class RouterPortMapper : IDisposable
public void Dispose()
{
if (disposed) return;
disposed = true;
// Run the full Stop() teardown FIRST (remove the router mapping, stop discovery, unsubscribe the
// static NatUtility.DeviceFound handler) while disposed is still false — otherwise Stop()'s own
// `if (disposed) return;` guard would skip all of it, leaving the forwarded port open until its
// lease expires and the object subscribed to the process-wide event. THEN mark disposed.
try { Stop(); } catch { /* shutting down */ }
lock (gate) { disposed = true; }
}
private void OnDeviceFound(object? sender, DeviceEventArgs args)
+29
View File
@@ -97,4 +97,33 @@ internal static class StartupAutoStart
return false;
}
}
/// <summary>Remove the Run-key entry ONLY if it currently points at an exe inside
/// <paramref name="folder"/>. Used by the uninstaller so removing an INSTALLED copy never wipes a
/// DIFFERENT copy's autostart entry (e.g. a portable copy the user still wants launching at login)
/// that happens to share the single "RemSound" value name. Returns true if the entry is gone
/// afterwards or was left alone because it points elsewhere; false only on registry error.</summary>
public static bool TryDisableIfPointsInto(string folder)
{
try
{
if (string.IsNullOrWhiteSpace(folder)) return false;
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
if (key is null) return true; // No Run subkey → nothing to disable.
var value = (key.GetValue(ValueName) as string)?.Trim().Trim('"');
if (string.IsNullOrWhiteSpace(value)) return true; // nothing set for us.
var target = System.IO.Path.GetFullPath(folder)
.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar);
// Only our own installed copy's entry (its exe lives inside the folder being removed).
if (value.StartsWith(target, StringComparison.OrdinalIgnoreCase))
{
key.DeleteValue(ValueName, throwOnMissingValue: false);
}
return true;
}
catch
{
return false;
}
}
}
+7 -2
View File
@@ -15,6 +15,9 @@ namespace RemSound.App;
internal static class TabSwitchSoundService
{
private static CuePlayer? switchSound;
// Cached at Reload() so Play() (fires on every focused tab change) doesn't re-read + re-deserialize
// the whole config file just to fetch one bool. Reload() runs whenever cue settings change.
private static bool enableTabSwitch;
/// <summary>When true, <see cref="Play"/> is a no-op. Reserved for bulk programmatic tab changes
/// the focus gate doesn't already cover; mirrors <see cref="CheckSoundService.Suppressed"/>.</summary>
@@ -24,13 +27,15 @@ internal static class TabSwitchSoundService
/// settings change, alongside <see cref="CheckSoundService.Reload"/>.</summary>
public static void Reload()
{
switchSound = LoadCue(MainForm.CueId.TabSwitch, "tab switch.wav", AppConfig.Load());
var cfg = AppConfig.Load();
enableTabSwitch = cfg.EnableTabSwitchCue;
switchSound = LoadCue(MainForm.CueId.TabSwitch, "tab switch.wav", cfg);
}
public static void Play()
{
if (Suppressed) return;
if (AppConfig.Load().EnableTabSwitchCue) switchSound?.Play();
if (enableTabSwitch) switchSound?.Play();
}
private static CuePlayer? LoadCue(string cueId, string defaultFile, AppConfig cfg)
+13 -1
View File
@@ -204,12 +204,24 @@ internal static class UpdateApplier
{
var exe = Path.Combine(target, "RemSound.exe");
if (!File.Exists(exe)) { log($"cannot restart — {exe} missing"); return; }
Process.Start(new ProcessStartInfo { FileName = exe, WorkingDirectory = target, UseShellExecute = true });
// Give the restarted copy the same foreground treatment as the post-install relaunch, so it
// doesn't reopen BEHIND other windows where a blind user wouldn't notice it came back (the
// old copy has already exited, so a fresh process has no foreground credit of its own).
// --foreground makes it pull itself forward; the AllowSetForegroundWindow grant is
// best-effort (this staged updater may not hold foreground rights to give away).
var psi = new ProcessStartInfo { FileName = exe, WorkingDirectory = target, UseShellExecute = true };
psi.ArgumentList.Add("--foreground");
using var child = Process.Start(psi);
if (child is not null) { try { AllowSetForegroundWindow(child.Id); } catch { } }
log("RemSound restarted");
}
catch (Exception ex) { log($"could not restart RemSound: {ex.Message}"); }
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool AllowSetForegroundWindow(int dwProcessId);
/// <summary>Best-effort temp cleanup. We're running FROM the stage, so we can't delete our own
/// exe's folder here — the restarted app finishes that on startup (see Program.CleanUpUpdateStages).</summary>
private static void CleanupStage(string? stageRoot, Action<string> log)