2026-07-12 15:27:37 +01:00
using RemSound.Core ;
using RemSound.Sender ;
namespace RemSound.App ;
/// <summary>
/// The "Configure RemSound service profile" dialog: a self-contained, modal editor for the send-only
2026-07-12 20:21:48 +01:00
/// service profile, built to match the main window — same house controls, same layout rows, and the
/// same screen-reader wiring on the lists (via <see cref="CheckedListAccessibility"/>), so it reads and
2026-07-17 12:38:29 +01:00
/// behaves exactly like the real tabs. Two tabs: Connectivity, then Audio send. The Audio profile tab
/// was removed (2026-07-17) — codec/packet/lock-to-clock are fixed to known-good live values so nobody
/// mis-tunes the service. Send-only and WASAPI-only: no receive controls, no ASIO, no "Send my audio"
2026-07-12 20:21:48 +01:00
/// toggle. Edits a <see cref="Profile"/> clone; nothing is persisted until the caller acts on OK.
2026-07-12 15:27:37 +01:00
/// </summary>
internal sealed class ServiceProfileDialog : Form
{
private readonly Profile working ;
private readonly QuietTabControl tabs = new () { Dock = DockStyle . Fill };
2026-07-12 20:30:37 +01:00
// --- Connectivity tab (mirrors the main window: a wired peer list + Add peer by IP) ---
private readonly CheckedListBox peersList = new () { CheckOnClick = true , Width = 460 , Height = 120 , AccessibleName = "Peers to send to (Alt+C)" };
private readonly Label peersStatus = new () { AutoSize = true , Text = "No peers." };
private readonly Button manualAddButton = new () { Text = "Add peer by IP (Alt+&A)" , AutoSize = true , AccessibleName = "Add peer by IP" };
2026-07-13 08:23:10 +01:00
private readonly Button passwordButton = new () { Text = "Set service profile pass&word..." , AutoSize = true , AccessibleName = "Set service profile password" };
2026-07-12 15:27:37 +01:00
private readonly Label passwordStatus = new () { AutoSize = true };
2026-07-12 20:21:48 +01:00
// --- Audio send tab ---
private readonly ListBox sendModeList = new () { Width = 460 , Height = 40 , IntegralHeight = false , AccessibleName = "How to send WASAPI audio (Alt+1)" };
private readonly CheckedListBox outputsList = new () { CheckOnClick = true , Width = 460 , Height = 110 , AccessibleName = "WASAPI audio outputs to send (Alt+2)" };
private readonly Label outputsStatus = new () { AutoSize = true , Text = "No output device selected." };
2026-07-17 17:20:47 +01:00
private readonly CheckedListBox appsList = new () { CheckOnClick = true , Width = 460 , Height = 110 , AccessibleName = "Applications to send (Alt+3)" };
2026-07-12 20:21:48 +01:00
private readonly Label appsStatus = new () { AutoSize = true , Text = "No application selected." };
2026-07-17 17:20:47 +01:00
private readonly CheckedListBox inputsList = new () { CheckOnClick = true , Width = 460 , Height = 90 , AccessibleName = "WASAPI audio inputs to send (Alt+4)" };
2026-07-12 20:21:48 +01:00
private readonly Label inputsStatus = new () { AutoSize = true , Text = "No input device selected." };
private MnemonicLabel ? sendModeLabel , outputsLabel , appsLabel , inputsLabel ;
2026-07-17 12:38:29 +01:00
// No "Audio profile" tab: the service always sends Opus at the live-jamming frame (2.5 ms), Small
// packets, and locked to the audio clock — the settings Ed found sound good. They're forced in
// SaveToProfile below and again at runtime in ServiceSendHost, so there's nothing to misconfigure
// (Ed, 2026-07-17: removed the tab; nobody should be touching these).
private const AudioTransportCodec ServiceCodec = AudioTransportCodec . Opus ;
private const int ServiceOpusFrameSamples = 120 ; // 2.5 ms at 48 kHz — live latency
private const SendRate ServiceSendRate = SendRate . Tight ; // "Small" packets
2026-07-12 20:21:48 +01:00
2026-07-12 15:27:37 +01:00
// --- 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 ;
public Profile Result => working ;
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 ;
2026-07-12 20:21:48 +01:00
ClientSize = new Size ( 560 , 580 );
2026-07-12 15:27:37 +01:00
AccessibleName = "Configure RemSound service profile" ;
2026-07-12 20:21:48 +01:00
if ( Theme . AppIcon is { } icon ) Icon = icon ;
2026-07-12 15:27:37 +01:00
2026-07-17 12:38:29 +01:00
// Two tabs only now: Connectivity and Audio send. The Audio profile tab was removed — the codec,
// packet size and lock-to-clock are fixed to known-good live-jamming values (see fields above).
2026-07-12 20:21:48 +01:00
BuildConnectivityTab ();
2026-07-12 15:27:37 +01:00
BuildAudioSendTab ();
BuildButtonRow ();
LoadFromProfile ();
ApplySendModeVisibility ();
UpdatePasswordStatus ();
AcceptButton = saveButton ;
CancelButton = cancelButton ;
saveButton . Click += ( _ , _ ) => SaveToProfile ();
additionalButton . Click += ( _ , _ ) => ShowAdditionalOptions ();
}
// ---------------- layout ----------------
2026-07-12 20:21:48 +01:00
private static TableLayoutPanel NewPanel ()
2026-07-12 15:27:37 +01:00
{
2026-07-12 20:21:48 +01:00
var panel = new TableLayoutPanel { Dock = DockStyle . Fill , ColumnCount = 2 , AutoScroll = true , Padding = new Padding ( 12 ) };
panel . ColumnStyles . Add ( new ColumnStyle ( SizeType . AutoSize ));
panel . ColumnStyles . Add ( new ColumnStyle ( SizeType . Percent , 100 ));
return panel ;
}
private void BuildConnectivityTab ()
{
var page = new TabPage ( "Connectivity" );
var panel = NewPanel ();
2026-07-12 20:30:37 +01:00
var header = Theme . SectionHeader ( "Peers" );
panel . Controls . Add ( header , 0 , 0 );
panel . SetColumnSpan ( header , 2 );
2026-07-12 20:21:48 +01:00
2026-07-12 20:30:37 +01:00
// The peer list, wired for the screen reader exactly like the main window. A ticked peer is one
// the service sends to; untick to keep it in the list but not send. Delete removes it entirely.
FormLayoutRows . AddCheckedListRow ( panel , 1 , "Peers to send to (Alt+&C)" , peersList , peersStatus , l => l . Focus ());
CheckedListAccessibility . Wire ( peersList , peersStatus , "peer" );
peersList . KeyDown += ( _ , e ) =>
{
if ( e . KeyCode == Keys . Delete && peersList . SelectedIndex >= 0 )
{
peersList . Items . RemoveAt ( peersList . SelectedIndex );
e . Handled = true ; e . SuppressKeyPress = true ;
}
};
2026-07-12 20:21:48 +01:00
2026-07-12 20:30:37 +01:00
// Add a peer by address — the same "Add peer by IP" prompt the main window uses.
panel . Controls . Add ( new Label { Text = "Manual peer" , AutoSize = true , Anchor = AnchorStyles . Left }, 0 , 2 );
panel . Controls . Add ( manualAddButton , 1 , 2 );
manualAddButton . Click += ( _ , _ ) =>
{
var entry = ManualPeerPrompt . Show ( this );
if ( string . IsNullOrWhiteSpace ( entry )) return ;
if (! peersList . Items . OfType < string >(). Any ( p => string . Equals ( p , entry , StringComparison . OrdinalIgnoreCase )))
{
var idx = peersList . Items . Add ( entry . Trim ());
peersList . SetItemChecked ( idx , true );
}
};
2026-07-12 20:21:48 +01:00
var pwWrap = new FlowLayoutPanel { AutoSize = true , Dock = DockStyle . Fill };
pwWrap . Controls . Add ( passwordButton );
pwWrap . Controls . Add ( passwordStatus );
2026-07-12 20:30:37 +01:00
panel . Controls . Add ( pwWrap , 1 , 3 );
2026-07-12 20:21:48 +01:00
passwordButton . Click += ( _ , _ ) => SetPassword ();
page . Controls . Add ( panel );
tabs . TabPages . Add ( page );
}
2026-07-12 15:27:37 +01:00
private void BuildAudioSendTab ()
{
var page = new TabPage ( "Audio send" );
2026-07-12 20:21:48 +01:00
var panel = NewPanel ();
2026-07-12 15:27:37 +01:00
sendModeList . Items . Add ( "Send whole audio devices" );
sendModeList . Items . Add ( "Send specific applications" );
sendModeList . SelectedIndex = 0 ;
sendModeList . SelectedIndexChanged += ( _ , _ ) => { if (! suppressAppEvents ) ApplySendModeVisibility (); };
2026-07-12 20:21:48 +01:00
sendModeLabel = AddListRow ( panel , 0 , "How to send WASAPI audio (Alt+&1)" , sendModeList );
outputsLabel = FormLayoutRows . AddCheckedListRow ( panel , 1 , "WASAPI audio outputs to send (Alt+&2)" , outputsList , outputsStatus , l => l . Focus ());
2026-07-17 17:20:47 +01:00
appsLabel = FormLayoutRows . AddCheckedListRow ( panel , 2 , "Applications to send (Alt+&3)" , appsList , appsStatus , l => l . Focus ());
inputsLabel = FormLayoutRows . AddCheckedListRow ( panel , 3 , "WASAPI audio inputs to send (Alt+&4)" , inputsList , inputsStatus , l => l . Focus ());
2026-07-12 20:21:48 +01:00
CheckedListAccessibility . Wire ( outputsList , outputsStatus , "output device" );
CheckedListAccessibility . Wire ( appsList , appsStatus , "application" );
CheckedListAccessibility . Wire ( inputsList , inputsStatus , "input device" );
2026-07-12 15:27:37 +01:00
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 ;
}
// ---------------- data in/out ----------------
private void LoadFromProfile ()
{
suppressAppEvents = true ;
try
{
2026-07-17 23:08:55 +01:00
// Offer "Use Windows default output" as the first output choice (Christopher's request):
// send whatever this machine currently plays, following the Windows default. Same shared
// follower + sentinel the main window uses, so a ticked follower persists and resolves identically.
var outputChoices = new List < AudioDeviceChoice > { AudioDefaultFollower . LoopbackSendChoice () };
outputChoices . AddRange ( AudioDeviceCatalog . LoadOutputs ());
PopulateDeviceList ( outputsList , outputChoices , working . SelectedWasapiSendOutputs );
2026-07-12 15:27:37 +01:00
PopulateDeviceList ( inputsList , AudioDeviceCatalog . LoadInputs (), working . SelectedWasapiSendInputs );
var appsMode = ProcessLoopbackCapture . IsSupported
&& string . Equals ( working . WasapiSendMode , "applications" , StringComparison . OrdinalIgnoreCase );
sendModeList . SelectedIndex = appsMode ? 1 : 0 ;
PopulateAppsList ();
peersList . Items . Clear ();
2026-07-12 20:30:37 +01:00
var allPeers = working . RememberedPeers . Concat ( working . SelectedConnectedPeers )
. Where ( p => ! string . IsNullOrWhiteSpace ( p )). Distinct ( StringComparer . OrdinalIgnoreCase ). ToList ();
var selectedPeers = new HashSet < string >( working . SelectedConnectedPeers , StringComparer . OrdinalIgnoreCase );
foreach ( var p in allPeers )
{
var idx = peersList . Items . Add ( p );
// Ticked = the service sends to it. If the profile carried no explicit selection, tick all.
if ( selectedPeers . Count == 0 || selectedPeers . Contains ( p )) peersList . SetItemChecked ( idx , true );
}
2026-07-12 15:27:37 +01:00
}
finally { suppressAppEvents = false ; }
}
private void SaveToProfile ()
{
working . SelectedWasapiSendOutputs = CheckedIds ( outputsList );
working . SelectedWasapiSendInputs = CheckedIds ( inputsList );
working . WasapiSendMode = sendModeList . SelectedIndex == 1 ? "applications" : "devices" ;
2026-07-17 17:20:47 +01:00
// Applications mode = pick specific apps only (matches the main app; the "send all applications"
// option was removed from both). Force the legacy flag off so a stale profile that still carries
// SendAllApplications=true can't make the service quietly capture the whole system.
working . SendAllApplications = false ;
2026-07-12 15:27:37 +01:00
working . SelectedSendApplications = appsList . CheckedItems . OfType < AppRow >(). Select ( a => a . ProcessName ). Distinct (). ToList ();
2026-07-17 12:38:29 +01:00
// Audio profile is fixed for the service (no tab): Opus live-jamming frame, Small packets, locked
// to the audio clock. These are also re-forced at runtime in ServiceSendHost.
working . Codec = ServiceCodec ;
working . OpusFrameSamplesPerChannel = ServiceOpusFrameSamples ;
working . SendRate = ServiceSendRate ;
working . TightLatencyMode = true ;
2026-07-12 15:27:37 +01:00
2026-07-12 20:30:37 +01:00
// Ticked peers are the ones the service sends to; keep every listed peer as remembered.
working . SelectedConnectedPeers = peersList . CheckedItems . OfType < string >(). Distinct (). ToList ();
working . RememberedPeers = peersList . Items . OfType < string >(). Distinct (). ToList ();
2026-07-12 15:27:37 +01:00
}
2026-07-12 20:21:48 +01:00
private static void PopulateDeviceList ( CheckedListBox list , IReadOnlyList < AudioDeviceChoice > devices , IReadOnlyList < string > checkedIds )
2026-07-12 15:27:37 +01:00
{
list . Items . Clear ();
var wanted = new HashSet < string >( 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 < string > CheckedIds ( CheckedListBox list ) =>
list . CheckedItems . OfType < AudioDeviceChoice >(). Where ( c => c . DeviceId is not null ). Select ( c => c . DeviceId !). Distinct (). ToList ();
private void PopulateAppsList ()
{
var ticked = new HashSet < string >( working . SelectedSendApplications , StringComparer . OrdinalIgnoreCase );
var running = ProcessLoopbackCapture . IsSupported ? AudioAppEnumerator . Snapshot () : Array . Empty < AudioApp >();
var runningNames = new HashSet < string >( 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 < string > tickedSet )
{
var idx = appsList . Items . Add ( new AppRow ( proc , display , running ));
if ( tickedSet . Contains ( proc )) appsList . SetItemChecked ( idx , true );
}
}
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 );
2026-07-17 17:20:47 +01:00
// Applications mode always shows the specific-apps list (no "send all" option any more).
if ( appsLabel is not null ) appsLabel . Visible = appsMode ;
SetRowVisible ( appsList , appsMode );
2026-07-12 15:27:37 +01:00
}
private static void SetRowVisible ( Control c , bool visible )
{
c . Visible = visible ;
if ( c . Parent is not null ) c . Parent . Visible = visible ;
}
private void SetPassword ()
{
var current = string . IsNullOrEmpty ( working . Password ) ? "" : RemSoundCrypto . Deobfuscate ( working . Password );
var result = ProfilePasswordDialog . Show ( ServiceControl . ServiceProfileTitle , current );
2026-07-12 20:21:48 +01:00
if ( result is null ) return ;
2026-07-12 15:27:37 +01:00
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 < Profile >( System . Text . Json . JsonSerializer . Serialize ( p )) ?? new Profile ();
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)" ;
}
}