diff --git a/MANUAL.md b/MANUAL.md
index 18e3108..3bde400 100644
--- a/MANUAL.md
+++ b/MANUAL.md
@@ -1099,7 +1099,9 @@ Option| What it does
`--devices`| Lists every microphone and line-in, every speaker and headphone output, and every ASIO driver on the machine — each with its sample rate, channel count and the exact device id RemSound uses internally. This is the quickest way to confirm an interface is actually present and seen by Windows.
`--selftest`
(or `--smoke-test`)| Runs RemSound's built-in self-test and reports **PASS** or **FAIL**. It works through a list of named checks: a full audio round-trip on the machine on its own (capture → encode → send across the network layer to itself → receive → decode, for both quality settings), the audio encryption, the network packet format, saving and reloading settings and a profile, that a diagnostics report never leaks a password, and that the bundled sounds and manual are present. No sound is played out, so it is safe to run silently. Add `--seconds N` to make the audio part run for longer than the default.
-`--diagnostics`| Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, and the tail of the most recent log. With no path it saves into the **user settings and logs** folder and prints where it put it; you can also give a path, for example `--diagnostics C:\Users\you\Desktop\report.txt`. This is the file to send when asking for help — it answers most questions in one go.
+`--perftest`
+[`--seconds N`]| Runs the audio path through several short cycles and reports whether RemSound's handle, memory and thread use stays bounded — a quick check for resource leaks. Prints the numbers each cycle so two builds can be compared. No sound is played out.
+`--diagnostics`| Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, a quick live audio self-check, the most recent session snapshot and recent warnings from the log, and the tail of the most recent log. With no path it saves into the **user settings and logs** folder and prints where it put it; you can also give a path, for example `--diagnostics C:\Users\you\Desktop\report.txt`. This is the file to send when asking for help — it answers most questions in one go.
### Options that change a setting or control a running copy, then exit
diff --git a/readme.html b/readme.html
index db3149f..e6702c4 100644
--- a/readme.html
+++ b/readme.html
@@ -1155,7 +1155,8 @@ Use whatever key combinations you prefer (for example Ctrl+Shift+Up / Ctrl+Shift
--version
Prints which version of RemSound is installed, for example “RemSound 3.9”.
--devices
Lists every microphone and line-in, every speaker and headphone output, and every ASIO driver on the machine — each with its sample rate, channel count and the exact device id RemSound uses internally. This is the quickest way to confirm an interface is actually present and seen by Windows.
--selftest (or --smoke-test)
Runs RemSound's built-in self-test and reports PASS or FAIL. It works through a list of named checks: a full audio round-trip on the machine on its own (capture → encode → send across the network layer to itself → receive → decode, for both quality settings), the audio encryption, the network packet format, saving and reloading settings and a profile, that a diagnostics report never leaks a password, and that the bundled sounds and manual are present. No sound is played out, so it is safe to run silently. Add --seconds N to make the audio part run for longer than the default.
-
--diagnostics
Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, and the tail of the most recent log. With no path it saves into the user settings and logs folder and prints where it put it; you can also give a path, for example --diagnostics C:\Users\you\Desktop\report.txt. This is the file to send when asking for help — it answers most questions in one go.
+
--perftest [--seconds N]
Runs the audio path through several short cycles and reports whether RemSound's handle, memory and thread use stays bounded — a quick check for resource leaks. Prints the numbers each cycle so two builds can be compared. No sound is played out.
+
--diagnostics
Writes a single plain-text report file holding the version, the operating system, the current settings, the list of profiles, the full device list, a check of the Windows microphone-privacy permission, a quick live audio self-check, the most recent session snapshot and recent warnings from the log, and the tail of the most recent log. With no path it saves into the user settings and logs folder and prints where it put it; you can also give a path, for example --diagnostics C:\Users\you\Desktop\report.txt. This is the file to send when asking for help — it answers most questions in one go.
Options that change a setting or control a running copy, then exit
diff --git a/run-tests.ps1 b/run-tests.ps1
index f7f879b..ca18f0f 100644
--- a/run-tests.ps1
+++ b/run-tests.ps1
@@ -113,6 +113,14 @@ foreach ($line in ($st.Text -split "`r?`n")) {
}
if ($st.Code -eq 0) { Pass "self-test passed (exit 0)" } else { Fail "self-test failed (exit $($st.Code))" }
+Write-Host "`nResource sanity (handle/memory leak check):" -ForegroundColor Cyan
+$pf = Invoke-RsCli @('--perftest', '--seconds', '12')
+foreach ($line in ($pf.Text -split "`r?`n")) {
+ if ($line -match 'baseline:|cycle \d|net change|^\s*RESULT:') { Write-Host " $($line.Trim())" }
+}
+if ($pf.Code -eq 0) { Pass "resources stayed bounded across cycles (or skipped - no audio device)" }
+else { Fail "perf sanity flagged possible runaway (exit $($pf.Code))" }
+
# ---- 5. COLD START + CLEAN CLOSE, against an isolated --config-dir so the real settings are never
# touched (smoke-test brief, safety rule 1 + baseline steps 3-4) ----
Write-Host "`nCold start and clean close (isolated config):" -ForegroundColor Cyan
diff --git a/src/RemSound.App/AudioLoopback.cs b/src/RemSound.App/AudioLoopback.cs
new file mode 100644
index 0000000..1b5e062
--- /dev/null
+++ b/src/RemSound.App/AudioLoopback.cs
@@ -0,0 +1,77 @@
+using System.Net;
+using RemSound.Core;
+using RemSound.Receiver;
+using RemSound.Sender;
+
+namespace RemSound.App;
+
+///
+/// A localhost audio loopback used by the self-test, the diagnostics report and the perf check:
+/// capture the default output (as loopback) → encode → send to 127.0.0.1 → receive → decode, for a
+/// few seconds, with the receiver rendering to nothing (so no sound is ever produced). Exposes the
+/// runtime counters afterwards. Shared so all three callers exercise the identical real audio path.
+///
+internal static class AudioLoopback
+{
+ /// A dedicated test port, separate from the live DefaultPort (47830), so a loopback
+ /// never clashes with a RemSound instance the user already has running.
+ internal const int TestPort = 47929;
+
+ internal sealed record Result(
+ bool Ran, string Codec,
+ long PacketsSent, long PacketsReceived, long BytesReceived,
+ long Underruns, long Drops, int BufferMs, int TargetLatencyMs,
+ string? SkipReason)
+ {
+ public bool Flowed => Ran && PacketsSent > 0 && PacketsReceived > 0;
+ }
+
+ private static Result Skipped(string codec, string why) =>
+ new(false, codec, 0, 0, 0, 0, 0, 0, 0, why);
+
+ /// Run the loopback for and return the counters. Never
+ /// throws for an environment reason (no output device, port busy) - it returns a Result with
+ /// = false and a instead, so callers
+ /// can treat "no audio hardware" as a skip rather than a failure.
+ internal static Result Run(bool opus, int seconds, int port = TestPort)
+ {
+ var codec = opus ? "Opus" : "PCM";
+
+ IReadOnlyList outputs;
+ try { outputs = AudioDeviceCatalog.LoadOutputs(); }
+ catch (Exception ex) { return Skipped(codec, "could not enumerate outputs: " + ex.Message); }
+ var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
+ if (dev?.DeviceId is not { } deviceId) return Skipped(codec, "no usable output device to capture from");
+
+ using var receiver = new AudioReceiver();
+ using var sender = new AudioSender();
+ try
+ {
+ try { receiver.Start(port); }
+ catch (Exception ex) { return Skipped(codec, $"could not bind test port {port}: {ex.Message}"); }
+ receiver.SetOutputDevices(Array.Empty()); // decode only - never make sound
+ sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
+ sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
+ sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, port) });
+ sender.Start();
+ Thread.Sleep(Math.Max(1, seconds) * 1000);
+
+ // Read the counters while still running (before the finally stops the engines).
+ return new Result(
+ Ran: true, Codec: codec,
+ PacketsSent: sender.PacketsSent,
+ PacketsReceived: receiver.PacketsReceived,
+ BytesReceived: receiver.BytesReceived,
+ Underruns: receiver.Underruns,
+ Drops: receiver.Drops,
+ BufferMs: receiver.CurrentBufferMs,
+ TargetLatencyMs: receiver.TargetLatencyMs,
+ SkipReason: null);
+ }
+ finally
+ {
+ try { sender.Stop(); } catch { /* ignore */ }
+ try { receiver.Stop(); } catch { /* ignore */ }
+ }
+ }
+}
diff --git a/src/RemSound.App/CommandLine.cs b/src/RemSound.App/CommandLine.cs
index 757ece2..f337a4e 100644
--- a/src/RemSound.App/CommandLine.cs
+++ b/src/RemSound.App/CommandLine.cs
@@ -82,6 +82,8 @@ internal static class CommandLine
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
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":
@@ -150,7 +152,9 @@ internal static class CommandLine
Console.WriteLine(" with their formats and device ids.");
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 and bundled files - and report PASS/FAIL.");
+ 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.");
@@ -225,6 +229,62 @@ internal static class CommandLine
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";
@@ -256,7 +316,7 @@ internal static class CommandLine
/// 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)
+ internal static string BuildDiagnosticsReport(AppConfig cfg, bool runLiveAudioProbe = true)
{
var sb = new StringBuilder();
sb.AppendLine($"RemSound diagnostics");
@@ -295,6 +355,21 @@ internal static class CommandLine
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();
@@ -302,6 +377,70 @@ internal static class CommandLine
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)
diff --git a/src/RemSound.App/RemSound.App.csproj b/src/RemSound.App/RemSound.App.csproj
index 2483884..03905ba 100644
--- a/src/RemSound.App/RemSound.App.csproj
+++ b/src/RemSound.App/RemSound.App.csproj
@@ -62,19 +62,23 @@
user-supplied custom cue paths set in Preferences override the defaults at runtime.
Filenames containing a space (e.g. "record start.wav") are preserved verbatim on
copy so the load-by-filename path in TryLoadCueSound finds them exactly as written. -->
-
+
+
sounds\connect.wav
PreserveNewest
-
+
sounds\disconnect.wav
PreserveNewest
-
+
sounds\record start.wav
PreserveNewest
-
+
sounds\record stop.wav
PreserveNewest
diff --git a/src/RemSound.App/SelfTest.cs b/src/RemSound.App/SelfTest.cs
index 023a700..452efc3 100644
--- a/src/RemSound.App/SelfTest.cs
+++ b/src/RemSound.App/SelfTest.cs
@@ -3,9 +3,8 @@ using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
+using System.Windows.Forms;
using RemSound.Core;
-using RemSound.Receiver;
-using RemSound.Sender;
namespace RemSound.App;
@@ -60,6 +59,7 @@ internal static class SelfTest
RunStep(results, "Profile save and reload", ProfileRoundTrip);
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
RunStep(results, "Bundled resources present", ResourcesPresent);
+ RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
@@ -99,38 +99,10 @@ internal static class SelfTest
/// box) so the suite stays green where there's simply nothing to capture.
private static string? AudioRoundTrip(bool opus, int seconds)
{
- IReadOnlyList outputs;
- try { outputs = AudioDeviceCatalog.LoadOutputs(); }
- catch (Exception ex) { return Skip("could not enumerate outputs: " + ex.Message); }
- var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
- if (dev?.DeviceId is not { } deviceId) return Skip("no usable output device to capture from");
-
- // A dedicated test port, separate from the live DefaultPort (47830), so the self-test
- // doesn't clash with a RemSound instance the user already has running.
- const int testPort = 47929;
- using var receiver = new AudioReceiver();
- using var sender = new AudioSender();
- try
- {
- try { receiver.Start(testPort); }
- catch (Exception ex) { return Skip($"could not bind test port {testPort}: {ex.Message}"); }
- receiver.SetOutputDevices(Array.Empty()); // decode only - never make sound during a test
- sender.ConfigureCodec(opus ? AudioTransportCodec.Opus : AudioTransportCodec.Pcm);
- sender.Configure(new[] { new CaptureSourceSpec(deviceId, CaptureKind.Loopback, dev.Name) });
- sender.SetReceivers(new[] { new IPEndPoint(IPAddress.Loopback, testPort) });
- sender.Start();
- Thread.Sleep(seconds * 1000);
- }
- finally
- {
- try { sender.Stop(); } catch { /* ignore */ }
- try { receiver.Stop(); } catch { /* ignore */ }
- }
-
- var sent = sender.PacketsSent;
- var got = receiver.PacketsReceived;
- Check(sent > 0 && got > 0, $"audio did not flow end-to-end (sent={sent}, received={got})");
- return $"sent={sent}, received={got}";
+ var r = AudioLoopback.Run(opus, seconds);
+ if (!r.Ran) return Skip(r.SkipReason ?? "audio loopback unavailable");
+ Check(r.Flowed, $"audio did not flow end-to-end (sent={r.PacketsSent}, received={r.PacketsReceived})");
+ return $"sent={r.PacketsSent}, received={r.PacketsReceived}";
}
/// Audio encryption: the right password decrypts to the original, the wrong one fails
@@ -301,7 +273,7 @@ internal static class SelfTest
p.Password = canaryPassword;
store.Save(p);
- var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
+ var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp }, runLiveAudioProbe: false);
Check(report.Contains("RemSound diagnostics") && report.Contains(Environment.MachineName),
"the diagnostics report must contain its basic header");
Check(report.Contains(canaryTitle), "the diagnostics report should list the profile title");
@@ -340,6 +312,89 @@ internal static class SelfTest
return "manual, cue sounds, native Opus";
}
+ /// Headless accessibility audit of the dialogs that can be built without hardware: every
+ /// actionable control announces a name to a screen reader, and the Alt-key mnemonic letters are
+ /// unique within a container so keyboard navigation is never ambiguous. The main window can't be
+ /// built headlessly (its constructor opens audio devices, registers hotkeys and binds sockets),
+ /// so it's out of scope here. A dialog that won't construct in this context is skipped, not
+ /// failed.
+ private static string? AccessibilityAudit()
+ {
+ var factories = new (string Name, Func