diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index 1d9b0d3..237350f 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -2159,10 +2159,110 @@ public sealed class MainForm : Form menu.Items.Add(fileMenu); menu.Items.Add(recordMenu); menu.Items.Add(optionsMenu); + menu.Items.Add(BuildServiceMenu()); menu.Items.Add(helpMenu); return menu; } + /// The Service menu: configure the send-only lock-screen service's profile, and + /// install / uninstall / start / stop it. The status line and item enabling refresh each time the + /// menu opens. Install/uninstall/start/stop re-launch RemSound elevated (one UAC prompt each); + /// querying status needs no elevation. + private ToolStripMenuItem BuildServiceMenu() + { + var serviceMenu = new ToolStripMenuItem("&Service") { AccessibleName = "Service menu" }; + var status = new ToolStripMenuItem("Service: …") { Enabled = false, AccessibleName = "Service status" }; + var configure = new ToolStripMenuItem("&Configure service profile...") { AccessibleName = "Configure service profile" }; + configure.Click += (_, _) => ConfigureServiceProfile(); + var install = new ToolStripMenuItem("&Install service") { AccessibleName = "Install service" }; + install.Click += (_, _) => ServiceAction(ServiceControl.InstallVerb, "install", confirm: true); + var uninstall = new ToolStripMenuItem("&Uninstall service") { AccessibleName = "Uninstall service" }; + uninstall.Click += (_, _) => ServiceAction(ServiceControl.UninstallVerb, "uninstall", confirm: true); + var start = new ToolStripMenuItem("S&tart service") { AccessibleName = "Start service" }; + start.Click += (_, _) => ServiceAction(ServiceControl.StartVerb, "start", confirm: false); + var stop = new ToolStripMenuItem("Sto&p service") { AccessibleName = "Stop service" }; + stop.Click += (_, _) => ServiceAction(ServiceControl.StopVerb, "stop", confirm: false); + + serviceMenu.DropDownItems.AddRange(new ToolStripItem[] + { + status, new ToolStripSeparator(), + configure, new ToolStripSeparator(), + install, uninstall, start, stop, + }); + serviceMenu.DropDownOpening += (_, _) => + { + var state = ServiceControl.Query(); + status.Text = "Service: " + DescribeServiceState(state); + var installed = state != ServiceState.NotInstalled; + install.Enabled = !installed; + uninstall.Enabled = installed; + start.Enabled = installed && state is ServiceState.Stopped; + stop.Enabled = installed && state is ServiceState.Running; + }; + return serviceMenu; + } + + private static string DescribeServiceState(ServiceState s) => s switch + { + ServiceState.NotInstalled => "not installed", + ServiceState.Running => "installed, running", + ServiceState.Stopped => "installed, stopped", + ServiceState.StartPending => "starting…", + ServiceState.StopPending => "stopping…", + _ => "unknown", + }; + + /// Opens the modal service-profile editor, then persists the result: saves the reserved + /// service profile, points AppConfig at it, and stores the machine-wide service-logging choice. If + /// the service is running, restarts it so the edits take effect. + private void ConfigureServiceProfile() + { + if (profileStore is null) return; + var cfg = AppConfig.Load(); + Profile current; + try { current = profileStore.Load(ServiceControl.ServiceProfileTitle) ?? Profile.NewBlank(); } + catch { current = Profile.NewBlank(); } + + using var dlg = new ServiceProfileDialog(current, cfg.ServiceLoggingEnabled); + if (dlg.ShowDialog(this) != DialogResult.OK) return; + try + { + profileStore.Save(dlg.Result); + var c = AppConfig.Load(); + c.ServiceProfileName = ServiceControl.ServiceProfileTitle; + c.ServiceLoggingEnabled = dlg.ServiceLoggingEnabled; + c.Save(); + if (ServiceControl.Query() == ServiceState.Running) + { + ServiceControl.RunElevated(ServiceControl.StopVerb); + ServiceControl.RunElevated(ServiceControl.StartVerb); + } + MessageBox.Show(this, "Service profile saved.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + } + catch (Exception ex) + { + MessageBox.Show(this, $"Could not save the service profile: {ex.Message}", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + } + + private void ServiceAction(string verb, string label, bool confirm) + { + if (confirm) + { + var msg = verb == ServiceControl.InstallVerb + ? "Install the RemSound send-only service? It will start automatically at boot and stream your service profile whenever you're not using RemSound normally.\n\nWindows will ask for administrator permission." + : "Uninstall the RemSound service?\n\nWindows will ask for administrator permission."; + if (MessageBox.Show(this, msg, AppName, MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; + } + var rc = ServiceControl.RunElevated(verb); + if (rc == 0) + MessageBox.Show(this, $"Service {label} succeeded.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Information); + else if (rc == -1) + MessageBox.Show(this, $"Service {label} was cancelled, or administrator rights were declined.", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + else + MessageBox.Show(this, $"Service {label} failed (code {rc}).", AppName, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + /// Rebuild the Recent profiles submenu from . /// Called once during menu construction (so it's not visibly empty before the first /// open) and on every DropDownOpening so the latest list is always shown. Entries that diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs index df31d36..8344eb7 100644 --- a/src/RemSound.App/SelfTest.cs +++ b/src/RemSound.App/SelfTest.cs @@ -795,6 +795,7 @@ internal static class SelfTest () => false, _ => { }, () => { }, () => 0, () => { }, () => { }, _ => { }, () => (default(RouterMappingStatus), (IPEndPoint?)null, ""), _ => { }, _ => { })), + ("Service profile", () => new ServiceProfileDialog(RemSound.Core.Profile.NewBlank(), false)), }; var audited = new List(); diff --git a/src/RemSound.App/ServiceControl.cs b/src/RemSound.App/ServiceControl.cs index 62cfdc8..b88136a 100644 --- a/src/RemSound.App/ServiceControl.cs +++ b/src/RemSound.App/ServiceControl.cs @@ -18,6 +18,11 @@ public static class ServiceControl { public const string ServiceName = "RemSoundService"; public const string DisplayName = "RemSound send-only service"; + + /// Reserved title of the profile the service streams from. Edited only through the service + /// config dialog and filtered out of the normal profile picker so it can't be loaded by accident. + /// Single source of truth lives in . + public const string ServiceProfileTitle = RemSound.Core.ProfileStore.ReservedServiceProfileTitle; public const string Description = "Streams this machine's audio to its RemSound peers without a logged-in user (lock screen). " + "Send-only; yields to the interactive RemSound app while it is open."; diff --git a/src/RemSound.App/ServiceProfileDialog.cs b/src/RemSound.App/ServiceProfileDialog.cs new file mode 100644 index 0000000..2ecbf46 --- /dev/null +++ b/src/RemSound.App/ServiceProfileDialog.cs @@ -0,0 +1,381 @@ +using RemSound.Core; +using RemSound.Sender; + +namespace RemSound.App; + +/// +/// The "Configure RemSound service profile" dialog: a self-contained, modal editor for the send-only +/// service profile. Three tabs — Audio send, Audio profile, Connectivity — plus a button row of +/// Save and Close / Cancel / Additional options. Being modal, there are no menus and no profile +/// switching to worry about. It edits a clone; nothing is persisted until the +/// caller acts on an OK result. Send-only and WASAPI-only: no receive controls, no ASIO, and no +/// "Send my audio" toggle (it always sends). +/// +internal sealed class ServiceProfileDialog : Form +{ + private readonly Profile working; + + private readonly QuietTabControl tabs = new() { Dock = DockStyle.Fill }; + + // --- Audio send tab --- + private readonly ListBox sendModeList = new() { Width = 460, Height = 38, IntegralHeight = false, AccessibleName = "How to send WASAPI audio (Alt+6)" }; + private readonly CheckedListBox outputsList = new() { CheckOnClick = true, Width = 460, Height = 110, AccessibleName = "WASAPI audio outputs to send (Alt+4)" }; + private readonly AccessibleCheckBox sendAllAppsBox = new() { Text = "Send all applications (Alt+&7)", AccessibleName = "Send all applications", AutoSize = true, Checked = true }; + private readonly CheckedListBox appsList = new() { CheckOnClick = true, Width = 460, Height = 110, AccessibleName = "Applications to send (Alt+8)" }; + private readonly CheckedListBox inputsList = new() { CheckOnClick = true, Width = 460, Height = 90, AccessibleName = "WASAPI audio inputs to send (Alt+5)" }; + private MnemonicLabel? sendModeLabel, outputsLabel, appsLabel, inputsLabel; + + // --- Audio profile tab --- + private readonly ComboBox codecBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 360, AccessibleName = "Audio codec (Alt+C)" }; + private readonly ComboBox sendRateBox = new() { DropDownStyle = ComboBoxStyle.DropDownList, Width = 360, AccessibleName = "Send rate (Alt+E)" }; + private readonly AccessibleCheckBox tightLatencyBox = new() { Text = "&Lock to audio clock — steadier timing, slightly higher latency (Alt+L)", AccessibleName = "Lock to audio clock", AutoSize = true }; + + // --- Connectivity tab --- + private readonly ListBox peersList = new() { Width = 360, Height = 120, AccessibleName = "Peers to send to (Alt+P)" }; + private readonly TextBox addPeerBox = new() { Width = 260, AccessibleName = "Add a peer address or hostname (Alt+A)" }; + private readonly Button addPeerButton = new() { Text = "A&dd", AutoSize = true, AccessibleName = "Add peer" }; + private readonly Button removePeerButton = new() { Text = "&Remove", AutoSize = true, AccessibleName = "Remove selected peer" }; + private readonly Button passwordButton = new() { Text = "Set pass&word...", AutoSize = true, AccessibleName = "Set the service profile password" }; + private readonly Label passwordStatus = new() { AutoSize = true }; + + // --- Button row --- + private readonly Button saveButton = new() { Text = "&Save and Close", AutoSize = true, DialogResult = DialogResult.OK }; + private readonly Button cancelButton = new() { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel }; + private readonly Button additionalButton = new() { Text = "Additional &options...", AutoSize = true, AccessibleName = "Additional options" }; + + private bool suppressAppEvents; + + /// The edited profile (valid after an OK result). + public Profile Result => working; + + /// Whether the service should write its own log — machine-wide, so it's returned separately + /// from the profile. Seeded from AppConfig; the caller writes it back to AppConfig on OK. + public bool ServiceLoggingEnabled { get; private set; } + + public ServiceProfileDialog(Profile current, bool serviceLoggingEnabled) + { + working = CloneProfile(current); + working.Title = ServiceControl.ServiceProfileTitle; + ServiceLoggingEnabled = serviceLoggingEnabled; + + Text = "Configure RemSound service profile"; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; + MaximizeBox = false; + ShowInTaskbar = false; + StartPosition = FormStartPosition.CenterParent; + KeyPreview = true; + ClientSize = new Size(540, 560); + AccessibleName = "Configure RemSound service profile"; + + BuildAudioSendTab(); + BuildAudioProfileTab(); + BuildConnectivityTab(); + BuildButtonRow(); + + LoadFromProfile(); + ApplySendModeVisibility(); + UpdatePasswordStatus(); + + AcceptButton = saveButton; + CancelButton = cancelButton; + saveButton.Click += (_, _) => SaveToProfile(); + additionalButton.Click += (_, _) => ShowAdditionalOptions(); + } + + // ---------------- layout ---------------- + + private TableLayoutPanel NewColumn() => new() + { + Dock = DockStyle.Fill, + ColumnCount = 2, + AutoScroll = true, + Padding = new Padding(10), + }; + + private void BuildAudioSendTab() + { + var page = new TabPage("Audio send"); + var panel = NewColumn(); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + sendModeList.Items.Add("Send whole audio devices"); + sendModeList.Items.Add("Send specific applications"); + sendModeList.SelectedIndex = 0; + sendModeList.SelectedIndexChanged += (_, _) => { if (!suppressAppEvents) ApplySendModeVisibility(); }; + sendAllAppsBox.CheckedChanged += (_, _) => { if (!suppressAppEvents) ApplySendModeVisibility(); }; + + sendModeLabel = AddListRow(panel, 0, "How to send WASAPI audio (Alt+&6)", sendModeList); + outputsLabel = AddListRow(panel, 1, "WASAPI audio outputs to send (Alt+&4)", outputsList); + var allAppsWrap = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + allAppsWrap.Controls.Add(sendAllAppsBox); + panel.Controls.Add(allAppsWrap, 1, 2); + appsLabel = AddListRow(panel, 3, "Applications to send (Alt+&8)", appsList); + inputsLabel = AddListRow(panel, 4, "WASAPI audio inputs to send (Alt+&5)", inputsList); + + page.Controls.Add(panel); + tabs.TabPages.Add(page); + } + + private void BuildAudioProfileTab() + { + var page = new TabPage("Audio profile"); + var panel = NewColumn(); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + codecBox.Items.AddRange(new object[] + { + new CodecChoice("Uncompressed PCM (best quality, most bandwidth)", AudioTransportCodec.Pcm, 480), + new CodecChoice("Opus broadcast quality (20 ms, loss tolerant)", AudioTransportCodec.Opus, 960), + new CodecChoice("Opus live latency (2.5 ms, for jamming)", AudioTransportCodec.Opus, 120), + }); + sendRateBox.Items.AddRange(new object[] { SendRate.Standard, SendRate.Tight }); + + AddControlRow(panel, 0, "Audio &codec (Alt+C)", codecBox); + AddControlRow(panel, 1, "Send rat&e (Alt+E)", sendRateBox); + var tightWrap = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + tightWrap.Controls.Add(tightLatencyBox); + panel.Controls.Add(tightWrap, 1, 2); + + page.Controls.Add(panel); + tabs.TabPages.Add(page); + } + + private void BuildConnectivityTab() + { + var page = new TabPage("Connectivity"); + var panel = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, AutoScroll = true, Padding = new Padding(10) }; + panel.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + panel.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + + AddListRow(panel, 0, "Peers to send to (Alt+&P)", peersList); + + var addRow = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + addRow.Controls.Add(addPeerBox); + addRow.Controls.Add(addPeerButton); + addRow.Controls.Add(removePeerButton); + var addLabel = new MnemonicLabel { Text = "Add peer (Alt+&A)", AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = addPeerBox }; + panel.Controls.Add(addLabel, 0, 1); + panel.Controls.Add(addRow, 1, 1); + + addPeerButton.Click += (_, _) => AddPeerFromBox(); + removePeerButton.Click += (_, _) => { if (peersList.SelectedItem is string s) { peersList.Items.Remove(s); } }; + addPeerBox.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) { AddPeerFromBox(); e.SuppressKeyPress = true; } }; + + var pwWrap = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; + pwWrap.Controls.Add(passwordButton); + pwWrap.Controls.Add(passwordStatus); + panel.Controls.Add(pwWrap, 1, 2); + passwordButton.Click += (_, _) => SetPassword(); + + page.Controls.Add(panel); + tabs.TabPages.Add(page); + } + + private void BuildButtonRow() + { + var outer = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 2 }; + outer.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + outer.RowStyles.Add(new RowStyle(SizeType.AutoSize)); + outer.Controls.Add(tabs, 0, 0); + + var row = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.LeftToRight, Padding = new Padding(8) }; + row.Controls.Add(saveButton); + row.Controls.Add(cancelButton); + row.Controls.Add(additionalButton); + outer.Controls.Add(row, 0, 1); + Controls.Add(outer); + } + + private MnemonicLabel AddListRow(TableLayoutPanel panel, int row, string label, Control list) + { + var l = new MnemonicLabel { Text = label, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = list }; + l.Click += (_, _) => list.Focus(); + panel.Controls.Add(l, 0, row); + var wrap = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, FlowDirection = FlowDirection.TopDown, WrapContents = false, TabStop = false }; + wrap.Controls.Add(list); + panel.Controls.Add(wrap, 1, row); + return l; + } + + private void AddControlRow(TableLayoutPanel panel, int row, string label, Control control) + { + var l = new MnemonicLabel { Text = label, AutoSize = true, Anchor = AnchorStyles.Left, MnemonicTarget = control }; + l.Click += (_, _) => control.Focus(); + panel.Controls.Add(l, 0, row); + panel.Controls.Add(control, 1, row); + } + + // ---------------- data in/out ---------------- + + private void LoadFromProfile() + { + suppressAppEvents = true; + try + { + PopulateDeviceList(outputsList, AudioDeviceCatalog.LoadOutputs(), working.SelectedWasapiSendOutputs); + PopulateDeviceList(inputsList, AudioDeviceCatalog.LoadInputs(), working.SelectedWasapiSendInputs); + + var appsMode = ProcessLoopbackCapture.IsSupported + && string.Equals(working.WasapiSendMode, "applications", StringComparison.OrdinalIgnoreCase); + sendModeList.SelectedIndex = appsMode ? 1 : 0; + sendAllAppsBox.Checked = working.SendAllApplications; + PopulateAppsList(); + + SelectCodec(); + sendRateBox.SelectedItem = working.SendRate; + tightLatencyBox.Checked = working.TightLatencyMode; + + peersList.Items.Clear(); + foreach (var p in (working.SelectedConnectedPeers.Count > 0 ? working.SelectedConnectedPeers : working.RememberedPeers).Distinct()) + peersList.Items.Add(p); + } + finally { suppressAppEvents = false; } + } + + private void SaveToProfile() + { + working.SelectedWasapiSendOutputs = CheckedIds(outputsList); + working.SelectedWasapiSendInputs = CheckedIds(inputsList); + working.WasapiSendMode = sendModeList.SelectedIndex == 1 ? "applications" : "devices"; + working.SendAllApplications = sendAllAppsBox.Checked; + working.SelectedSendApplications = appsList.CheckedItems.OfType().Select(a => a.ProcessName).Distinct().ToList(); + + if (codecBox.SelectedItem is CodecChoice c) { working.Codec = c.Codec; working.OpusFrameSamplesPerChannel = c.OpusFrameSamples; } + if (sendRateBox.SelectedItem is SendRate r) working.SendRate = r; + working.TightLatencyMode = tightLatencyBox.Checked; + + var peers = peersList.Items.OfType().Distinct().ToList(); + working.SelectedConnectedPeers = peers; + working.RememberedPeers = peers; + } + + private void PopulateDeviceList(CheckedListBox list, IReadOnlyList devices, IReadOnlyList checkedIds) + { + list.Items.Clear(); + var wanted = new HashSet(checkedIds, StringComparer.OrdinalIgnoreCase); + foreach (var d in devices) + { + if (d.DeviceId is null) continue; + var idx = list.Items.Add(d); + if (wanted.Contains(d.DeviceId)) list.SetItemChecked(idx, true); + } + } + + private static List CheckedIds(CheckedListBox list) => + list.CheckedItems.OfType().Where(c => c.DeviceId is not null).Select(c => c.DeviceId!).Distinct().ToList(); + + private void PopulateAppsList() + { + var ticked = new HashSet(working.SelectedSendApplications, StringComparer.OrdinalIgnoreCase); + var running = ProcessLoopbackCapture.IsSupported ? AudioAppEnumerator.Snapshot() : Array.Empty(); + var runningNames = new HashSet(running.Select(a => a.ProcessName), StringComparer.OrdinalIgnoreCase); + appsList.Items.Clear(); + foreach (var a in running) AddApp(a.ProcessName, a.DisplayName, running: true, ticked); + foreach (var name in ticked) if (!runningNames.Contains(name)) AddApp(name, name, running: false, ticked); + + void AddApp(string proc, string display, bool running, HashSet tickedSet) + { + var idx = appsList.Items.Add(new AppRow(proc, display, running)); + if (tickedSet.Contains(proc)) appsList.SetItemChecked(idx, true); + } + } + + private void SelectCodec() + { + foreach (var item in codecBox.Items.OfType()) + if (item.Codec == working.Codec && (working.Codec == AudioTransportCodec.Pcm || item.OpusFrameSamples == working.OpusFrameSamplesPerChannel)) + { codecBox.SelectedItem = item; return; } + if (codecBox.Items.Count > 0) codecBox.SelectedIndex = 0; + } + + private void ApplySendModeVisibility() + { + var supported = ProcessLoopbackCapture.IsSupported; + if (sendModeLabel is not null) sendModeLabel.Visible = supported; + SetRowVisible(sendModeList, supported); + if (!supported && sendModeList.SelectedIndex != 0) { suppressAppEvents = true; sendModeList.SelectedIndex = 0; suppressAppEvents = false; } + + var appsMode = supported && sendModeList.SelectedIndex == 1; + if (outputsLabel is not null) outputsLabel.Visible = !appsMode; + SetRowVisible(outputsList, !appsMode); + SetRowVisible(sendAllAppsBox, appsMode); + var showApps = appsMode && !sendAllAppsBox.Checked; + if (appsLabel is not null) appsLabel.Visible = showApps; + SetRowVisible(appsList, showApps); + } + + private static void SetRowVisible(Control c, bool visible) + { + c.Visible = visible; + if (c.Parent is not null) c.Parent.Visible = visible; + } + + private void AddPeerFromBox() + { + var text = addPeerBox.Text.Trim(); + if (text.Length == 0) return; + if (!peersList.Items.OfType().Any(p => string.Equals(p, text, StringComparison.OrdinalIgnoreCase))) + peersList.Items.Add(text); + addPeerBox.Clear(); + } + + private void SetPassword() + { + var current = string.IsNullOrEmpty(working.Password) ? "" : RemSoundCrypto.Deobfuscate(working.Password); + var result = ProfilePasswordDialog.Show(ServiceControl.ServiceProfileTitle, current); + if (result is null) return; // cancelled + working.Password = string.IsNullOrEmpty(result) ? null : RemSoundCrypto.Obfuscate(result); + UpdatePasswordStatus(); + } + + private void UpdatePasswordStatus() + => passwordStatus.Text = string.IsNullOrEmpty(working.Password) ? "No password set." : "Password set."; + + private void ShowAdditionalOptions() + { + using var dlg = new Form + { + Text = "Additional service options", + FormBorderStyle = FormBorderStyle.FixedDialog, + MinimizeBox = false, + MaximizeBox = false, + ShowInTaskbar = false, + StartPosition = FormStartPosition.CenterParent, + ClientSize = new Size(460, 200), + AccessibleName = "Additional service options", + }; + var connect = new AccessibleCheckBox { Text = "Play a sound when a peer &connects (Alt+C)", AccessibleName = "Play connect sound", AutoSize = true, Checked = working.EnableConnectCue ?? true }; + var disconnect = new AccessibleCheckBox { Text = "Play a sound when a peer &disconnects (Alt+D)", AccessibleName = "Play disconnect sound", AutoSize = true, Checked = working.EnableDisconnectCue ?? true }; + var logging = new AccessibleCheckBox { Text = "Enable service &logging (Alt+L)", AccessibleName = "Enable service logging", AutoSize = true, Checked = ServiceLoggingEnabled }; + var ok = new Button { Text = "&OK", AutoSize = true, DialogResult = DialogResult.OK }; + + var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, Padding = new Padding(12), AutoSize = true }; + foreach (var c in new Control[] { connect, disconnect, logging, ok }) { var w = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill }; w.Controls.Add(c); layout.Controls.Add(w); } + dlg.Controls.Add(layout); + dlg.AcceptButton = ok; + + if (ForegroundDialog.Show(owner => dlg.ShowDialog(owner)) == DialogResult.OK) + { + working.EnableConnectCue = connect.Checked; + working.EnableDisconnectCue = disconnect.Checked; + ServiceLoggingEnabled = logging.Checked; + } + } + + private static Profile CloneProfile(Profile p) => + System.Text.Json.JsonSerializer.Deserialize(System.Text.Json.JsonSerializer.Serialize(p)) ?? new Profile(); + + /// One row in the applications list. Identity is the process name; display adds a + /// "(not running)" hint for a remembered-but-closed app. + private sealed class AppRow + { + public string ProcessName { get; } + private readonly string display; + private readonly bool running; + public AppRow(string processName, string displayName, bool running) { ProcessName = processName; display = displayName; this.running = running; } + public override string ToString() => running ? display : $"{display} (not running)"; + } +} diff --git a/src/RemSound.Core/ProfileStore.cs b/src/RemSound.Core/ProfileStore.cs index af97366..2d3da2c 100644 --- a/src/RemSound.Core/ProfileStore.cs +++ b/src/RemSound.Core/ProfileStore.cs @@ -49,9 +49,14 @@ public sealed class ProfileStore /// Folder this store reads from and writes into. public string BaseDirectory => baseDir; + /// Reserved title of the send-only Windows service's profile. It lives in the same folder + /// as normal profiles (so the service loads it via ), but it's edited only through + /// the Service menu's config dialog and hidden from the normal picker by . + public const string ReservedServiceProfileTitle = "RemSound service"; + /// Returns the user-facing titles of every profile in the folder, sorted - /// alphabetically (case-insensitive). Excludes the synthetic blank-template; the - /// caller decides whether to surface that. + /// alphabetically (case-insensitive). Excludes the synthetic blank-template and the reserved + /// service profile; the caller decides whether to surface the blank template. public IReadOnlyList ListProfileTitles() { if (!Directory.Exists(baseDir)) return []; @@ -60,6 +65,7 @@ public sealed class ProfileStore return Directory.GetFiles(baseDir, "*.json") .Select(p => TryReadTitle(p) ?? Path.GetFileNameWithoutExtension(p)) .Where(static t => !string.IsNullOrWhiteSpace(t)) + .Where(static t => !string.Equals(t, ReservedServiceProfileTitle, StringComparison.OrdinalIgnoreCase)) .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) .ToList(); }