using System.Buffers.Binary;
using System.Diagnostics;
using System.Net;
using System.Text;
using System.Text.Json;
using System.Windows.Forms;
using RemSound.Core;
namespace RemSound.App;
///
/// The in-app self-test, run by --selftest. 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.
///
internal static class SelfTest
{
private sealed class Result
{
public string Name = "";
public string Status = ""; // PASS | FAIL | SKIP
public string Message = "";
public long Ms;
}
/// A step asserts with (failure) or bails with
/// (not applicable on this machine, e.g. no audio device). Both are signalled by exception so a
/// step body reads as straight-line code.
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();
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);
RunStep(results, "Dialog accessibility (names + mnemonics)", AccessibilityAudit);
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 results, string name, Func 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 ----------------
/// 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.
private static string? AudioRoundTrip(bool opus, int seconds)
{
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
/// (silence, never garbage), fingerprints match/differ correctly, and the on-disk password
/// scramble round-trips without leaving the password in plain text.
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";
}
/// 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.
private static string? PacketFraming()
{
Span 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 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";
}
///
/// Client-to-server compatibility guard. The Pi relay (server/remsound-relay.py)
/// 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 server-* 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.
///
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 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";
}
/// App settings survive a save-and-reload (the same JSON serialisation
/// / use) without touching the real
/// config on disk.
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(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;
}
/// A profile saved through reloads with its fields intact.
/// Runs entirely inside a throwaway temp folder — the user's real profiles are never touched.
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 */ }
}
}
/// 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.
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 }, 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");
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 */ }
}
}
/// 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.
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)");
// Cues ship as numbered variants ("connect 1.wav", ...); each required cue must have at
// least one variant present.
foreach (var cue in new[]
{
"connect.wav", "disconnect.wav", "start up.wav",
"send on.wav", "send off.wav", "recieve on.wav", "recieve off.wav", "minimise.wav", "maximise.wav",
"check.wav", "uncheck.wav",
})
{
Check(CueSounds.Variants(cue).Count > 0,
$"no sound variant present for the '{Path.GetFileNameWithoutExtension(cue)}' cue (was the shipped sounds\\ folder empty?)");
}
// Keyboard-click typing sounds + the password passkey sound.
Check(File.Exists(Path.Combine(soundsDir, "key 1.wav")), "keyboard-click sound 'key 1.wav' must be present");
Check(File.Exists(Path.Combine(soundsDir, "passkey.wav")), "password 'passkey.wav' must be present");
// 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";
}
/// 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