Initial commit: RemSound v1.0

This commit is contained in:
Ednunp
2026-05-13 15:15:27 +01:00
commit 17259438c6
80 changed files with 17892 additions and 0 deletions
+130
View File
@@ -0,0 +1,130 @@
using System.Reflection;
namespace RemSound.App;
/// <summary>
/// About dialog. Shows the running version, a short blurb about RemSound and the latest
/// release notes (built in below — bumped per release alongside the project's
/// <see cref="System.Version"/> property).
///
/// Layout follows the same NVDA-friendly conventions the rest of the app uses: a small
/// modal dialog with a heading label, a read-only multi-line text box for the notes that
/// the user can tab into and arrow through, and a Close button as the AcceptButton /
/// CancelButton. Escape dismisses.
/// </summary>
internal sealed class AboutDialog : Form
{
/// <summary>Markdown-ish release notes shown in the About box's scrolling text area.
/// Bumped per release. Keep it short — the canonical release notes also live on the
/// GitHub Releases page, which the user can reach via the Help menu's "Check for
/// updates" path.</summary>
private const string ReleaseNotes =
"""
RemSound v1.0
Initial public release.
Highlights:
* Low-latency peer-to-peer audio over UDP. WASAPI for any Windows audio device,
and a parallel ASIO lane for pro audio interfaces (Audient, Komplete Audio,
Focusrite, RME). Each lane keeps its own native callback latency.
* Pick an ASIO driver from the dropdown at the top of the Audio inputs and outputs
tab to bring ASIO into the pipeline; select "(none)" to run WASAPI-only.
* Profile system. Save your entire setup device ticks, peers, codec, latency
targets, hotkeys, ASIO driver choice into a JSON file. Pick which profile to
load at every launch.
* Continuous auto-tune on either lane. Watches receive jitter and nudges the
latency target up or down to stay click-free without forcing you to overshoot.
* Opus inband FEC. Single-packet losses recover transparently in both Opus modes;
you don't hear them at all. PCM is also available for clean LAN connections.
* Remote control. Configurable global hotkeys can nudge a peer's RemSound volume
or their Windows default-output-device master volume, opt-in on the receiver.
* Built-in self-updater. Optionally polls GitHub for newer releases on a schedule
you set; can install them silently if you want.
See the user manual (Help menu, or F1 from anywhere in the app) for full details
on every control, the keyboard shortcuts, and the troubleshooting guide.
""";
public AboutDialog()
{
Text = "About RemSound";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(560, 420);
var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "?";
var headingLabel = new Label
{
Text = $"RemSound version {version}",
AutoSize = true,
Font = new Font(SystemFonts.MessageBoxFont!.FontFamily, 11f, FontStyle.Bold),
AccessibleName = $"RemSound version {version}",
};
var notesBox = new TextBox
{
Multiline = true,
ReadOnly = true,
TabStop = true,
Dock = DockStyle.Fill,
ScrollBars = ScrollBars.Vertical,
BorderStyle = BorderStyle.FixedSingle,
Text = ReleaseNotes,
AccessibleName = "Release notes (tab into and arrow to read)",
};
var closeButton = new Button
{
Text = "Close",
AutoSize = true,
DialogResult = DialogResult.OK,
TabIndex = 1,
};
closeButton.Click += (_, _) => Close();
notesBox.TabIndex = 0;
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 3,
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
};
buttons.Controls.Add(closeButton);
root.Controls.Add(headingLabel, 0, 0);
root.Controls.Add(notesBox, 0, 1);
root.Controls.Add(buttons, 0, 2);
Controls.Add(root);
AcceptButton = closeButton;
CancelButton = closeButton;
KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Escape)
{
Close();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
}
}
+65
View File
@@ -0,0 +1,65 @@
using System.Runtime.InteropServices;
namespace RemSound.App;
/// <summary>
/// Direct NotifyWinEvent shim. WinForms focus changes nominally fire MSAA EVENT_OBJECT_FOCUS,
/// but in some scenarios (focus moving from a key handler that runs synchronously in
/// ProcessCmdKey, focus into a control inside a wrapper container, etc.) NVDA's screen-reader
/// listener doesn't pick up the announcement. Re-firing the event explicitly forces it.
///
/// Same pattern documented in claude-notes.md for the AccessibleCheckBox state-change fix —
/// "the load-bearing piece is the FOCUS re-fire."
/// </summary>
internal static class WinEventNotifier
{
private const uint EVENT_OBJECT_FOCUS = 0x8005;
private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC);
private const int CHILDID_SELF = 0;
[DllImport("user32.dll")]
private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild);
public static void NotifyFocus(Control control)
{
if (control.IsHandleCreated)
{
NotifyWinEvent(EVENT_OBJECT_FOCUS, control.Handle, OBJID_CLIENT, CHILDID_SELF);
}
}
}
/// <summary>
/// CheckBox variant that fires the right MSAA WinEvents on every state change so NVDA
/// reliably announces "checked" / "not checked" — including for spacebar toggles while the
/// checkbox already has focus, which is the failure mode plain WinForms CheckBox has on
/// .NET 10. The recipe (proven in the loxone desktop app):
/// 1. Fire EVENT_OBJECT_STATECHANGE so any listener knows the toggle state changed.
/// 2. If the checkbox is currently focused, ALSO re-fire EVENT_OBJECT_FOCUS — this is what
/// forces NVDA to re-announce the focused control, bringing the new state with it.
/// We call <c>user32.NotifyWinEvent</c> directly because the managed
/// <see cref="Control.AccessibilityNotifyClients"/> path only fires the state event without
/// the focus re-fire, which leaves NVDA silent.
/// </summary>
internal sealed class AccessibleCheckBox : CheckBox
{
private const uint EVENT_OBJECT_FOCUS = 0x8005;
private const uint EVENT_OBJECT_STATECHANGE = 0x800A;
private const int OBJID_CLIENT = unchecked((int)0xFFFFFFFC);
private const int CHILDID_SELF = 0;
[DllImport("user32.dll")]
private static extern void NotifyWinEvent(uint eventMin, nint hwnd, int idObject, int idChild);
protected override void OnCheckedChanged(EventArgs e)
{
base.OnCheckedChanged(e);
if (!IsHandleCreated) return;
NotifyWinEvent(EVENT_OBJECT_STATECHANGE, Handle, OBJID_CLIENT, CHILDID_SELF);
if (Focused)
{
NotifyWinEvent(EVENT_OBJECT_FOCUS, Handle, OBJID_CLIENT, CHILDID_SELF);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
using NAudio.CoreAudioApi;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Enumerates currently active Windows audio endpoints, separately for output (render — used
/// for loopback capture) and input (capture — mics, line-ins) devices. Used by the App to
/// populate the two send-device check-lists.
///
/// Selection state is intentionally NOT persisted: every session starts with all checkboxes
/// unticked and nothing being sent. The user re-ticks once per session. Stops the
/// "wrong-device-still-checked" surprise after a card unplug, ID change, etc.
/// </summary>
internal static class AudioDeviceCatalog
{
public static IReadOnlyList<AudioDeviceChoice> LoadOutputs()
{
using var enumerator = new MMDeviceEnumerator();
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active).ToList();
var choices = devices
.Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Loopback))
.ToList();
foreach (var device in devices) device.Dispose();
return choices;
}
public static IReadOnlyList<AudioDeviceChoice> LoadInputs()
{
using var enumerator = new MMDeviceEnumerator();
var devices = enumerator.EnumerateAudioEndPoints(DataFlow.Capture, DeviceState.Active).ToList();
var choices = devices
.Select(d => new AudioDeviceChoice(d.FriendlyName, d.ID, CaptureKind.Input))
.ToList();
foreach (var device in devices) device.Dispose();
return choices;
}
}
+115
View File
@@ -0,0 +1,115 @@
namespace RemSound.App;
internal static class FormLayoutRows
{
public static void AddRow(TableLayoutPanel panel, int row, string labelText, Control control, Action<Control> focusControl)
{
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = control };
label.Click += (_, _) => focusControl(control);
panel.Controls.Add(label, 0, row);
panel.Controls.Add(control, 1, row);
}
public static MnemonicLabel AddCheckedListRow(TableLayoutPanel panel, int row, string labelText, CheckedListBox list, Label statusLabel, Action<CheckedListBox> focusList)
{
// Restored the FlowLayoutPanel wrapping (matches the legacy/working RSound layout).
// Removing it caused NVDA to mis-pair labels with controls (off-by-one shift across
// the form), so the keyboard-shortcut announcements went to the wrong controls. The
// wrapping isn't ideal for label.ProcessMnemonic forwarding, but ProcessCmdKey handles
// the actual Alt+letter activation explicitly so we don't need to rely on that path.
// Returns the label so callers can update its text on mode changes (e.g. ASIO ⇄ WASAPI).
var label = new MnemonicLabel { Text = labelText, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = list };
label.Click += (_, _) => focusList(list);
panel.Controls.Add(label, 0, row);
var container = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.TopDown,
WrapContents = false,
TabStop = false,
};
container.Controls.Add(list);
container.Controls.Add(statusLabel);
panel.Controls.Add(container, 1, row);
return label;
}
}
/// <summary>
/// Form that exposes a delegate hook for ProcessCmdKey, so the dialog's Alt+letter activations
/// can be wired up from the closure-based dialog construction code without subclassing Form per
/// dialog type. Every Alt+key combo passes through the delegate first; if it returns true the
/// keystroke is consumed.
/// </summary>
internal sealed class CmdKeyForm : Form
{
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public Func<Keys, bool>? CmdKeyHandler { get; set; }
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (CmdKeyHandler is { } handler && handler(keyData)) return true;
return base.ProcessCmdKey(ref msg, keyData);
}
}
/// <summary>
/// TabControl subclass that suppresses the parent role announcement NVDA picks up from the
/// modern .NET 10 UIA exposure. The base class reports itself as a UIA Tab control type;
/// NVDA reads "tab control" before each tab's name. By returning AccessibleRole.None from
/// our custom AccessibleObject we hide the parent's role from screen readers entirely.
///
/// Andre's accessible-readout app reads cleanly because it's compiled against .NET Framework
/// 4.x, whose WinForms TabControl exposes less detail to MSAA/UIA. .NET 10 added more, and
/// Microsoft removed the opt-out — so this subclass is the only path.
///
/// Risk: dotnet/winforms#11831 was filed against .NET 8 reporting that overriding
/// CreateAccessibilityInstance throws InvalidOperationException. Status uncertain on .NET 10.
/// If we hit that exception at runtime, the fallback is to remove the override and accept
/// the announcement.
/// </summary>
internal sealed class QuietTabControl : TabControl
{
protected override AccessibleObject CreateAccessibilityInstance()
=> new QuietAcc(this);
private sealed class QuietAcc : ControlAccessibleObject
{
public QuietAcc(Control owner) : base(owner) { }
// None hides the role from screen readers. NVDA falls through to the focused TabItem
// child whose role is "tab" — and reads only that. No "tab control" prefix.
public override AccessibleRole Role => AccessibleRole.None;
// Empty name so NVDA doesn't read a parent name either.
public override string? Name { get => string.Empty; set { } }
}
}
/// <summary>
/// Label that forwards its Alt+letter mnemonic activation to an explicit target control rather
/// than to "the next focusable control" (the default WinForms behaviour). Necessary because
/// SelectNextControl is unreliable across container boundaries — when a list is wrapped in a
/// FlowLayoutPanel for layout purposes, the default mnemonic walk skips past the panel and
/// focuses whatever comes after it in the parent panel.
/// </summary>
internal sealed class MnemonicLabel : Label
{
[System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)]
public Control? MnemonicTarget { get; set; }
protected override bool ProcessMnemonic(char charCode)
{
if (!UseMnemonic || !IsMnemonic(charCode, Text)) return false;
if (MnemonicTarget is { } target && target.CanFocus)
{
target.Focus();
// Force NVDA to re-announce. Same load-bearing pattern the AccessibleCheckBox uses
// for state changes — the WinForms-built-in focus event isn't always picked up by
// the screen reader, especially when focus moves into a control wrapped in a
// FlowLayoutPanel via a synchronous Focus() call.
WinEventNotifier.NotifyFocus(target);
return true;
}
return false;
}
}
+81
View File
@@ -0,0 +1,81 @@
using System.Diagnostics;
namespace RemSound.App;
/// <summary>
/// Opens the bundled <c>readme.html</c> manual in the user's default browser. Wired to F1
/// app-wide via <see cref="HelpKeyMessageFilter"/>, which is installed once at startup and
/// catches F1 (without modifiers) before the message reaches any control. Works in every
/// modal dialog and on the very first form the user sees (the profile picker), because the
/// filter is registered before the first <c>ShowDialog</c>/<c>Application.Run</c> call.
///
/// File location: <c>&lt;exe&gt;\readme.html</c> (resolved via <see cref="AppContext.BaseDirectory"/>).
/// The .csproj copies it from the project root via a Content/Link rule so a fresh
/// <c>dotnet publish</c> always lands a current copy next to the executable.
/// </summary>
internal static class HelpLauncher
{
/// <summary>Open the manual via Windows' shell association (default browser). Shows a
/// MessageBox if the file is missing or shell-execute fails — better to surface an
/// explanation than silently swallow the F1.</summary>
public static void OpenManual()
{
var path = Path.Combine(AppContext.BaseDirectory, "readme.html");
if (!File.Exists(path))
{
MessageBox.Show(
$"Manual not found at:\n\n{path}\n\nThe readme.html file should sit next to RemSound.exe. Re-publishing the build will restore it.",
"Manual not found",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
return;
}
try
{
// UseShellExecute=true is the load-bearing flag — it lets the OS pick the .html
// handler (Edge / Chrome / Firefox / whatever the user defaulted). Without it
// Process.Start would treat the .html as an executable and fail.
Process.Start(new ProcessStartInfo(path) { UseShellExecute = true });
}
catch (Exception ex)
{
MessageBox.Show(
$"Could not open the manual:\n\n{ex.Message}",
"Manual open failed",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
/// <summary>Install the F1-catches-help message filter on the current thread's message
/// loop. Call once from <c>Program.Main</c> before any form is shown. Idempotent — calling
/// it twice would register two filters which is wasteful but not harmful.</summary>
public static void Install()
{
Application.AddMessageFilter(new HelpKeyMessageFilter());
}
}
/// <summary>
/// Catches F1 keypresses anywhere in the application before they reach the focused control.
/// Modifier-aware: bare F1 only — Ctrl+F1, Shift+F1, Alt+F1 fall through unchanged so we
/// don't steal future combos. Single-instance state is fine because the filter chain is
/// per-thread and RemSound is a single-threaded WinForms app.
/// </summary>
internal sealed class HelpKeyMessageFilter : IMessageFilter
{
private const int WM_KEYDOWN = 0x0100;
private const int WM_SYSKEYDOWN = 0x0104;
private const int VK_F1 = 0x70;
public bool PreFilterMessage(ref Message m)
{
if (m.Msg != WM_KEYDOWN && m.Msg != WM_SYSKEYDOWN) return false;
if (m.WParam.ToInt32() != VK_F1) return false;
// Bare F1 only — modifier combos are passed through. Lets a future "Shift+F1" do
// something else (context help, etc.) without colliding with us.
if ((Control.ModifierKeys & (Keys.Control | Keys.Shift | Keys.Alt)) != Keys.None) return false;
HelpLauncher.OpenManual();
return true; // consumed — no further dispatch
}
}
File diff suppressed because it is too large Load Diff
+98
View File
@@ -0,0 +1,98 @@
using System.Windows.Forms;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// CheckedListBox subclass that exposes the protected <c>RefreshItem</c> method publicly. Used
/// for the connectivity dialog's "Connected peers" list, where each row's text is updated in
/// place (live RTT, codec, direction) without re-adding items — that would destroy NVDA's row
/// focus on every tick.
/// </summary>
internal sealed class LiveCheckedListBox : CheckedListBox
{
public void RefreshItemPublic(int index) => RefreshItem(index);
}
/// <summary>
/// User-facing choice that maps a friendly label to a codec + Opus frame size pair. The frame
/// size is only meaningful when Codec == Opus; for PCM it's ignored.
/// </summary>
internal sealed record CodecChoice(string Label, AudioTransportCodec Codec, int OpusFrameMs)
{
public override string ToString() => Label;
}
internal sealed record AudioDeviceChoice(string Name, string? DeviceId, CaptureKind Kind = CaptureKind.Loopback)
{
public override string ToString() => Name;
}
internal sealed record RememberedPeerItem(string Entry)
{
public override string ToString() => Entry;
}
/// <summary>
/// Live per-peer status surfaced in the connectivity dialog's listbox text. Mutated in place
/// each tick by MainForm.SyncAllDialogPeerLists, then ListBox.RefreshItem(i) is called on the
/// containing item so the visible label updates without rebuilding the listbox (which would
/// destroy NVDA focus on the row).
/// </summary>
internal sealed class PeerLineStatus
{
public bool Connected;
/// <summary>True when our sender is actively pushing audio at this peer.</summary>
public bool Sending;
/// <summary>True when audio is arriving from this peer's IP (a fresh receiver session exists).</summary>
public bool Receiving;
/// <summary>Codec label like "Opus 10ms", "Opus 20ms", "PCM". Null when not connected.</summary>
public string? CodecLabel;
/// <summary>Round-trip ping ms from heartbeat. Null when not connected or pending.</summary>
public int? RttMs;
}
internal sealed class PeerListItem
{
public PeerAnnouncement Peer { get; }
public PeerLineStatus Status { get; } = new();
public PeerListItem(PeerAnnouncement peer) { Peer = peer; }
/// <summary>
/// Stable identity for signature-based listbox-rebuild detection. Does NOT include live
/// status — that gets updated in-place via RefreshItem so NVDA focus survives tick updates.
/// </summary>
public string StableKey() => $"{Peer.InstanceId}:{Peer.Name}:{Peer.Address}:{Peer.AudioPort}";
public override string ToString()
{
// Base label: "hostname (ip)" for discovered peers, just "ip" for manual-by-IP entries
// (where hostname equals the IP address). Avoids "192.168.1.95 (192.168.1.95)" duplication.
var addr = Peer.Address.ToString();
var basePart = Peer.Name == addr ? addr : $"{Peer.Name} ({addr})";
if (!Status.Connected)
{
return basePart;
}
// Connected line — extra metadata after a dash. Comma-separated so NVDA reads naturally:
// "Andre's PC (1.2.3.4) — connected, Opus 10ms, send and receive, 32ms"
var parts = new List<string> { "connected" };
if (Status.CodecLabel is { Length: > 0 } codec) parts.Add(codec);
var direction = (Status.Sending, Status.Receiving) switch
{
(true, true) => "send and receive",
(true, false) => "send only",
(false, true) => "receive only",
_ => null,
};
if (direction is not null) parts.Add(direction);
if (Status.RttMs is { } rtt) parts.Add($"{rtt}ms");
return $"{basePart} — {string.Join(", ", parts)}";
}
}
@@ -0,0 +1,605 @@
using System.Runtime.InteropServices;
using RemSound.Core;
namespace RemSound.App;
internal sealed class MainFormHotkeyController : IDisposable
{
private readonly RemSoundSettingsStore settingsStore;
private readonly Action toggleSend;
private readonly Action toggleReceive;
private readonly Action toggleTray;
private readonly Action volumeUp;
private readonly Action volumeDown;
// Remote control hotkeys: trigger this machine to send a Control packet to its connected
// peers. The local volume slider on this machine isn't touched — receivers that have opted
// in handle the change. See Profile.AcceptRemoteVolumeCommands and the RemPacketType.Control
// wire format.
// * sendRemote* → adjust the receiver's RemSound app volume slider (in-app).
// * sendSystem* → adjust the receiver's Windows default-output-device volume
// (system-wide on the receiving machine — affects every app
// there, including the screen reader).
private readonly Action sendRemoteVolumeUp;
private readonly Action sendRemoteVolumeDown;
private readonly Action sendRemoteMuteToggle;
private readonly Action sendSystemVolumeUp;
private readonly Action sendSystemVolumeDown;
private readonly Action sendSystemMuteToggle;
private Form? owner;
private HotkeyInfo sendMuteHotkey;
private HotkeyInfo receiveMuteHotkey;
private HotkeyInfo trayHotkey;
private HotkeyInfo volumeUpHotkey;
private HotkeyInfo volumeDownHotkey;
private HotkeyInfo remoteVolumeUpHotkey;
private HotkeyInfo remoteVolumeDownHotkey;
private HotkeyInfo remoteMuteToggleHotkey;
private HotkeyInfo systemVolumeUpHotkey;
private HotkeyInfo systemVolumeDownHotkey;
private HotkeyInfo systemMuteToggleHotkey;
private GlobalHotkey? sendMuteGlobalHotkey;
private GlobalHotkey? receiveMuteGlobalHotkey;
private GlobalHotkey? trayGlobalHotkey;
private GlobalHotkey? volumeUpGlobalHotkey;
private GlobalHotkey? volumeDownGlobalHotkey;
private GlobalHotkey? remoteVolumeUpGlobalHotkey;
private GlobalHotkey? remoteVolumeDownGlobalHotkey;
private GlobalHotkey? remoteMuteToggleGlobalHotkey;
private GlobalHotkey? systemVolumeUpGlobalHotkey;
private GlobalHotkey? systemVolumeDownGlobalHotkey;
private GlobalHotkey? systemMuteToggleGlobalHotkey;
/// <summary>Optional log sink. MainForm wires this to <c>logFile.Event(...)</c> so each
/// hotkey change writes a clear trail of "user opened capture", "captured X", "registered X
/// successfully" / "registration FAILED with Win32 error N" to the diagnostic log. Lets
/// us tell the difference between a capture that didn't fire, a save that didn't persist,
/// and a Windows-side RegisterHotKey rejection.</summary>
public Action<string>? Log { get; set; }
/// <summary>Optional callback fired when the user successfully captures and saves a new
/// hotkey via the Keyboard shortcuts dialog. MainForm wires this to MarkProfileDirty so
/// the unsaved-changes prompt fires on close and the user gets a Save reminder. Without
/// this hook, hotkey edits silently bypass the dirty-flag and the user finds out their
/// new bindings never made it into the profile JSON.</summary>
public Action? OnHotkeyChanged { get; set; }
public MainFormHotkeyController(
RemSoundSettingsStore settingsStore,
Action toggleSend,
Action toggleReceive,
Action toggleTray,
Action volumeUp,
Action volumeDown,
Action sendRemoteVolumeUp,
Action sendRemoteVolumeDown,
Action sendRemoteMuteToggle,
Action sendSystemVolumeUp,
Action sendSystemVolumeDown,
Action sendSystemMuteToggle)
{
this.settingsStore = settingsStore;
this.toggleSend = toggleSend;
this.toggleReceive = toggleReceive;
this.toggleTray = toggleTray;
this.volumeUp = volumeUp;
this.volumeDown = volumeDown;
this.sendRemoteVolumeUp = sendRemoteVolumeUp;
this.sendRemoteVolumeDown = sendRemoteVolumeDown;
this.sendRemoteMuteToggle = sendRemoteMuteToggle;
this.sendSystemVolumeUp = sendSystemVolumeUp;
this.sendSystemVolumeDown = sendSystemVolumeDown;
this.sendSystemMuteToggle = sendSystemMuteToggle;
sendMuteHotkey = settingsStore.LoadSendMuteHotkey();
receiveMuteHotkey = settingsStore.LoadReceiveMuteHotkey();
trayHotkey = settingsStore.LoadTrayHotkey();
volumeUpHotkey = settingsStore.LoadVolumeUpHotkey();
volumeDownHotkey = settingsStore.LoadVolumeDownHotkey();
remoteVolumeUpHotkey = settingsStore.LoadRemoteVolumeUpHotkey();
remoteVolumeDownHotkey = settingsStore.LoadRemoteVolumeDownHotkey();
remoteMuteToggleHotkey = settingsStore.LoadRemoteMuteToggleHotkey();
systemVolumeUpHotkey = settingsStore.LoadSystemVolumeUpHotkey();
systemVolumeDownHotkey = settingsStore.LoadSystemVolumeDownHotkey();
systemMuteToggleHotkey = settingsStore.LoadSystemMuteToggleHotkey();
}
public void Initialize(Form ownerForm)
{
owner = ownerForm;
sendMuteGlobalHotkey = new GlobalHotkey(ownerForm);
receiveMuteGlobalHotkey = new GlobalHotkey(ownerForm);
trayGlobalHotkey = new GlobalHotkey(ownerForm);
volumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
volumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
remoteVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
remoteVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
remoteMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
systemVolumeUpGlobalHotkey = new GlobalHotkey(ownerForm);
systemVolumeDownGlobalHotkey = new GlobalHotkey(ownerForm);
systemMuteToggleGlobalHotkey = new GlobalHotkey(ownerForm);
sendMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleSend);
receiveMuteGlobalHotkey.Pressed += () => InvokeOnOwner(toggleReceive);
trayGlobalHotkey.Pressed += () => InvokeOnOwner(toggleTray);
volumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(volumeUp);
volumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(volumeDown);
remoteVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeUp);
remoteVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteVolumeDown);
remoteMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendRemoteMuteToggle);
systemVolumeUpGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeUp);
systemVolumeDownGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemVolumeDown);
systemMuteToggleGlobalHotkey.Pressed += () => InvokeOnOwner(sendSystemMuteToggle);
RegisterSendMuteHotkey();
RegisterReceiveMuteHotkey();
RegisterTrayHotkey();
RegisterVolumeUpHotkey();
RegisterVolumeDownHotkey();
RegisterRemoteVolumeUpHotkey();
RegisterRemoteVolumeDownHotkey();
RegisterRemoteMuteToggleHotkey();
RegisterSystemVolumeUpHotkey();
RegisterSystemVolumeDownHotkey();
RegisterSystemMuteToggleHotkey();
}
public void ShowKeyboardShortcutsDialog(IWin32Window dialogOwner)
{
// Modeled on the SpaceBlaster menu dialogs:
// * A ListBox fills the dialog. Each row is one bindable hotkey shown as
// "Action: current binding" — self-describing for NVDA on arrow-up/down.
// * Enter on the list (or double-click) → opens the capture form for that row.
// * Escape (or the Close button) closes the dialog.
// * Tab cycles list → Close button. No "Change selected" intermediate button —
// 2026-05-08 cleanup; the workflow is "arrow + Enter" exclusively, removing
// the extra Tab-to-button step the user had to make for every change.
//
// Why a ListBox instead of one Button per row: the hotkey count grew to eleven
// (5 local + 3 remote-app + 3 system-volume) and the per-row Button stack made
// arrow-key / Tab navigation slow. ListBox is one focusable control with native
// arrow-key navigation and NVDA reads each item as the selection moves — much
// quicker to triage which binding you want to change.
// CmdKeyForm gives us a ProcessCmdKey hook that runs BEFORE the form's
// ProcessDialogKey path (which is what would fire AcceptButton on Enter). We
// need that to make Enter-on-the-list rebind a hotkey instead of closing the
// dialog. Without this, AcceptButton swallowed Enter regardless of which
// control had focus and the user got bounced straight back to the Profiles
// and preferences tab. (KeyPreview + the form-level KeyDown wasn't enough on
// its own — that fires AFTER ProcessCmdKey/ProcessDialogKey, so AcceptButton
// had already won.)
using var dialog = new CmdKeyForm
{
Text = "Keyboard shortcuts",
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
ShowInTaskbar = false,
KeyPreview = true, // form-level Esc handler
ClientSize = new Size(640, 440),
};
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 3, // 0 intro, 1 list, 2 buttons
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var introLabel = new Label
{
Text = "Arrow up and down to pick a shortcut. Press Enter to rebind it, Del to clear it. Escape closes the dialog.\n\n"
+ "The remote-control rows send commands to connected peers; they only have an effect on peers that have 'Accept remote volume commands from peers' enabled.",
AutoSize = true,
MaximumSize = new Size(600, 0),
Anchor = AnchorStyles.Left,
};
root.Controls.Add(introLabel, 0, 0);
var list = new ListBox
{
Dock = DockStyle.Fill,
IntegralHeight = false,
// NVDA reads this on first focus, then the per-item text on each arrow move.
AccessibleName = "Keyboard shortcuts",
TabIndex = 0,
};
root.Controls.Add(list, 0, 1);
var buttonsPanel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 8, 0, 0),
};
var closeButton = new Button { Text = "Close", AutoSize = true, DialogResult = DialogResult.OK, TabIndex = 1 };
buttonsPanel.Controls.Add(closeButton);
root.Controls.Add(buttonsPanel, 0, 2);
dialog.Controls.Add(root);
// The list rows correspond to the order below. Index → which Change* helper to call.
// Stable ordering keeps the user's muscle memory between sessions: local hotkeys
// first, then the remote-app trio, then the Windows system-volume trio.
void RefreshList()
{
var prev = list.SelectedIndex;
list.BeginUpdate();
list.Items.Clear();
list.Items.Add($"Toggle sending audio: {sendMuteHotkey}");
list.Items.Add($"Toggle receiving audio: {receiveMuteHotkey}");
list.Items.Add($"Show or hide window: {trayHotkey}");
list.Items.Add($"Volume up for received sound on this machine: {volumeUpHotkey}");
list.Items.Add($"Volume down for received sound on this machine: {volumeDownHotkey}");
list.Items.Add($"Send remote volume up to peers: {remoteVolumeUpHotkey}");
list.Items.Add($"Send remote volume down to peers: {remoteVolumeDownHotkey}");
list.Items.Add($"Send remote receive mute toggle to peers: {remoteMuteToggleHotkey}");
list.Items.Add($"Send Windows global volume up to peers: {systemVolumeUpHotkey}");
list.Items.Add($"Send Windows global volume down to peers: {systemVolumeDownHotkey}");
list.Items.Add($"Send Windows global mute toggle to peers: {systemMuteToggleHotkey}");
if (prev >= 0 && prev < list.Items.Count)
{
list.SelectedIndex = prev;
}
else if (list.Items.Count > 0)
{
list.SelectedIndex = 0;
}
list.EndUpdate();
}
void ChangeSelected()
{
// Pass `dialog` (the shortcuts dialog itself) as the modal owner of the
// HotkeyCaptureForm — NOT the original `dialogOwner` (which is MainForm).
// Original bug: when MainForm was the owner, the new capture form was modal
// to MainForm rather than to the shortcuts dialog, which (a) put it behind
// the still-modal-to-MainForm shortcuts dialog in the Z-order, sometimes
// invisibly so, and (b) created two parallel modal-to-MainForm chains. With
// `dialog` as the owner, the capture form sits cleanly on top of the
// shortcuts dialog, the shortcuts dialog is correctly disabled while it's
// showing, and focus returns to the shortcuts list when it closes.
switch (list.SelectedIndex)
{
case 0: ChangeSendMuteHotkey(dialog); break;
case 1: ChangeReceiveMuteHotkey(dialog); break;
case 2: ChangeTrayHotkey(dialog); break;
case 3: ChangeVolumeUpHotkey(dialog); break;
case 4: ChangeVolumeDownHotkey(dialog); break;
case 5: ChangeRemoteVolumeUpHotkey(dialog); break;
case 6: ChangeRemoteVolumeDownHotkey(dialog); break;
case 7: ChangeRemoteMuteToggleHotkey(dialog); break;
case 8: ChangeSystemVolumeUpHotkey(dialog); break;
case 9: ChangeSystemVolumeDownHotkey(dialog); break;
case 10: ChangeSystemMuteToggleHotkey(dialog); break;
default: return;
}
RefreshList();
// Move focus back to the list so the user can immediately arrow to another
// row without an extra Tab. Without this, focus stays on the Change button
// (which is what was clicked / Enter'd) — which is fine but feels sticky.
list.Focus();
}
// Clear the selected row's binding (set it to "(not set)"). Mirrors the same path
// the rebinding flow uses: writes Unset into the in-memory settings cache, calls
// RegisterIfSet (which unregisters because the new info IsUnset), marks the
// profile dirty, and refreshes the list display. NB: we do this from the list's
// KeyDown rather than ProcessCmdKey because Del isn't intercepted by the form-
// level AcceptButton dance and works fine via the standard event.
void UnsetSelected()
{
switch (list.SelectedIndex)
{
case 0: ApplyUnset("send-mute", h => sendMuteHotkey = h, RegisterSendMuteHotkey, settingsStore.SaveSendMuteHotkey); break;
case 1: ApplyUnset("receive-mute", h => receiveMuteHotkey = h, RegisterReceiveMuteHotkey, settingsStore.SaveReceiveMuteHotkey); break;
case 2: ApplyUnset("tray", h => trayHotkey = h, RegisterTrayHotkey, settingsStore.SaveTrayHotkey); break;
case 3: ApplyUnset("volume-up", h => volumeUpHotkey = h, RegisterVolumeUpHotkey, settingsStore.SaveVolumeUpHotkey); break;
case 4: ApplyUnset("volume-down", h => volumeDownHotkey = h, RegisterVolumeDownHotkey, settingsStore.SaveVolumeDownHotkey); break;
case 5: ApplyUnset("send-remote-volume-up", h => remoteVolumeUpHotkey = h, RegisterRemoteVolumeUpHotkey, settingsStore.SaveRemoteVolumeUpHotkey); break;
case 6: ApplyUnset("send-remote-volume-down", h => remoteVolumeDownHotkey = h, RegisterRemoteVolumeDownHotkey, settingsStore.SaveRemoteVolumeDownHotkey); break;
case 7: ApplyUnset("send-remote-mute-toggle", h => remoteMuteToggleHotkey = h, RegisterRemoteMuteToggleHotkey, settingsStore.SaveRemoteMuteToggleHotkey); break;
case 8: ApplyUnset("send-system-volume-up", h => systemVolumeUpHotkey = h, RegisterSystemVolumeUpHotkey, settingsStore.SaveSystemVolumeUpHotkey); break;
case 9: ApplyUnset("send-system-volume-down", h => systemVolumeDownHotkey = h, RegisterSystemVolumeDownHotkey, settingsStore.SaveSystemVolumeDownHotkey); break;
case 10: ApplyUnset("send-system-mute-toggle", h => systemMuteToggleHotkey = h, RegisterSystemMuteToggleHotkey, settingsStore.SaveSystemMuteToggleHotkey); break;
default: return;
}
RefreshList();
list.Focus();
}
// Helper for UnsetSelected — assign Unset to the field, re-register (which
// unregisters since IsUnset is true), persist to the settings cache, log, and
// mark the profile dirty so the close-prompt fires.
void ApplyUnset(string description, Action<HotkeyInfo> setField, Action register, Action<HotkeyInfo> save)
{
setField(HotkeyInfo.Unset);
register();
save(HotkeyInfo.Unset);
Log?.Invoke($"unset {description}: cleared (was bound, now (not set))");
OnHotkeyChanged?.Invoke();
}
// Enter-on-the-list rebinds via ProcessCmdKey at the form, so that the form's
// AcceptButton dispatch (Close) doesn't get the keystroke first. ProcessCmdKey
// runs ahead of ProcessDialogKey in WinForms' message pipeline; returning true
// marks the key as consumed and the AcceptButton path is skipped. When focus
// is anywhere else (e.g. the Close button) we let Enter fall through, so
// Tab-to-Close + Enter still closes the dialog naturally.
dialog.CmdKeyHandler = keyData =>
{
if (keyData != Keys.Enter) return false;
if (dialog.ActiveControl != list) return false;
ChangeSelected();
return true;
};
list.DoubleClick += (_, _) => ChangeSelected();
// Del on the list clears the highlighted binding back to "(not set)". No confirm
// dialog — the user can rebind in two key presses (Enter + capture) if they hit Del
// by mistake. Mirrors the SpaceBlaster-style "list + Del" idiom Ed asked for.
list.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Delete)
{
UnsetSelected();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
dialog.KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Escape)
{
dialog.DialogResult = DialogResult.Cancel;
dialog.Close();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
RefreshList();
// Enter on Close closes — works because list KeyDown above handled Enter when
// focus was on the list. AcceptButton fires only when no control consumed Enter.
dialog.AcceptButton = closeButton;
dialog.CancelButton = closeButton;
dialog.Load += (_, _) => list.Focus();
dialog.ShowDialog(dialogOwner);
}
public void Dispose()
{
sendMuteGlobalHotkey?.Dispose();
receiveMuteGlobalHotkey?.Dispose();
trayGlobalHotkey?.Dispose();
volumeUpGlobalHotkey?.Dispose();
volumeDownGlobalHotkey?.Dispose();
remoteVolumeUpGlobalHotkey?.Dispose();
remoteVolumeDownGlobalHotkey?.Dispose();
remoteMuteToggleGlobalHotkey?.Dispose();
systemVolumeUpGlobalHotkey?.Dispose();
systemVolumeDownGlobalHotkey?.Dispose();
systemMuteToggleGlobalHotkey?.Dispose();
}
public HotkeyInfo SendMuteHotkey => sendMuteHotkey;
public HotkeyInfo ReceiveMuteHotkey => receiveMuteHotkey;
public HotkeyInfo TrayHotkey => trayHotkey;
public HotkeyInfo VolumeUpHotkey => volumeUpHotkey;
public HotkeyInfo VolumeDownHotkey => volumeDownHotkey;
public HotkeyInfo RemoteVolumeUpHotkey => remoteVolumeUpHotkey;
public HotkeyInfo RemoteVolumeDownHotkey => remoteVolumeDownHotkey;
public HotkeyInfo RemoteMuteToggleHotkey => remoteMuteToggleHotkey;
public HotkeyInfo SystemVolumeUpHotkey => systemVolumeUpHotkey;
public HotkeyInfo SystemVolumeDownHotkey => systemVolumeDownHotkey;
public HotkeyInfo SystemMuteToggleHotkey => systemMuteToggleHotkey;
/// <summary>Open the capture dialog, log what came back, and (on a successful capture)
/// run <paramref name="apply"/> with the captured hotkey. Centralises the boilerplate
/// the eleven per-row Change methods used to duplicate. The <paramref name="description"/>
/// is what shows in the diagnostic log so a user / developer can see the trail of
/// "capture send-system-volume-down: OK = Ctrl+Shift+Alt+J / register …: OK" or
/// "capture …: cancelled (DialogResult=Cancel)" / "register …: FAILED Win32 1409".</summary>
private void ChangeHotkey(IWin32Window dialogOwner, string description, Action<HotkeyInfo> apply)
{
using var dialog = new HotkeyCaptureForm();
var result = dialog.ShowDialog(dialogOwner);
if (result == DialogResult.OK && dialog.CapturedHotkey is not null)
{
Log?.Invoke($"capture {description}: OK = {dialog.CapturedHotkey}");
apply(dialog.CapturedHotkey);
// Mark the active profile dirty so the unsaved-changes prompt fires on close.
// The previous design relied on MarkProfileDirty being called from each UI event
// hook in MainForm — but the hotkey controller is its own object that doesn't
// know about that flag. Without this callback, hotkey edits silently slipped
// past the dirty-check and the user closed without being prompted to save.
OnHotkeyChanged?.Invoke();
}
else
{
// Detect the "low-level hook ate your combination" case. If the capture form
// observed modifier presses but never received the non-modifier key the user
// was trying to bind, something else (NVDA / NVDA Remote / AutoHotkey / similar
// accessibility / hotkey-manager tool that hooks at WH_KEYBOARD_LL level) is
// intercepting the combination before Windows can deliver it to our window.
// RegisterHotKey would have succeeded if we'd ever reached that point, so the
// existing 1409-style warning never fires for this case — that's why the user
// saw "no popup" even though their combination genuinely was unusable.
//
// The popup is shown TopMost via the same Win32 path the register-warning uses,
// so it's guaranteed visible regardless of modal-stack Z-order.
Log?.Invoke($"capture {description}: cancelled (DialogResult={result}, sawModifier={dialog.SawAnyModifier}, sawNonModifier={dialog.SawAnyNonModifier})");
if (dialog.SawAnyModifier && !dialog.SawAnyNonModifier)
{
Log?.Invoke($"capture {description}: warning user about likely low-level hook interception");
ShowRegisterWarning(
"RemSound saw your modifier keys (Ctrl, Shift, Alt) but never received the non-modifier key you were pressing with them.\n\n"
+ "That almost always means another app on this machine — NVDA, NVDA Remote, AutoHotkey, or a similar tool — is intercepting that key combination at a low level, before it can reach RemSound. The combination is unusable as a RemSound hotkey on this PC until the conflicting tool is reconfigured or that combination is freed up.\n\n"
+ "Try a different key combination.");
}
}
}
private void ChangeSendMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-mute", h =>
{
sendMuteHotkey = h;
RegisterSendMuteHotkey();
settingsStore.SaveSendMuteHotkey(h);
});
private void ChangeReceiveMuteHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "receive-mute", h =>
{
receiveMuteHotkey = h;
RegisterReceiveMuteHotkey();
settingsStore.SaveReceiveMuteHotkey(h);
});
private void ChangeTrayHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "tray", h =>
{
trayHotkey = h;
RegisterTrayHotkey();
settingsStore.SaveTrayHotkey(h);
});
private void ChangeVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-up", h =>
{
volumeUpHotkey = h;
RegisterVolumeUpHotkey();
settingsStore.SaveVolumeUpHotkey(h);
});
private void ChangeVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "volume-down", h =>
{
volumeDownHotkey = h;
RegisterVolumeDownHotkey();
settingsStore.SaveVolumeDownHotkey(h);
});
private void ChangeRemoteVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-up", h =>
{
remoteVolumeUpHotkey = h;
RegisterRemoteVolumeUpHotkey();
settingsStore.SaveRemoteVolumeUpHotkey(h);
});
private void ChangeRemoteVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-volume-down", h =>
{
remoteVolumeDownHotkey = h;
RegisterRemoteVolumeDownHotkey();
settingsStore.SaveRemoteVolumeDownHotkey(h);
});
private void ChangeRemoteMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-remote-mute-toggle", h =>
{
remoteMuteToggleHotkey = h;
RegisterRemoteMuteToggleHotkey();
settingsStore.SaveRemoteMuteToggleHotkey(h);
});
private void ChangeSystemVolumeUpHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-up", h =>
{
systemVolumeUpHotkey = h;
RegisterSystemVolumeUpHotkey();
settingsStore.SaveSystemVolumeUpHotkey(h);
});
private void ChangeSystemVolumeDownHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-volume-down", h =>
{
systemVolumeDownHotkey = h;
RegisterSystemVolumeDownHotkey();
settingsStore.SaveSystemVolumeDownHotkey(h);
});
private void ChangeSystemMuteToggleHotkey(IWin32Window dialogOwner) => ChangeHotkey(dialogOwner, "send-system-mute-toggle", h =>
{
systemMuteToggleHotkey = h;
RegisterSystemMuteToggleHotkey();
settingsStore.SaveSystemMuteToggleHotkey(h);
});
// Hotkeys come in two flavours and need different Windows-side registration:
// * Toggle hotkeys (mute, tray show/hide) — re-firing on hold would flip state back
// and forth. Registered with MOD_NOREPEAT (allowRepeat=false). One press, one fire.
// * Step hotkeys (volume up/down, both local-receive and remote-app and remote-system
// variants) — holding the key is the natural way to ramp through a range. Registered
// WITHOUT MOD_NOREPEAT so Windows fires WM_HOTKEY at the user's keyboard auto-repeat
// rate, exactly mirroring how the physical volume keys feel. The remote-control
// packet send-path is light-weight enough that a held key doesn't strain the link;
// the receiver-side COM volume call is hoisted out of the COM-enumeration cost via
// SystemVolumeHelper's cached endpoint reference.
private void RegisterSendMuteHotkey() => RegisterIfSet(sendMuteGlobalHotkey, sendMuteHotkey, "toggle sending");
private void RegisterReceiveMuteHotkey() => RegisterIfSet(receiveMuteGlobalHotkey, receiveMuteHotkey, "toggle receiving");
private void RegisterTrayHotkey() => RegisterIfSet(trayGlobalHotkey, trayHotkey, "tray");
private void RegisterVolumeUpHotkey() => RegisterIfSet(volumeUpGlobalHotkey, volumeUpHotkey, "volume up", allowRepeat: true);
private void RegisterVolumeDownHotkey() => RegisterIfSet(volumeDownGlobalHotkey, volumeDownHotkey, "volume down", allowRepeat: true);
private void RegisterRemoteVolumeUpHotkey() => RegisterIfSet(remoteVolumeUpGlobalHotkey, remoteVolumeUpHotkey, "send remote volume up", allowRepeat: true);
private void RegisterRemoteVolumeDownHotkey() => RegisterIfSet(remoteVolumeDownGlobalHotkey, remoteVolumeDownHotkey, "send remote volume down", allowRepeat: true);
private void RegisterRemoteMuteToggleHotkey() => RegisterIfSet(remoteMuteToggleGlobalHotkey, remoteMuteToggleHotkey, "send remote mute toggle");
private void RegisterSystemVolumeUpHotkey() => RegisterIfSet(systemVolumeUpGlobalHotkey, systemVolumeUpHotkey, "send Windows global volume up", allowRepeat: true);
private void RegisterSystemVolumeDownHotkey() => RegisterIfSet(systemVolumeDownGlobalHotkey, systemVolumeDownHotkey, "send Windows global volume down", allowRepeat: true);
private void RegisterSystemMuteToggleHotkey() => RegisterIfSet(systemMuteToggleGlobalHotkey, systemMuteToggleHotkey, "send Windows global mute toggle");
private void RegisterIfSet(GlobalHotkey? globalHotkey, HotkeyInfo hotkey, string description, bool allowRepeat = false)
{
if (globalHotkey is null) return;
globalHotkey.Unregister();
if (hotkey.IsUnset)
{
Log?.Invoke($"register {description}: SKIPPED (unset)");
return;
}
if (globalHotkey.Register(hotkey, allowRepeat))
{
Log?.Invoke($"register {description}: OK = {hotkey}");
}
else
{
// Win32 error 1409 = ERROR_HOTKEY_ALREADY_REGISTERED. Anything else is unusual
// (e.g. invalid VK code, no handle). Logging the raw code lets us distinguish
// "another app/process owns this combo" from genuine registration weirdness.
var err = globalHotkey.LastWin32ErrorOnRegister;
var hint = err switch
{
1409 => "another app or another RemSound process already registered this combo",
_ => "Win32 error",
};
Log?.Invoke($"register {description}: FAILED = {hotkey} (Win32 error {err}: {hint})");
ShowRegisterWarning($"Could not register {description} hotkey {hotkey}. " + (err == 1409
? "Another app — or another running copy of RemSound — is already using that combo. The hotkey is saved in your profile, so the binding will take effect once the conflict is resolved."
: $"Windows reported error {err}. The hotkey is saved in your profile but Windows didn't accept the registration."));
}
}
private void InvokeOnOwner(Action action)
{
if (owner is null || owner.IsDisposed) return;
owner.BeginInvoke(action);
}
private void ShowRegisterWarning(string message)
{
// Use the Win32 MessageBox API directly with MB_TOPMOST + MB_SETFOREGROUND so the
// popup is guaranteed to sit above every other window on the desktop, including
// any modal dialog stack RemSound currently has open. The previous WinForms
// MessageBox.Show(parent, …) calls were sometimes hiding behind the still-modal
// Keyboard shortcuts dialog — the user reported "no popup" when in fact the popup
// had been created and then occluded.
//
// MB_SETFOREGROUND on its own is sometimes ignored by Windows under foreground-lock
// rules, but MB_TOPMOST overrides that. Together they're the most reliable way to
// get a hotkey-conflict warning into the user's face at the moment the conflict is
// detected.
var hwnd = (Form.ActiveForm?.Handle) ?? owner?.Handle ?? IntPtr.Zero;
const uint MB_OK = 0x00000000;
const uint MB_ICONWARNING = 0x00000030;
const uint MB_TOPMOST = 0x00040000;
const uint MB_SETFOREGROUND = 0x00010000;
MessageBoxW(hwnd, message, "RemSound — hotkey conflict", MB_OK | MB_ICONWARNING | MB_TOPMOST | MB_SETFOREGROUND);
}
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
private static extern int MessageBoxW(IntPtr hWnd, string lpText, string lpCaption, uint uType);
}
@@ -0,0 +1,44 @@
namespace RemSound.App;
internal sealed class MainFormTrayController : IDisposable
{
private readonly Form owner;
private readonly NotifyIcon trayIcon = new();
public MainFormTrayController(Form owner, Action enableSending, Action enableReceiving, Action exit)
{
this.owner = owner;
trayIcon.Text = "RemSound";
trayIcon.Icon = SystemIcons.Application;
trayIcon.Visible = false;
trayIcon.DoubleClick += (_, _) => Restore();
var menu = new ContextMenuStrip();
menu.Items.Add("Show", null, (_, _) => Restore());
menu.Items.Add("Enable sending", null, (_, _) => enableSending());
menu.Items.Add("Enable receiving", null, (_, _) => enableReceiving());
menu.Items.Add("Exit", null, (_, _) => exit());
trayIcon.ContextMenuStrip = menu;
}
public void Toggle()
{
if (owner.Visible && owner.WindowState != FormWindowState.Minimized) Minimize();
else Restore();
}
public void Restore()
{
owner.Show();
owner.WindowState = FormWindowState.Normal;
owner.Activate();
trayIcon.Visible = false;
}
public void Minimize()
{
owner.Hide();
trayIcon.Visible = true;
}
public void Dispose() => trayIcon.Dispose();
}
+51
View File
@@ -0,0 +1,51 @@
namespace RemSound.App;
internal static class ManualPeerPrompt
{
public static string? Show(IWin32Window owner)
{
using var dialog = new Form
{
Text = "Add manual peer",
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
ShowInTaskbar = false,
ClientSize = new Size(440, 130),
};
// No port hint — RemSound now uses a single canonical UDP port (RemPacket.DefaultPort,
// 47830 as of 2026-05-05) for Tailscale, LAN, and relay peers alike. Users just type a
// bare IP or hostname; the port is implied. Advanced users can still suffix `:port` for
// a non-standard server, but it's no longer the common path that needed onboarding.
var textBox = new TextBox
{
Dock = DockStyle.Top,
Width = 380,
AccessibleName = "Peer IP address or hostname",
};
var okButton = new Button { Text = "OK", AutoSize = true, DialogResult = DialogResult.OK };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
textBox.KeyDown += (_, args) =>
{
if (args.KeyCode == Keys.Enter)
{
dialog.DialogResult = DialogResult.OK;
dialog.Close();
args.Handled = true;
args.SuppressKeyPress = true;
}
};
var panel = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(12), RowCount = 3, ColumnCount = 1 };
panel.Controls.Add(new Label { Text = "Peer IP address or hostname:", AutoSize = true }, 0, 0);
panel.Controls.Add(textBox, 0, 1);
var buttons = new FlowLayoutPanel { AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Dock = DockStyle.Fill };
buttons.Controls.Add(okButton);
buttons.Controls.Add(cancelButton);
panel.Controls.Add(buttons, 0, 2);
dialog.Controls.Add(panel);
dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton;
return dialog.ShowDialog(owner) == DialogResult.OK ? textBox.Text.Trim() : null;
}
}
+286
View File
@@ -0,0 +1,286 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Preferences dialog. Holds the three settings that used to live on the (now-removed)
/// Profiles and preferences tab and aren't profile-management actions in their own right:
/// * Mute connect/disconnect sounds — the small ding on peer state changes.
/// * Accept remote volume commands from peers — opt-in for the remote-control feature.
/// * Startup behaviour — opens the existing <see cref="StartupBehaviourDialog"/> sub-dialog.
///
/// Both checkboxes save through <see cref="RemSoundSettingsStore"/> on every change (so
/// the user doesn't need to re-confirm via an OK button). The Startup behaviour button
/// just opens the existing modal sub-dialog. Esc or the Close button dismisses.
///
/// Reachable via the File → Preferences menu item or Ctrl+P from the main window.
/// </summary>
internal sealed class PreferencesDialog : Form
{
private readonly Button browseProfilesFolderButton = new()
{
Text = "&Browse for RemSound profiles folder...",
AccessibleName = "Browse for RemSound profiles folder",
AutoSize = true,
};
private readonly AccessibleCheckBox muteCuesBox = new()
{
Text = "Mute connect/disconnect sounds (Alt+&M)",
AccessibleName = "Mute connect/disconnect sounds",
AutoSize = true,
};
private readonly AccessibleCheckBox acceptRemoteVolumeBox = new()
{
Text = "Accept remote volume commands from peers (Alt+&A)",
AccessibleName = "Accept remote volume commands from peers",
AutoSize = true,
};
private readonly Button startupBehaviourButton = new()
{
Text = "Startup behaviour... (Alt+&S)",
AccessibleName = "Startup behaviour",
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".
private readonly Label updateFrequencyLabel = new()
{
Text = "Check for updates (Alt+&U):",
AccessibleName = "Check for updates frequency",
AutoSize = true,
};
private readonly ComboBox updateFrequencyBox = new()
{
DropDownStyle = ComboBoxStyle.DropDownList,
Width = 200,
AccessibleName = "Check for updates (Alt+U)",
};
private readonly Button checkForUpdatesNowButton = new()
{
Text = "Check for updates &now",
AccessibleName = "Check for updates now",
AutoSize = true,
};
private readonly AccessibleCheckBox silentlyInstallUpdatesBox = new()
{
Text = "Silently &install updates when available",
AccessibleName = "Silently install updates when available",
AutoSize = true,
};
private readonly AccessibleCheckBox loggingBox = new()
{
Text = "Enable &logs",
AccessibleName = "Enable logs",
AutoSize = true,
};
private readonly Button writeLogsNowButton = new()
{
Text = "&Write logs now",
AccessibleName = "Write logs now",
AutoSize = true,
};
private readonly Button closeButton = new()
{
Text = "Close",
AutoSize = true,
DialogResult = DialogResult.OK,
};
/// <summary>True if the user toggled Mute cues or Accept remote during this dialog
/// session. The owner uses this to know whether to MarkProfileDirty after the dialog
/// closes (since both settings live on Profile and need to flag a save-pending state).</summary>
public bool ChangedAnyProfileSetting { get; private set; }
public PreferencesDialog(
RemSoundSettingsStore settings,
ProfileStore? profileStore,
Func<bool> getLoggingEnabled,
Action<bool> applyLoggingEnabled,
Action writeLogsNow,
Action checkForUpdatesNow,
Action onUpdateFrequencyChanged)
{
Text = "Preferences";
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
StartPosition = FormStartPosition.CenterParent;
KeyPreview = true;
ClientSize = new Size(560, 440);
// 1st row — Browse for profiles folder. Same FolderBrowserDialog the startup
// ProfileSelectionDialog uses; the choice is persisted to AppConfig.ProfilesDirectory
// and applied on next launch (mid-session reload would force a re-pick of profile
// which is more disruption than the change is worth — users restart RemSound when
// they want to switch folders).
browseProfilesFolderButton.Click += (_, _) =>
{
using var picker = new FolderBrowserDialog
{
Description = "Choose a folder for RemSound profiles",
UseDescriptionForTitle = true,
SelectedPath = profileStore?.BaseDirectory ?? AppContext.BaseDirectory,
ShowNewFolderButton = true,
};
if (picker.ShowDialog(this) != DialogResult.OK) return;
if (string.IsNullOrWhiteSpace(picker.SelectedPath)) return;
var cfg = AppConfig.Load();
cfg.ProfilesDirectory = picker.SelectedPath;
try
{
cfg.Save();
}
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
MessageBox.Show(this,
$"Profiles folder updated to:\n\n{picker.SelectedPath}\n\nThe new folder will be used next time RemSound launches.",
"Profiles folder updated", MessageBoxButtons.OK, MessageBoxIcon.Information);
};
muteCuesBox.Checked = settings.LoadMuteConnectionCues();
muteCuesBox.CheckedChanged += (_, _) =>
{
settings.SaveMuteConnectionCues(muteCuesBox.Checked);
ChangedAnyProfileSetting = true;
};
acceptRemoteVolumeBox.Checked = settings.LoadAcceptRemoteVolumeCommands();
acceptRemoteVolumeBox.CheckedChanged += (_, _) =>
{
settings.SaveAcceptRemoteVolumeCommands(acceptRemoteVolumeBox.Checked);
ChangedAnyProfileSetting = true;
};
startupBehaviourButton.Click += (_, _) =>
{
using var dialog = new StartupBehaviourDialog(profileStore);
dialog.ShowDialog(this);
// Startup behaviour persists through AppConfig + registry directly, so we
// don't need to flag profile-dirty for that.
};
// Update settings — wired against AppConfig directly since they're machine-local.
// The frequency combo's index maps 1:1 to the UpdateCheckFrequency enum so reordering
// either side stays in lockstep.
updateFrequencyBox.Items.AddRange(new object[] { "Never", "Every hour", "Every 6 hours", "Every 24 hours" });
var cfgForLoad = AppConfig.Load();
updateFrequencyBox.SelectedIndex = (int)cfgForLoad.UpdateCheckFrequency;
silentlyInstallUpdatesBox.Checked = cfgForLoad.SilentlyInstallUpdates;
updateFrequencyBox.SelectedIndexChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.UpdateCheckFrequency = (UpdateCheckFrequency)updateFrequencyBox.SelectedIndex;
try { cfg.Save(); } catch { /* harmless — choice just won't survive a restart */ }
onUpdateFrequencyChanged();
};
silentlyInstallUpdatesBox.CheckedChanged += (_, _) =>
{
var cfg = AppConfig.Load();
cfg.SilentlyInstallUpdates = silentlyInstallUpdatesBox.Checked;
try { cfg.Save(); } catch { /* harmless */ }
};
checkForUpdatesNowButton.Click += (_, _) => checkForUpdatesNow();
loggingBox.Checked = getLoggingEnabled();
loggingBox.CheckedChanged += (_, _) =>
{
applyLoggingEnabled(loggingBox.Checked);
ChangedAnyProfileSetting = true;
};
writeLogsNowButton.Click += (_, _) => writeLogsNow();
closeButton.Click += (_, _) => Close();
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 10,
};
panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
for (var i = 0; i < 9; i++) panel.RowStyles.Add(new RowStyle(SizeType.AutoSize));
panel.RowStyles.Add(new RowStyle(SizeType.Percent, 100));
// Tab order top-to-bottom: browse, mute cues, accept remote, startup, 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.
browseProfilesFolderButton.TabIndex = 0;
muteCuesBox.TabIndex = 1;
acceptRemoteVolumeBox.TabIndex = 2;
startupBehaviourButton.TabIndex = 3;
updateFrequencyBox.TabIndex = 4;
checkForUpdatesNowButton.TabIndex = 5;
silentlyInstallUpdatesBox.TabIndex = 6;
loggingBox.TabIndex = 7;
writeLogsNowButton.TabIndex = 8;
closeButton.TabIndex = 9;
// 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.
var freqRow = new FlowLayoutPanel
{
AutoSize = true,
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.LeftToRight,
WrapContents = false,
Padding = new Padding(0, 4, 0, 0),
};
updateFrequencyLabel.Padding = new Padding(0, 6, 8, 0);
freqRow.Controls.Add(updateFrequencyLabel);
freqRow.Controls.Add(updateFrequencyBox);
panel.Controls.Add(browseProfilesFolderButton, 0, 0);
panel.Controls.Add(muteCuesBox, 0, 1);
panel.Controls.Add(acceptRemoteVolumeBox, 0, 2);
panel.Controls.Add(startupBehaviourButton, 0, 3);
panel.Controls.Add(freqRow, 0, 4);
panel.Controls.Add(checkForUpdatesNowButton, 0, 5);
panel.Controls.Add(silentlyInstallUpdatesBox, 0, 6);
panel.Controls.Add(loggingBox, 0, 7);
panel.Controls.Add(writeLogsNowButton, 0, 8);
var buttons = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 0, 12, 12),
};
buttons.Controls.Add(closeButton);
Controls.Add(panel);
Controls.Add(buttons);
AcceptButton = closeButton;
CancelButton = closeButton;
KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Escape)
{
Close();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
}
}
+95
View File
@@ -0,0 +1,95 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>Tiny single-line modal — "give this profile a name". Used by File → Rename
/// (and historically by File → Save As, before that flow moved to a real Windows
/// SaveFileDialog on 2026-05-10). Returns the trimmed name or null on cancel.
///
/// Two parameter knobs let the dialog title and prompt label change between use cases:
/// * Rename: title = "Rename profile", prompt = "Please enter a new name for your profile:"
/// * Legacy save-as (close-confirm path): title = "Save profile as", prompt = "Profile name:"
/// and pass <paramref name="store"/> non-null so the dialog refuses an existing-name unless
/// the user confirms overwrite.
///
/// When <paramref name="store"/> is null, no overwrite check is performed — the caller is
/// responsible for handling name collisions (rename has its own conflict logic in MainForm).</summary>
internal static class ProfileSaveAsPrompt
{
public static string? Show(
IWin32Window owner,
ProfileStore? store,
string? defaultName = null,
string dialogTitle = "Save profile as",
string promptLabel = "Profile name:")
{
using var dialog = new Form
{
Text = dialogTitle,
StartPosition = FormStartPosition.CenterParent,
FormBorderStyle = FormBorderStyle.FixedDialog,
MinimizeBox = false,
MaximizeBox = false,
ShowInTaskbar = false,
ClientSize = new Size(420, 140),
};
var textBox = new TextBox
{
Width = 380,
Text = defaultName ?? "",
AccessibleName = promptLabel.TrimEnd(':', ' '),
};
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK };
var cancelButton = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
textBox.KeyDown += (_, args) =>
{
if (args.KeyCode == Keys.Enter)
{
dialog.DialogResult = DialogResult.OK;
dialog.Close();
args.Handled = true;
args.SuppressKeyPress = true;
}
};
var panel = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
RowCount = 3,
ColumnCount = 1,
};
panel.Controls.Add(new Label { Text = promptLabel, AutoSize = true }, 0, 0);
panel.Controls.Add(textBox, 0, 1);
var buttons = new FlowLayoutPanel
{
AutoSize = true,
FlowDirection = FlowDirection.RightToLeft,
Dock = DockStyle.Fill,
};
buttons.Controls.Add(okButton);
buttons.Controls.Add(cancelButton);
panel.Controls.Add(buttons, 0, 2);
dialog.Controls.Add(panel);
dialog.AcceptButton = okButton;
dialog.CancelButton = cancelButton;
while (true)
{
if (dialog.ShowDialog(owner) != DialogResult.OK) return null;
var name = textBox.Text.Trim();
if (string.IsNullOrWhiteSpace(name))
{
MessageBox.Show(owner, "Please enter a profile name.", "RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
continue;
}
if (store is not null && store.Exists(name))
{
var overwrite = MessageBox.Show(owner,
$"A profile named \"{name}\" already exists. Overwrite?",
"Confirm overwrite", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
if (overwrite != DialogResult.Yes) continue;
}
return name;
}
}
}
+253
View File
@@ -0,0 +1,253 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Modal dialog shown at app startup to pick which profile to load. Listbox of saved
/// profile titles plus a synthetic "(Blank template)" entry for an unsaved-defaults
/// session. Enter or OK selects; Esc does nothing (deliberately disabled — picking is
/// required); Alt+F4 closes the dialog and exits the app; Del on a profile prompts to
/// delete it with a yes/no confirm. The user can also browse to a custom profiles
/// folder, which persists in <c>remsound.config.json</c> next to the exe.
///
/// On OK, exposes:
/// * <see cref="SelectedTitle"/> — the chosen title, or null for blank template.
/// * <see cref="SelectedProfile"/> — the loaded <see cref="Profile"/>, or null for blank.
/// * <see cref="Store"/> — the (possibly-rebuilt) profile store. If the user clicked
/// Browse and changed the folder, this points at the new folder; the caller should
/// use this reference rather than the one it passed in.
/// </summary>
internal sealed class ProfileSelectionDialog : Form
{
private const string BlankTemplateLabel = "(Blank template)";
private ProfileStore store;
private readonly ListBox listBox;
private readonly Label folderLabel;
public string? SelectedTitle { get; private set; }
public Profile? SelectedProfile { get; private set; }
/// <summary>Current profile store. If the user clicked Browse during the dialog,
/// this is rebuilt to point at the new folder; otherwise it's the same instance the
/// caller passed in.</summary>
public ProfileStore Store => store;
public ProfileSelectionDialog(ProfileStore store)
{
this.store = store;
Text = "RemSound — pick a profile";
StartPosition = FormStartPosition.CenterScreen;
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = true;
ClientSize = new Size(480, 420);
// Esc is deliberately ignored (no CancelButton). Alt+F4 routes through the
// window manager to FormClosing → DialogResult.Cancel, which the caller treats
// as "user wants to quit".
KeyPreview = true;
listBox = new ListBox
{
Dock = DockStyle.Fill,
IntegralHeight = false,
AccessibleName = "Profiles",
};
listBox.KeyDown += OnListKeyDown;
listBox.DoubleClick += (_, _) => Accept();
var instructions = new Label
{
Text = "Select a profile and press Enter, or pick \"" + BlankTemplateLabel + "\" to start fresh.",
Dock = DockStyle.Top,
AutoSize = false,
Height = 36,
Padding = new Padding(8, 8, 8, 4),
};
// Folder status + Browse button row at the bottom. Browse opens a folder picker;
// on OK we save AppConfig, rebuild the store, and refresh the list. Status label
// shows the active folder so the user can verify where their profiles are coming
// from. NVDA reads the label as a sibling of the listbox.
folderLabel = new Label
{
Text = "Profiles folder: " + store.BaseDirectory,
Dock = DockStyle.Top,
AutoSize = false,
Height = 28,
Padding = new Padding(8, 4, 8, 4),
AccessibleName = "Profiles folder: " + store.BaseDirectory,
};
var okButton = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.None };
okButton.Click += (_, _) => Accept();
var deleteButton = new Button { Text = "&Delete", AutoSize = true };
deleteButton.Click += (_, _) => DeleteSelected();
var browseButton = new Button { Text = "&Browse for profiles folder…", AutoSize = true };
browseButton.Click += (_, _) => BrowseForFolder();
var resetFolderButton = new Button { Text = "&Reset to default folder", AutoSize = true };
resetFolderButton.Click += (_, _) => ResetToDefaultFolder();
var buttonRow = new FlowLayoutPanel
{
Dock = DockStyle.Bottom,
FlowDirection = FlowDirection.LeftToRight,
Height = 80,
AutoSize = false,
Padding = new Padding(8),
WrapContents = true,
};
buttonRow.Controls.Add(okButton);
buttonRow.Controls.Add(deleteButton);
buttonRow.Controls.Add(browseButton);
buttonRow.Controls.Add(resetFolderButton);
Controls.Add(listBox);
Controls.Add(buttonRow);
Controls.Add(folderLabel);
Controls.Add(instructions);
AcceptButton = okButton; // makes Enter work in the form context too
Load += (_, _) =>
{
RefreshList();
listBox.Focus();
};
}
private void RefreshList()
{
var prevSelected = listBox.SelectedItem as string;
listBox.BeginUpdate();
listBox.Items.Clear();
listBox.Items.Add(BlankTemplateLabel);
foreach (var t in store.ListProfileTitles())
{
listBox.Items.Add(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);
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;
}
private void OnListKeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
Accept();
e.Handled = true;
e.SuppressKeyPress = true;
}
else if (e.KeyCode == Keys.Delete)
{
DeleteSelected();
e.Handled = true;
e.SuppressKeyPress = true;
}
}
private void Accept()
{
var selected = listBox.SelectedItem as string;
if (string.IsNullOrEmpty(selected)) return;
if (selected == BlankTemplateLabel)
{
SelectedTitle = null;
SelectedProfile = null;
}
else
{
SelectedTitle = selected;
SelectedProfile = store.Load(selected);
if (SelectedProfile is null)
{
MessageBox.Show(this,
$"Could not read profile \"{selected}\". Treating as blank template.",
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
SelectedTitle = null;
}
}
DialogResult = DialogResult.OK;
Close();
}
private void DeleteSelected()
{
var selected = listBox.SelectedItem as string;
if (string.IsNullOrEmpty(selected) || selected == BlankTemplateLabel) return;
var result = MessageBox.Show(this,
$"Delete profile \"{selected}\"? This cannot be undone.",
"Confirm delete", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button2);
if (result != DialogResult.Yes) return;
if (!store.Delete(selected))
{
MessageBox.Show(this,
$"Could not delete \"{selected}\".",
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
RefreshList();
}
/// <summary>Open a folder picker, persist the choice to AppConfig, and rebuild the
/// profile store + list against the new folder. No-op on cancel. If the new folder
/// has no profiles yet, the listbox simply shows just the blank-template entry; the
/// user can save into the new folder later.</summary>
private void BrowseForFolder()
{
using var picker = new FolderBrowserDialog
{
Description = "Choose a folder for RemSound profiles",
UseDescriptionForTitle = true,
SelectedPath = store.BaseDirectory,
ShowNewFolderButton = true,
};
if (picker.ShowDialog(this) != DialogResult.OK) return;
ApplyFolder(picker.SelectedPath);
}
private void ResetToDefaultFolder()
{
// Clearing the AppConfig field and reloading swings the store back to the legacy
// default (per-machine subfolder under the exe). Cheap and reversible — user can
// Browse to a custom folder again any time.
var cfg = AppConfig.Load();
cfg.ProfilesDirectory = null;
try { cfg.Save(); }
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
store = cfg.CreateStore();
RefreshList();
}
private void ApplyFolder(string folderPath)
{
if (string.IsNullOrWhiteSpace(folderPath)) return;
var cfg = AppConfig.Load();
cfg.ProfilesDirectory = folderPath;
try { cfg.Save(); }
catch (Exception ex)
{
MessageBox.Show(this, $"Could not save app config: {ex.Message}",
"RemSound", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return;
}
store = cfg.CreateStore();
RefreshList();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
// Eat plain Esc — selection is required, no implicit cancel.
if (keyData == Keys.Escape) return true;
return base.ProcessCmdKey(ref msg, keyData);
}
}
+136
View File
@@ -0,0 +1,136 @@
using System.Runtime;
using System.Windows.Forms;
using RemSound.Core;
namespace RemSound.App;
internal static class Program
{
[STAThread]
private static void Main()
{
// SustainedLowLatency tells the GC to avoid full (gen 2) collections while audio is streaming.
// Gen 0/1 collections still happen but are sub-millisecond; the long pauses that were causing
// the receiver to fall behind in clusters of 4-5 underruns at a time were almost certainly
// gen 2 sweeps. This trades a bit of memory headroom (the GC will hold on to garbage longer)
// for dramatically more predictable timing — exactly the trade real-time audio wants.
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
ApplicationConfiguration.Initialize();
// F1 anywhere = open the bundled manual. Installed *before* the first ShowDialog so
// it works on the profile picker (the very first thing the user sees). The filter
// is per-thread and modifier-aware: bare F1 only, so Shift/Ctrl/Alt+F1 stay free.
HelpLauncher.Install();
// Outer loop: lets ProfileManagementDialog change the profiles folder mid-session.
// When that happens, MainForm sets ReloadFromScratch=true, we re-read AppConfig, build
// a fresh ProfileStore, and re-show ProfileSelectionDialog so the user picks a profile
// (or blank template) from the *new* folder. Inner loop handles the cheaper "switch to
// a profile in the same folder" case.
while (true)
{
var appConfig = AppConfig.Load();
var store = appConfig.CreateStore();
Profile? profile;
string? title;
// Auto-load shortcut: if AppConfig.StartWithProfileTitle is set and the named
// profile actually exists in the current store, skip the picker entirely and
// load that profile directly. This is what the Startup behaviour dialog's
// "Start with a specific profile" toggle drives. Combined with the Windows
// auto-start registry entry and the StartMinimised flag, it lets the user
// boot a machine and have RemSound up and streaming with no clicks. Falls
// through to the normal picker if the configured profile no longer exists
// (deleted since it was selected, or the profiles folder changed) so the user
// isn't stuck.
Profile? autoLoaded = null;
string? autoLoadedTitle = null;
if (!string.IsNullOrWhiteSpace(appConfig.StartWithProfileTitle))
{
try
{
autoLoaded = store.Load(appConfig.StartWithProfileTitle!);
if (autoLoaded is not null) autoLoadedTitle = appConfig.StartWithProfileTitle;
}
catch { /* fall back to picker */ }
}
if (autoLoaded is not null)
{
profile = autoLoaded;
title = autoLoadedTitle;
}
else
{
using var dialog = new ProfileSelectionDialog(store);
if (dialog.ShowDialog() != DialogResult.OK) return;
// ProfileSelectionDialog can have changed the folder via its Browse button;
// if so, it's already saved AppConfig and rebuilt its internal store. Pick up
// its post-Browse store reference for the rest of the session.
store = dialog.Store;
profile = dialog.SelectedProfile;
title = dialog.SelectedTitle;
}
// Switch-profile loop: when the user clicks "Switch to profile" in the Manage
// Profiles dialog, the form sets NextProfileTitleToLoad and closes; we re-open
// MainForm under the newly chosen profile. Null = user closed the form normally
// → exit. ReloadFromScratch = the user changed the profiles FOLDER mid-session,
// so we break out of this inner loop and let the outer loop redo the selection
// dialog under the new folder.
var reloadFromScratch = false;
string? nextPath = null;
while (true)
{
using var form = new MainForm(store, profile, title, nextPath);
Application.Run(form);
if (form.ReloadFromScratch)
{
reloadFromScratch = true;
break;
}
// Path-based reload (File → Open profile from a path that may be outside
// the active store's BaseDirectory) takes precedence — read JSON directly
// from that path. Falls back to title-based store.Load when no path is set
// (e.g. legacy switch-by-title flows that pre-date the path tracking).
nextPath = form.NextProfilePathToLoad;
var nextTitle = form.NextProfileTitleToLoad;
if (!string.IsNullOrEmpty(nextPath))
{
try
{
var json = File.ReadAllText(nextPath);
profile = System.Text.Json.JsonSerializer.Deserialize<Profile>(json) ?? Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
}
catch
{
// Malformed / unreadable JSON. Fall back to blank template under
// whatever title we have, rather than crashing the loop.
profile = Profile.NewBlank();
title = !string.IsNullOrEmpty(nextTitle)
? nextTitle
: Path.GetFileNameWithoutExtension(nextPath);
nextPath = null;
}
}
else if (!string.IsNullOrEmpty(nextTitle))
{
title = nextTitle;
profile = store.Load(nextTitle) ?? Profile.NewBlank();
}
else
{
return; // form closed normally — exit app
}
}
if (!reloadFromScratch) return;
}
}
}
+47
View File
@@ -0,0 +1,47 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>RemSound.App</RootNamespace>
<AssemblyName>RemSound</AssemblyName>
<ApplicationManifest>app.manifest</ApplicationManifest>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ApplicationHighDpiMode>SystemAware</ApplicationHighDpiMode>
<!-- Release version. The self-updater (RemSoundUpdater) compares this against the
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>1.0.0</Version>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\RemSound.Core\RemSound.Core.csproj" />
<ProjectReference Include="..\RemSound.Sender\RemSound.Sender.csproj" />
<ProjectReference Include="..\RemSound.Receiver\RemSound.Receiver.csproj" />
<PackageReference Include="NAudio" Version="2.3.0" />
</ItemGroup>
<ItemGroup>
<!-- Peer-state notification sounds. Copied next to RemSound.exe so SoundPlayer can find
them via AppContext.BaseDirectory. -->
<Content Include="..\..\connect.wav">
<Link>connect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="..\..\disconnect.wav">
<Link>disconnect.wav</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<!-- User manual. F1 anywhere in the app opens this via the user's default browser
(HelpLauncher.OpenManual). The PreserveNewest mode means a fresh publish overwrites
the published copy whenever the source is newer; manually-edited copies inside
publish/ get clobbered, which is correct (publish is build output, not config). -->
<Content Include="..\..\readme.html">
<Link>readme.html</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
+184
View File
@@ -0,0 +1,184 @@
namespace RemSound.App;
/// <summary>
/// Lightweight tab-separated log file written next to the executable in <c>logs\</c>.
/// Two row kinds:
/// SNAP — periodic snapshot of runtime counters (one per second)
/// EVT — one-off events (connect, disconnect, codec change, errors)
/// Format:
/// SNAP\t{Timestamp}\t{Machine}\t{Connected}\t{SendRunning}\t{ReceiveRunning}\t{Codec}\t
/// {MaxLatencyMs}\t{TargetLatencyMs}\t{BufferMs}\t{SenderPackets}\t{SenderKB}\t
/// {SenderDevice}\t{ReceiverPackets}\t{ReceiverKB}\t{Underruns}\t{Drops}\t{ReceiveDevice}
/// EVT\t{Timestamp}\t{Message}
///
/// The log file is created lazily on the first write that arrives while <see cref="Enabled"/>
/// is true. When the user has logs turned off in Preferences (the default), no file is created
/// at all — the App can construct the log object freely without spawning empty files in
/// <c>logs\</c>. Flipping <see cref="Enabled"/> back on mid-session is also safe: the next
/// write creates the file with its header and the session continues normally.
/// </summary>
internal sealed class RemSoundLog : IDisposable
{
// MaxLatencyMsAsio / TargetLatencyMsAsio columns hold the per-lane numbers in
// BothIndependent mode. In WasapiOnly they are 0 and the legacy MaxLatencyMs /
// TargetLatencyMs columns continue to mean "the receiver's only latency".
private const string SnapHeader =
"Kind\tTimestamp\tMachine\tConnected\tSendRunning\tReceiveRunning\tCodec\t" +
"MaxLatencyMs\tTargetLatencyMs\tBufferMs\tSenderPackets\tSenderKB\t" +
"SenderDevice\tReceiverPackets\tReceiverKB\tUnderruns\tDrops\tReceiveDevice\tHeartbeat\t" +
"OpusFecRecoveries\tOpusUnrecoveredGaps\tMaxLatencyMsAsio\tTargetLatencyMsAsio";
private StreamWriter? writer;
private bool fileCreationFailed;
/// <summary>Serialises all writes. StreamWriter is documented as non-thread-safe and
/// concurrent WriteLine calls from the mix loop, heartbeat thread, network listener,
/// UI thread and ASIO callback can interleave bytes into a single line in the output
/// file. Worse, an interleaved write can leave the StreamWriter's internal char buffer
/// in a state that throws on the next Flush — that exception escapes the inner try/catch
/// and can bring the process down. A single gate around every WriteLine, every Dispose
/// and every lazy-init step serialises writes cleanly; the cost is microseconds and
/// worth the diagnostic integrity.</summary>
private readonly object writeGate = new();
/// <summary>Path of the log file once it has been created. Null until the first write
/// arrives with <see cref="Enabled"/> true (or null forever if logging is never enabled
/// or file creation fails).</summary>
public string? Path { get; private set; }
/// <summary>Master gate for all writes. When false, both <see cref="Event"/> and
/// <see cref="Snapshot"/> short-circuit before touching the file system — no creation,
/// no headers, no data. Defaults to false so the App can construct the log object
/// before reading the user's preference; the App pushes the real value in after.</summary>
public bool Enabled { get; set; }
public RemSoundLog()
{
// No file work in the constructor. EnsureFileOpenLocked does it lazily on first
// write, only when Enabled has been confirmed true.
}
/// <summary>Open the underlying file if it hasn't been opened yet and write the schema
/// header + a "log started" event line. Must be called while holding
/// <see cref="writeGate"/>. Returns true on success or if the file is already open;
/// false if creation has failed (either now or earlier) — caller should give up on the
/// current write.</summary>
private bool EnsureFileOpenLocked()
{
if (writer is not null) return true;
if (fileCreationFailed) return false;
try
{
var dir = System.IO.Path.Combine(AppContext.BaseDirectory, "logs");
Directory.CreateDirectory(dir);
var name = $"RemSound-{Sanitize(Environment.MachineName)}-{Environment.ProcessId}-{DateTime.Now:yyyyMMdd-HHmmss}.log";
Path = System.IO.Path.Combine(dir, name);
writer = new StreamWriter(new FileStream(Path, FileMode.CreateNew, FileAccess.Write, FileShare.ReadWrite))
{
AutoFlush = true,
};
writer.WriteLine(SnapHeader);
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog started");
return true;
}
catch
{
// Logging is best-effort. Locked dir, permissions, disk full — any of these
// shouldn't kill the app. Park the file as failed so we don't keep retrying
// creation on every subsequent write attempt.
writer = null;
Path = null;
fileCreationFailed = true;
return false;
}
}
public void Snapshot(
bool connected,
bool sendRunning,
bool receiveRunning,
string codec,
int maxLatencyMs,
int targetLatencyMs,
int bufferMs,
long senderPackets,
long senderBytes,
string senderDevice,
long receiverPackets,
long receiverBytes,
long underruns,
long drops,
string receiveDevice,
string heartbeat,
long opusFecRecoveries,
long opusUnrecoveredGaps,
int maxLatencyMsAsio = 0,
int targetLatencyMsAsio = 0)
{
if (!Enabled) return;
lock (writeGate)
{
if (!EnsureFileOpenLocked()) return;
try
{
writer!.WriteLine(string.Join('\t',
"SNAP",
DateTime.Now.ToString("o"),
Environment.MachineName,
connected,
sendRunning,
receiveRunning,
codec,
maxLatencyMs,
targetLatencyMs,
bufferMs,
senderPackets,
senderBytes / 1024,
Sanitize(senderDevice),
receiverPackets,
receiverBytes / 1024,
underruns,
drops,
Sanitize(receiveDevice),
Sanitize(heartbeat),
opusFecRecoveries,
opusUnrecoveredGaps,
maxLatencyMsAsio,
targetLatencyMsAsio));
}
catch { /* swallow — log is best-effort */ }
}
}
public void Event(string message)
{
if (!Enabled) return;
lock (writeGate)
{
if (!EnsureFileOpenLocked()) return;
try
{
writer!.WriteLine($"EVT\t{DateTime.Now:o}\t{message.Replace('\t', ' ').Replace('\n', ' ')}");
}
catch { /* swallow */ }
}
}
public void Dispose()
{
lock (writeGate)
{
try
{
if (writer is not null && Enabled)
{
// Inline the "log stopped" write so we don't re-acquire the gate.
writer.WriteLine($"EVT\t{DateTime.Now:o}\tlog stopped");
}
writer?.Dispose();
}
catch { /* swallow */ }
}
}
private static string Sanitize(string value) => value.Replace('\t', ' ').Replace('\n', ' ');
}
+264
View File
@@ -0,0 +1,264 @@
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Self-updater. Polls the GitHub Releases API for the latest published version, compares it
/// to the running assembly's version, and (optionally) downloads and installs the new build.
///
/// Update install flow (Windows-only): RemSound.exe can't overwrite itself while it's running,
/// so a successful install does the swap via a detached <c>cmd.exe</c> helper:
/// <list type="number">
/// <item>Download the release ZIP to <c>%TEMP%\RemSound-update-&lt;tag&gt;.zip</c>.</item>
/// <item>Extract to <c>&lt;exe&gt;\_update\</c>.</item>
/// <item>Write a one-shot batch file at <c>&lt;exe&gt;\_apply-update.cmd</c> that waits for
/// RemSound.exe to exit, robocopies the staged folder over the publish folder, deletes
/// the staging area, restarts RemSound.exe, and removes itself.</item>
/// <item>Start the batch with <c>CreateNoWindow</c> + detached, then call
/// <see cref="Application.Exit"/>.</item>
/// </list>
/// The batch survives RemSound's exit because <c>cmd.exe</c> is its own process. Robocopy's
/// retry/wait flags handle the brief moment between RemSound exit and the file unlock.
///
/// The GitHub repo to poll is hard-coded — the App was designed to be redistributed from a
/// single canonical release stream, not to be re-pointed at a fork. If you need to publish
/// from a different repo, change <see cref="RepoOwner"/> / <see cref="RepoName"/>.
/// </summary>
internal sealed class RemSoundUpdater : IDisposable
{
public const string RepoOwner = "Ednunp";
public const string RepoName = "RemSound";
/// <summary>Asset name on the GitHub release that the updater downloads. The release
/// publisher's <c>gh release create</c> command must attach exactly this filename for
/// the auto-install path to work; other assets in the release are ignored. The literal
/// "{tag}" placeholder is replaced with the release's <c>tag_name</c> at runtime.</summary>
public const string AssetNameTemplate = "RemSound-{tag}.zip";
private static readonly HttpClient http = CreateClient();
/// <summary>Sink for diagnostic lines — the App wires this to <c>logFile.Event</c> so an
/// admin can see what the updater did (which version it saw, whether it downloaded, why
/// an install attempt failed). Updater output never goes to a popup unless the user
/// triggered a manual check.</summary>
public Action<string>? Log { get; set; }
public string CurrentVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
public void Dispose()
{
// HttpClient is static and shared across the process; nothing to dispose here.
}
/// <summary>Hit the GitHub Releases API, parse the latest release, return a struct
/// describing what was found. Returns null if the request fails (network down, rate
/// limited, repo not found) or if the latest version is not newer than the running
/// assembly. Caller decides whether to surface "you're up to date" vs silently doing
/// nothing — both paths get null back.</summary>
public async Task<UpdateInfo?> CheckForUpdateAsync(CancellationToken token = default)
{
try
{
var url = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/releases/latest";
Log?.Invoke($"updater: GET {url}");
using var req = new HttpRequestMessage(HttpMethod.Get, url);
req.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
using var resp = await http.SendAsync(req, token).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode)
{
Log?.Invoke($"updater: HTTP {(int)resp.StatusCode} from GitHub");
return null;
}
await using var stream = await resp.Content.ReadAsStreamAsync(token).ConfigureAwait(false);
var release = await JsonSerializer.DeserializeAsync<GitHubRelease>(stream, JsonOpts, token).ConfigureAwait(false);
if (release?.TagName is null)
{
Log?.Invoke("updater: response had no tag_name");
return null;
}
var latest = ParseTag(release.TagName);
var current = Assembly.GetExecutingAssembly().GetName().Version ?? new Version(0, 0, 0);
Log?.Invoke($"updater: current={current.ToString(3)} latest={latest.ToString(3)} ({release.TagName})");
if (latest <= current) return null;
var expectedAsset = AssetNameTemplate.Replace("{tag}", release.TagName);
var asset = release.Assets?.FirstOrDefault(a => string.Equals(a.Name, expectedAsset, StringComparison.OrdinalIgnoreCase));
if (asset?.BrowserDownloadUrl is null)
{
Log?.Invoke($"updater: latest release has no asset named '{expectedAsset}'");
return null;
}
return new UpdateInfo(
Tag: release.TagName,
Version: latest,
DownloadUrl: asset.BrowserDownloadUrl,
ReleaseNotes: release.Body ?? "",
ReleaseUrl: release.HtmlUrl ?? "");
}
catch (Exception ex)
{
Log?.Invoke($"updater: check failed: {ex.GetType().Name}: {ex.Message}");
return null;
}
}
/// <summary>Download the update ZIP, stage it next to RemSound.exe, spawn the detached
/// install helper, and ask the App to exit so the helper can take over. Returns true if
/// the helper was launched (caller should Application.Exit immediately afterwards);
/// false on any failure earlier in the pipeline. A false return leaves the running
/// instance untouched.</summary>
public async Task<bool> DownloadAndStageInstallAsync(UpdateInfo info, CancellationToken token = default)
{
try
{
var baseDir = AppContext.BaseDirectory;
var stagingDir = Path.Combine(baseDir, "_update");
var zipPath = Path.Combine(Path.GetTempPath(), $"RemSound-update-{info.Tag}.zip");
var batchPath = Path.Combine(baseDir, "_apply-update.cmd");
// Tidy any leftover from a previous failed attempt before we start.
TryDelete(zipPath);
TryDeleteDirectory(stagingDir);
Log?.Invoke($"updater: downloading {info.DownloadUrl}");
await using (var src = await http.GetStreamAsync(info.DownloadUrl, token).ConfigureAwait(false))
await using (var dst = File.Create(zipPath))
{
await src.CopyToAsync(dst, token).ConfigureAwait(false);
}
Log?.Invoke($"updater: extracting to {stagingDir}");
Directory.CreateDirectory(stagingDir);
System.IO.Compression.ZipFile.ExtractToDirectory(zipPath, stagingDir, overwriteFiles: true);
// Some release zips wrap everything in a single top-level folder
// (e.g. "RemSound-v1.1/RemSound.exe"). Flatten if that's the case so the
// robocopy step copies the right tree over the install location.
var stagingRoot = ResolveStagingRoot(stagingDir);
Log?.Invoke($"updater: writing install helper {batchPath}");
File.WriteAllText(batchPath, BuildInstallScript(stagingRoot, baseDir));
var pid = System.Environment.ProcessId;
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = $"/c \"\"{batchPath}\" {pid}\"",
UseShellExecute = false,
CreateNoWindow = true,
WorkingDirectory = baseDir,
};
Log?.Invoke($"updater: launching install helper, parent PID {pid}");
System.Diagnostics.Process.Start(psi);
return true;
}
catch (Exception ex)
{
Log?.Invoke($"updater: install failed: {ex.GetType().Name}: {ex.Message}");
return false;
}
}
/// <summary>One-shot installer batch. Waits for the supplied PID to exit (so file locks
/// release), robocopies the staged folder over the install folder, removes the staging
/// area, restarts RemSound.exe, and self-deletes. Robocopy's <c>/R:5 /W:1</c> flags give
/// the audio threads a few extra seconds to wind down if the exe is slow to release. The
/// helper is detached from RemSound at start time, so it survives the parent's exit.</summary>
private static string BuildInstallScript(string stagingRoot, string installDir) =>
$"""
@echo off
setlocal
rem RemSound auto-installer helper. Generated by RemSoundUpdater. Self-deleting.
set "PID=%~1"
:wait_loop
tasklist /FI "PID eq %PID%" 2>nul | find "%PID%" >nul
if not errorlevel 1 (
timeout /t 1 /nobreak >nul
goto wait_loop
)
robocopy "{stagingRoot}" "{installDir}" /E /IS /IT /NFL /NDL /NJH /NJS /R:5 /W:1 /XF _apply-update.cmd >nul
rmdir /S /Q "{Path.Combine(installDir, "_update")}" 2>nul
start "" "{Path.Combine(installDir, "RemSound.exe")}"
del "%~f0"
""";
/// <summary>If the zip extracted to a single subfolder (typical when GitHub zips a tag),
/// return that subfolder so the copy works from the inner level. Otherwise return the
/// staging dir itself.</summary>
private static string ResolveStagingRoot(string stagingDir)
{
var subdirs = Directory.GetDirectories(stagingDir);
var files = Directory.GetFiles(stagingDir);
if (files.Length == 0 && subdirs.Length == 1) return subdirs[0];
return stagingDir;
}
/// <summary>Parses a release tag like <c>v1.2</c> or <c>1.2.3</c> into a <see cref="Version"/>.
/// Leading "v" is stripped. Missing minor/build parts get filled with zeros so the result
/// always compares meaningfully against <see cref="Assembly.GetName"/>.Version.</summary>
public static Version ParseTag(string tag)
{
if (string.IsNullOrWhiteSpace(tag)) return new Version(0, 0, 0);
var trimmed = tag.TrimStart('v', 'V').Trim();
var parts = trimmed.Split('.', '-', '+');
var nums = new int[3];
for (var i = 0; i < 3 && i < parts.Length; i++)
{
int.TryParse(parts[i], out nums[i]);
}
return new Version(nums[0], nums[1], nums[2]);
}
private static HttpClient CreateClient()
{
var c = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20),
};
// GitHub rejects API requests without a User-Agent. The header doubles as a way for
// their abuse team to contact us if our polling misbehaves at scale.
c.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("RemSound-Updater", "1.0"));
return c;
}
private static void TryDelete(string path) { try { if (File.Exists(path)) File.Delete(path); } catch { /* ignore */ } }
private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { /* ignore */ } }
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
PropertyNameCaseInsensitive = true,
};
private sealed class GitHubRelease
{
[JsonPropertyName("tag_name")] public string? TagName { get; set; }
[JsonPropertyName("body")] public string? Body { get; set; }
[JsonPropertyName("html_url")] public string? HtmlUrl { get; set; }
[JsonPropertyName("assets")] public List<GitHubAsset>? Assets { get; set; }
}
private sealed class GitHubAsset
{
[JsonPropertyName("name")] public string? Name { get; set; }
[JsonPropertyName("browser_download_url")] public string? BrowserDownloadUrl { get; set; }
}
}
/// <summary>What <see cref="RemSoundUpdater.CheckForUpdateAsync"/> returns when there's a
/// newer release available. <see cref="ReleaseNotes"/> is the raw Markdown body of the
/// release on GitHub — show it directly in a confirmation dialog if the install isn't
/// silent.</summary>
internal sealed record UpdateInfo(
string Tag,
Version Version,
string DownloadUrl,
string ReleaseNotes,
string ReleaseUrl);
+90
View File
@@ -0,0 +1,90 @@
using Microsoft.Win32;
namespace RemSound.App;
/// <summary>
/// Wires "run RemSound automatically when this user logs in" via the per-user Run
/// registry key at <c>HKCU\Software\Microsoft\Windows\CurrentVersion\Run</c>. This is the
/// same mechanism many Windows apps use for "launch on login"; the entry shows up in Task
/// Manager's Startup tab so the user can also toggle it off there if they ever want to.
///
/// Why HKCU\...\Run rather than dropping a .lnk into the Startup folder:
/// - No COM interop / IShellLink wrangling; just RegistryKey.SetValue.
/// - User-scoped (HKCU): no admin elevation needed and only affects this user.
/// - Manageable via Task Manager → Startup, which is where Windows users now expect to
/// find login-launched apps.
///
/// All methods catch all exceptions and return success bools — flipping the toggle in the
/// Startup behaviour dialog should never throw, even on policy-locked machines.
/// </summary>
internal static class StartupAutoStart
{
private const string RunKeyPath = @"Software\Microsoft\Windows\CurrentVersion\Run";
private const string ValueName = "RemSound";
/// <summary>True when an entry called "RemSound" exists under the per-user Run key.
/// Reads the registry each call (cheap; single key open + value read). Never throws.</summary>
public static bool IsEnabled
{
get
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: false);
if (key is null) return false;
var value = key.GetValue(ValueName) as string;
return !string.IsNullOrWhiteSpace(value);
}
catch
{
return false;
}
}
}
/// <summary>Add or update the Run-key entry to point at the currently-running exe.
/// Quotes the path so spaces work. Returns true on success.</summary>
public static bool TryEnable()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true)
?? Registry.CurrentUser.CreateSubKey(RunKeyPath, writable: true);
if (key is null) return false;
var exePath = Environment.ProcessPath;
if (string.IsNullOrEmpty(exePath))
{
// Fallback: use AppContext.BaseDirectory. .NET hosting produces a
// different process path for self-contained vs framework-dependent
// publish, but BaseDirectory is reliable.
exePath = System.IO.Path.Combine(AppContext.BaseDirectory, "RemSound.exe");
}
// Wrap in double-quotes so a path containing spaces (e.g. C:\Program Files\)
// parses correctly when Windows launches it.
key.SetValue(ValueName, $"\"{exePath}\"");
return true;
}
catch
{
return false;
}
}
/// <summary>Remove the Run-key entry. Returns true if the entry is gone afterwards
/// (whether we deleted it or it never existed). Returns false only on registry
/// access errors.</summary>
public static bool TryDisable()
{
try
{
using var key = Registry.CurrentUser.OpenSubKey(RunKeyPath, writable: true);
if (key is null) return true; // No Run subkey at all → nothing to disable.
key.DeleteValue(ValueName, throwOnMissingValue: false);
return true;
}
catch
{
return false;
}
}
}
+278
View File
@@ -0,0 +1,278 @@
using RemSound.Core;
namespace RemSound.App;
/// <summary>
/// Modal dialog for the three "what should RemSound do at launch" toggles:
/// * Start minimised — main window goes to the tray immediately after Show.
/// * Start RemSound automatically with this user — wires HKCU\...\Run.
/// * Start with a specific profile — skips the startup picker and loads the chosen
/// profile directly. Companion listbox of saved profiles appears alongside.
///
/// The dialog persists changes through <see cref="AppConfig"/> (StartMinimised /
/// StartWithProfileTitle) and through the Windows registry (the auto-start checkbox).
/// Each change is committed immediately, no OK/Apply button — same per-tick-saves
/// pattern as the rest of the Profiles and preferences tab.
///
/// Keyboard shape (per Ed's spec):
/// * Tab cycles: minimised checkbox → auto-start checkbox → specific-profile
/// checkbox → profiles list (when shown) → Close button.
/// * Esc or the Close button closes.
/// * Each checkbox has an Alt+letter mnemonic.
/// </summary>
internal sealed class StartupBehaviourDialog : Form
{
private readonly AccessibleCheckBox startMinimisedBox = new()
{
Text = "Start minimised to tray (Alt+&M)",
AccessibleName = "Start minimised to tray",
AutoSize = true,
};
private readonly AccessibleCheckBox startWithUserBox = new()
{
Text = "Start RemSound automatically when this user logs in (Alt+&A)",
AccessibleName = "Start RemSound automatically when this user logs in",
AutoSize = true,
};
private readonly AccessibleCheckBox startWithProfileBox = new()
{
Text = "Start with a specific profile (Alt+&P)",
AccessibleName = "Start with a specific profile",
AutoSize = true,
};
private readonly Label profileListLabel = new()
{
Text = "Profile to start with (Alt+&L):",
AutoSize = true,
AccessibleName = "Profile to start with",
};
private readonly ListBox profileList = new()
{
IntegralHeight = false,
Width = 360,
Height = 140,
AccessibleName = "Profile to start with",
};
private readonly Button closeButton = new()
{
Text = "Close",
AutoSize = true,
DialogResult = DialogResult.OK,
AccessibleName = "Close",
};
public StartupBehaviourDialog(ProfileStore? profileStore)
{
Text = "Startup behaviour";
StartPosition = FormStartPosition.CenterParent;
FormBorderStyle = FormBorderStyle.FixedDialog;
MinimizeBox = false;
MaximizeBox = false;
ShowInTaskbar = false;
KeyPreview = true; // form-level Esc handler
ClientSize = new Size(540, 360);
// === Layout ===
var root = new TableLayoutPanel
{
Dock = DockStyle.Fill,
Padding = new Padding(12),
ColumnCount = 1,
RowCount = 6, // 0 intro, 1 minimise, 2 auto-start, 3 specific-profile, 4 list (with label), 5 close
};
root.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
for (var i = 0; i < 5; i++) root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
root.RowStyles.Add(new RowStyle(SizeType.AutoSize));
var intro = new Label
{
Text = "These options control what RemSound does when it launches. They persist across sessions and affect every launch (whether started by the user or by Windows on login).",
AutoSize = true,
MaximumSize = new Size(500, 0),
Anchor = AnchorStyles.Left,
};
root.Controls.Add(intro, 0, 0);
// Each checkbox lives on its own row, plus the profile list row when relevant.
root.Controls.Add(startMinimisedBox, 0, 1);
root.Controls.Add(startWithUserBox, 0, 2);
root.Controls.Add(startWithProfileBox, 0, 3);
// Profile list: label + list stacked, hidden until startWithProfileBox is ticked.
// FlowLayoutPanel keeps them tidy and the whole sub-block can be toggled visible
// as a unit.
var listSubPanel = new FlowLayoutPanel
{
FlowDirection = FlowDirection.TopDown,
AutoSize = true,
WrapContents = false,
Padding = new Padding(20, 0, 0, 0), // indent slightly so it visually belongs to the checkbox above
};
listSubPanel.Controls.Add(profileListLabel);
listSubPanel.Controls.Add(profileList);
root.Controls.Add(listSubPanel, 0, 4);
var closePanel = new FlowLayoutPanel
{
Dock = DockStyle.Fill,
FlowDirection = FlowDirection.RightToLeft,
AutoSize = true,
Padding = new Padding(0, 8, 0, 0),
};
closePanel.Controls.Add(closeButton);
root.Controls.Add(closePanel, 0, 5);
Controls.Add(root);
// === Tab order ===
startMinimisedBox.TabIndex = 0;
startWithUserBox.TabIndex = 1;
startWithProfileBox.TabIndex = 2;
profileList.TabIndex = 3;
closeButton.TabIndex = 4;
// === Initial state ===
var cfg = AppConfig.Load();
startMinimisedBox.Checked = cfg.StartMinimised;
startWithUserBox.Checked = StartupAutoStart.IsEnabled;
var hasProfile = !string.IsNullOrWhiteSpace(cfg.StartWithProfileTitle);
startWithProfileBox.Checked = hasProfile;
// Populate profile list and select the saved choice if any.
if (profileStore is not null)
{
foreach (var title in profileStore.ListProfileTitles())
{
profileList.Items.Add(title);
}
}
if (hasProfile && cfg.StartWithProfileTitle is { } savedTitle)
{
var idx = profileList.Items.IndexOf(savedTitle);
if (idx >= 0) profileList.SelectedIndex = idx;
}
UpdateProfileListVisibility();
// === Wiring ===
startMinimisedBox.CheckedChanged += (_, _) =>
{
var c = AppConfig.Load();
c.StartMinimised = startMinimisedBox.Checked;
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save Start minimised preference: " + ex.Message); }
};
startWithUserBox.CheckedChanged += (_, _) =>
{
// Source of truth for the auto-start state is the registry — we don't keep a
// duplicate in AppConfig. So this just flips the registry entry directly.
var ok = startWithUserBox.Checked
? StartupAutoStart.TryEnable()
: StartupAutoStart.TryDisable();
if (!ok)
{
MessageBox.Show(this,
"RemSound could not change the auto-start setting in the Windows registry. The setting did not change. (This usually means a policy or another security tool is blocking it.)",
"Auto-start change failed",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
// Re-read truth and reflect it without re-firing this handler.
var actual = StartupAutoStart.IsEnabled;
if (startWithUserBox.Checked != actual)
{
// Temporarily detach the handler to avoid a recursive call.
var savedChecked = actual;
startWithUserBox.CheckedChanged -= AutoStartReentryGuard;
startWithUserBox.Checked = savedChecked;
startWithUserBox.CheckedChanged += AutoStartReentryGuard;
}
}
};
// Empty handler used as a target-for-removal in the re-entry-guard path above.
// Kept so the +=/-= pair is symmetrical even though it does nothing on its own.
void AutoStartReentryGuard(object? _, EventArgs __) { }
startWithProfileBox.CheckedChanged += (_, _) =>
{
UpdateProfileListVisibility();
if (startWithProfileBox.Checked)
{
if (profileList.Items.Count == 0)
{
MessageBox.Show(this,
"You don't have any saved profiles yet. Save a profile first (Profiles and preferences tab → Save profile as), then come back here and pick it.",
"No saved profiles",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
// Untick without re-firing.
startWithProfileBox.Checked = false;
return;
}
if (profileList.SelectedIndex < 0) profileList.SelectedIndex = 0;
CommitProfileSelection();
}
else
{
ClearProfileSelection();
}
};
profileList.SelectedIndexChanged += (_, _) =>
{
if (!startWithProfileBox.Checked) return;
if (profileList.SelectedIndex < 0) return;
CommitProfileSelection();
};
profileList.DoubleClick += (_, _) =>
{
// Same effect as picking a row + closing — convenient for mouse users.
if (startWithProfileBox.Checked && profileList.SelectedIndex >= 0)
{
CommitProfileSelection();
}
DialogResult = DialogResult.OK;
Close();
};
KeyDown += (_, e) =>
{
if (e.KeyCode == Keys.Escape)
{
DialogResult = DialogResult.Cancel;
Close();
e.SuppressKeyPress = true;
e.Handled = true;
}
};
AcceptButton = closeButton;
CancelButton = closeButton;
Load += (_, _) => startMinimisedBox.Focus();
}
private void UpdateProfileListVisibility()
{
var visible = startWithProfileBox.Checked;
profileListLabel.Visible = visible;
profileList.Visible = visible;
}
private void CommitProfileSelection()
{
if (profileList.SelectedItem is not string title || string.IsNullOrWhiteSpace(title)) return;
var c = AppConfig.Load();
c.StartWithProfileTitle = title;
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); }
}
private void ClearProfileSelection()
{
var c = AppConfig.Load();
c.StartWithProfileTitle = null;
try { c.Save(); } catch (Exception ex) { ShowSaveWarning("Could not save the start-with-profile choice: " + ex.Message); }
}
private void ShowSaveWarning(string message)
{
MessageBox.Show(this, message, "Startup behaviour", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
}
+110
View File
@@ -0,0 +1,110 @@
using NAudio.CoreAudioApi;
namespace RemSound.App;
/// <summary>
/// Thin wrapper around NAudio's <see cref="MMDeviceEnumerator"/> + <see cref="AudioEndpointVolume"/>
/// to drive the Windows master volume on the system's <em>default</em> render device — the same
/// device the system-tray volume slider and the keyboard's volume keys control.
///
/// Why default-render-device specifically (and not e.g. the WASAPI outputs RemSound is currently
/// playing through): in Ed's primary use case the listener machine is using ASIO for RemSound
/// playback, but the Windows default output device is what NVDA + browsers + everything else
/// runs through, and that's the volume Ed wants to nudge. Targeting the default device matches
/// what the user already mentally maps "the system volume" to. ASIO devices don't expose a
/// MasterVolumeLevelScalar via this CoreAudio surface anyway — they have hardware gain — so the
/// "what about ASIO?" question doesn't apply here.
///
/// 2026-05-11 — switched to a cached enumerator/device/endpoint-volume trio (previously each call
/// created and disposed a fresh set). Reason: the system-volume hotkeys now allow Windows-side
/// auto-repeat on hold (see <see cref="RemSound.Core.GlobalHotkey.Register"/>), which can fire
/// the receiver-side handler at ~30 Hz. Each fresh enumeration is multiple COM calls into the
/// Windows audio service; doing that 30 times a second was correlated with receive-side audio
/// glitches in testing logs. Caching collapses every steady-state call to one VolumeStepUp/Down
/// on the cached endpoint-volume. The cache is invalidated on any COM exception so a device
/// hot-swap or audio-service restart self-heals on the next call.
/// </summary>
internal static class SystemVolumeHelper
{
private static readonly object cacheLock = new();
private static MMDeviceEnumerator? cachedEnumerator;
private static MMDevice? cachedDevice;
/// <summary>Bumps the default render device's master volume by Windows' native step
/// (typically ~2% — same as one keyboard-volume-up press). Returns true on success,
/// false if the device couldn't be enumerated (catches all exceptions to keep a remote
/// hotkey press from ever throwing).</summary>
public static bool TryStepUp() => TryDo(v => v.VolumeStepUp());
/// <summary>Mirror of <see cref="TryStepUp"/> in the down direction.</summary>
public static bool TryStepDown() => TryDo(v => v.VolumeStepDown());
/// <summary>Toggles the default render device's master mute. Reads the current state,
/// flips it, writes it back. Returns true on success.</summary>
public static bool TryToggleMute() => TryDo(v => v.Mute = !v.Mute);
/// <summary>Reads the current default-render-device master volume scalar (0.0..1.0) and
/// mute state, for diagnostic logging. Returns null on any failure. Uses the same cached
/// endpoint as the step/mute calls.</summary>
public static (float scalar, bool mute)? TryReadState()
{
lock (cacheLock)
{
try
{
var device = GetOrCreateDeviceLocked();
if (device is null) return null;
return (device.AudioEndpointVolume.MasterVolumeLevelScalar, device.AudioEndpointVolume.Mute);
}
catch
{
InvalidateCacheLocked();
return null;
}
}
}
private static bool TryDo(Action<AudioEndpointVolume> action)
{
lock (cacheLock)
{
try
{
var device = GetOrCreateDeviceLocked();
if (device is null) return false;
// Multimedia role matches the system tray slider's idea of "default device" on a
// typical setup. (Console role is for system sounds; the user's default playback
// is normally configured the same for both. Multimedia is the right default for
// "audio I'm listening to".)
action(device.AudioEndpointVolume);
return true;
}
catch
{
// Possible failure modes: default device changed, audio service restarted,
// device disconnected, COM marshalling glitch. Drop the cache so the next call
// re-enumerates fresh; the user just sees a missed tick rather than a thrown
// exception or a stuck-stale endpoint.
InvalidateCacheLocked();
return false;
}
}
}
private static MMDevice? GetOrCreateDeviceLocked()
{
if (cachedDevice is not null) return cachedDevice;
cachedEnumerator ??= new MMDeviceEnumerator();
cachedDevice = cachedEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia);
return cachedDevice;
}
private static void InvalidateCacheLocked()
{
try { cachedDevice?.Dispose(); } catch { /* ignore */ }
cachedDevice = null;
// Keep the enumerator across invalidations — the enumerator itself doesn't go stale
// when the default device changes, only the device handle does. Cheaper to keep one
// enumerator alive for the app's lifetime than to re-create it on every device hop.
}
}
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="RemSound.App"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>