Implement QR pairing window and mDNS announcements; update documentation and tests

This commit is contained in:
2026-09-09 09:53:37 +03:00
parent d5e827112a
commit 2da3c9544b
7 changed files with 321 additions and 61 deletions
+82
View File
@@ -44,6 +44,31 @@ catch (ArgumentException) { expiredInvitationRejected = true; }
Check(expiredInvitationRejected, "expired pairing window cannot generate invitation");
var descriptor = DiscoveryDescriptor.Create(new string('a', 64));
Check(descriptor.AgentId == new string('A', 64) && descriptor.TxtRecords().Count == 2, "discovery descriptor stable and contains no pairing secrets");
var qrPng = PairingQr.Render(invitation);
using (var qrStream = new MemoryStream(qrPng))
using (var bitmap = new Bitmap(qrStream))
{
var pixels = new byte[bitmap.Width * bitmap.Height * 3];
for (var y = 0; y < bitmap.Height; y++)
for (var x = 0; x < bitmap.Width; x++)
{
var color = bitmap.GetPixel(x, y);
var offset = (y * bitmap.Width + x) * 3;
pixels[offset] = color.R; pixels[offset + 1] = color.G; pixels[offset + 2] = color.B;
}
var reader = new ZXing.BarcodeReaderGeneric();
var decodedQr = reader.Decode(pixels, bitmap.Width, bitmap.Height, ZXing.RGBLuminanceSource.BitmapFormat.RGB24);
Check(decodedQr?.Text == invitation.ToQrPayload(), "generated QR decodes to exact pairing invitation");
Check(bitmap.Width == bitmap.Height && bitmap.GetPixel(0, 0).R == 255, "QR is square with white quiet zone");
}
var serviceProfile = MdnsPublisher.CreateProfile(new string('A', 64), 18765, [IPAddress.Parse("192.168.1.10")]);
Check(serviceProfile.Resources.OfType<Makaretu.Dns.SRVRecord>().Single().Port == 18765, "DNS-SD advertises actual HTTPS port");
Check(serviceProfile.Resources.OfType<Makaretu.Dns.ARecord>().Single().Address.Equals(IPAddress.Parse("192.168.1.10")), "DNS-SD contains selected LAN address");
Check(serviceProfile.Resources.OfType<Makaretu.Dns.TXTRecord>().Single().Strings.Contains("id=" + new string('A', 64)), "DNS-SD includes stable agent id");
var invalidProfileRejected = false;
try { MdnsPublisher.CreateProfile(new string('A', 64), 8765, [IPAddress.Loopback]); }
catch (ArgumentException) { invalidProfileRejected = true; }
Check(invalidProfileRejected, "mDNS cannot advertise loopback address");
Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(-5), now, 2) == 30, "position uses elapsed time and rate");
Check(TimelineMath.Position(20, 0, 200, false, now.AddSeconds(-5), now, 1) == 20, "pause does not advance position");
Check(TimelineMath.Position(199, 0, 200, true, now.AddSeconds(-5), now, 1) == 200, "position clamps to end");
@@ -320,6 +345,63 @@ if (args.Contains("--live-read"))
Check(next.ArtworkId == id, "real cover cache survives progress refresh");
}
}
if (args.Contains("--qr-window-smoke"))
{
var uiClock = new TestClock(DateTimeOffset.UtcNow);
var uiPairing = new PairingService(uiClock);
uiPairing.Open();
using var host = new MusicBridge.Agent.PairingWindowHost(uiPairing, new string('A', 64),
() => [new Uri("https://192.168.1.10:8765")], visible: false);
host.Open();
var field = typeof(MusicBridge.Agent.PairingWindowHost).GetField("form", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!;
Form? uiForm = null;
using var uiTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
while (uiForm is null || !uiForm.IsHandleCreated)
{
await Task.Delay(50, uiTimeout.Token);
uiForm = field.GetValue(host) as Form;
}
var actualWindow = uiForm;
bool HasImage() => (bool)actualWindow.Invoke(() => actualWindow.Controls.OfType<TableLayoutPanel>().Single()
.Controls.OfType<PictureBox>().Single().Image is not null);
while (!HasImage()) await Task.Delay(50, uiTimeout.Token);
Check(HasImage(), "QR window renders active pairing image");
actualWindow.Invoke(() =>
{
using var frame = new Bitmap(actualWindow.Width, actualWindow.Height);
actualWindow.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height));
frame.Save(Path.Combine(AppContext.BaseDirectory, "qr-window-smoke.png"));
});
uiClock.Now = uiClock.Now.AddMinutes(6);
while (HasImage()) await Task.Delay(50, uiTimeout.Token);
Check(!HasImage(), "QR window removes expired pairing image");
}
if (args.Contains("--mdns-smoke"))
{
var local = LocalAddresses.Read();
if (local.Length == 0) throw new InvalidOperationException("No private multicast-capable IPv4 interface for mDNS smoke test.");
var testFingerprint = Convert.ToHexString(SHA256.HashData(Guid.NewGuid().ToByteArray()));
var name = MdnsPublisher.CreateProfile(testFingerprint, 18799, local).FullyQualifiedName;
using var listener = new Makaretu.Dns.MulticastService { UseIpv4 = true, UseIpv6 = false };
var seen = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var gone = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
listener.AnswerReceived += (_, e) =>
{
foreach (var ptr in e.Message.Answers.OfType<Makaretu.Dns.PTRRecord>().Where(r => r.DomainName == name))
{
if (ptr.TTL == TimeSpan.Zero) gone.TrySetResult(true);
else seen.TrySetResult(true);
}
};
listener.Start();
using (var publisher = new MdnsPublisher(testFingerprint, 18799))
{
publisher.Refresh(local);
listener.SendQuery("_musicbridge._tcp.local", type: Makaretu.Dns.DnsType.PTR);
Check(await seen.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives service announcement");
}
Check(await gone.Task.WaitAsync(TimeSpan.FromSeconds(8)), "real multicast listener receives goodbye on shutdown");
}
Console.WriteLine($"All {checks} checks passed. No real playback or volume was changed.");
}