Add build-and-test suite (in-app self-test + publish gate); fix release-zip missing sounds

The test suite, modelled on Andre's Sensor Readout (an in-app self-test + a build
script), runnable as one step before every publish.

Part 1 - in-app multi-step self-test (SelfTest.cs), run by --selftest:
  audio round-trip (PCM + Opus over localhost, dedicated test port so it never
  clashes with a running instance), encryption right/wrong-password + fingerprint,
  packet framing + malformed rejection, client<->server wire-format compatibility,
  settings save/reload, profile save/reload (temp folder), diagnostics-report
  privacy (never leaks a password), and bundled-resources present. Each step is
  timed and reported PASS/FAIL/SKIP; exit 0 only if nothing failed. Replaces the
  old single-shot --selftest. RunDiagnostics refactored to expose
  BuildDiagnosticsReport(AppConfig) for the privacy step.

Part 2 - run-tests.ps1: builds, then checks the package (sounds, readme, native
  opus, framework-dependent, dll version == csproj), the About-box changelog, the
  client/server wire contract (relay magic/version/port still match RemPacket),
  the CLI surface, and runs --selftest. build-release.ps1 now runs this gate first
  and aborts the release if it fails.

Bug caught + fixed: the published release zip carried ZERO cue sounds (startup
  sound + connect/disconnect/etc.) - MSBuild's incremental Content-copy marker
  skipped sounds\ on a fresh publish. Added an AfterTargets=Publish copy in the
  csproj that lands every cue WAV in the published sounds\ folder regardless of
  the marker. Verified: a staging publish now contains all 9 cue WAVs.

