2026-06-12 19:57:36 +01:00
using System.Net ;
using System.Reflection ;
using System.Runtime.InteropServices ;
using System.Text ;
using Microsoft.Win32 ;
using NAudio.CoreAudioApi ;
using NAudio.Wave ;
using RemSound.Core ;
using RemSound.Receiver ;
using RemSound.Sender ;
namespace RemSound.App ;
/// <summary>
/// Command-line interface for RemSound. RemSound is primarily a GUI app, but - inspired by Andre's
/// Sensor Readout - it also accepts command-line options so a screen-reader user (or a script) can
/// list devices, run an audio self-test, dump a diagnostics bundle, toggle logging, close a running
/// copy, or launch straight into a profile / connection without touching the UI.
///
/// Two kinds of option:
/// * "do-and-exit" commands (--help, --version, --devices, --selftest, --diagnostics, --log,
/// --close) print to the calling terminal (and/or a file) and terminate the process.
/// * "launch options" (--profile, --connect, --minimized) modify a normal GUI start.
///
/// Wired into <see cref="Program"/> right after the legacy-layout migration and before the
/// single-instance guard. The do-and-exit commands need no window and no instance lock.
/// </summary>
internal static class CommandLine
{
/// <summary>Overrides applied to a normal GUI launch when no do-and-exit command ran.</summary>
internal sealed class LaunchOverrides
{
public bool StartMinimized ;
public string? ProfileName ;
public readonly List < IPEndPoint > ConnectPeers = new ();
/// <summary>--connect given with no --profile → start on a blank profile and connect.</summary>
public bool ForceBlankProfile ;
}
[DllImport("kernel32.dll")] private static extern bool AttachConsole ( int dwProcessId );
private const int ATTACH_PARENT_PROCESS = - 1 ;
2026-06-12 23:12:22 +01:00
/// <summary>The folder given after <c>--config-dir</c>, or null. Read at the very start of
/// <see cref="Program"/> - before the layout migration and any config/profile/log/sound access -
/// so it can redirect ALL user state via <see cref="AppConfig.SetUserDataDirectoryOverride"/>.
/// Applies to every command (e.g. <c>--selftest --config-dir</c>, <c>--diagnostics --config-dir</c>)
/// and to a normal GUI launch, so a test can exercise a real build without touching live settings.</summary>
public static bool TryGetConfigDir ( string [] args , out string dir )
{
dir = "" ;
for ( var i = 0 ; i < args . Length - 1 ; i ++)
{
if ( args [ i ]. Equals ( "--config-dir" , StringComparison . OrdinalIgnoreCase ) && ! args [ i + 1 ]. StartsWith ( '-' ))
{
dir = args [ i + 1 ];
return ! string . IsNullOrWhiteSpace ( dir );
}
}
return false ;
}
2026-06-12 19:57:36 +01:00
/// <summary>
/// Process the command line. Returns a non-null exit code when a do-and-exit command ran (the
/// caller should <c>Environment.Exit</c> it); returns null to continue into the GUI launch with
/// <paramref name="overrides"/> populated.
/// </summary>
public static int? Process ( string [] args , out LaunchOverrides overrides )
{
overrides = new LaunchOverrides ();
if ( args . Length == 0 ) return null ;
// --- do-and-exit commands (first match wins) ---
foreach ( var raw in args )
{
switch ( raw . ToLowerInvariant ())
{
case "--help" : case "-h" : case "/?" : case "-?" : case "--?" :
return WithConsole ( PrintHelp );
case "--version" : case "-v" : case "--ver" :
return WithConsole ( PrintVersion );
case "--devices" : case "--list-devices" :
return WithConsole (() => { WriteDevices ( Console . Out ); return 0 ; });
2026-07-08 18:19:01 +01:00
case "--profiles" : case "--list-profiles" :
return WithConsole ( ListProfiles );
case "--named-peers" : case "--list-named-peers" :
return WithConsole ( ListNamedPeers );
2026-06-12 23:12:22 +01:00
case "--selftest" : case "--self-test" : case "--smoke-test" : case "--smoketest" :
2026-06-12 23:01:40 +01:00
return WithConsole (() => SelfTest . Run ( args ));
2026-06-12 23:45:08 +01:00
case "--perftest" : case "--perf-test" :
return WithConsole (() => RunPerfTest ( args ));
2026-06-12 19:57:36 +01:00
case "--diagnostics" : case "--diag" :
return WithConsole (() => RunDiagnostics ( ValueAfter ( args , raw )));
case "--log" :
return WithConsole (() => SetLogging ( ValueAfter ( args , raw )));
case "--close" : case "--quit" :
return WithConsole ( CloseRunning );
2026-07-27 08:19:20 +01:00
case "--sign-update" :
// Publish-pipeline verb (build-release.ps1): sign a release zip with the private
// key so the updater's signature enforcement accepts it. Not a user command.
return WithConsole (() => SignUpdate ( ValueAfter ( args , raw )));
2026-06-12 19:57:36 +01:00
}
}
// --- launch options (applied to the normal GUI start) ---
for ( var i = 0 ; i < args . Length ; i ++)
{
switch ( args [ i ]. ToLowerInvariant ())
{
case "--minimized" : case "--minimised" : case "--tray" :
overrides . StartMinimized = true ;
break ;
case "--profile" :
overrides . ProfileName = ValueAt ( args , i + 1 );
break ;
case "--connect" :
if ( TryParsePeer ( ValueAt ( args , i + 1 ), out var ep )) overrides . ConnectPeers . Add ( ep );
break ;
}
}
overrides . ForceBlankProfile = overrides . ConnectPeers . Count > 0 && overrides . ProfileName is null ;
return null ;
}
2026-07-27 08:19:20 +01:00
/// <summary>Where the release-signing PRIVATE key lives on the publisher's machine (chosen by
/// Ed, 2026-07-27). Overridable via REMSOUND_SIGNING_KEY for a future move. The key is never
/// in the repo or a release; the matching public key is embedded (UpdateSignature).</summary>
private static string SigningKeyPath =>
Environment . GetEnvironmentVariable ( "REMSOUND_SIGNING_KEY" )
?? @"D:\Dropbox\proj\rsound key\remsound-signing-key.pem" ;
/// <summary>--sign-update <zip>: write <zip>.sig (base64 ECDSA P-256 / SHA-256 over the
/// zip bytes) and self-check it against the EMBEDDED public key before reporting success — so a
/// key/embed mismatch fails the publish pipeline loudly instead of shipping a release every
/// updater would refuse.</summary>
private static int SignUpdate ( string? zipPath )
{
if ( string . IsNullOrWhiteSpace ( zipPath ) || ! File . Exists ( zipPath ))
{
Console . WriteLine ( $"sign-update: zip not found: [{zipPath}]" );
return 2 ;
}
if (! File . Exists ( SigningKeyPath ))
{
Console . WriteLine ( $"sign-update: signing key not found at [{SigningKeyPath}] (set REMSOUND_SIGNING_KEY to override)" );
return 3 ;
}
try
{
var bytes = File . ReadAllBytes ( zipPath );
var signature = UpdateSignature . SignWithKey ( bytes , File . ReadAllText ( SigningKeyPath ));
if (! UpdateSignature . Verify ( bytes , signature ))
{
Console . WriteLine ( "sign-update: FAILED self-check — the private key does not match the public key embedded in this build. Update UpdateSignature.PublicKeyPem or restore the right key file." );
return 4 ;
}
var sigPath = zipPath + UpdateSignature . SignatureAssetSuffix ;
File . WriteAllText ( sigPath , signature );
Console . WriteLine ( $"sign-update: OK — wrote {sigPath} (verified against the embedded public key)" );
return 0 ;
}
catch ( Exception ex )
{
Console . WriteLine ( $"sign-update: FAILED — {ex.GetType().Name}: {ex.Message}" );
return 5 ;
}
}
2026-06-12 19:57:36 +01:00
// ---------------- console plumbing ----------------
/// <summary>Attach to the calling terminal (when launched from one), point Console.Out at the
/// real stdout handle (works for an interactive console AND a redirected pipe), run the command,
/// and return its exit code. A WinExe has no console of its own, hence the attach dance.</summary>
private static int WithConsole ( Func < int > body )
{
try { AttachConsole ( ATTACH_PARENT_PROCESS ); } catch { /* no parent console - fine */ }
try { Console . OutputEncoding = Encoding . UTF8 ; } catch { /* redirected / no console */ }
try { Console . SetOut ( new StreamWriter ( Console . OpenStandardOutput (), new UTF8Encoding ( false )) { AutoFlush = true }); }
catch { /* no usable stdout - file-writing commands still work */ }
try { return body (); }
catch ( Exception ex ) { Console . WriteLine ( $"RemSound: error - {ex.GetType().Name}: {ex.Message}" ); return 1 ; }
}
// ---------------- commands ----------------
2026-06-12 23:01:40 +01:00
internal static string AppVersion =>
2026-06-12 19:57:36 +01:00
Assembly . GetExecutingAssembly (). GetName (). Version ?. ToString ( 3 ) ?? "0.0.0" ;
private static int PrintVersion ()
{
Console . WriteLine ( $"RemSound {AppVersion}" );
return 0 ;
}
2026-07-08 18:19:01 +01:00
/// <summary>--list-profiles: print the saved profile titles (read-only).</summary>
private static int ListProfiles ()
{
var cfg = AppConfig . Load ();
var store = new ProfileStore ( cfg . ProfilesDirectory ?? AppConfig . ProfilesBaseDirectory );
var titles = store . ListProfileTitles ();
if ( titles . Count == 0 ) { Console . WriteLine ( "No saved profiles." ); return 0 ; }
Console . WriteLine ( $"Saved profiles ({titles.Count}):" );
foreach ( var t in titles ) Console . WriteLine ( $" {t}" );
return 0 ;
}
/// <summary>--list-named-peers: print the machine-wide named-peers book (read-only) — the friendly
/// names you've given peers, with where and when each was last seen.</summary>
private static int ListNamedPeers ()
{
var named = AppConfig . Load (). NamedPeers ;
if ( named . Count == 0 ) { Console . WriteLine ( "No named peers." ); return 0 ; }
Console . WriteLine ( $"Named peers ({named.Count}):" );
foreach ( var kv in named . OrderBy ( k => k . Value . FriendlyName , StringComparer . CurrentCultureIgnoreCase ))
{
var np = kv . Value ;
var seen = np . LastSeenUtc == default
? "not seen yet"
: $"last seen {np.LastSeenUtc.ToLocalTime():d MMM yyyy}"
+ ( string . IsNullOrWhiteSpace ( np . LastAddress ) ? "" : $", {np.LastAddress}" );
Console . WriteLine ( $" {np.FriendlyName} ({np.MachineName}) - {seen}" );
}
return 0 ;
}
2026-06-12 19:57:36 +01:00
private static int PrintHelp ()
{
Console . WriteLine ( $"RemSound {AppVersion} - command-line options" );
Console . WriteLine ();
Console . WriteLine ( "Run RemSound.exe with no options to start normally." );
Console . WriteLine ();
Console . WriteLine ( "Information and tests (these print, then exit):" );
Console . WriteLine ( " --help, -h Show this help." );
Console . WriteLine ( " --version Show the installed version." );
Console . WriteLine ( " --devices List all microphones, outputs and ASIO drivers," );
Console . WriteLine ( " with their formats and device ids." );
2026-07-08 18:19:01 +01:00
Console . WriteLine ( " --list-profiles List your saved profile names." );
Console . WriteLine ( " --list-named-peers List the friendly names you've given peers, with" );
Console . WriteLine ( " where and when each was last seen." );
2026-06-12 23:01:40 +01:00
Console . WriteLine ( " --selftest [--seconds N] Run the built-in self-test - a localhost audio" );
2026-06-12 23:12:22 +01:00
Console . WriteLine ( " (or --smoke-test) round-trip plus checks of encryption, the wire format," );
2026-06-12 23:45:08 +01:00
Console . WriteLine ( " settings, profiles, dialog accessibility and bundled files." );
Console . WriteLine ( " --perftest [--seconds N] Run repeated audio cycles and report whether handle," );
Console . WriteLine ( " memory and thread counts stay bounded (leak sanity check)." );
2026-06-12 19:57:36 +01:00
Console . WriteLine ( " --diagnostics [path] Write a diagnostics report (version, config, profiles," );
Console . WriteLine ( " devices, mic-privacy check, recent log) and exit. With" );
Console . WriteLine ( " no path, it is saved in the user settings and logs folder." );
Console . WriteLine ();
Console . WriteLine ( "Settings and control (these act, then exit):" );
Console . WriteLine ( " --log on|off Turn the diagnostic log on or off." );
Console . WriteLine ( " --close Close a running copy of RemSound." );
2026-07-09 15:45:21 +01:00
Console . WriteLine ( " --uninstall Uninstall an installed copy of RemSound (asks first)." );
2026-06-12 19:57:36 +01:00
Console . WriteLine ();
Console . WriteLine ( "Start-up options (these change how RemSound launches):" );
Console . WriteLine ( " --profile \"<name>\" Start straight into the named profile (skip the picker)." );
Console . WriteLine ( " --connect <ip[:port]> Start and connect to a peer at this address. With no" );
Console . WriteLine ( " --profile, starts on a fresh profile connected to it." );
Console . WriteLine ( " --minimized, --tray Start minimized to the notification area." );
2026-06-12 23:12:22 +01:00
Console . WriteLine ( " --config-dir <folder> Use an explicit folder for this run's settings, profiles," );
Console . WriteLine ( " logs and sounds, instead of the usual location. Lets a test" );
Console . WriteLine ( " exercise RemSound without touching your real settings." );
Console . WriteLine ( " Works with any command (e.g. --selftest --config-dir ...)." );
2026-06-13 11:54:30 +01:00
Console . WriteLine ( " --silent Play no cue sounds and show no missing-sound pop-ups for" );
Console . WriteLine ( " this run - for automated / unattended launches." );
2026-06-12 19:57:36 +01:00
Console . WriteLine ();
Console . WriteLine ( "Examples:" );
Console . WriteLine ( " RemSound.exe --devices" );
Console . WriteLine ( " RemSound.exe --selftest --opus" );
Console . WriteLine ( " RemSound.exe --diagnostics" );
Console . WriteLine ( " RemSound.exe --profile \"Studio\" --minimized" );
Console . WriteLine ( " RemSound.exe --connect 192.168.1.42" );
return 0 ;
}
/// <summary>Write the full device inventory (capture inputs, render outputs, ASIO drivers) to a
/// writer - shared by <c>--devices</c> and the diagnostics report. Formats come straight from
/// each endpoint's mix format, the same value the audio engine negotiates.</summary>
private static void WriteDevices ( TextWriter w )
{
using var en = new MMDeviceEnumerator ();
void ListEndpoints ( DataFlow flow , string header )
{
w . WriteLine ( header );
string? defaultId = null ;
try { using var def = en . GetDefaultAudioEndpoint ( flow , Role . Multimedia ); defaultId = def . ID ; }
catch { /* no default of this kind */ }
var any = false ;
foreach ( var d in en . EnumerateAudioEndPoints ( flow , DeviceState . Active ))
{
any = true ;
var isDefault = defaultId is not null && d . ID == defaultId ? " [default]" : "" ;
string fmt ;
try
{
var f = d . AudioClient . MixFormat ;
var enc = f . Encoding is WaveFormatEncoding . IeeeFloat or WaveFormatEncoding . Extensible
? "float" : f . Encoding . ToString ();
fmt = $"{f.SampleRate} Hz, {f.Channels} ch, {f.BitsPerSample}-bit {enc}" ;
}
catch { fmt = "format unavailable" ; }
w . WriteLine ( $" {d.FriendlyName}{isDefault}" );
w . WriteLine ( $" {fmt}" );
w . WriteLine ( $" id: {d.ID}" );
d . Dispose ();
}
if (! any ) w . WriteLine ( " (none)" );
w . WriteLine ();
}
ListEndpoints ( DataFlow . Capture , "Microphones / line-in (WASAPI capture inputs):" );
ListEndpoints ( DataFlow . Render , "Speakers / headphones (WASAPI outputs; also capturable as system-audio loopback):" );
w . WriteLine ( "ASIO drivers:" );
IReadOnlyList < string > asio ;
try { asio = AsioDeviceProbe . EnumerateDriverNames (); } catch { asio = Array . Empty < string >(); }
if ( asio . Count == 0 ) w . WriteLine ( " (none installed)" );
else foreach ( var n in asio ) w . WriteLine ( $" {n}" );
w . WriteLine ();
}
2026-06-12 23:45:08 +01:00
/// <summary>Resource-sanity check: run several short audio-loopback cycles and watch this
/// process's handle / memory / thread counts. Each cycle builds and tears down the audio
/// engine, so a handle or thread count that ratchets up cycle on cycle is the fingerprint of a
/// leak (RemSound has a handle-leak history). Lenient thresholds - it flags obvious runaway, not
/// normal fluctuation - and logs the numbers so builds can be compared. Note: the loopback
/// renders to nothing (no real output device, to stay silent), so it exercises the
/// capture/encode/network/decode path, not the WASAPI render path.</summary>
private static int RunPerfTest ( string [] args )
{
var seconds = int . TryParse ( ValueAfter ( args , "--seconds" ), out var s ) && s is >= 6 and <= 120 ? s : 15 ;
const int cycles = 3 ;
var perCycle = Math . Max ( 2 , seconds / cycles );
var proc = System . Diagnostics . Process . GetCurrentProcess ();
Console . WriteLine ( $"RemSound perf sanity: {cycles} x {perCycle}s audio loopback, watching handles/memory/threads..." );
Console . WriteLine ();
static ( int Handles , long WorkingSetMb , long PrivateMb , int Threads ) Measure ( System . Diagnostics . Process p )
{
GC . Collect ();
GC . WaitForPendingFinalizers ();
GC . Collect ();
p . Refresh ();
return ( p . HandleCount , p . WorkingSet64 / ( 1024 * 1024 ), p . PrivateMemorySize64 / ( 1024 * 1024 ), p . Threads . Count );
}
var baseline = Measure ( proc );
Console . WriteLine ( $" baseline: handles={baseline.Handles} workingSet={baseline.WorkingSetMb}MB private={baseline.PrivateMb}MB threads={baseline.Threads}" );
for ( var i = 1 ; i <= cycles ; i ++)
{
var r = AudioLoopback . Run ( opus : true , perCycle );
if (! r . Ran )
{
Console . WriteLine ( $" RESULT: SKIP - {r.SkipReason} (no audio device to exercise)." );
return 0 ;
}
var m = Measure ( proc );
Console . WriteLine ( $" cycle {i}/{cycles}: handles={m.Handles} workingSet={m.WorkingSetMb}MB private={m.PrivateMb}MB threads={m.Threads} (sent={r.PacketsSent}, received={r.PacketsReceived})" );
}
var final = Measure ( proc );
var handleGrowth = final . Handles - baseline . Handles ;
var threadGrowth = final . Threads - baseline . Threads ;
var memGrowth = final . WorkingSetMb - baseline . WorkingSetMb ;
Console . WriteLine ();
Console . WriteLine ( $" net change over {cycles} cycles: handles {handleGrowth:+#;-#;0}, threads {threadGrowth:+#;-#;0}, workingSet {memGrowth:+#;-#;0}MB" );
// Lenient: flag obvious runaway (e.g. a handle leak ratcheting up each cycle), not noise.
var runaway = handleGrowth > 1500 || threadGrowth > 100 || final . WorkingSetMb > 1500 ;
Console . WriteLine ( runaway
? " RESULT: FAIL - resource use looks like it is running away (possible leak); compare the per-cycle trend above."
: " RESULT: PASS - handles, threads and memory stayed bounded across cycles." );
return runaway ? 1 : 0 ;
}
2026-06-12 19:57:36 +01:00
private static int SetLogging ( string? value )
{
var on = value is not null && value . ToLowerInvariant () is "on" or "true" or "1" or "enable" or "enabled" or "yes" ;
var off = value is not null && value . ToLowerInvariant () is "off" or "false" or "0" or "disable" or "disabled" or "no" ;
if (! on && ! off )
{
Console . WriteLine ( "Usage: --log on (or) --log off" );
return 1 ;
}
var cfg = AppConfig . Load ();
cfg . LoggingEnabled = on ;
try { cfg . Save (); }
catch ( Exception ex ) { Console . WriteLine ( $"Could not save the setting: {ex.Message}" ); return 1 ; }
Console . WriteLine ( $"RemSound logging is now {(on ? " ON " : " OFF ")}. The change takes effect next time RemSound starts." );
return 0 ;
}
private static int CloseRunning ()
{
bool closed ;
try { closed = SingleInstanceCoordinator . ForceCloseOtherInstances (); }
catch ( Exception ex ) { Console . WriteLine ( $"Could not close RemSound: {ex.Message}" ); return 1 ; }
Console . WriteLine ( closed
? "Closed the running copy of RemSound."
: "No running copy of RemSound was found (or it could not be closed - it may be running as administrator)." );
return 0 ;
}
2026-06-12 23:01:40 +01:00
/// <summary>Build the support diagnostics report text for a given config (version, settings,
/// profiles, devices, mic-privacy, recent log). Shared by <c>--diagnostics</c> and the
/// self-test's privacy check. Lists profile titles only - never their contents.</summary>
2026-06-12 23:45:08 +01:00
internal static string BuildDiagnosticsReport ( AppConfig cfg , bool runLiveAudioProbe = true )
2026-06-12 19:57:36 +01:00
{
var sb = new StringBuilder ();
sb . AppendLine ( $"RemSound diagnostics" );
sb . AppendLine ( $"Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}" );
sb . AppendLine ( $"Version: {AppVersion}" );
sb . AppendLine ( $"Machine: {Environment.MachineName}" );
sb . AppendLine ( $"OS: {Environment.OSVersion}" );
sb . AppendLine ( $".NET: {Environment.Version}" );
sb . AppendLine ( $"Exe: {Environment.ProcessPath}" );
sb . AppendLine ();
sb . AppendLine ( "Settings:" );
sb . AppendLine ( $" Logging enabled: {cfg.LoggingEnabled}" );
sb . AppendLine ( $" Start minimised: {cfg.StartMinimised}" );
sb . AppendLine ( $" Start with profile: {cfg.StartWithProfileTitle ?? " ( picker ) "}" );
sb . AppendLine ( $" Profiles folder: {cfg.ProfilesDirectory ?? AppConfig.ProfilesBaseDirectory}" );
sb . AppendLine ( $" Update check frequency: {cfg.UpdateCheckFrequency}" );
sb . AppendLine ( $" Startup cue enabled: {cfg.EnableStartupCue}" );
sb . AppendLine ();
sb . AppendLine ( "Profiles:" );
try
{
var store = cfg . CreateStore ();
var titles = store . ListProfileTitles ();
if ( titles . Count == 0 ) sb . AppendLine ( " (none)" );
else foreach ( var t in titles ) sb . AppendLine ( $" {t}{(store.IsProfileReadOnly(t) ? " ( read - only ) " : "")}" );
}
catch ( Exception ex ) { sb . AppendLine ( $" (could not list profiles: {ex.Message})" ); }
sb . AppendLine ();
try { using var sw = new StringWriter ( sb ); WriteDevices ( sw ); }
catch ( Exception ex ) { sb . AppendLine ( $"Devices: (could not enumerate: {ex.Message})" ); sb . AppendLine (); }
sb . AppendLine ( "Microphone privacy (Windows):" );
sb . AppendLine ( $" {DescribeMicPrivacy()}" );
sb . AppendLine ();
2026-06-12 23:45:08 +01:00
if ( runLiveAudioProbe )
{
sb . AppendLine ( "Live audio self-check (localhost loopback, no sound output):" );
AppendLiveAudioCheck ( sb );
sb . AppendLine ();
}
sb . AppendLine ( "Most recent session snapshot (from the log):" );
sb . AppendLine ( $" {LastSessionSnapshot()}" );
sb . AppendLine ();
sb . AppendLine ( "Recent warnings and errors (from the log):" );
sb . AppendLine ( RecentLogProblems ( 15 ));
sb . AppendLine ();
2026-06-12 19:57:36 +01:00
sb . AppendLine ( "Most recent log (tail):" );
sb . AppendLine ( TailNewestLog ( 40 ));
sb . AppendLine ();
2026-06-12 23:01:40 +01:00
return sb . ToString ();
}
2026-06-12 23:45:08 +01:00
/// <summary>Run a short localhost loopback for each codec and report the live counters - proves
/// this machine's capture/encode/network/decode path works, with real packet/underrun/drop
/// numbers. Skips (no failure) when there's no audio device.</summary>
private static void AppendLiveAudioCheck ( StringBuilder sb )
{
foreach ( var opus in new [] { false , true })
{
var r = AudioLoopback . Run ( opus , 2 );
if (! r . Ran ) { sb . AppendLine ( $" {r.Codec}: skipped ({r.SkipReason})" ); continue ; }
sb . AppendLine ( $" {r.Codec}: sent={r.PacketsSent} pkts, received={r.PacketsReceived} pkts ({r.BytesReceived} bytes), "
+ $"underruns={r.Underruns}, drops={r.Drops}, buffer={r.BufferMs}ms, target latency={r.TargetLatencyMs}ms "
+ $"-> {(r.Flowed ? " OK " : " NO AUDIO ")}" );
}
}
/// <summary>The last one-second SNAP row from the newest log, as a readable line: the real
/// running session's codec, send/receive state, buffer, underruns, drops and heartbeat. These
/// are the live values a static report can't otherwise show. Empty when logging is off.</summary>
private static string LastSessionSnapshot ()
{
try
{
var dir = AppConfig . LogsDirectory ;
if (! Directory . Exists ( dir )) return "(no logs - logging may be off)" ;
var newest = new DirectoryInfo ( dir ). GetFiles ( "*.log" ). OrderByDescending ( f => f . LastWriteTimeUtc ). FirstOrDefault ();
if ( newest is null ) return "(no log files - logging may be off)" ;
var lines = File . ReadAllLines ( newest . FullName );
var header = lines . FirstOrDefault ( l => l . StartsWith ( "Kind\t" ) || l . Contains ( "\tCodec\t" ));
var lastSnap = lines . LastOrDefault ( l => l . StartsWith ( "SNAP\t" ));
if ( header is null || lastSnap is null ) return "(no session snapshots in the newest log yet)" ;
var cols = header . Split ( '\t' );
var vals = lastSnap . Split ( '\t' );
string Col ( string name ) { var i = Array . IndexOf ( cols , name ); return i >= 0 && i < vals . Length ? vals [ i ] : "?" ; }
return $"Connected={Col(" Connected ")}, Send={Col(" SendRunning ")}, Receive={Col(" ReceiveRunning ")}, "
+ $"Codec={Col(" Codec ")}, Buffer={Col(" BufferMs ")}ms, Underruns={Col(" Underruns ")}, "
+ $"Drops={Col(" Drops ")}, Heartbeat={Col(" Heartbeat ")}, TargetLatency={Col(" TargetLatencyMs ")}ms" ;
}
catch ( Exception ex ) { return $"(could not read snapshot: {ex.Message})" ; }
}
/// <summary>The recent WARN/ERROR/exception-style lines from the newest log, so a support
/// reader sees the problems without scrolling the whole file. Last <paramref name="max"/>.</summary>
private static string RecentLogProblems ( int max )
{
try
{
var dir = AppConfig . LogsDirectory ;
if (! Directory . Exists ( dir )) return " (no logs - logging may be off)" ;
var newest = new DirectoryInfo ( dir ). GetFiles ( "*.log" ). OrderByDescending ( f => f . LastWriteTimeUtc ). FirstOrDefault ();
if ( newest is null ) return " (no log files - logging may be off)" ;
var problems = File . ReadLines ( newest . FullName )
. Where ( l => l . Contains ( "error" , StringComparison . OrdinalIgnoreCase )
|| l . Contains ( "warn" , StringComparison . OrdinalIgnoreCase )
|| l . Contains ( "exception" , StringComparison . OrdinalIgnoreCase )
|| l . Contains ( "unreachable" , StringComparison . OrdinalIgnoreCase )
|| l . Contains ( "failed" , StringComparison . OrdinalIgnoreCase ))
. ToList ();
if ( problems . Count == 0 ) return " (none in the newest log)" ;
var tail = problems . Count <= max ? problems : problems . GetRange ( problems . Count - max , max );
return string . Join ( Environment . NewLine , tail . Select ( l => " " + l ));
}
catch ( Exception ex ) { return $" (could not scan log: {ex.Message})" ; }
}
2026-06-12 23:01:40 +01:00
/// <summary>Write a support-friendly diagnostics report and exit. Always writes a file so it
/// works even when launched without a terminal; prints the path if a console is attached.</summary>
private static int RunDiagnostics ( string? pathArg )
{
AppConfig cfg ;
try { cfg = AppConfig . Load (); } catch { cfg = new AppConfig (); }
var report = BuildDiagnosticsReport ( cfg );
2026-06-12 19:57:36 +01:00
var path = ! string . IsNullOrWhiteSpace ( pathArg )
? pathArg !
: Path . Combine ( AppConfig . UserDataDirectory ,
$"RemSound-diagnostics-{Environment.MachineName}-{DateTime.Now:yyyyMMdd-HHmmss}.txt" );
try
{
Directory . CreateDirectory ( Path . GetDirectoryName ( Path . GetFullPath ( path ))!);
2026-06-12 23:01:40 +01:00
File . WriteAllText ( path , report );
Console . WriteLine ( "Diagnostics written to:" );
2026-06-12 19:57:36 +01:00
Console . WriteLine ( $" {path}" );
}
catch ( Exception ex )
{
Console . WriteLine ( $"Could not write the diagnostics file: {ex.Message}" );
Console . WriteLine ();
2026-06-12 23:01:40 +01:00
Console . Write ( report ); // last resort - dump to the terminal
2026-06-12 19:57:36 +01:00
return 1 ;
}
return 0 ;
}
private static string DescribeMicPrivacy ()
{
try
{
const string consent = @"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\CapabilityAccessManager\ConsentStore\microphone" ;
var perUser = Registry . GetValue ( consent , "Value" , null ) as string ;
var policy = Registry . GetValue ( @"HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\AppPrivacy" , "LetAppsAccessMicrophone" , null );
var bits = new List < string > { $"user mic access = {perUser ?? " Allow ( default ) "}" };
if ( policy is int p ) bits . Add ( $"group-policy LetAppsAccessMicrophone = {p}{(p == 2 ? " ( FORCE DENY ) " : "")}" );
return string . Join ( "; " , bits );
}
catch ( Exception ex ) { return $"(could not read: {ex.Message})" ; }
}
private static string TailNewestLog ( int lines )
{
try
{
var dir = AppConfig . LogsDirectory ;
if (! Directory . Exists ( dir )) return " (no logs folder - logging may be off)" ;
var newest = new DirectoryInfo ( dir ). GetFiles ( "*.log" )
. OrderByDescending ( f => f . LastWriteTimeUtc ). FirstOrDefault ();
if ( newest is null ) return " (no log files - logging may be off)" ;
var all = File . ReadAllLines ( newest . FullName );
var tail = all . Length <= lines ? all : all [^ lines ..];
return $" file: {newest.Name}" + Environment . NewLine
+ string . Join ( Environment . NewLine , tail . Select ( l => " " + l ));
}
catch ( Exception ex ) { return $" (could not read log: {ex.Message})" ; }
}
// ---------------- arg helpers ----------------
/// <summary>The token after the first occurrence of <paramref name="flag"/>, unless that token
/// is itself a flag (starts with '-'); null when absent. Used for optional values like a path.</summary>
private static string? ValueAfter ( string [] args , string flag )
{
for ( var i = 0 ; i < args . Length - 1 ; i ++)
{
if ( args [ i ]. Equals ( flag , StringComparison . OrdinalIgnoreCase ))
return ValueAt ( args , i + 1 );
}
return null ;
}
private static string? ValueAt ( string [] args , int index ) =>
index >= 0 && index < args . Length && ! args [ index ]. StartsWith ( '-' ) ? args [ index ] : null ;
private static bool TryParsePeer ( string? text , out IPEndPoint endpoint )
{
endpoint = new IPEndPoint ( IPAddress . Loopback , RemPacket . DefaultPort );
if ( string . IsNullOrWhiteSpace ( text )) return false ;
var parts = text . Split ( ':' );
if ( parts . Length == 1 && IPAddress . TryParse ( parts [ 0 ], out var ip1 ))
{
endpoint = new IPEndPoint ( ip1 , RemPacket . DefaultPort );
return true ;
}
if ( parts . Length == 2 && IPAddress . TryParse ( parts [ 0 ], out var ip2 )
&& int . TryParse ( parts [ 1 ], out var port ) && port is > 0 and <= 65535 )
{
endpoint = new IPEndPoint ( ip2 , port );
return true ;
}
return false ;
}
}