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:
co-authored by
Claude Opus 4.8
parent
946e6f4be4
commit
4dbe9a47a0
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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><exe>\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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 += (_, _) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user