Initial commit: RemSound v1.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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><exe>\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
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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', ' ');
|
||||
}
|
||||
@@ -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-<tag>.zip</c>.</item>
|
||||
/// <item>Extract to <c><exe>\_update\</c>.</item>
|
||||
/// <item>Write a one-shot batch file at <c><exe>\_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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user