Manual/help: --selftest description updated (readme.html + MANUAL.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-06-12 23:01:40 +01:00
co-authored by Claude Opus 4.8
parent 05b218825d
commit 141c5e8ce1
7 changed files with 539 additions and 64 deletions
+23 -62
View File
@@ -62,7 +62,7 @@ internal static class CommandLine
case "--devices": case "--list-devices":
return WithConsole(() => { WriteDevices(Console.Out); return 0; });
case "--selftest": case "--self-test":
return WithConsole(() => RunSelfTest(args));
return WithConsole(() => SelfTest.Run(args));
case "--diagnostics": case "--diag":
return WithConsole(() => RunDiagnostics(ValueAfter(args, raw)));
case "--log":
@@ -109,7 +109,7 @@ internal static class CommandLine
// ---------------- commands ----------------
private static string AppVersion =>
internal static string AppVersion =>
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
private static int PrintVersion()
@@ -129,8 +129,9 @@ internal static class CommandLine
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(" --selftest [--opus] Run a localhost audio round-trip (capture -> encode ->");
Console.WriteLine(" [--seconds N] network -> decode) and report PASS or FAIL.");
Console.WriteLine(" --selftest [--seconds N] Run the built-in self-test - a localhost audio");
Console.WriteLine(" round-trip plus checks of encryption, the wire format,");
Console.WriteLine(" settings, profiles and bundled files - and report PASS/FAIL.");
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.");
@@ -201,56 +202,6 @@ internal static class CommandLine
w.WriteLine();
}
/// <summary>Localhost audio round-trip: capture the default output (as loopback) → encode →
/// send to 127.0.0.1 → receive → decode. The receiver renders to nothing (no sound), so this is
/// safe to run any time. PASS when packets flow end-to-end; exit code 0 = PASS, 1 = FAIL.</summary>
private static int RunSelfTest(string[] args)
{
var opus = args.Any(a => a.Equals("--opus", StringComparison.OrdinalIgnoreCase));
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 60 ? s : 5;
Console.WriteLine($"RemSound self-test: localhost {(opus ? "Opus" : "PCM")} round-trip for {seconds}s...");
IReadOnlyList<AudioDeviceChoice> outputs;
try { outputs = AudioDeviceCatalog.LoadOutputs(); }
catch (Exception ex) { Console.WriteLine($" could not enumerate outputs: {ex.Message}"); return 1; }
var dev = outputs.FirstOrDefault(o => o.DeviceId is not null);
var deviceId = dev?.DeviceId;
if (dev is null || deviceId is null)
{
Console.WriteLine(" RESULT: SKIP - no usable output device to capture from.");
return 1;
}
using var receiver = new AudioReceiver();
using var sender = new AudioSender();
try
{
receiver.Start();
receiver.SetOutputDevices(Array.Empty<string>()); // 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, RemPacket.DefaultPort) });
sender.Start();
Console.WriteLine($" capturing \"{dev.Name}\" -> 127.0.0.1:{RemPacket.DefaultPort}");
Thread.Sleep(seconds * 1000);
}
finally
{
try { sender.Stop(); } catch { /* ignore */ }
try { receiver.Stop(); } catch { /* ignore */ }
}
var sent = sender.PacketsSent;
var got = receiver.PacketsReceived;
Console.WriteLine($" packets sent={sent} received={got} bytes received={receiver.BytesReceived}");
var pass = sent > 0 && got > 0;
Console.WriteLine(pass
? " RESULT: PASS - capture, encode, network and decode are all working."
: " RESULT: FAIL - audio did not flow end-to-end (sent or received was zero).");
return pass ? 0 : 1;
}
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";
@@ -279,9 +230,10 @@ internal static class CommandLine
return 0;
}
/// <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)
/// <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)
{
var sb = new StringBuilder();
sb.AppendLine($"RemSound diagnostics");
@@ -293,8 +245,6 @@ internal static class CommandLine
sb.AppendLine($"Exe: {Environment.ProcessPath}");
sb.AppendLine();
AppConfig cfg;
try { cfg = AppConfig.Load(); } catch { cfg = new AppConfig(); }
sb.AppendLine("Settings:");
sb.AppendLine($" Logging enabled: {cfg.LoggingEnabled}");
sb.AppendLine($" Start minimised: {cfg.StartMinimised}");
@@ -326,6 +276,17 @@ internal static class CommandLine
sb.AppendLine(TailNewestLog(40));
sb.AppendLine();
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);
var path = !string.IsNullOrWhiteSpace(pathArg)
? pathArg!
: Path.Combine(AppConfig.UserDataDirectory,
@@ -333,15 +294,15 @@ internal static class CommandLine
try
{
Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!);
File.WriteAllText(path, sb.ToString());
Console.WriteLine($"Diagnostics written to:");
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(sb.ToString()); // last resort - dump to the terminal
Console.Write(report); // last resort - dump to the terminal
return 1;
}
return 0;
+17
View File
@@ -119,4 +119,21 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!-- Guarantee the cue WAVs land in a PUBLISHED build. The sounds\ Content items above use
CopyToOutputDirectory=PreserveNewest, but MSBuild's incremental "copy already done" marker
(obj\...\.csproj.CopyComplete) can skip that copy when publishing into a fresh output folder
whose marker is up to date — which silently shipped the v3.9 zip with NO cue sounds at all
(startup sound + connect/disconnect/record/etc.). This explicit post-publish copy is marker-
independent: it always copies every WAV from the source sounds\ folder into the published
sounds\ folder. Build-release.ps1 zips the publish output, so this is what makes the release
reliably contain the sounds. (The self-test "Bundled resources present" step verifies it.) -->
<Target Name="EnsureCueSoundsPublished" AfterTargets="Publish">
<ItemGroup>
<_CueWavs Include="..\..\sounds\*.wav" />
</ItemGroup>
<Copy SourceFiles="@(_CueWavs)"
DestinationFolder="$(PublishDir)sounds"
SkipUnchangedFiles="false" />
</Target>
</Project>
+354
View File
@@ -0,0 +1,354 @@
using System.Buffers.Binary;
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using RemSound.Core;
using RemSound.Receiver;
using RemSound.Sender;
namespace RemSound.App;
/// <summary>
/// The in-app self-test, run by <c>--selftest</c>. Modelled on Andre's Sensor Readout: a list of
/// named steps, each timed and reported PASS / FAIL / SKIP, a one-line summary, and an exit code
/// (0 = every step passed or skipped, 1 = at least one failed) so a build-and-publish script can
/// gate on it.
///
/// The steps run INSIDE a real RemSound process on purpose — that's the only way to exercise the
/// genuine audio path, encryption, wire format and config/profile code rather than a stand-in.
/// Everything here is read-only or temp-folder-scoped: a self-test never touches the user's real
/// settings, profiles or logs, and never makes a sound.
/// </summary>
internal static class SelfTest
{
private sealed class Result
{
public string Name = "";
public string Status = ""; // PASS | FAIL | SKIP
public string Message = "";
public long Ms;
}
/// <summary>A step asserts with <see cref="Check"/> (failure) or bails with <see cref="Skip"/>
/// (not applicable on this machine, e.g. no audio device). Both are signalled by exception so a
/// step body reads as straight-line code.</summary>
private sealed class CheckFailed : Exception { public CheckFailed(string m) : base(m) { } }
private sealed class StepSkipped : Exception { public StepSkipped(string m) : base(m) { } }
private static void Check(bool condition, string failMessage)
{
if (!condition) throw new CheckFailed(failMessage);
}
private static string Skip(string why) => throw new StepSkipped(why);
public static int Run(string[] args)
{
var seconds = int.TryParse(ValueAfter(args, "--seconds"), out var s) && s is > 0 and <= 30 ? s : 3;
Console.WriteLine($"RemSound self-test {CommandLine.AppVersion} ({DateTime.Now:yyyy-MM-dd HH:mm:ss})");
Console.WriteLine();
var results = new List<Result>();
RunStep(results, "Audio round-trip (PCM)", () => AudioRoundTrip(opus: false, seconds));
RunStep(results, "Audio round-trip (Opus)", () => AudioRoundTrip(opus: true, seconds));
RunStep(results, "Encryption round-trip", Encryption);
RunStep(results, "Packet framing and rejection", PacketFraming);
RunStep(results, "Server wire-format compatibility", ServerWireCompat);
RunStep(results, "App settings save and reload", SettingsRoundTrip);
RunStep(results, "Profile save and reload", ProfileRoundTrip);
RunStep(results, "Diagnostics report privacy", DiagnosticsPrivacy);
RunStep(results, "Bundled resources present", ResourcesPresent);
var failed = results.Count(r => r.Status == "FAIL");
var skipped = results.Count(r => r.Status == "SKIP");
var passed = results.Count(r => r.Status == "PASS");
Console.WriteLine();
if (failed == 0)
{
Console.WriteLine($"RESULT: PASS - {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
return 0;
}
var names = string.Join(", ", results.Where(r => r.Status == "FAIL").Select(r => r.Name));
Console.WriteLine($"RESULT: FAIL - {failed} failed, {passed} passed{(skipped > 0 ? $", {skipped} skipped" : "")} of {results.Count}.");
Console.WriteLine($" Failed: {names}");
return 1;
}
private static void RunStep(List<Result> results, string name, Func<string?> body)
{
var sw = Stopwatch.StartNew();
var r = new Result { Name = name };
try { r.Message = body() ?? ""; r.Status = "PASS"; }
catch (StepSkipped sk) { r.Status = "SKIP"; r.Message = sk.Message; }
catch (CheckFailed cf) { r.Status = "FAIL"; r.Message = cf.Message; }
catch (Exception ex) { r.Status = "FAIL"; r.Message = $"{ex.GetType().Name}: {ex.Message}"; }
sw.Stop();
r.Ms = sw.ElapsedMilliseconds;
results.Add(r);
Console.WriteLine($" [{r.Status}] {name} ({r.Ms} ms){(r.Message.Length > 0 ? " - " + r.Message : "")}");
}
// ---------------- steps ----------------
/// <summary>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 (no sound). PASS when
/// packets flow both ways. SKIP on a machine with no usable output device (e.g. a headless CI
/// box) so the suite stays green where there's simply nothing to capture.</summary>
private static string? AudioRoundTrip(bool opus, int seconds)
{
IReadOnlyList<AudioDeviceChoice> 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<string>()); // 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}";
}
/// <summary>Audio encryption: the right password decrypts to the original, the wrong one fails
/// (silence, never garbage), fingerprints match/differ correctly, and the on-disk password
/// scramble round-trips without leaving the password in plain text.</summary>
private static string? Encryption()
{
var message = Encoding.UTF8.GetBytes("RemSound self-test payload 0123456789 the quick brown fox");
var keyA = RemSoundCrypto.DeriveKey("correct horse battery staple");
var keyB = RemSoundCrypto.DeriveKey("a different password entirely");
var cipher = RemSoundCrypto.Encrypt(keyA, message);
Check(RemSoundCrypto.TryDecrypt(keyA, cipher, out var plain) && plain.AsSpan().SequenceEqual(message),
"the right password must decrypt to the original bytes");
Check(!RemSoundCrypto.TryDecrypt(keyB, cipher, out _),
"the wrong password must fail to decrypt (silence, not garbage)");
Check(RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("shared")),
"the same password must produce the same fingerprint");
Check(!RemSoundCrypto.FingerprintsEqual(RemSoundCrypto.Fingerprint("shared"), RemSoundCrypto.Fingerprint("other")),
"different passwords must produce different fingerprints");
const string pw = "p@ss w0rd!";
Check(RemSoundCrypto.Obfuscate(pw) != pw, "a stored password must not be plain text");
Check(RemSoundCrypto.Deobfuscate(RemSoundCrypto.Obfuscate(pw)) == pw, "the stored-password scramble must round-trip");
return "AES-256-GCM, PBKDF2 fingerprint, on-disk scramble";
}
/// <summary>The packet header writes and reads back for every type, and malformed packets
/// (too short, bad magic, wrong version) are rejected rather than mis-parsed. Plus the PCM
/// multi-part sub-header round-trips.</summary>
private static string? PacketFraming()
{
Span<byte> header = stackalloc byte[RemPacket.HeaderSize];
foreach (var type in new[] { RemPacketType.Format, RemPacketType.Audio, RemPacketType.Heartbeat, RemPacketType.Control })
{
RemPacket.WriteHeader(header, type, streamId: 7, sequence: 42);
Check(RemPacket.TryReadHeader(header, out var t, out var sid, out var seq) && t == type && sid == 7 && seq == 42,
$"header round-trip failed for {type}");
}
Check(!RemPacket.TryReadHeader(new byte[5], out _, out _, out _), "a too-short packet must be rejected");
Check(!RemPacket.TryReadHeader(new byte[RemPacket.HeaderSize], out _, out _, out _), "a zero/bad-magic packet must be rejected");
var wrongVersion = new byte[RemPacket.HeaderSize];
RemPacket.WriteHeader(wrongVersion, RemPacketType.Audio, 1, 1);
wrongVersion[4] = 99;
Check(!RemPacket.TryReadHeader(wrongVersion, out _, out _, out _), "a wrong-version packet must be rejected");
Span<byte> sub = stackalloc byte[RemPcmFrame.SubHeaderSize];
RemPcmFrame.WriteSubHeader(sub, frameId: 12345, partIndex: 1, totalParts: 3);
Check(RemPcmFrame.TryReadSubHeader(sub, out var fid, out var pi, out var tp) && fid == 12345 && pi == 1 && tp == 3,
"PCM sub-header round-trip failed");
return "header + PCM sub-header round-trip, malformed rejected";
}
/// <summary>
/// Client-to-server compatibility guard. The Pi relay (<c>server/remsound-relay.py</c>)
/// forwards packets by reading ONLY the wire header at fixed byte offsets — it never looks at
/// the audio. These are the exact field positions and values it assumes. If RemSound's header
/// ever changes shape, this step FAILS, which is the reminder that the relay must be updated
/// and a new <c>server-*</c> release cut before shipping. Ideally we never touch the server —
/// this check is how we keep proving that, in case the network stack changes underneath us.
/// </summary>
private static string? ServerWireCompat()
{
// Golden contract the relay parses (see remsound-relay.py: MAGIC, V1_VERSION, header offsets).
Check(RemPacket.HeaderSize == 12, "the relay reads a 12-byte header; RemPacket.HeaderSize must stay 12");
Check(RemPacket.Version == 1, "the relay matches version byte 1 (V1_VERSION); RemPacket.Version must stay 1");
Check(RemPacket.DefaultPort == 47830, "the relay listens on UDP 47830; RemPacket.DefaultPort must stay 47830");
// Packet-type values both ends agree on — changing any breaks interop with the relay/peers.
Check((byte)RemPacketType.Format == 1 && (byte)RemPacketType.Audio == 2
&& (byte)RemPacketType.KeepAlive == 3 && (byte)RemPacketType.Heartbeat == 4
&& (byte)RemPacketType.Control == 5,
"packet type values must stay Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5");
// Build a real header and assert the byte-level layout the relay reads.
Span<byte> h = stackalloc byte[RemPacket.HeaderSize];
RemPacket.WriteHeader(h, RemPacketType.Audio, streamId: 0x1234, sequence: 0xAABBCCDD);
Check(h[0] == (byte)'R' && h[1] == (byte)'M' && h[2] == (byte)'N' && h[3] == (byte)'D',
"magic must be ASCII 'RMND' at offset 0 (the relay's first-four-byte check)");
Check(h[4] == 1, "version byte must be at offset 4");
Check(h[5] == (byte)RemPacketType.Audio, "type byte must be at offset 5");
Check(BinaryPrimitives.ReadUInt16LittleEndian(h.Slice(6, 2)) == 0x1234,
"streamId must be a little-endian uint16 at offset 6 (the relay's pairing key)");
Check(BinaryPrimitives.ReadUInt32LittleEndian(h.Slice(8, 4)) == 0xAABBCCDD,
"sequence must be a little-endian uint32 at offset 8");
return "12-byte 'RMND' header; relay-visible fields unchanged";
}
/// <summary>App settings survive a save-and-reload (the same JSON serialisation
/// <see cref="AppConfig.Save"/> / <see cref="AppConfig.Load"/> use) without touching the real
/// config on disk.</summary>
private static string? SettingsRoundTrip()
{
var original = new AppConfig
{
LoggingEnabled = true,
StartMinimised = true,
EnableStartupCue = false,
UpdateCheckFrequency = UpdateCheckFrequency.EveryHour,
StartWithProfileTitle = "Studio link",
ProfilesDirectory = @"X:\some\profiles\folder",
};
var json = JsonSerializer.Serialize(original, new JsonSerializerOptions { WriteIndented = true });
var loaded = JsonSerializer.Deserialize<AppConfig>(json);
Check(loaded is not null, "config must deserialise");
Check(loaded!.LoggingEnabled == original.LoggingEnabled
&& loaded.StartMinimised == original.StartMinimised
&& loaded.EnableStartupCue == original.EnableStartupCue
&& loaded.UpdateCheckFrequency == original.UpdateCheckFrequency
&& loaded.StartWithProfileTitle == original.StartWithProfileTitle
&& loaded.ProfilesDirectory == original.ProfilesDirectory,
"settings must survive a save/reload unchanged");
return null;
}
/// <summary>A profile saved through <see cref="ProfileStore"/> reloads with its fields intact.
/// Runs entirely inside a throwaway temp folder — the user's real profiles are never touched.</summary>
private static string? ProfileRoundTrip()
{
var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-" + Guid.NewGuid().ToString("N"));
try
{
var store = new ProfileStore(temp);
var p = Profile.NewBlank();
p.Title = "selftest roundtrip";
p.ReceiveAudioOn = true;
p.SendAudioOn = false;
p.Volume = 73;
p.AudioPort = 47830;
p.AsioDriverName = "Some ASIO Driver";
p.SelectedWasapiSendInputs.Add("device-id-abc");
store.Save(p);
var back = store.Load("selftest roundtrip");
Check(back is not null, "the profile must load back from disk");
Check(back!.Title == p.Title
&& back.Volume == 73
&& back.ReceiveAudioOn && !back.SendAudioOn
&& back.AudioPort == 47830
&& back.AsioDriverName == "Some ASIO Driver"
&& back.SelectedWasapiSendInputs.Contains("device-id-abc"),
"profile fields must survive a save/reload");
return null;
}
finally
{
try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
}
}
/// <summary>The diagnostics report lists a profile's title but never its password (plain or
/// scrambled). Guards against a future change accidentally dumping profile contents into a
/// support bundle. Uses a throwaway temp profiles folder with a known canary password.</summary>
private static string? DiagnosticsPrivacy()
{
var temp = Path.Combine(Path.GetTempPath(), "remsound-selftest-priv-" + Guid.NewGuid().ToString("N"));
const string canaryTitle = "PrivacyCanaryProfile";
const string canaryPassword = "SENTINEL-PW-DO-NOT-LEAK-7f3a91";
try
{
Directory.CreateDirectory(temp);
var store = new ProfileStore(temp);
var p = Profile.NewBlank();
p.Title = canaryTitle;
p.Password = canaryPassword;
store.Save(p);
var report = CommandLine.BuildDiagnosticsReport(new AppConfig { ProfilesDirectory = temp });
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");
Check(!report.Contains(canaryPassword), "the diagnostics report must NOT contain a profile password (plain text)");
Check(!report.Contains(RemSoundCrypto.Obfuscate(canaryPassword)),
"the diagnostics report must NOT contain a profile password (scrambled form)");
return "title listed, password withheld";
}
finally
{
try { Directory.Delete(temp, recursive: true); } catch { /* best-effort temp cleanup */ }
}
}
/// <summary>The files a shipped RemSound needs at runtime are actually next to the exe: the
/// bundled manual, the cue sounds, and the native Opus library.</summary>
private static string? ResourcesPresent()
{
var root = AppContext.BaseDirectory;
Check(File.Exists(Path.Combine(root, "readme.html")), "readme.html (the F1 manual) must ship next to the exe");
// Cues are consolidated from the shipped sounds\ folder into the runtime sounds folder at
// startup (Program.ConsolidateSounds), so by the time the self-test runs they live here.
// An empty runtime folder means the shipped build had no sounds to seed from - exactly the
// bug that shipped the v3.9 zip with no cue sounds.
var soundsDir = AppConfig.SoundsDirectory;
Check(Directory.Exists(soundsDir), "the runtime sounds folder must exist (cues are consolidated at startup)");
foreach (var cue in new[] { "connect.wav", "disconnect.wav", "start up.wav" })
{
Check(File.Exists(Path.Combine(soundsDir, cue)), $"cue sound '{cue}' must be present (was the shipped sounds\\ folder empty?)");
}
// Native Opus (Concentus.Native) keeps the encoder off the allocation-heavy managed fallback.
var nativeOpus = Path.Combine(root, "runtimes", "win-x64", "native", "opus.dll");
Check(File.Exists(nativeOpus), "native opus.dll must ship under runtimes\\win-x64\\native\\");
return "manual, cue sounds, native Opus";
}
// ---------------- helper ----------------
private static string? ValueAfter(string[] args, string flag)
{
for (var i = 0; i < args.Length - 1; i++)
{
if (args[i].Equals(flag, StringComparison.OrdinalIgnoreCase) && !args[i + 1].StartsWith('-'))
return args[i + 1];
}
return null;
}
}