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();
}