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; /// /// 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 right after the legacy-layout migration and before the /// single-instance guard. The do-and-exit commands need no window and no instance lock. /// internal static class CommandLine { /// Overrides applied to a normal GUI launch when no do-and-exit command ran. internal sealed class LaunchOverrides { public bool StartMinimized; public string? ProfileName; public readonly List ConnectPeers = new(); /// --connect given with no --profile → start on a blank profile and connect. public bool ForceBlankProfile; } [DllImport("kernel32.dll")] private static extern bool AttachConsole(int dwProcessId); private const int ATTACH_PARENT_PROCESS = -1; /// The folder given after --config-dir, or null. Read at the very start of /// - before the layout migration and any config/profile/log/sound access - /// so it can redirect ALL user state via . /// Applies to every command (e.g. --selftest --config-dir, --diagnostics --config-dir) /// and to a normal GUI launch, so a test can exercise a real build without touching live settings. 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; } /// /// Process the command line. Returns a non-null exit code when a do-and-exit command ran (the /// caller should Environment.Exit it); returns null to continue into the GUI launch with /// populated. /// 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; }); case "--profiles": case "--list-profiles": return WithConsole(ListProfiles); case "--named-peers": case "--list-named-peers": return WithConsole(ListNamedPeers); case "--selftest": case "--self-test": case "--smoke-test": case "--smoketest": return WithConsole(() => SelfTest.Run(args)); case "--perftest": case "--perf-test": return WithConsole(() => RunPerfTest(args)); 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 ---------------- /// 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. private static int WithConsole(Func 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 ---------------- internal static string AppVersion => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0"; private static int PrintVersion() { Console.WriteLine($"RemSound {AppVersion}"); return 0; } /// --list-profiles: print the saved profile titles (read-only). 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; } /// --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. 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; } 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."); 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."); Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio"); Console.WriteLine(" (or --smoke-test) round-trip plus checks of encryption, the wire format,"); 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)."); 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(" --uninstall Uninstall an installed copy of RemSound (asks first)."); Console.WriteLine(); Console.WriteLine("Start-up options (these change how RemSound launches):"); Console.WriteLine(" --profile \"\" Start straight into the named profile (skip the picker)."); Console.WriteLine(" --connect 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."); Console.WriteLine(" --config-dir 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 ...)."); Console.WriteLine(" --silent Play no cue sounds and show no missing-sound pop-ups for"); Console.WriteLine(" this run - for automated / unattended launches."); 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; } /// Write the full device inventory (capture inputs, render outputs, ASIO drivers) to a /// writer - shared by --devices and the diagnostics report. Formats come straight from /// each endpoint's mix format, the same value the audio engine negotiates. 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 asio; try { asio = AsioDeviceProbe.EnumerateDriverNames(); } catch { asio = Array.Empty(); } if (asio.Count == 0) w.WriteLine(" (none installed)"); else foreach (var n in asio) w.WriteLine($" {n}"); w.WriteLine(); } /// 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. 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; } 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; } /// Build the support diagnostics report text for a given config (version, settings, /// profiles, devices, mic-privacy, recent log). Shared by --diagnostics and the /// self-test's privacy check. Lists profile titles only - never their contents. internal static string BuildDiagnosticsReport(AppConfig cfg, bool runLiveAudioProbe = true) { 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(); 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(); sb.AppendLine("Most recent log (tail):"); sb.AppendLine(TailNewestLog(40)); sb.AppendLine(); return sb.ToString(); } /// 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. 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")}"); } } /// 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. 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})"; } } /// The recent WARN/ERROR/exception-style lines from the newest log, so a support /// reader sees the problems without scrolling the whole file. Last . 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})"; } } /// 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. private static int RunDiagnostics(string? pathArg) { AppConfig cfg; try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); } var report = BuildDiagnosticsReport(cfg); 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))!); File.WriteAllText(path, report); Console.WriteLine("Diagnostics written to:"); Console.WriteLine($" {path}"); } catch (Exception ex) { Console.WriteLine($"Could not write the diagnostics file: {ex.Message}"); Console.WriteLine(); Console.Write(report); // last resort - dump to the terminal 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 { $"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 ---------------- /// The token after the first occurrence of , unless that token /// is itself a flag (starts with '-'); null when absent. Used for optional values like a path. 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; } }