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-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 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ---------------- 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-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:01:40 +01:00
|
|
|
Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
|
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.");
|
|
|
|
|
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-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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>
|
|
|
|
|
internal static string BuildDiagnosticsReport(AppConfig cfg)
|
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();
|
|
|
|
|
|
|
|
|
|
sb.AppendLine("Most recent log (tail):");
|
|
|
|
|
sb.AppendLine(TailNewestLog(40));
|
|
|
|
|
sb.AppendLine();
|
|
|
|
|
|
2026-06-12 23:01:40 +01:00
|
|
|
return sb.ToString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <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;
|
|
|
|
|
}
|
|
|
|
|
}
|