Bump to v2.1.0: UPnP, read-only profile lock, sleep/hibernate audio fix
Headline features: * Automatic router port opening (UPnP / NAT-PMP / PCP). Opt-in via Preferences; surfaces external address + carrier-grade NAT detection. * Lock profile (read-only). New File-menu tick that makes a profile load-only — session changes don't persist, no save prompt on close. Unblocks unattended shutdowns (NVDA gone, remote dropped, hibernate) where the existing save prompt could deadlock. * Check for updates on startup (default on) + brief countdown notice before silent updates install, so a launch-time update doesn't make the app silently vanish. * "Cue sounds" -> "Audio cue sounds" label clarification. Bug fixes: * No sound after the computer wakes from sleep. PowerResumeHandler rebuilds the audio backend automatically on resume; brief "Reconnecting to audio driver" splash during the rebuild. * Receiver audio silent after waking from hibernate. RefreshAudioDeviceLists now treats a transient ASIO probe failure (returns -1/-1 because the driver is mid-teardown / mid-reinit) as "retry next tick" instead of clearing the user's tick selection. Diagnostic-only changes (gated on the existing Enable-logs checkbox, zero cost when off): * AudioStepProbe split into cross-buffer vs within-buffer maxes so log inspection can tell a real-content sharp transient apart from a pipeline-boundary glitch. Plumbed through every probe owner. * New rxNetGapMs + gc0/gc1/gc2 delta columns in the diag log to split receive-side jitter into network-layer vs managed-runtime causes. Files touched: RELEASE_NOTES.md + readme.html + 24 source files across RemSound.Core / RemSound.Sender / RemSound.Receiver / RemSound.App. Three new app files: PowerResumeHandler, RouterPortMapper, UpdateInstallNoticeDialog. Wire format and audio pipeline unchanged from v1.5 onward — v1.5 through v2.1 peers interoperate.
This commit is contained in:
@@ -20,6 +20,87 @@ internal sealed class AboutDialog : Form
|
||||
/// updates" path.</summary>
|
||||
private const string ReleaseNotes =
|
||||
"""
|
||||
RemSound v2.1
|
||||
|
||||
Automatic router setup for internet streaming, a small
|
||||
notice before background updates install, a "lock this
|
||||
profile" option for users who don't want close prompts,
|
||||
and a fix for the "no sound after the laptop wakes up"
|
||||
problem. No wire-format or audio-pipeline changes —
|
||||
v1.5 through v2.1 peers interoperate.
|
||||
|
||||
What's new:
|
||||
* Automatic router port opening (UPnP). RemSound can
|
||||
now ask your router to let peers on the internet
|
||||
reach you, so you don't have to set up port
|
||||
forwarding by hand. Off by default. Tick the new
|
||||
"Automatically open my router for incoming
|
||||
connections (UPnP)" box in Preferences to turn it
|
||||
on. A status line right below the tick tells you
|
||||
what happened — found your router and opened the
|
||||
port, found your router but the port couldn't be
|
||||
opened, or no router found that supports this
|
||||
feature. If your internet provider puts you behind
|
||||
a second layer of NAT (common on mobile broadband
|
||||
and some cable connections), the status line will
|
||||
say so and suggest using Tailscale or the relay
|
||||
instead.
|
||||
* Check for updates on startup. New checkbox in
|
||||
Preferences, on by default. Shortly after RemSound
|
||||
launches it has a quiet look for a new release.
|
||||
Combined with "Silently install updates", this
|
||||
means leaving RemSound to keep itself up to date
|
||||
without you ever needing to think about it.
|
||||
* Brief notice before a silent update installs. When
|
||||
RemSound finds an update at startup and is set to
|
||||
install silently, it now shows a small window with
|
||||
the version it's about to install and an 8-second
|
||||
countdown. Press Enter (or wait) to install now,
|
||||
"Skip this version" to leave the update for
|
||||
another day, or "Postpone" to try again at the
|
||||
next check. Without this notice, the app could
|
||||
silently close on you a few seconds after launch
|
||||
and you'd have no idea why.
|
||||
* Lock profile (read-only). New tickable item in the
|
||||
File menu (Alt+F, L). When ticked, anything you
|
||||
change while RemSound is running stays in this
|
||||
session and is forgotten on close — your saved
|
||||
profile is left untouched, and there's NO "save
|
||||
changes?" prompt on exit. Useful when you have a
|
||||
default profile you want to keep clean even if you
|
||||
toggle send/receive or volume during the day, and
|
||||
essential if a save prompt could block shutdown
|
||||
when you can't reach it (screen reader gone,
|
||||
remote session dropped, machine hibernating).
|
||||
Saved per profile, off by default, toggle as often
|
||||
as you like. Save As on a locked profile produces
|
||||
an unlocked copy you can edit normally.
|
||||
* "Cue sounds" in Preferences is now labelled "Audio
|
||||
cue sounds" for clarity.
|
||||
|
||||
Bug fixes:
|
||||
* No sound after the computer wakes from sleep. On
|
||||
many setups (especially USB audio interfaces),
|
||||
waking the computer left RemSound's audio engine
|
||||
in a state where it looked like it was running but
|
||||
no sound actually came out — you'd have to quit
|
||||
and reopen RemSound to get audio back. RemSound
|
||||
now notices when the system has woken up, waits a
|
||||
moment for the USB devices to settle, and rebuilds
|
||||
its audio engine automatically. The "Loading audio
|
||||
driver" window briefly appears during the rebuild
|
||||
so you can see it's happening.
|
||||
* Receiver audio silent after waking from hibernate.
|
||||
A follow-up to the wake-from-sleep fix above: on
|
||||
hibernate (rather than ordinary sleep) the ASIO
|
||||
receive output's tick selection could be silently
|
||||
wiped during hibernation entry, leaving the
|
||||
receiver running silent on resume even though
|
||||
everything looked normal in the logs. Fixed by
|
||||
recognising the transient driver-disappeared state
|
||||
and preserving the user's tick until the driver
|
||||
comes back.
|
||||
|
||||
RemSound v2.0
|
||||
|
||||
A smoother startup when a profile uses an ASIO driver.
|
||||
|
||||
@@ -27,16 +27,21 @@ namespace RemSound.App;
|
||||
/// </summary>
|
||||
internal sealed class AsioLoadingSplash
|
||||
{
|
||||
/// <summary>Default message used by <see cref="StartIfNeeded"/> on first launch.</summary>
|
||||
public const string DefaultMessage = "Loading audio driver, please wait...";
|
||||
|
||||
private readonly Thread thread;
|
||||
private readonly ManualResetEventSlim shown = new(false);
|
||||
private readonly string message;
|
||||
private volatile Form? form;
|
||||
|
||||
private AsioLoadingSplash()
|
||||
private AsioLoadingSplash(string message)
|
||||
{
|
||||
this.message = message;
|
||||
thread = new Thread(RunSplash)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "RemSound startup splash",
|
||||
Name = "RemSound audio-driver splash",
|
||||
};
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
@@ -51,12 +56,20 @@ internal sealed class AsioLoadingSplash
|
||||
/// the only case where MainForm construction is slow. Returns null for WASAPI-only
|
||||
/// profiles. Dismiss the returned handle once the main window has been built.
|
||||
/// </summary>
|
||||
public static AsioLoadingSplash? StartIfNeeded(Profile? profile)
|
||||
public static AsioLoadingSplash? StartIfNeeded(Profile? profile) =>
|
||||
StartIfAsioDriverName(profile?.AsioDriverName, DefaultMessage);
|
||||
|
||||
/// <summary>
|
||||
/// Generic version of <see cref="StartIfNeeded"/>: starts the splash when an ASIO driver
|
||||
/// name is configured, with a caller-supplied message. Used by the system-resume handler
|
||||
/// to show "Reconnecting to audio driver…" while the audio backend is rebuilt after wake.
|
||||
/// </summary>
|
||||
public static AsioLoadingSplash? StartIfAsioDriverName(string? asioDriverName, string? message = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(profile?.AsioDriverName)) return null;
|
||||
if (string.IsNullOrWhiteSpace(asioDriverName)) return null;
|
||||
try
|
||||
{
|
||||
return new AsioLoadingSplash();
|
||||
return new AsioLoadingSplash(message ?? DefaultMessage);
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -87,8 +100,8 @@ internal sealed class AsioLoadingSplash
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
TextAlign = ContentAlignment.MiddleCenter,
|
||||
Text = "Loading audio driver, please wait...",
|
||||
AccessibleName = "Loading audio driver, please wait",
|
||||
Text = message,
|
||||
AccessibleName = message.TrimEnd('.', ' '),
|
||||
});
|
||||
splash.Shown += (_, _) => shown.Set();
|
||||
form = splash;
|
||||
|
||||
+557
-21
@@ -38,6 +38,17 @@ public sealed class MainForm : Form
|
||||
private readonly MainFormHotkeyController hotkeyController;
|
||||
private readonly MainFormTrayController trayController;
|
||||
private readonly RecordingController recordingController;
|
||||
// Hook into Windows sleep/resume so the audio backend gets rebuilt after wake. USB
|
||||
// audio devices (ASIO / WASAPI) commonly come back in a degraded post-resume state
|
||||
// where the pipeline runs but no sound actually comes out of the interface — restarting
|
||||
// the backend on resume clears it. Subscribed in the constructor, disposed in FormClosing.
|
||||
private readonly PowerResumeHandler powerResumeHandler;
|
||||
// Optional UPnP / NAT-PMP / PCP router-port opener (Mono.Nat under the hood). Off by
|
||||
// default; the user opts in via the "Automatically open my router for incoming
|
||||
// connections" tick in Preferences (AppConfig.UpnpEnabled). Started lazily in Shown
|
||||
// when the flag is on, restarted from OnSystemResume so a sleep-drop on the router's
|
||||
// NAT table is recovered automatically, and stopped in FormClosing.
|
||||
private readonly RouterPortMapper routerPortMapper;
|
||||
// Menu items for the Record menu kept as fields so RecordingStateChanged can flip
|
||||
// the visible text + accessibility name between "Start recording" and "Stop recording"
|
||||
// without rebuilding the menu.
|
||||
@@ -393,6 +404,14 @@ public sealed class MainForm : Form
|
||||
// itself doesn't create steps but is a signal that the input is hot enough that something
|
||||
// could be saturating.
|
||||
private long prevDiagClippedSamples;
|
||||
// Per-second GC delta. .NET tracks cumulative collection counts per generation; we
|
||||
// remember the previous tick's values and emit gen-0 / gen-1 / gen-2 deltas in the diag
|
||||
// log so a click-event correlation analysis can spot when a GC pause coincided with a
|
||||
// receive-side arrival-gap spike. Gen-2 in particular implies a multi-millisecond stall
|
||||
// that's a plausible click source. 2026-05-21.
|
||||
private int prevDiagGc0Count;
|
||||
private int prevDiagGc1Count;
|
||||
private int prevDiagGc2Count;
|
||||
|
||||
// Profile system (2026-05-02). The active profile (if any) was selected at app start and
|
||||
// populated `settings` with its values BEFORE the constructor body runs (see ApplyProfile
|
||||
@@ -403,6 +422,27 @@ public sealed class MainForm : Form
|
||||
// re-launch the form under that profile."
|
||||
private ProfileStore? profileStore;
|
||||
private string? currentProfileTitle;
|
||||
// True when the active profile has its ReadOnly flag set. Drives three behaviours:
|
||||
// * The window title gets a " (read-only)" suffix so NVDA / sighted users see
|
||||
// immediately that changes won't persist.
|
||||
// * Ctrl+S / File → Save politely refuses (with a "use Save As instead" message).
|
||||
// * OnFormClosing skips the unsaved-changes prompt entirely — that's the whole
|
||||
// point of read-only mode, so a profile you live in and toggle send/receive
|
||||
// on doesn't block shutdown with a dialog you can't reach (NVDA crashed, remote
|
||||
// session dropped, machine hibernating).
|
||||
// 2026-05-22 — Andre's request: he toggles send/receive on his default profile and
|
||||
// it shouldn't block shutdown when his screen reader can't reach the dirty-prompt.
|
||||
// Toggled via File → Lock profile (read-only) and persisted on the profile JSON.
|
||||
private bool currentProfileReadOnly;
|
||||
// The actual menu item — kept as a field so profile-load (or read-only toggle) can
|
||||
// sync .Checked without rebuilding the menu. CheckOnClick lets the menu item flip
|
||||
// itself on every click; the CheckedChanged handler reads the new value and runs
|
||||
// OnLockProfileToggled.
|
||||
private ToolStripMenuItem? lockProfileMenuItem;
|
||||
// Guards CheckedChanged on lockProfileMenuItem against the programmatic sync that
|
||||
// happens on profile-load — without it, loading a profile that's read-only would
|
||||
// re-fire the toggle handler and re-persist the flag pointlessly.
|
||||
private bool suppressLockProfileToggleHandler;
|
||||
/// <summary>Full filesystem path of the active profile's JSON file. Tracked separately
|
||||
/// from <see cref="currentProfileTitle"/> because Save As (2026-05-10) lets the user
|
||||
/// write a profile to an arbitrary path outside <see cref="ProfileStore.BaseDirectory"/>.
|
||||
@@ -469,6 +509,10 @@ public sealed class MainForm : Form
|
||||
catch { /* benign — recents tracking is a convenience, not load-critical */ }
|
||||
}
|
||||
pendingProfile = profile;
|
||||
// Carry the profile's ReadOnly flag through to the in-memory tracking field. Blank
|
||||
// template (profile == null) implicitly starts as not-read-only; users still have
|
||||
// the menu toggle available if they want to lock the working state mid-session.
|
||||
currentProfileReadOnly = profile?.ReadOnly ?? false;
|
||||
// Push the profile's settings-shaped fields (codec, hotkeys, smoothness, etc.) into
|
||||
// the in-memory settings cache BEFORE the rest of the constructor body reads from it.
|
||||
// Control states (device ticks, checkboxes, volume) come later in OnShown.
|
||||
@@ -901,6 +945,17 @@ public sealed class MainForm : Form
|
||||
PushDiscoveryUnicastHints();
|
||||
hotkeyController.Initialize(this);
|
||||
|
||||
// Hook system sleep/resume so we can rebuild the audio backend after wake (USB
|
||||
// audio devices often come back wedged). The handler routes back through
|
||||
// OnSystemResume on a background thread; that marshals to the UI thread.
|
||||
powerResumeHandler = new PowerResumeHandler(OnSystemResume, msg => logFile.Event($"power: {msg}"));
|
||||
|
||||
// Build the UPnP router-port opener up-front but don't start it — Shown decides
|
||||
// whether to invoke Start() based on AppConfig.UpnpEnabled. Constructing the field
|
||||
// here (rather than lazily on tick) keeps the field non-null so the Preferences
|
||||
// dialog can subscribe to StatusChanged without us juggling instance lifetimes.
|
||||
routerPortMapper = new RouterPortMapper(msg => logFile.Event($"upnp: {msg}"));
|
||||
|
||||
FormClosing += (_, _) =>
|
||||
{
|
||||
statusTimer.Stop();
|
||||
@@ -908,6 +963,8 @@ public sealed class MainForm : Form
|
||||
continuousTuneTimer.Stop();
|
||||
updateCheckTimer.Stop();
|
||||
asioDriverChangeDebounce.Stop();
|
||||
try { powerResumeHandler?.Dispose(); } catch { }
|
||||
try { routerPortMapper?.Dispose(); } catch { }
|
||||
// Reverse every Win32 lever PerformanceMode applied. The kernel would clean
|
||||
// these up on process exit anyway, but doing it explicitly releases the power
|
||||
// request handle and matches our timeBeginPeriod with a timeEndPeriod.
|
||||
@@ -971,6 +1028,41 @@ public sealed class MainForm : Form
|
||||
{
|
||||
BeginInvoke(() => trayController.Minimize());
|
||||
}
|
||||
|
||||
// Kick off UPnP discovery if the user has the box ticked. Off by default; the
|
||||
// mapper itself coalesces redundant Start() calls so a re-enter via Shown after
|
||||
// a sleep cycle is harmless.
|
||||
var startupCfg = AppConfig.Load();
|
||||
if (startupCfg.UpnpEnabled)
|
||||
{
|
||||
try { routerPortMapper.Start(); }
|
||||
catch (Exception ex) { logFile.Event($"upnp: start failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
|
||||
// Startup update check — separate from the periodic timer because users who
|
||||
// launch RemSound, find an update, and stay running for less than the timer
|
||||
// interval would otherwise miss the release entirely. Default on. The
|
||||
// background-poll path handles both silent install and the user-prompt flow.
|
||||
if (startupCfg.CheckForUpdatesOnStartup)
|
||||
{
|
||||
// Defer a few seconds so the network stack, audio engine, and any device
|
||||
// hot-swap has settled before we touch GitHub. The visible cue (silent-
|
||||
// install notice dialog) appears inside the check path, so a small delay
|
||||
// is invisible to the user.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(4)).ConfigureAwait(false);
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke(new Action(CheckForUpdatesOnStartup));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logFile.Event($"updater: startup check scheduling failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
statusTimer.Start();
|
||||
@@ -1090,6 +1182,30 @@ public sealed class MainForm : Form
|
||||
};
|
||||
renameItem.Click += (_, _) => RenameCurrentProfile();
|
||||
|
||||
// Lock profile (read-only). When checked, the active profile is loaded for use but
|
||||
// never written back: Save / Ctrl+S politely refuses (with a "use Save As" message)
|
||||
// and FormClosing skips the unsaved-changes prompt entirely. Andre's request — he
|
||||
// toggles send/receive on his default profile and doesn't want a save prompt
|
||||
// blocking shutdown when his screen reader can't reach it. Off by default; the
|
||||
// flag is per-profile (stored in the profile JSON) so different profiles can
|
||||
// independently choose lock vs editable.
|
||||
//
|
||||
// CheckOnClick = true makes WinForms flip the .Checked state on every click and
|
||||
// NVDA reads "Lock profile read-only, checked / not checked". The mnemonic Alt+F, L
|
||||
// doesn't collide with any existing File-menu letter (O / R / S / A / M / N / X
|
||||
// are in use).
|
||||
lockProfileMenuItem = new ToolStripMenuItem("&Lock profile (read-only)")
|
||||
{
|
||||
AccessibleName = "Lock profile read-only",
|
||||
CheckOnClick = true,
|
||||
Checked = currentProfileReadOnly,
|
||||
};
|
||||
lockProfileMenuItem.CheckedChanged += (_, _) =>
|
||||
{
|
||||
if (suppressLockProfileToggleHandler) return;
|
||||
OnLockProfileToggled(lockProfileMenuItem.Checked);
|
||||
};
|
||||
|
||||
var minimiseItem = new ToolStripMenuItem("Mi&nimise to tray")
|
||||
{
|
||||
// No global ShortcutKeys binding — the in-app menu mnemonic (Alt+F → N now —
|
||||
@@ -1115,6 +1231,7 @@ public sealed class MainForm : Form
|
||||
saveItem,
|
||||
saveAsItem,
|
||||
renameItem,
|
||||
lockProfileMenuItem,
|
||||
new ToolStripSeparator(),
|
||||
minimiseItem,
|
||||
new ToolStripSeparator(),
|
||||
@@ -1441,13 +1558,53 @@ public sealed class MainForm : Form
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+S / File → Save behaviour: if a profile is currently loaded, overwrite
|
||||
/// it; if we're on the blank template (no current profile), fall through to Save as.</summary>
|
||||
/// it; if we're on the blank template (no current profile), fall through to Save as.
|
||||
/// Read-only profiles refuse here with a hint pointing at Save As — that's the whole
|
||||
/// point of read-only mode, so silently ignoring Ctrl+S would be more confusing than
|
||||
/// a one-time message explaining why nothing happened. The message is suppressible
|
||||
/// via the "Do not show again" tick (same pattern as the Save-success popup).</summary>
|
||||
private void SaveOrSaveAs()
|
||||
{
|
||||
if (currentProfileReadOnly)
|
||||
{
|
||||
if (!AppConfig.Load().SaveOnReadOnlyMessageSuppressed)
|
||||
{
|
||||
ShowSaveBlockedByReadOnlyDialog();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (string.IsNullOrEmpty(currentProfileTitle)) SaveProfileAs();
|
||||
else UpdateExistingProfile();
|
||||
}
|
||||
|
||||
/// <summary>Native TaskDialog explaining why Ctrl+S / File → Save did nothing on a
|
||||
/// read-only profile. Verification checkbox lets the user suppress future occurrences;
|
||||
/// same shape as <see cref="ShowSaveConfirmationDialog"/>. NVDA reads the heading +
|
||||
/// body + checkbox as part of the normal tab order. 2026-05-22.</summary>
|
||||
private void ShowSaveBlockedByReadOnlyDialog()
|
||||
{
|
||||
var verification = new TaskDialogVerificationCheckBox("Do not show me this message again");
|
||||
var page = new TaskDialogPage
|
||||
{
|
||||
Caption = AppName,
|
||||
Heading = "This profile is read-only",
|
||||
Text = "This profile is locked, so Save was skipped. Use File → Save as... to save your changes to a new profile, or untick File → Lock profile (read-only) to unlock this one.",
|
||||
Icon = TaskDialogIcon.Information,
|
||||
Verification = verification,
|
||||
Buttons = { TaskDialogButton.OK },
|
||||
DefaultButton = TaskDialogButton.OK,
|
||||
AllowCancel = true,
|
||||
};
|
||||
TaskDialog.ShowDialog(this, page);
|
||||
if (verification.Checked)
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.SaveOnReadOnlyMessageSuppressed = true;
|
||||
try { cfg.Save(); } catch { /* harmless — preference just won't persist */ }
|
||||
AppendLogEntry("save-blocked-by-read-only message suppressed by user");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Rename the currently-active profile JSON on disk. No-op on the blank
|
||||
/// template (nothing to rename). Renames update window title + active-profile state
|
||||
/// in place — no reload required.</summary>
|
||||
@@ -1550,7 +1707,26 @@ public sealed class MainForm : Form
|
||||
},
|
||||
writeLogsNow: () => logFile.Event("user requested write logs now"),
|
||||
checkForUpdatesNow: () => CheckForUpdatesManually(),
|
||||
onUpdateFrequencyChanged: ApplyUpdateCheckTimer);
|
||||
onUpdateFrequencyChanged: ApplyUpdateCheckTimer,
|
||||
applyUpnpEnabled: enabled =>
|
||||
{
|
||||
// The persist already happened in the dialog; this callback only flips the
|
||||
// live RouterPortMapper. Start kicks off discovery; Stop politely removes any
|
||||
// existing mapping.
|
||||
if (enabled)
|
||||
{
|
||||
try { routerPortMapper.Start(); }
|
||||
catch (Exception ex) { logFile.Event($"upnp: start from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
else
|
||||
{
|
||||
try { routerPortMapper.Stop(); }
|
||||
catch (Exception ex) { logFile.Event($"upnp: stop from prefs failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
},
|
||||
getUpnpSnapshot: () => (routerPortMapper.Status, routerPortMapper.ExternalEndpoint, routerPortMapper.LastError),
|
||||
subscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged += handler,
|
||||
unsubscribeUpnpStatusChanged: handler => routerPortMapper.StatusChanged -= handler);
|
||||
dialog.ShowDialog(this);
|
||||
if (dialog.ChangedAnyProfileSetting) MarkProfileDirty();
|
||||
}
|
||||
@@ -1596,6 +1772,9 @@ public sealed class MainForm : Form
|
||||
if (info is null) return;
|
||||
if (AppConfig.Load().SilentlyInstallUpdates)
|
||||
{
|
||||
// Notice the user before the app vanishes and the helper takes over. Hidden from
|
||||
// the periodic-poll path on the assumption the user knows they ticked "silently
|
||||
// install"; the startup path is the noisy one (see CheckForUpdatesOnStartup).
|
||||
await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
@@ -1607,6 +1786,79 @@ public sealed class MainForm : Form
|
||||
if (choice == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Startup-poll path. Fired ~4 s after the main window finishes loading when
|
||||
/// <see cref="AppConfig.CheckForUpdatesOnStartup"/> is true. Distinct from
|
||||
/// <see cref="CheckForUpdatesInBackground"/> because the startup case is where the
|
||||
/// "you launched the app and it's already installing an update" surprise is loudest —
|
||||
/// silent install here is preceded by a brief notice dialog so the user sees the version
|
||||
/// number and understands why the app is about to vanish. The non-silent path uses the
|
||||
/// same MessageBox flow as the background and manual paths so the user-visible question
|
||||
/// stays consistent.</summary>
|
||||
private async void CheckForUpdatesOnStartup()
|
||||
{
|
||||
UpdateInfo? info;
|
||||
try
|
||||
{
|
||||
info = await updater.CheckForUpdateAsync().ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logFile.Event($"updater: startup check failed: {ex.GetType().Name}: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.LastUpdateCheckUtc = DateTime.UtcNow;
|
||||
cfg.Save();
|
||||
}
|
||||
catch { /* harmless */ }
|
||||
if (info is null)
|
||||
{
|
||||
logFile.Event($"updater: startup check — up to date (v{updater.CurrentVersion})");
|
||||
return;
|
||||
}
|
||||
logFile.Event($"updater: startup check found {info.Tag}");
|
||||
if (AppConfig.Load().SilentlyInstallUpdates)
|
||||
{
|
||||
// Heads-up the user before we exit and the helper takes over. The notice is its
|
||||
// own dialog so NVDA reads "RemSound is installing version X" before focus moves;
|
||||
// a MessageBox would force the user to dismiss it, which defeats the point of
|
||||
// "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);
|
||||
switch (choice)
|
||||
{
|
||||
case DialogResult.OK:
|
||||
// "Install now" — same as the countdown elapsing.
|
||||
await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
break;
|
||||
case DialogResult.Ignore:
|
||||
// "Skip this version" — log and leave the user be; the next startup
|
||||
// check will probably find the same version and ask again. We don't
|
||||
// persist a skip list because release tempo is low enough that the
|
||||
// user can dismiss once or twice without resenting it.
|
||||
logFile.Event($"updater: user skipped {info.Tag} from startup notice");
|
||||
break;
|
||||
case DialogResult.Cancel:
|
||||
default:
|
||||
// "Postpone" / closed dialog — silent install at the next opportunity
|
||||
// (timer tick or next launch).
|
||||
logFile.Event($"updater: user postponed {info.Tag} from startup notice");
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Non-silent: same prompt the background poll uses.
|
||||
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);
|
||||
if (pick == DialogResult.Yes) await InstallUpdateAsync(info).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Download the new release, stage it, spawn the install helper and exit. On
|
||||
/// any failure shows a MessageBox and stays running — partial installs leave the app
|
||||
/// untouched.</summary>
|
||||
@@ -2816,6 +3068,20 @@ public sealed class MainForm : Form
|
||||
IReadOnlyList<AudioDeviceChoice> wasapiInputs;
|
||||
IReadOnlyList<AudioDeviceChoice> asioInputChoices = [];
|
||||
IReadOnlyList<AudioDeviceChoice> asioOutputChoices = [];
|
||||
// True if ASIO mode is on AND a driver is configured AND probing it just failed this
|
||||
// tick. Used to skip the asio list sync below — without this guard, a transient probe
|
||||
// failure (most commonly during hibernate entry or resume, when the USB stack is being
|
||||
// torn down or rebuilt) silently clears the user's ASIO tick, and on the next refresh
|
||||
// when the probe succeeds the list re-populates EMPTY of checks because the tick state
|
||||
// was lost in the previous clear. Net symptom: receiver-side audio falls silent after
|
||||
// resume even though all "audio backend re-initialised" log lines look fine.
|
||||
// 2026-05-22 — traced to a real overnight repro: SNAP at 23:37:33 had ReceiveDevice
|
||||
// = "ASIO 1/2"; SNAP at 23:37:34 (one second later, mid-hibernate-entry) had "(none)";
|
||||
// resume at 06:32:06 then opened the audio backend but the asio receive list was empty
|
||||
// so AsioRenderBackend.SetOutputDevices got an empty pairs list and silently returned
|
||||
// without opening the AsioOut — the AsioLane sessions queued packets into a ring with
|
||||
// no consumer (bufMs grew to 970+ ms, TrimDropBytes climbed into the millions).
|
||||
var asioProbeAttemptedAndFailed = false;
|
||||
try
|
||||
{
|
||||
wasapiOutputs = AudioDeviceCatalog.LoadOutputs();
|
||||
@@ -2831,6 +3097,17 @@ public sealed class MainForm : Form
|
||||
asioInputChoices = BuildAsioChannelPairChoices(asioDriver, info.InputChannelNames);
|
||||
asioOutputChoices = BuildAsioChannelPairChoices(asioDriver, info.OutputChannelNames);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Probe came back -1/-1 — driver is configured but can't enumerate right
|
||||
// now. Treat as transient; preserve current list state and try again on
|
||||
// the next tick. The legitimate "driver is genuinely gone" cases (user
|
||||
// selected "(none)", or settings.LoadAsioDriverName() returned null/empty)
|
||||
// take the outer-if's else branch and correctly produce an empty list
|
||||
// that DOES sync (clearing the UI), so removing a driver from the system
|
||||
// still wipes the ticks as expected.
|
||||
asioProbeAttemptedAndFailed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -2842,8 +3119,22 @@ public sealed class MainForm : Form
|
||||
var sendOutputChanged = MaybeSyncList(sendOutputDevicesList, wasapiOutputs, ref sendOutputDevicesSignature);
|
||||
var sendInputChanged = MaybeSyncList(sendInputDevicesList, wasapiInputs, ref sendInputDevicesSignature);
|
||||
var receiveOutputChanged = MaybeSyncList(receiveOutputDevicesList, wasapiOutputs, ref receiveOutputDevicesSignature);
|
||||
var asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
|
||||
var asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
|
||||
bool asioSendChanged;
|
||||
bool asioReceiveChanged;
|
||||
if (asioProbeAttemptedAndFailed)
|
||||
{
|
||||
// Skip both asio list syncs. Crucially do NOT update the signature fields — leaving
|
||||
// them unchanged means the NEXT successful probe will still see "signature differs"
|
||||
// and re-sync the lists with the freshly-probed channel pairs, restoring tick state
|
||||
// by DeviceId from whatever was preserved in the UI.
|
||||
asioSendChanged = false;
|
||||
asioReceiveChanged = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
asioSendChanged = MaybeSyncList(asioSendDevicesList, asioInputChoices, ref asioSendDevicesSignature);
|
||||
asioReceiveChanged = MaybeSyncList(asioReceiveOutputDevicesList, asioOutputChoices, ref asioReceiveOutputDevicesSignature);
|
||||
}
|
||||
|
||||
if (sendOutputChanged || sendInputChanged || asioSendChanged)
|
||||
{
|
||||
@@ -3236,6 +3527,81 @@ public sealed class MainForm : Form
|
||||
if (wipedSomething) logFile.Event($"audio mode change wiped now-hidden device ticks");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by <see cref="PowerResumeHandler"/> on a background thread after the system has
|
||||
/// woken from sleep / hibernate (plus a short USB-settle delay). Marshals onto the UI
|
||||
/// thread and runs the audio-backend re-init. Swallows the form-already-torn-down race —
|
||||
/// the handler can fire just as the app is being closed.
|
||||
/// </summary>
|
||||
private void OnSystemResume()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
BeginInvoke(ReinitAudioBackendsForResume);
|
||||
}
|
||||
catch (ObjectDisposedException) { /* form torn down — nothing to do */ }
|
||||
catch (InvalidOperationException) { /* handle not created yet — same */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs on the UI thread. Closes and reopens the audio backend on both sides (receiver
|
||||
/// render and sender capture) so any post-sleep wedged state in the USB audio drivers is
|
||||
/// cleared. Shows the audio-driver splash on its own thread while the reset happens, so
|
||||
/// the user sees "Reconnecting to audio driver…" instead of a frozen window.
|
||||
///
|
||||
/// Implementation note: the receiver's <see cref="RemSound.Receiver.AudioReceiver.SetAudioMode"/>
|
||||
/// always tears down and rebuilds its render backend, which is exactly the reset we
|
||||
/// want. The sender's <see cref="RemSound.Sender.AudioSender.SetAudioMode"/> persists
|
||||
/// its ASIO driver across same-driver calls (to avoid an expensive reopen on every
|
||||
/// device-tick change) — so we explicitly bounce the sender through <c>WasapiOnly</c>
|
||||
/// first to force the ASIO driver to be disposed, then <see cref="ApplyAsioMode"/>
|
||||
/// puts both sides back to the real configuration. The net effect is a full close-and-
|
||||
/// reopen on both sides; same code path as a manual driver re-pick from the picker.
|
||||
/// </summary>
|
||||
private void ReinitAudioBackendsForResume()
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
var mode = settings.LoadAudioMode();
|
||||
var driver = settings.LoadAsioDriverName();
|
||||
logFile.Event($"power: re-initialising audio backend after system resume (mode={mode}, driver={driver ?? "(none)"})");
|
||||
|
||||
var splash = AsioLoadingSplash.StartIfAsioDriverName(driver, "Reconnecting to audio driver, please wait...");
|
||||
try
|
||||
{
|
||||
// Force the sender's persistent ASIO driver to be disposed by bouncing through
|
||||
// WasapiOnly. Skipped when there's no ASIO in the current mode — nothing to dispose.
|
||||
if (mode != AudioMode.WasapiOnly && !string.IsNullOrWhiteSpace(driver))
|
||||
{
|
||||
try { sender.SetAudioMode(AudioMode.WasapiOnly, null); }
|
||||
catch (Exception ex) { logFile.Event($"power: sender WasapiOnly bounce failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
// ApplyAsioMode re-applies sender + receiver mode, refreshes device lists, and
|
||||
// re-pushes the audio-runtime + receive-device configuration. The receiver's
|
||||
// SetAudioMode call inside it does an unconditional render-backend rebuild; the
|
||||
// sender's, post-bounce, recreates its persistent ASIO from scratch.
|
||||
ApplyAsioMode();
|
||||
logFile.Event("power: audio backend re-initialised");
|
||||
|
||||
// Re-poke the router. UPnP/NAT-PMP mappings often survive a sleep, but cheap
|
||||
// routers and ISP-supplied combo boxes sometimes drop their NAT table — easier
|
||||
// to just rediscover than to guess. Refresh() is a no-op if UPnP is off.
|
||||
if (AppConfig.Load().UpnpEnabled)
|
||||
{
|
||||
try { routerPortMapper.Refresh(); }
|
||||
catch (Exception ex) { logFile.Event($"upnp: refresh-on-resume failed: {ex.GetType().Name}: {ex.Message}"); }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logFile.Event($"power: audio backend re-init failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
splash?.Dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
// ===================== Peers =====================
|
||||
|
||||
private void RefreshKnownPeers()
|
||||
@@ -3896,11 +4262,32 @@ public sealed class MainForm : Form
|
||||
var emitMs = sender.TakeMaxEmitMs();
|
||||
var sendCallMs = sender.TakeMaxSendCallMs();
|
||||
var rxDispatchMs = receiver.TakeMaxOnPacketMs();
|
||||
// rxNetGapMs = worst inter-packet arrival gap at the user-space UDP socket.
|
||||
// Distinct from maxGapMs (which is measured at the per-stream-session level
|
||||
// after decode + assembly): this one is the raw "did ReceiveFrom return on
|
||||
// time" timing, with no per-session bookkeeping in between. A spike here
|
||||
// when the sender's sendCbGapMs is small fingers the OS/network path between
|
||||
// sender and receiver — NIC IRQ servicing, scheduler not waking the receive
|
||||
// thread, kernel batching, GC pause — rather than the sender stalling or
|
||||
// RemSound's own decode/dispatch chain. 2026-05-21.
|
||||
var rxNetGapMs = receiver.TakeMaxInterPacketGapMs();
|
||||
// fanCacheMs = worst BothIndependent FanOut cache occupancy this tick. Single
|
||||
// active render lane should sit at ~0; non-zero says the FanOut is sitting on
|
||||
// samples that aren't reaching the audio output, i.e. extra perceived latency
|
||||
// not visible in bufAvg. Always 0 in WasapiOnly (no FanOut).
|
||||
var fanCacheMs = receiver.TakeMaxFanOutCacheMs();
|
||||
// GC pressure delta. .NET's GC.CollectionCount is cumulative; subtracting the
|
||||
// previous tick gives the per-second collection count per generation. Gen-0
|
||||
// collections are cheap (microseconds); Gen-1 takes longer; Gen-2 / LOH can
|
||||
// pause the runtime for many milliseconds, which is enough to explain a
|
||||
// 30–50 ms rxNetGapMs spike in isolation. Read directly here — GC.CollectionCount
|
||||
// is essentially free, no need to gate further. 2026-05-21.
|
||||
var gc0Now = GC.CollectionCount(0);
|
||||
var gc1Now = GC.CollectionCount(1);
|
||||
var gc2Now = GC.CollectionCount(2);
|
||||
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
|
||||
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
|
||||
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
|
||||
// Per-stage discontinuity probes. Compare these to localise where in the
|
||||
// pipeline a click is introduced:
|
||||
// stepPreEnc = sender's float buffer just before encoding. Non-zero =
|
||||
@@ -3919,15 +4306,37 @@ public sealed class MainForm : Form
|
||||
// can show which lane is producing the discontinuity, free of the cross-
|
||||
// stream artefact that the old shared probe registered when both lanes'
|
||||
// callbacks interleaved into one probe's lastL/R carry.
|
||||
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
|
||||
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
|
||||
//
|
||||
// 2026-05-21 — also surface the cross-buffer (boundary) vs within-buffer
|
||||
// (content) split for every probe. A non-zero combined step combined with a
|
||||
// near-zero within-buffer reading means the click is at a buffer / packet
|
||||
// boundary (lost or duplicated sample, pipeline glitch); a non-zero
|
||||
// within-buffer reading with a near-zero cross-buffer reading means it's a
|
||||
// sharp transient inside one buffer (real audio content, system sound). All
|
||||
// probe drains here go through the XB/WB pair and recompute the combined
|
||||
// max from the split values — calling Take*Step() AND the split methods on
|
||||
// the same probe in the same drain window would double-drain.
|
||||
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
|
||||
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
|
||||
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
|
||||
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
|
||||
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
|
||||
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
|
||||
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
|
||||
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
|
||||
var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
|
||||
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
|
||||
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
|
||||
var clippedNow = sender.ClippedSampleCount;
|
||||
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
|
||||
var stepPostDec = receiver.TakeMaxPostDecodeStep();
|
||||
var stepPostRing = receiver.TakeMaxPostRingReadStep();
|
||||
var stepPostRsm = receiver.TakeMaxPostResamplerStep();
|
||||
var stepPostDecXB = receiver.TakeMaxPostDecodeStepCrossBuffer();
|
||||
var stepPostDecWB = receiver.TakeMaxPostDecodeStepWithinBuffer();
|
||||
var stepPostDec = stepPostDecXB > stepPostDecWB ? stepPostDecXB : stepPostDecWB;
|
||||
var stepPostRingXB = receiver.TakeMaxPostRingReadStepCrossBuffer();
|
||||
var stepPostRingWB = receiver.TakeMaxPostRingReadStepWithinBuffer();
|
||||
var stepPostRing = stepPostRingXB > stepPostRingWB ? stepPostRingXB : stepPostRingWB;
|
||||
var stepPostRsmXB = receiver.TakeMaxPostResamplerStepCrossBuffer();
|
||||
var stepPostRsmWB = receiver.TakeMaxPostResamplerStepWithinBuffer();
|
||||
var stepPostRsm = stepPostRsmXB > stepPostRsmWB ? stepPostRsmXB : stepPostRsmWB;
|
||||
// Wire-level packet-sequence stats. wireInOrderΔ is the count of packets that
|
||||
// arrived with the sequence we expected this second. wireMissΔ / wireReordΔ /
|
||||
// wireDupΔ are the smoking-gun counters — any non-zero value here means the
|
||||
@@ -3944,12 +4353,19 @@ public sealed class MainForm : Form
|
||||
|
||||
logFile.Event($"diag bufAvg={diag.BufferAvgMs}ms bufMin={diag.BufferMinMs}ms bufMax={diag.BufferMaxMs}ms " +
|
||||
$"maxGapMs={diag.MaxArrivalGapMs} sendCbGapMs={sendCbGapMs} renderCbGapMs={diag.MaxRenderCallbackGapMs} maxReadMs={diag.MaxRenderReadMs} reads={diag.RenderReadCount} " +
|
||||
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} fanCacheMs={fanCacheMs} " +
|
||||
$"emitMs={emitMs} sndCallMs={sendCallMs} rxDispMs={rxDispatchMs} rxNetGapMs={rxNetGapMs} fanCacheMs={fanCacheMs} " +
|
||||
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
|
||||
$"trimB={trimBytes} trimN={trimFires} trimΔ={trimDelta} drainB={drainBytes} ovfB={ovfBytes} pktRej={pktRej} " +
|
||||
$"driftDrop={driftDrops} driftDropΔ={driftDropDelta} driftRep={driftReps} driftRepΔ={driftRepDelta} " +
|
||||
$"concealΔ={concealDelta} shortReadΔ={shortReadDelta} " +
|
||||
$"filtErr={filteredErrorFrames:0.0}f driftAcc={driftAccumulator:0.000} " +
|
||||
$"stepRawCap={stepRawCap:0.000} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepPostDec={stepPostDec:0.000} stepPostRing={stepPostRing:0.000} stepPostRsm={stepPostRsm:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||
$"stepPostDecXB={stepPostDecXB:0.000} stepPostDecWB={stepPostDecWB:0.000} " +
|
||||
$"stepPostRingXB={stepPostRingXB:0.000} stepPostRingWB={stepPostRingWB:0.000} " +
|
||||
$"stepPostRsmXB={stepPostRsmXB:0.000} stepPostRsmWB={stepPostRsmWB:0.000} " +
|
||||
$"clipΔ={clippedDelta} sampleStepMax={diag.MaxOutputSampleStep:0.000} spikesN={diag.EnvelopeSpikeCount} " +
|
||||
$"wireOkΔ={wireInOrderDelta} wireMissΔ={wireMissedDelta} wireReordΔ={wireReorderedDelta} wireDupΔ={wireDuplicatedDelta} " +
|
||||
$"pcmRej={receiver.PcmFrameRejections} pcmDiscard={receiver.PcmFrameDiscardedPartials}");
|
||||
@@ -3968,16 +4384,42 @@ public sealed class MainForm : Form
|
||||
var sendCallMs = sender.TakeMaxSendCallMs();
|
||||
// Per-lane pre-encode probes — see the full-diag comment above for the
|
||||
// rationale (per-lane fixes the cross-stream artefact in BothIndependent).
|
||||
var stepPreEncWas = sender.TakeMaxPreEncodeStepWasapiLane();
|
||||
var stepPreEncAsi = sender.TakeMaxPreEncodeStepAsioLane();
|
||||
// 2026-05-21: drain XB / WB separately so we can localise click events at
|
||||
// the buffer boundary (cross-buffer) vs within-buffer (real content). The
|
||||
// combined step is just the larger of the two for back-compat readers.
|
||||
var stepPreEncWasXB = sender.TakeMaxPreEncodeStepWasapiLaneCrossBuffer();
|
||||
var stepPreEncWasWB = sender.TakeMaxPreEncodeStepWasapiLaneWithinBuffer();
|
||||
var stepPreEncWas = stepPreEncWasXB > stepPreEncWasWB ? stepPreEncWasXB : stepPreEncWasWB;
|
||||
var stepPreEncAsiXB = sender.TakeMaxPreEncodeStepAsioLaneCrossBuffer();
|
||||
var stepPreEncAsiWB = sender.TakeMaxPreEncodeStepAsioLaneWithinBuffer();
|
||||
var stepPreEncAsi = stepPreEncAsiXB > stepPreEncAsiWB ? stepPreEncAsiXB : stepPreEncAsiWB;
|
||||
var stepPreEnc = stepPreEncWas > stepPreEncAsi ? stepPreEncWas : stepPreEncAsi;
|
||||
// Raw-capture step: now per-backend (each backend owns its own probe). The
|
||||
// accessor returns max across all backends. PushModeWasapiBackend has been
|
||||
// wired to feed this probe as of 2026-05-15; pull-mode MixingEngine returns 0.
|
||||
var stepRawCap = sender.TakeMaxSenderRawCaptureStep();
|
||||
var stepRawCapXB = sender.TakeMaxSenderRawCaptureStepCrossBuffer();
|
||||
var stepRawCapWB = sender.TakeMaxSenderRawCaptureStepWithinBuffer();
|
||||
var stepRawCap = stepRawCapXB > stepRawCapWB ? stepRawCapXB : stepRawCapWB;
|
||||
var clippedNow = sender.ClippedSampleCount;
|
||||
var clippedDelta = clippedNow - prevDiagClippedSamples; prevDiagClippedSamples = clippedNow;
|
||||
logFile.Event($"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
|
||||
// Per-second GC delta on the send-only side too. A send stall caused by a
|
||||
// gen-2 pause on the SENDER would have a different signature in the SNAP
|
||||
// log than one caused by a receive-side pause — they'd show up here even
|
||||
// though no receiver activity is happening on this machine.
|
||||
var gc0Now = GC.CollectionCount(0);
|
||||
var gc1Now = GC.CollectionCount(1);
|
||||
var gc2Now = GC.CollectionCount(2);
|
||||
var gc0Delta = gc0Now - prevDiagGc0Count; prevDiagGc0Count = gc0Now;
|
||||
var gc1Delta = gc1Now - prevDiagGc1Count; prevDiagGc1Count = gc1Now;
|
||||
var gc2Delta = gc2Now - prevDiagGc2Count; prevDiagGc2Count = gc2Now;
|
||||
logFile.Event(
|
||||
$"sender-diag sendCbGapMs={sendCbGapMs} emitMs={emitMs} sndCallMs={sendCallMs} " +
|
||||
$"stepPreEnc={stepPreEnc:0.000} stepPreEncWas={stepPreEncWas:0.000} stepPreEncAsi={stepPreEncAsi:0.000} stepRawCap={stepRawCap:0.000} " +
|
||||
$"stepRawCapXB={stepRawCapXB:0.000} stepRawCapWB={stepRawCapWB:0.000} " +
|
||||
$"stepPreEncWasXB={stepPreEncWasXB:0.000} stepPreEncWasWB={stepPreEncWasWB:0.000} " +
|
||||
$"stepPreEncAsiXB={stepPreEncAsiXB:0.000} stepPreEncAsiWB={stepPreEncAsiWB:0.000} " +
|
||||
$"gc0Δ={gc0Delta} gc1Δ={gc1Delta} gc2Δ={gc2Delta} " +
|
||||
$"clipΔ={clippedDelta} packets={sender.PacketsSent} captureCallbacks={sender.CaptureCallbacks}");
|
||||
}
|
||||
|
||||
// Synthesised end-to-end one-way latency estimate. Sums:
|
||||
@@ -4183,11 +4625,16 @@ public sealed class MainForm : Form
|
||||
|
||||
/// <summary>Window title shows the active profile name explicitly so the user knows what
|
||||
/// they're editing. Format: "RemSound — Active profile: My profile name" (loaded) or
|
||||
/// just "RemSound" (blank template).</summary>
|
||||
private static string FormatWindowTitle(string? loadedTitle) =>
|
||||
string.IsNullOrEmpty(loadedTitle)
|
||||
? AppName
|
||||
: $"{AppName} — Active profile: {loadedTitle}";
|
||||
/// just "RemSound" (blank template). Read-only profiles get a " (read-only)" suffix so
|
||||
/// NVDA announces the lock state on every title change and sighted users see it at a
|
||||
/// glance — important context that "anything I change here won't be saved".</summary>
|
||||
private string FormatWindowTitle(string? loadedTitle)
|
||||
{
|
||||
var readOnlySuffix = currentProfileReadOnly ? " (read-only)" : "";
|
||||
return string.IsNullOrEmpty(loadedTitle)
|
||||
? $"{AppName}{readOnlySuffix}"
|
||||
: $"{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
|
||||
@@ -4261,6 +4708,18 @@ public sealed class MainForm : Form
|
||||
|
||||
currentProfileTitle = title;
|
||||
currentProfilePath = path;
|
||||
// Save As always produces an editable copy — even if the source profile was
|
||||
// read-only. Anything else would be surprising: the user picked Save As
|
||||
// specifically to fork, and they reasonably expect the fork to be editable
|
||||
// without having to hunt for the menu toggle. The original (locked) profile on
|
||||
// disk is untouched; this is purely about the new file and the in-memory state.
|
||||
currentProfileReadOnly = false;
|
||||
if (lockProfileMenuItem is not null)
|
||||
{
|
||||
suppressLockProfileToggleHandler = true;
|
||||
try { lockProfileMenuItem.Checked = false; }
|
||||
finally { suppressLockProfileToggleHandler = false; }
|
||||
}
|
||||
Text = FormatWindowTitle(title);
|
||||
AccessibleName = Text;
|
||||
UpdateProfileButtonsVisibility();
|
||||
@@ -4377,6 +4836,74 @@ public sealed class MainForm : Form
|
||||
unsavedChanges = true;
|
||||
}
|
||||
|
||||
/// <summary>Handle the user ticking / unticking File → Lock profile (read-only). Updates
|
||||
/// the in-memory flag, refreshes the window title's "(read-only)" suffix, and persists
|
||||
/// the new value to the profile JSON on disk via <see cref="PersistReadOnlyFlagOnly"/>.
|
||||
/// We MUST persist immediately because the very next user action might be the close
|
||||
/// (the whole point of the feature is that close is unattended); waiting for an explicit
|
||||
/// Save would defeat the point. 2026-05-22 — Andre's request.</summary>
|
||||
private void OnLockProfileToggled(bool readOnly)
|
||||
{
|
||||
currentProfileReadOnly = readOnly;
|
||||
Text = FormatWindowTitle(currentProfileTitle);
|
||||
AccessibleName = Text;
|
||||
PersistReadOnlyFlagOnly(readOnly);
|
||||
AppendLogEntry($"profile read-only flag set to {readOnly} for \"{currentProfileTitle ?? "(blank template)"}\"");
|
||||
}
|
||||
|
||||
/// <summary>Write JUST the ReadOnly flag back to the profile file on disk, without
|
||||
/// touching any of the user's in-session edits. Used by <see cref="OnLockProfileToggled"/>
|
||||
/// so toggling lock-state writes the flag immediately but leaves every other unsaved
|
||||
/// change exactly as-is — without this carve-out, unlocking a profile that has unsaved
|
||||
/// edits would either have to ignore them (losing user intent) or flush them (defeating
|
||||
/// "the lock writes the lock, nothing else"). Approach: read the profile JSON, deserialise,
|
||||
/// flip ONE field, re-serialise, write back. Blank-template case (no path) is a silent
|
||||
/// no-op — there's no file to update, and the user's lock state lives in memory until
|
||||
/// they Save As, at which point Save As builds a fresh Profile and writes whatever
|
||||
/// flag the in-memory state has.</summary>
|
||||
private void PersistReadOnlyFlagOnly(bool readOnly)
|
||||
{
|
||||
if (string.IsNullOrEmpty(currentProfilePath)) return;
|
||||
if (!File.Exists(currentProfilePath)) return;
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(currentProfilePath);
|
||||
var profile = JsonSerializer.Deserialize<Profile>(json);
|
||||
if (profile is null) return;
|
||||
if (profile.ReadOnly == readOnly) return; // no change, skip the rewrite
|
||||
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)
|
||||
{
|
||||
// Don't bother the user with a MessageBox for a flag-write failure — they'd just
|
||||
// see "couldn't persist the lock flag" with no actionable detail. Log and move
|
||||
// on; the in-memory state already reflects the toggle, so the current session
|
||||
// works correctly. Next launch the file's flag wins, but a single failed write
|
||||
// is rare enough that it's not worth a dialog.
|
||||
AppendLogEntry($"failed to persist read-only flag: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>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>
|
||||
@@ -5250,7 +5777,16 @@ public sealed class MainForm : Form
|
||||
// controlled close paths where the user has already confirmed their intent via the
|
||||
// management dialog, and the MainForm gets reconstructed under the new profile
|
||||
// immediately afterwards.
|
||||
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch;
|
||||
//
|
||||
// Also skip the prompt when the active profile is read-only — the whole point of
|
||||
// read-only mode (Andre's request, 2026-05-22) is that the user has explicitly
|
||||
// declared "anything I changed this session is throwaway, don't save it and don't
|
||||
// ask me about it". Without this branch the dirty-prompt would block shutdown on
|
||||
// a profile where the user wants exactly the opposite: silent exit. Crucially this
|
||||
// is what unblocks NVDA-less or remote-session-dropped shutdowns from deadlocking
|
||||
// on a dialog the user can't reach.
|
||||
var skipPrompt = !string.IsNullOrEmpty(NextProfileTitleToLoad) || ReloadFromScratch
|
||||
|| currentProfileReadOnly;
|
||||
|
||||
if (!skipPrompt && profileStore is not null && unsavedChanges)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to Windows' system power-state changes and fires a single callback on
|
||||
/// <see cref="PowerModes.Resume"/> — i.e. when the system has just come back from sleep
|
||||
/// (S3) or hibernate (S4). Both states raise the same Resume event, so the one hook covers
|
||||
/// both. Used by <see cref="MainForm"/> to re-initialise the audio backend after wake:
|
||||
/// after a sleep cycle the USB audio device (ASIO / WASAPI) can come back in a degraded
|
||||
/// state where the pipeline appears to run but no sound actually comes out of the
|
||||
/// interface, and a clean close-and-reopen of the audio backend clears it.
|
||||
///
|
||||
/// Threading: PowerModeChanged is raised on a system-message thread, NOT the UI thread.
|
||||
/// The handler returns from that thread quickly (no work done inline) and schedules the
|
||||
/// real reset on a background task — which then marshals onto the UI thread via the
|
||||
/// caller's callback. The caller's callback is responsible for any UI-thread marshaling.
|
||||
///
|
||||
/// USB settle delay: Windows raises Resume early, sometimes before USB devices have
|
||||
/// finished re-enumerating. The handler waits <see cref="SettleDelay"/> before firing the
|
||||
/// callback so the audio backend re-init has a fully-ready USB stack to talk to.
|
||||
///
|
||||
/// Debounce: Windows can in rare cases fire Resume twice in quick succession after a
|
||||
/// short sleep. The handler ignores Resume events within <see cref="DebounceWindow"/> of
|
||||
/// the previous one so the audio backend isn't torn down and rebuilt twice for one wake.
|
||||
/// </summary>
|
||||
internal sealed class PowerResumeHandler : IDisposable
|
||||
{
|
||||
/// <summary>How long to wait after Resume before firing the callback — gives the USB
|
||||
/// bus and audio drivers time to finish re-enumerating.</summary>
|
||||
public static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(1500);
|
||||
|
||||
/// <summary>A second Resume event within this window of the first is treated as a
|
||||
/// duplicate and ignored.</summary>
|
||||
public static readonly TimeSpan DebounceWindow = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly Action onResume;
|
||||
private readonly Action<string>? log;
|
||||
private readonly object gate = new();
|
||||
private DateTime lastResumeUtc = DateTime.MinValue;
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="onResume">Invoked once per resume event, on a background thread, after
|
||||
/// the USB-settle delay. The callback is responsible for marshaling onto the UI thread
|
||||
/// if it touches UI or audio state.</param>
|
||||
/// <param name="log">Optional sink for diagnostic lines — wire to the app's log if you
|
||||
/// want resume events visible there.</param>
|
||||
public PowerResumeHandler(Action onResume, Action<string>? log = null)
|
||||
{
|
||||
this.onResume = onResume ?? throw new ArgumentNullException(nameof(onResume));
|
||||
this.log = log;
|
||||
SystemEvents.PowerModeChanged += OnPowerModeChanged;
|
||||
log?.Invoke("subscribed to PowerModeChanged");
|
||||
}
|
||||
|
||||
private void OnPowerModeChanged(object? sender, PowerModeChangedEventArgs e)
|
||||
{
|
||||
if (e.Mode != PowerModes.Resume) return;
|
||||
if (disposed) return;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - lastResumeUtc < DebounceWindow)
|
||||
{
|
||||
log?.Invoke($"Resume event ignored (within {DebounceWindow.TotalSeconds:0} s debounce of previous)");
|
||||
return;
|
||||
}
|
||||
lastResumeUtc = now;
|
||||
}
|
||||
|
||||
log?.Invoke($"system Resume detected — scheduling audio backend reset in {SettleDelay.TotalMilliseconds:0} ms");
|
||||
// Return from the system message thread immediately; do the work on a background task.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(SettleDelay).ConfigureAwait(false);
|
||||
if (disposed) return;
|
||||
onResume();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"Resume callback failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { SystemEvents.PowerModeChanged -= OnPowerModeChanged; } catch { /* shutting down */ }
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net;
|
||||
using RemSound.Core;
|
||||
|
||||
namespace RemSound.App;
|
||||
@@ -7,12 +8,19 @@ namespace RemSound.App;
|
||||
/// right:
|
||||
/// * Browse for RemSound profiles folder — picks the directory the profile picker scans
|
||||
/// next launch.
|
||||
/// * Cue sounds — per-cue enable list (connect, disconnect, recording start/stop). One
|
||||
/// CheckedListBox; ticked items play, unticked are silent. Replaced the old single
|
||||
/// * Audio cue sounds — per-cue enable list (connect, disconnect, recording start/stop).
|
||||
/// One CheckedListBox; ticked items play, unticked are silent. Replaced the old single
|
||||
/// "Mute connect/disconnect sounds" toggle (2026-05-15) when recording start/stop cues
|
||||
/// were added — a CheckedListBox scales to future cues without dialog re-layout.
|
||||
/// were added — a CheckedListBox scales to future cues without dialog re-layout. Label
|
||||
/// gained the "Audio" prefix on 2026-05-21 to disambiguate from the underlying engine's
|
||||
/// "buffer cues" and "ASIO cues" diagnostic terms, which look the same in writing.
|
||||
/// * Accept remote volume commands from peers — opt-in for the remote-control feature.
|
||||
/// * Update settings — frequency, manual check, silent-install toggle.
|
||||
/// * Update settings — startup-check toggle, frequency, manual check, silent-install
|
||||
/// toggle. Layout deliberately reads top-to-bottom as the question the user is
|
||||
/// answering: "Check for updates on startup? (yes/no) Then, in the background, every?
|
||||
/// (interval) When one's found? (silent install / ask first)".
|
||||
/// * UPnP — optional automatic router port-forwarding via Mono.Nat. Off by default; when
|
||||
/// ticked, we kick off discovery and surface the result + external address inline.
|
||||
/// * Enable logs + Write logs now.
|
||||
///
|
||||
/// Startup behaviour was previously a button here that opened <see cref="StartupBehaviourDialog"/>;
|
||||
@@ -39,8 +47,8 @@ internal sealed class PreferencesDialog : Form
|
||||
// the CueIndex enum below so the ItemCheck handler can dispatch by index.
|
||||
private readonly Label cueListLabel = new()
|
||||
{
|
||||
Text = "Cue sou&nds (Alt+N):",
|
||||
AccessibleName = "Cue sounds",
|
||||
Text = "Audio cue sou&nds (Alt+N):",
|
||||
AccessibleName = "Audio cue sounds",
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 6, 0, 4),
|
||||
};
|
||||
@@ -51,7 +59,7 @@ internal sealed class PreferencesDialog : Form
|
||||
IntegralHeight = false,
|
||||
Height = 100,
|
||||
Width = 360,
|
||||
AccessibleName = "Cue sounds",
|
||||
AccessibleName = "Audio cue sounds",
|
||||
};
|
||||
|
||||
private enum CueIndex
|
||||
@@ -69,14 +77,24 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// Update settings — frequency dropdown, manual check button, silent-install checkbox.
|
||||
// Sits above the logging row so users meet it during setup; the canonical order in the
|
||||
// dialog is "things related to the program staying current" before "things related to
|
||||
// diagnosing how it's running".
|
||||
// Update settings — startup-check checkbox, frequency dropdown, manual check button,
|
||||
// silent-install checkbox. Sits above the logging row so users meet it during setup; the
|
||||
// canonical order in the dialog is "things related to the program staying current" before
|
||||
// "things related to diagnosing how it's running".
|
||||
private readonly AccessibleCheckBox checkForUpdatesOnStartupBox = new()
|
||||
{
|
||||
Text = "Check for updates on &startup",
|
||||
AccessibleName = "Check for updates on startup",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Label updateFrequencyLabel = new()
|
||||
{
|
||||
Text = "Check for updates (Alt+&U):",
|
||||
AccessibleName = "Check for updates frequency",
|
||||
// "Then check every" — reads as a continuation of the startup-check checkbox above,
|
||||
// so the user understands the dropdown controls the *background* poll cadence, not
|
||||
// the launch behaviour.
|
||||
Text = "Then check every (Alt+&U):",
|
||||
AccessibleName = "Then check every",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
@@ -84,7 +102,7 @@ internal sealed class PreferencesDialog : Form
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Width = 200,
|
||||
AccessibleName = "Check for updates (Alt+U)",
|
||||
AccessibleName = "Then check every (Alt+U)",
|
||||
};
|
||||
|
||||
private readonly Button checkForUpdatesNowButton = new()
|
||||
@@ -101,6 +119,24 @@ internal sealed class PreferencesDialog : Form
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
// UPnP — automatic router port-forwarding via Mono.Nat. Off by default. The status label
|
||||
// is updated live from the RouterPortMapper.StatusChanged event so the user sees the
|
||||
// discovery result inline without having to close and reopen the dialog.
|
||||
private readonly AccessibleCheckBox upnpEnabledBox = new()
|
||||
{
|
||||
Text = "Automatically open my router for incoming connections (UPnP) (Alt+&O)",
|
||||
AccessibleName = "Automatically open my router for incoming connections via UPnP",
|
||||
AutoSize = true,
|
||||
};
|
||||
|
||||
private readonly Label upnpStatusLabel = new()
|
||||
{
|
||||
Text = "",
|
||||
AccessibleName = "UPnP status",
|
||||
AutoSize = true,
|
||||
Padding = new Padding(20, 0, 0, 4),
|
||||
};
|
||||
|
||||
private readonly AccessibleCheckBox loggingBox = new()
|
||||
{
|
||||
Text = "Enable &logs",
|
||||
@@ -127,6 +163,9 @@ internal sealed class PreferencesDialog : Form
|
||||
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
|
||||
public bool ChangedAnyProfileSetting { get; private set; }
|
||||
|
||||
private readonly Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot;
|
||||
private EventHandler? upnpStatusSubscription;
|
||||
|
||||
public PreferencesDialog(
|
||||
RemSoundSettingsStore settings,
|
||||
ProfileStore? profileStore,
|
||||
@@ -134,8 +173,14 @@ internal sealed class PreferencesDialog : Form
|
||||
Action<bool> applyLoggingEnabled,
|
||||
Action writeLogsNow,
|
||||
Action checkForUpdatesNow,
|
||||
Action onUpdateFrequencyChanged)
|
||||
Action onUpdateFrequencyChanged,
|
||||
Action<bool> applyUpnpEnabled,
|
||||
Func<(RouterMappingStatus Status, IPEndPoint? External, string LastError)> getUpnpSnapshot,
|
||||
Action<EventHandler> subscribeUpnpStatusChanged,
|
||||
Action<EventHandler> unsubscribeUpnpStatusChanged)
|
||||
{
|
||||
this.getUpnpSnapshot = getUpnpSnapshot;
|
||||
|
||||
Text = "Preferences";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MinimizeBox = false;
|
||||
@@ -143,7 +188,7 @@ internal sealed class PreferencesDialog : Form
|
||||
ShowInTaskbar = false;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
KeyPreview = true;
|
||||
ClientSize = new Size(560, 540);
|
||||
ClientSize = new Size(580, 640);
|
||||
|
||||
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
|
||||
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
|
||||
@@ -214,8 +259,17 @@ internal sealed class PreferencesDialog : Form
|
||||
// either side stays in lockstep.
|
||||
updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" });
|
||||
var cfgForLoad = AppConfig.Load();
|
||||
checkForUpdatesOnStartupBox.Checked = cfgForLoad.CheckForUpdatesOnStartup;
|
||||
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
|
||||
silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates;
|
||||
upnpEnabledBox.Checked = cfgForLoad.UpnpEnabled;
|
||||
|
||||
checkForUpdatesOnStartupBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.CheckForUpdatesOnStartup = checkForUpdatesOnStartupBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ }
|
||||
};
|
||||
updateFrequencyBox.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
@@ -231,6 +285,39 @@ internal sealed class PreferencesDialog : Form
|
||||
};
|
||||
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
|
||||
|
||||
// UPnP toggle — persists immediately and tells MainForm to start / stop the mapper.
|
||||
// Status label refresh wires up below.
|
||||
upnpEnabledBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
var cfg = AppConfig.Load();
|
||||
cfg.UpnpEnabled = upnpEnabledBox.Checked;
|
||||
try { cfg.Save(); } catch { /* harmless */ }
|
||||
applyUpnpEnabled(upnpEnabledBox.Checked);
|
||||
RefreshUpnpStatusLabel();
|
||||
};
|
||||
|
||||
// Live UPnP status — the RouterPortMapper raises StatusChanged from a thread-pool
|
||||
// thread, so marshal back onto the UI thread before touching the label. Subscribe
|
||||
// on show and unsubscribe on close to avoid leaking the handler past the dialog.
|
||||
upnpStatusSubscription = (_, _) =>
|
||||
{
|
||||
if (IsDisposed) return;
|
||||
try { BeginInvoke(new Action(RefreshUpnpStatusLabel)); }
|
||||
catch (ObjectDisposedException) { /* dialog already gone — ignore */ }
|
||||
catch (InvalidOperationException) { /* handle not created — ignore */ }
|
||||
};
|
||||
subscribeUpnpStatusChanged(upnpStatusSubscription);
|
||||
FormClosed += (_, _) =>
|
||||
{
|
||||
if (upnpStatusSubscription is not null)
|
||||
{
|
||||
try { unsubscribeUpnpStatusChanged(upnpStatusSubscription); }
|
||||
catch { /* shutdown — ignore */ }
|
||||
upnpStatusSubscription = null;
|
||||
}
|
||||
};
|
||||
RefreshUpnpStatusLabel();
|
||||
|
||||
loggingBox.Checked = getLoggingEnabled();
|
||||
loggingBox.CheckedChanged += (_, _) =>
|
||||
{
|
||||
@@ -251,26 +338,28 @@ internal sealed class PreferencesDialog : Form
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 9,
|
||||
RowCount = 13,
|
||||
};
|
||||
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
for (var i = 0; i < 8; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
for (var i = 0; i < 12; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
|
||||
// Tab order top-to-bottom: browse, cue-sound list, accept remote, update frequency,
|
||||
// check-now, silent install, enable logs, write logs now, close. Updates sit above
|
||||
// the log row so a user setting up the app meets them first. The Startup behaviour
|
||||
// button used to live here at tab index 3; it moved to the Options menu in the
|
||||
// 2026-05-15 reorg.
|
||||
// Tab order top-to-bottom: browse, cue-sound list, accept remote, check-on-startup,
|
||||
// frequency, check-now, silent install, UPnP, enable logs, write logs now, close.
|
||||
// Updates sit above the log row so a user setting up the app meets them first. The
|
||||
// Startup behaviour button used to live here at tab index 3; it moved to the Options
|
||||
// menu in the 2026-05-15 reorg.
|
||||
browseProfilesFolderButton.TabIndex = 0;
|
||||
cueList.TabIndex = 1;
|
||||
acceptRemoteVolumeBox.TabIndex = 2;
|
||||
updateFrequencyBox.TabIndex = 3;
|
||||
checkForUpdatesNowButton.TabIndex = 4;
|
||||
silentlyInstallUpdatesBox.TabIndex = 5;
|
||||
loggingBox.TabIndex = 6;
|
||||
writeLogsNowButton.TabIndex = 7;
|
||||
closeButton.TabIndex = 8;
|
||||
checkForUpdatesOnStartupBox.TabIndex = 3;
|
||||
updateFrequencyBox.TabIndex = 4;
|
||||
checkForUpdatesNowButton.TabIndex = 5;
|
||||
silentlyInstallUpdatesBox.TabIndex = 6;
|
||||
upnpEnabledBox.TabIndex = 7;
|
||||
loggingBox.TabIndex = 8;
|
||||
writeLogsNowButton.TabIndex = 9;
|
||||
closeButton.TabIndex = 10;
|
||||
|
||||
// Group the frequency label + combo on one FlowLayoutPanel row so the visible label
|
||||
// sits inline next to the combo while keeping the combo as the focusable target.
|
||||
@@ -305,11 +394,14 @@ internal sealed class PreferencesDialog : Form
|
||||
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
|
||||
panel.Controls.Add(cueGroup, 0, 1);
|
||||
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
|
||||
panel.Controls.Add(freqRow, 0, 3);
|
||||
panel.Controls.Add(checkForUpdatesNowButton, 0, 4);
|
||||
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 5);
|
||||
panel.Controls.Add(loggingBox, 0, 6);
|
||||
panel.Controls.Add(writeLogsNowButton, 0, 7);
|
||||
panel.Controls.Add(checkForUpdatesOnStartupBox, 0, 3);
|
||||
panel.Controls.Add(freqRow, 0, 4);
|
||||
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
|
||||
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
|
||||
panel.Controls.Add(upnpEnabledBox, 0, 7);
|
||||
panel.Controls.Add(upnpStatusLabel, 0, 8);
|
||||
panel.Controls.Add(loggingBox, 0, 9);
|
||||
panel.Controls.Add(writeLogsNowButton, 0, 10);
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
@@ -336,4 +428,39 @@ internal sealed class PreferencesDialog : Form
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Pull the latest UPnP snapshot and update the inline status label. Always
|
||||
/// called on the UI thread (either inline from a change handler or marshaled in from
|
||||
/// the StatusChanged subscription).</summary>
|
||||
private void RefreshUpnpStatusLabel()
|
||||
{
|
||||
var (status, external, lastError) = getUpnpSnapshot();
|
||||
// Skip the label entirely while UPnP is off — keeps the dialog quiet for users who
|
||||
// don't care, and stops the "Disabled" string from showing up next to an unticked
|
||||
// box (which would just read as redundant noise to NVDA).
|
||||
if (!upnpEnabledBox.Checked)
|
||||
{
|
||||
upnpStatusLabel.Text = "";
|
||||
upnpStatusLabel.AccessibleName = "UPnP status";
|
||||
return;
|
||||
}
|
||||
var text = status switch
|
||||
{
|
||||
RouterMappingStatus.Disabled => "Status: not yet started.",
|
||||
RouterMappingStatus.Searching => "Status: searching for a router that supports UPnP / NAT-PMP / PCP...",
|
||||
RouterMappingStatus.Mapped => external is not null
|
||||
? $"Status: router port opened. Peers can reach you at {external.Address}:{external.Port}."
|
||||
: "Status: router port opened.",
|
||||
RouterMappingStatus.NoRouterFound => "Status: no router with UPnP / NAT-PMP / PCP found. Check that the feature is enabled on your router, or forward UDP 47830 manually.",
|
||||
RouterMappingStatus.CgnatDetected => external is not null
|
||||
? $"Status: the router opened the port, but the external address ({external.Address}) is on a carrier-grade NAT — peers on the public internet will not be able to reach you. Consider Tailscale or the relay instead."
|
||||
: "Status: the router opened the port, but you are behind a carrier-grade NAT — peers on the public internet will not be able to reach you. Consider Tailscale or the relay instead.",
|
||||
RouterMappingStatus.MappingFailed => string.IsNullOrEmpty(lastError)
|
||||
? "Status: the router rejected the port-mapping request."
|
||||
: $"Status: the router rejected the port-mapping request — {lastError}",
|
||||
_ => "",
|
||||
};
|
||||
upnpStatusLabel.Text = text;
|
||||
upnpStatusLabel.AccessibleName = string.IsNullOrEmpty(text) ? "UPnP status" : text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,23 +118,67 @@ internal sealed class ProfileSelectionDialog : Form
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
var prevSelected = listBox.SelectedItem as string;
|
||||
// Remember the previously-selected title (NOT the displayed text — that includes any
|
||||
// " (read-only)" suffix, which would prevent matching across a refresh).
|
||||
var prevSelectedTitle = GetSelectedTitle();
|
||||
listBox.BeginUpdate();
|
||||
listBox.Items.Clear();
|
||||
listBox.Items.Add(BlankTemplateLabel);
|
||||
foreach (var t in store.ListProfileTitles())
|
||||
{
|
||||
listBox.Items.Add(t);
|
||||
// Wrap each title in a ProfileListItem so the displayed text can carry a
|
||||
// "(read-only)" suffix while the underlying title stays clean for store lookups.
|
||||
// ListBox.ToString() is what NVDA reads and what's shown visually; the inner
|
||||
// .Title is what code paths key off. Locked profiles get the suffix so users
|
||||
// know what they're picking before they hit Enter. 2026-05-22.
|
||||
listBox.Items.Add(new ProfileListItem(t, store.IsProfileReadOnly(t)));
|
||||
}
|
||||
// Try to restore selection; fall back to first item.
|
||||
var idx = prevSelected is null ? 0 : Math.Max(0, listBox.Items.IndexOf(prevSelected));
|
||||
listBox.SelectedIndex = Math.Min(idx, listBox.Items.Count - 1);
|
||||
// Try to restore selection by title; fall back to first item.
|
||||
var newIdx = 0;
|
||||
if (!string.IsNullOrEmpty(prevSelectedTitle))
|
||||
{
|
||||
for (var i = 0; i < listBox.Items.Count; i++)
|
||||
{
|
||||
if (string.Equals(TitleOfItem(listBox.Items[i]), prevSelectedTitle, StringComparison.Ordinal))
|
||||
{
|
||||
newIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
listBox.SelectedIndex = Math.Min(newIdx, listBox.Items.Count - 1);
|
||||
listBox.EndUpdate();
|
||||
// Keep the folder label in sync so it always reflects what the listbox is reading.
|
||||
folderLabel.Text = "Profiles folder: " + store.BaseDirectory;
|
||||
folderLabel.AccessibleName = folderLabel.Text;
|
||||
}
|
||||
|
||||
/// <summary>Returns the currently-selected profile title (or the BlankTemplateLabel
|
||||
/// constant for the blank template), unwrapping the ProfileListItem if needed. Returns
|
||||
/// null when nothing is selected. Used by accept / delete to key into the store.</summary>
|
||||
private string? GetSelectedTitle()
|
||||
{
|
||||
var item = listBox.SelectedItem;
|
||||
return TitleOfItem(item);
|
||||
}
|
||||
|
||||
private static string? TitleOfItem(object? item) => item switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
ProfileListItem p => p.Title,
|
||||
_ => item.ToString(),
|
||||
};
|
||||
|
||||
/// <summary>Listbox wrapper for a saved profile. The displayed text (which NVDA reads
|
||||
/// and which appears visually) decorates the title with "(read-only)" when the profile
|
||||
/// JSON has the lock flag set; the Title property stays clean so store lookups by title
|
||||
/// keep working. 2026-05-22.</summary>
|
||||
private sealed record ProfileListItem(string Title, bool ReadOnly)
|
||||
{
|
||||
public override string ToString() => ReadOnly ? $"{Title} (read-only)" : Title;
|
||||
}
|
||||
|
||||
private void OnListKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyCode == Keys.Enter)
|
||||
@@ -153,7 +197,7 @@ internal sealed class ProfileSelectionDialog : Form
|
||||
|
||||
private void Accept()
|
||||
{
|
||||
var selected = listBox.SelectedItem as string;
|
||||
var selected = GetSelectedTitle();
|
||||
if (string.IsNullOrEmpty(selected)) return;
|
||||
if (selected == BlankTemplateLabel)
|
||||
{
|
||||
@@ -178,7 +222,7 @@ internal sealed class ProfileSelectionDialog : Form
|
||||
|
||||
private void DeleteSelected()
|
||||
{
|
||||
var selected = listBox.SelectedItem as string;
|
||||
var selected = GetSelectedTitle();
|
||||
if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return;
|
||||
var result = MessageBox.Show(this,
|
||||
$"Delete profile \"{selected}\"? This cannot be undone.",
|
||||
|
||||
@@ -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>2.0.0</Version>
|
||||
<Version>2.1.0</Version>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -33,6 +33,11 @@
|
||||
no P/Invoke. Lossless, ships as managed IL, smaller files than WAV (~50%) without
|
||||
sample-data loss. -->
|
||||
<PackageReference Include="CUETools.Codecs.FLAKE" Version="1.0.5" />
|
||||
<!-- UPnP / NAT-PMP / PCP client library for automatically opening the audio port on
|
||||
the user's router so peers on the public internet can reach this machine without
|
||||
manual port forwarding. Cross-protocol — picks whichever the router speaks. Used
|
||||
under the AppConfig.UpnpEnabled toggle, off by default. -->
|
||||
<PackageReference Include="Mono.Nat" Version="3.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using Mono.Nat;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Status of the router port mapping attempt — used to drive the inline status label in
|
||||
/// the Preferences dialog.
|
||||
/// </summary>
|
||||
internal enum RouterMappingStatus
|
||||
{
|
||||
/// <summary>The feature is off (the user hasn't enabled UPnP).</summary>
|
||||
Disabled,
|
||||
/// <summary>Looking for a UPnP / NAT-PMP / PCP router on the LAN.</summary>
|
||||
Searching,
|
||||
/// <summary>Mapping opened successfully and the router is reachable.</summary>
|
||||
Mapped,
|
||||
/// <summary>No router with UPnP / NAT-PMP / PCP support was found. Either the router
|
||||
/// doesn't support it, has it disabled, or the network blocks discovery.</summary>
|
||||
NoRouterFound,
|
||||
/// <summary>A router was found and the mapping was added, but the reported external
|
||||
/// address is in the carrier-grade NAT (CGNAT) range — peers on the public internet
|
||||
/// will not be able to reach this machine even though the local router cooperated.</summary>
|
||||
CgnatDetected,
|
||||
/// <summary>A router was found but the mapping attempt failed (port already mapped to
|
||||
/// another device, router rejected the request, etc.). <see cref="LastError"/> has the
|
||||
/// detail.</summary>
|
||||
MappingFailed,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Asks the user's router to forward inbound UDP <see cref="AudioPort"/> traffic to this
|
||||
/// machine, using UPnP / NAT-PMP / PCP via the Mono.Nat library. The point is to spare
|
||||
/// home users from manual port-forwarding when they want peers on the public internet to
|
||||
/// reach them. Mono.Nat picks whichever protocol the router speaks.
|
||||
///
|
||||
/// Off by default and gated by <c>AppConfig.UpnpEnabled</c> — RemSound never pokes the
|
||||
/// router unless the user has explicitly ticked the Preferences checkbox. Failures are
|
||||
/// surfaced via <see cref="StatusChanged"/> and the Preferences status label; they never
|
||||
/// throw or pop a dialog (the network is too lumpy for a popup to be useful).
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// * <see cref="Start"/> kicks off discovery on a background task. When (or if) a router
|
||||
/// replies, the mapping is added and <see cref="StatusChanged"/> fires with
|
||||
/// <see cref="RouterMappingStatus.Mapped"/>.
|
||||
/// * Renewal happens automatically — Mono.Nat extends the lease before it expires.
|
||||
/// * <see cref="Refresh"/> can be called after a sleep / resume cycle to make sure the
|
||||
/// router didn't drop the mapping while the machine was off; this re-runs discovery.
|
||||
/// * <see cref="Stop"/> politely removes the mapping and stops discovery.
|
||||
///
|
||||
/// Detects CGNAT by checking whether the router's reported external address falls in
|
||||
/// <c>100.64.0.0/10</c> (RFC 6598) — when it does, UPnP technically succeeded but the user
|
||||
/// is still unreachable from the public internet because of an upstream ISP NAT layer.
|
||||
/// We surface that as a distinct status so the user understands why peers still can't
|
||||
/// connect and is pointed at Tailscale / the relay instead.
|
||||
/// </summary>
|
||||
internal sealed class RouterPortMapper : IDisposable
|
||||
{
|
||||
/// <summary>The UDP port RemSound uses for audio + heartbeat.</summary>
|
||||
public const int AudioPort = 47830;
|
||||
|
||||
/// <summary>Lease duration on the port mapping, in seconds. The router (and Mono.Nat's
|
||||
/// internal renewal) will refresh this before it expires; we set a deliberately
|
||||
/// short-ish lease so a long sleep on the machine doesn't leave a stale forwarded port
|
||||
/// pointing at us forever.</summary>
|
||||
private const int MappingLeaseSeconds = 3600;
|
||||
|
||||
private readonly Action<string>? log;
|
||||
private readonly object gate = new();
|
||||
private INatDevice? device;
|
||||
private Mapping? mapping;
|
||||
private IPAddress? externalAddress;
|
||||
private string lastError = "";
|
||||
private RouterMappingStatus status = RouterMappingStatus.Disabled;
|
||||
private bool searching;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>Raised whenever <see cref="Status"/> changes. Always fires on a thread-pool
|
||||
/// thread — the caller is responsible for marshaling onto the UI thread if it touches
|
||||
/// UI state.</summary>
|
||||
public event EventHandler? StatusChanged;
|
||||
|
||||
public RouterPortMapper(Action<string>? log = null)
|
||||
{
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/// <summary>Current state of the mapping attempt. Read by the Preferences dialog to
|
||||
/// keep its inline status label up to date.</summary>
|
||||
public RouterMappingStatus Status
|
||||
{
|
||||
get { lock (gate) { return status; } }
|
||||
}
|
||||
|
||||
/// <summary>The external (WAN-side) address and port the router reports for this
|
||||
/// machine when the mapping is open. Null until <see cref="Status"/> is
|
||||
/// <see cref="RouterMappingStatus.Mapped"/> or <see cref="RouterMappingStatus.CgnatDetected"/>.</summary>
|
||||
public IPEndPoint? ExternalEndpoint
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return externalAddress is null ? null : new IPEndPoint(externalAddress, AudioPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Last error message captured during a failed mapping attempt — surfaced in
|
||||
/// the status label so the user has a hint at what's going on.</summary>
|
||||
public string LastError
|
||||
{
|
||||
get { lock (gate) { return lastError; } }
|
||||
}
|
||||
|
||||
/// <summary>Start (or restart) the UPnP discovery + mapping cycle. Safe to call multiple
|
||||
/// times; redundant calls are coalesced.</summary>
|
||||
public void Start()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
if (searching) return;
|
||||
searching = true;
|
||||
status = RouterMappingStatus.Searching;
|
||||
lastError = "";
|
||||
}
|
||||
RaiseChanged();
|
||||
try
|
||||
{
|
||||
NatUtility.DeviceFound += OnDeviceFound;
|
||||
NatUtility.StartDiscovery();
|
||||
log?.Invoke("UPnP discovery started");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
searching = false;
|
||||
status = RouterMappingStatus.MappingFailed;
|
||||
lastError = ex.Message;
|
||||
}
|
||||
log?.Invoke($"UPnP discovery could not start: {ex.GetType().Name}: {ex.Message}");
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
// Mono.Nat doesn't fire DeviceFound at all when the network has no UPnP / NAT-PMP /
|
||||
// PCP router. Without a timeout the status would sit at Searching forever, which the
|
||||
// user-facing label reads as "still trying" indefinitely. Give it a reasonable window
|
||||
// and then declare no-router-found if nothing has replied.
|
||||
ThreadPool.QueueUserWorkItem(_ =>
|
||||
{
|
||||
Thread.Sleep(TimeSpan.FromSeconds(8));
|
||||
bool stillSearching;
|
||||
lock (gate)
|
||||
{
|
||||
stillSearching = searching && status == RouterMappingStatus.Searching;
|
||||
}
|
||||
if (!stillSearching) return;
|
||||
lock (gate)
|
||||
{
|
||||
searching = false;
|
||||
status = RouterMappingStatus.NoRouterFound;
|
||||
lastError = "";
|
||||
}
|
||||
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
||||
log?.Invoke("UPnP discovery timed out — no router responded");
|
||||
RaiseChanged();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Re-run discovery and re-create the mapping. Used by the resume handler to
|
||||
/// recover from routers that drop NAT entries during the user's sleep window.</summary>
|
||||
public void Refresh()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
}
|
||||
log?.Invoke("UPnP refresh requested");
|
||||
// Drop any existing mapping; Start() will rediscover and remap.
|
||||
RemoveMappingBestEffort();
|
||||
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
||||
lock (gate)
|
||||
{
|
||||
searching = false;
|
||||
status = RouterMappingStatus.Disabled;
|
||||
device = null;
|
||||
mapping = null;
|
||||
externalAddress = null;
|
||||
}
|
||||
RaiseChanged();
|
||||
Start();
|
||||
}
|
||||
|
||||
/// <summary>Politely remove the mapping and stop discovery. Safe to call from
|
||||
/// <c>FormClosing</c> or app shutdown.</summary>
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
}
|
||||
RemoveMappingBestEffort();
|
||||
try { NatUtility.StopDiscovery(); } catch { /* ignore */ }
|
||||
try { NatUtility.DeviceFound -= OnDeviceFound; } catch { /* ignore */ }
|
||||
lock (gate)
|
||||
{
|
||||
searching = false;
|
||||
status = RouterMappingStatus.Disabled;
|
||||
device = null;
|
||||
mapping = null;
|
||||
externalAddress = null;
|
||||
lastError = "";
|
||||
}
|
||||
log?.Invoke("UPnP stopped");
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { Stop(); } catch { /* shutting down */ }
|
||||
}
|
||||
|
||||
private void OnDeviceFound(object? sender, DeviceEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var found = args.Device;
|
||||
log?.Invoke($"UPnP device found: {found.GetType().Name}");
|
||||
|
||||
// Add the mapping. Mono.Nat's CreatePortMap is synchronous-but-quick; doing it
|
||||
// on the discovery thread is acceptable. If the same port is already mapped to
|
||||
// a different internal IP, the router will reject — surface that as MappingFailed
|
||||
// so the Preferences label can tell the user.
|
||||
try
|
||||
{
|
||||
var m = new Mapping(Protocol.Udp, AudioPort, AudioPort, MappingLeaseSeconds, "RemSound audio");
|
||||
found.CreatePortMap(m);
|
||||
IPAddress? ext = null;
|
||||
try { ext = found.GetExternalIP(); }
|
||||
catch (Exception ipEx) { log?.Invoke($"UPnP GetExternalIP failed: {ipEx.GetType().Name}: {ipEx.Message}"); }
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
device = found;
|
||||
mapping = m;
|
||||
externalAddress = ext;
|
||||
searching = false;
|
||||
|
||||
// Detect CGNAT — RFC 6598 reserves 100.64.0.0/10 for carrier-grade NAT.
|
||||
// If the router's "external" address is in that range, UPnP succeeded
|
||||
// but we're still behind another tier of NAT we can't open.
|
||||
if (ext is not null && IsCgnatAddress(ext))
|
||||
{
|
||||
status = RouterMappingStatus.CgnatDetected;
|
||||
lastError = "";
|
||||
log?.Invoke($"UPnP mapping added but external address {ext} is in the CGNAT range — peers will not reach this machine via UPnP alone");
|
||||
}
|
||||
else
|
||||
{
|
||||
status = RouterMappingStatus.Mapped;
|
||||
lastError = "";
|
||||
log?.Invoke($"UPnP mapping added: external {ext}:{AudioPort} -> internal :{AudioPort}");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
searching = false;
|
||||
status = RouterMappingStatus.MappingFailed;
|
||||
lastError = ex.Message;
|
||||
}
|
||||
log?.Invoke($"UPnP mapping creation failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"UPnP DeviceFound handler threw: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
RaiseChanged();
|
||||
}
|
||||
|
||||
private void RemoveMappingBestEffort()
|
||||
{
|
||||
INatDevice? d;
|
||||
Mapping? m;
|
||||
lock (gate)
|
||||
{
|
||||
d = device;
|
||||
m = mapping;
|
||||
}
|
||||
if (d is null || m is null) return;
|
||||
try
|
||||
{
|
||||
d.DeletePortMap(m);
|
||||
log?.Invoke($"UPnP mapping removed (port {AudioPort})");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
log?.Invoke($"UPnP mapping removal failed (harmless — the router will expire it): {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCgnatAddress(IPAddress addr)
|
||||
{
|
||||
if (addr.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) return false;
|
||||
var b = addr.GetAddressBytes();
|
||||
// 100.64.0.0/10 — RFC 6598 shared address space for CGNAT.
|
||||
return b[0] == 100 && b[1] >= 64 && b[1] <= 127;
|
||||
}
|
||||
|
||||
private void RaiseChanged()
|
||||
{
|
||||
try { StatusChanged?.Invoke(this, EventArgs.Empty); }
|
||||
catch { /* event handlers shouldn't escape on their own thread */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace RemSound.App;
|
||||
|
||||
/// <summary>
|
||||
/// Brief heads-up shown when the silent-install path is about to swap the app out from
|
||||
/// under the user. Why this exists: the startup-on-launch update check + the
|
||||
/// "silently install" tick combine into a UX where the user opens RemSound, expects it
|
||||
/// to start streaming, and instead the app exits and rebuilds itself a few seconds in.
|
||||
/// Without a notice the user has no idea why the window vanished — they assume a crash.
|
||||
///
|
||||
/// The dialog is deliberately small and short-lived:
|
||||
/// * Default focus and AcceptButton sit on "Install now" so a screen-reader user
|
||||
/// pressing Enter to confirm the dialog gets the same outcome as the countdown
|
||||
/// elapsing.
|
||||
/// * "Skip this version" returns <see cref="DialogResult.Ignore"/> so the caller can
|
||||
/// log the skip and decline to install on this launch.
|
||||
/// * "Postpone (close)" returns <see cref="DialogResult.Cancel"/> — the install will
|
||||
/// be re-attempted on the next periodic poll or next launch.
|
||||
/// * A countdown timer auto-triggers Install after a few seconds so the silent path
|
||||
/// remains effectively silent — the user who walks away from their desk during boot
|
||||
/// gets the install they asked for, while a user who's at the keyboard has a moment
|
||||
/// to intervene. The countdown is announced inline (label text changes), so NVDA
|
||||
/// reads each tick if the user is reading the dialog when it appears.
|
||||
///
|
||||
/// NVDA accessibility: the dialog uses standard WinForms <see cref="Button"/> and
|
||||
/// <see cref="Label"/>; AccessibleName is set explicitly on the heading and countdown
|
||||
/// so the screen reader reads both as the dialog opens. AcceptButton + CancelButton
|
||||
/// are wired so Enter / Esc do the obvious thing.
|
||||
///
|
||||
/// The dialog is intentionally NOT a TaskDialog — the verification-checkbox
|
||||
/// "Do not show me this message again" pattern doesn't fit here because the suppression
|
||||
/// would defeat the entire point of the notice (silent install with NO indication is
|
||||
/// exactly the problem we're solving). If the user doesn't want the heads-up, they can
|
||||
/// untick "Silently install updates" in Preferences.
|
||||
/// </summary>
|
||||
internal sealed class UpdateInstallNoticeDialog : Form
|
||||
{
|
||||
/// <summary>Seconds to wait before auto-confirming Install. Short enough that a user
|
||||
/// who walks away from their desk during boot still gets the update they asked for;
|
||||
/// long enough that someone at the keyboard can read the version and pick a button.</summary>
|
||||
private const int CountdownSeconds = 8;
|
||||
|
||||
private readonly Label headingLabel;
|
||||
private readonly Label countdownLabel;
|
||||
private readonly Button installNowButton;
|
||||
private readonly Button skipButton;
|
||||
private readonly Button postponeButton;
|
||||
private readonly System.Windows.Forms.Timer countdownTimer = new();
|
||||
private int secondsRemaining = CountdownSeconds;
|
||||
|
||||
public UpdateInstallNoticeDialog(UpdateInfo info)
|
||||
{
|
||||
if (info is null) throw new ArgumentNullException(nameof(info));
|
||||
|
||||
Text = "Installing RemSound update";
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
KeyPreview = true;
|
||||
ClientSize = new Size(500, 220);
|
||||
AccessibleName = "Installing RemSound update";
|
||||
|
||||
headingLabel = new Label
|
||||
{
|
||||
Text = $"RemSound {info.Tag} is ready to install.",
|
||||
AccessibleName = $"RemSound {info.Tag} is ready to install",
|
||||
AutoSize = true,
|
||||
Font = new Font(Font, FontStyle.Bold),
|
||||
Padding = new Padding(0, 0, 0, 8),
|
||||
};
|
||||
|
||||
// Body — explains what's about to happen so the user isn't surprised by the exit.
|
||||
// Wrapped Label rather than TextBox because TextBox steals focus from the default
|
||||
// button and breaks the NVDA reading order.
|
||||
var bodyLabel = new Label
|
||||
{
|
||||
Text = "RemSound will install the update and restart automatically. Your session will pick up again once the new version is running.",
|
||||
AccessibleName = "RemSound will install the update and restart automatically. Your session will pick up again once the new version is running.",
|
||||
AutoSize = false,
|
||||
Width = 460,
|
||||
Height = 50,
|
||||
Padding = new Padding(0, 0, 0, 8),
|
||||
};
|
||||
|
||||
countdownLabel = new Label
|
||||
{
|
||||
Text = FormatCountdownText(secondsRemaining),
|
||||
AccessibleName = FormatCountdownText(secondsRemaining),
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 0, 0, 8),
|
||||
};
|
||||
|
||||
installNowButton = new Button
|
||||
{
|
||||
Text = "&Install now",
|
||||
AccessibleName = "Install now",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.OK,
|
||||
TabIndex = 0,
|
||||
};
|
||||
skipButton = new Button
|
||||
{
|
||||
Text = "&Skip this version",
|
||||
AccessibleName = "Skip this version",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.Ignore,
|
||||
TabIndex = 1,
|
||||
};
|
||||
postponeButton = new Button
|
||||
{
|
||||
Text = "&Postpone",
|
||||
AccessibleName = "Postpone",
|
||||
AutoSize = true,
|
||||
DialogResult = DialogResult.Cancel,
|
||||
TabIndex = 2,
|
||||
};
|
||||
|
||||
// Any explicit click stops the countdown — the user has made a choice and we
|
||||
// shouldn't elapse-fire underneath them.
|
||||
installNowButton.Click += (_, _) => countdownTimer.Stop();
|
||||
skipButton.Click += (_, _) => countdownTimer.Stop();
|
||||
postponeButton.Click += (_, _) => countdownTimer.Stop();
|
||||
|
||||
var body = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Padding = new Padding(16, 14, 16, 12),
|
||||
ColumnCount = 1,
|
||||
RowCount = 4,
|
||||
};
|
||||
body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.AutoSize));
|
||||
body.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
|
||||
body.Controls.Add(headingLabel, 0, 0);
|
||||
body.Controls.Add(bodyLabel, 0, 1);
|
||||
body.Controls.Add(countdownLabel, 0, 2);
|
||||
|
||||
var buttonRow = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
AutoSize = true,
|
||||
Padding = new Padding(0, 0, 16, 12),
|
||||
};
|
||||
// RightToLeft fills back to front, so add the rightmost button (Install now) first.
|
||||
buttonRow.Controls.Add(installNowButton);
|
||||
buttonRow.Controls.Add(postponeButton);
|
||||
buttonRow.Controls.Add(skipButton);
|
||||
|
||||
Controls.Add(body);
|
||||
Controls.Add(buttonRow);
|
||||
|
||||
AcceptButton = installNowButton;
|
||||
CancelButton = postponeButton;
|
||||
|
||||
// Esc = postpone (matches CancelButton). Avoids the "I just opened the app, where
|
||||
// did the window go" surprise if the user mashes Esc to dismiss whatever popped up.
|
||||
KeyDown += (_, e) =>
|
||||
{
|
||||
if (e.KeyCode == Keys.Escape)
|
||||
{
|
||||
countdownTimer.Stop();
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
e.SuppressKeyPress = true;
|
||||
e.Handled = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Tick once per second, decrement, refresh the label, and fire OK when we hit zero.
|
||||
countdownTimer.Interval = 1000;
|
||||
countdownTimer.Tick += (_, _) =>
|
||||
{
|
||||
secondsRemaining--;
|
||||
if (secondsRemaining <= 0)
|
||||
{
|
||||
countdownTimer.Stop();
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
return;
|
||||
}
|
||||
countdownLabel.Text = FormatCountdownText(secondsRemaining);
|
||||
countdownLabel.AccessibleName = countdownLabel.Text;
|
||||
};
|
||||
|
||||
// Start the countdown when the dialog appears, not when it's constructed —
|
||||
// construction can happen a moment before ShowDialog hands the user focus.
|
||||
Shown += (_, _) =>
|
||||
{
|
||||
countdownTimer.Start();
|
||||
// Make sure the default action is Install (matches AcceptButton). Without this
|
||||
// tab focus might rest on the first added Control rather than the intended
|
||||
// primary action.
|
||||
installNowButton.Focus();
|
||||
};
|
||||
|
||||
FormClosed += (_, _) => countdownTimer.Stop();
|
||||
}
|
||||
|
||||
private static string FormatCountdownText(int seconds)
|
||||
{
|
||||
return seconds == 1
|
||||
? "Installing in 1 second... Press Skip or Postpone to choose otherwise."
|
||||
: $"Installing in {seconds} seconds... Press Skip or Postpone to choose otherwise.";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user