diff --git a/CONNECTION-ROADMAP.md b/CONNECTION-ROADMAP.md index b9293ac..9c5893c 100644 --- a/CONNECTION-ROADMAP.md +++ b/CONNECTION-ROADMAP.md @@ -1,41 +1,63 @@ -# Заготовки подключения — iOS не начинать до команды пользователя +# Подключение MusicBridge — Windows -## Уже есть +Разработка iOS начинается только после отдельной команды пользователя. -- `ConnectionInvitation`: проверяет домашний IPv4 HTTPS-адрес, код/срок и SHA-256 - сертификата; создаёт строку `musicbridge://pair?data=`. -- Клавиша `I` в агенте выводит эту строку для каждого подходящего адреса при - открытом сопряжении (`B`) и запуске с `--lan`. Это **данные для QR, не QR-картинка**. -- В JSON: `version:1`, `endpoint`, `certificateSha256`, `code` (строка из 8 цифр), - `expiresAt` (ISO 8601). Payload содержит секрет сопряжения: не публиковать/не - отправлять внешним генераторам QR. Его срок совпадает со сроком кода. -- `DiscoveryDescriptor`: сервис `_musicbridge._tcp.local.`, имя - `MusicBridge-<12 первых символов отпечатка>`, TXT `v=1`, `id=<полный SHA-256>`. -- Авторизованный `GET /v1/info` возвращает `protocolVersion`, `agentId`, - `serviceType`, `instanceName`; идентификатор равен отпечатку сертификата. +## Что работает -## Следующая реализация на Windows +- `dotnet run -c Release -- --lan` запускает HTTPS/WSS и mDNS для домашней IPv4-сети. +- `B` открывает новое окно сопряжения; `I` открывает отдельное Windows-окно QR. +- В окне можно выбрать адрес адаптера. QR обновляется при смене адреса или кода, + убирается при истечении кода/успешном сопряжении. Проверка состояния — каждые 500 мс. +- QR генерируется локально через QRCoder. Внешних сайтов и временных файлов с кодом нет. +- Payload: `musicbridge://pair?data=`, поля `version:1`, + `endpoint`, `certificateSha256`, `code`, `expiresAt`. +- Сервис DNS-SD: `_musicbridge._tcp.local.`, имя `MusicBridge-<12 символов SHA-256>`. + SRV указывает порт HTTPS; A — адреса домашних адаптеров; TXT — версия и ID. + Библиотека также добавляет служебный `txtvers`. Кодов и токенов в объявлениях нет. +- mDNS обслуживает запросы, повторяет объявление раз в 30 секунд. При изменении + списка IPv4-адресов снимает старое объявление и создаёт новое. При остановке + отправляет goodbye. При исчезновении сети ждёт её возвращения. +- `/v1/info` требует Bearer-токен и возвращает идентификатор, равный SHA-256 сертификата. -- [ ] Локальная генерация QR из payload; отображение в окне агента/трее. -- [ ] Закрывать/обновлять показанный QR при истечении кода, сопряжении или смене IP. -- [ ] Выбор адаптера при нескольких адресах; сейчас для каждого свой payload. -- [ ] mDNS/DNS-SD publisher: PTR/SRV/TXT/A, порт HTTPS агента, без кодов и токенов. -- [ ] Обработка конфликтов имён, смены адресов, сна/пробуждения и остановки сервиса. -- [ ] Проверка объявления со второго устройства. Сейчас multicast не отправляется. +Без `--lan` mDNS выключен, а окно QR сообщает, что нужен адрес домашней сети. +Поиск не доказывает подлинность ПК: клиент всегда сверяет закреплённый TLS-сертификат. + +## Что проверено + +Release-сборка без ошибок/предупреждений. Итоговый прогон — 95 проверок: + +```powershell +dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build -- --qr-window-smoke --mdns-smoke --live-read +``` + +QR декодирован независимой библиотекой ZXing в исходный payload. Окно проверено +в скрытом режиме, его снимок просмотрен, просроченный QR удаляется. Независимый +mDNS-слушатель на этом же ПК получил объявление и goodbye. Это ещё не проверка +доступности с iPhone/другого компьютера. Smoke-тест объявляет временный тестовый +сервис и снимает его после проверки; реальное воспроизведение не меняется. + +## Что остаётся на Windows + +- [ ] Проверить сканирование с физического устройства и обнаружение со второго ПК. +- [ ] Проверить смену Wi-Fi/IP, сон/пробуждение, несколько адаптеров и VPN. +- [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу + сертификата, а второй локальный экземпляр блокируется хранилищем. - [ ] Tray, кнопки сопряжения/отзыва, автозапуск и упаковка Windows. - -Не заменять mDNS рассылкой секретов. Объявления сети не являются доказательством -подлинности ПК: проверять сохранённый отпечаток TLS перед отправкой токена. +- [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия + портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353. ## Порядок подключения будущего нативного клиента -1. Считать QR внутри приложения, проверить схему/версию, размер payload, - HTTPS-адрес, отсутствие userinfo/параметров, формат отпечатка/кода и срок. -2. Проверить TLS по отпечатку из QR **до** отправки POST `/v1/pair`. -3. Сохранить токен и отпечаток в Keychain; не сохранять код как постоянный секрет. +1. Считать QR внутри приложения; проверить схему/версию, размер payload, HTTPS-адрес, + формат отпечатка/кода и срок. Не использовать Safari как клиент. +2. Сверить сертификат с отпечатком из QR до POST `/v1/pair`. +3. Сохранить токен и отпечаток в Keychain. Код сопряжения не является постоянным секретом. 4. Получить `/v1/info`, `/v1/state`, открыть WSS `/v1/events`. -5. При переподключении найти адрес по DNS-SD, сопоставить ID, проверить TLS; - при 401 запросить повторное сопряжение, не повторять команды автоматически. +5. При переподключении найти ПК по DNS-SD, сопоставить ID и снова проверить TLS. + При 401 запросить новое сопряжение; не повторять команды автоматически. -Схема `musicbridge://` пока не зарегистрирована на iPhone; Safari ничего делать -с ней не должен. Swift, Xcode-проект, установка и интерфейс iPhone отложены. +Схема `musicbridge://` пока не зарегистрирована на iPhone. Swift/Xcode-проект +и приложение ещё не создавались. + +Реализация использует [QRCoder](https://github.com/Shane32/QRCoder) и +[Makaretu.Dns.Multicast](https://github.com/richardschneider/net-mdns). diff --git a/Network/MdnsPublisher.cs b/Network/MdnsPublisher.cs index 930c0b7..f024137 100644 --- a/Network/MdnsPublisher.cs +++ b/Network/MdnsPublisher.cs @@ -46,7 +46,7 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable } if (DateTimeOffset.UtcNow >= nextAnnouncement) { - var message = new Message { QR = true, AA = true }; + var message = new Makaretu.Dns.Message { QR = true, AA = true }; message.Answers.Add(new PTRRecord { Name = profile!.QualifiedServiceName, DomainName = profile.FullyQualifiedName, TTL = TimeSpan.FromSeconds(120) diff --git a/PLAN.md b/PLAN.md index 6dda1e6..c8f2a6a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -69,10 +69,12 @@ Windows Core Audio → системная громкость устройств - [x] Постоянные доверенные устройства, безопасное хранение хешей токенов, отзыв доступа. - [x] Постоянный сертификат, защищённое хранилище Windows DPAPI, блокировка второго агента. - [ ] Обновление истекающего сертификата с явным повторным сопряжением. -- [ ] QR-сопряжение с адресом и отпечатком сертификата. +- [x] Локальный QR с адресом, кодом и отпечатком; Windows-окно по клавише I. +- [x] Обновление QR при смене адреса/кода; очистка при закрытии сопряжения. - [x] Заготовки: payload для QR, проверка адреса/срока, клавиша I. - [x] Заготовки: DNS-SD descriptor и авторизованный /v1/info; без multicast. -- [ ] Bonjour/mDNS для поиска из нативного приложения. +- [x] Объявление Bonjour/mDNS: PTR/SRV/TXT/A, повторные объявления и goodbye. +- [ ] Полноценное разрешение конфликтов имён mDNS (сейчас имя по хешу сертификата). - [ ] Проверить подключение со второго устройства в домашней сети. - [ ] Проверить смену плеера/аудиоустройства, отсутствие плеера, сон/пробуждение, отключение Wi-Fi и несколько клиентов. @@ -106,6 +108,14 @@ Windows Core Audio → системная громкость устройств 2026-09-09: +- Последний этап — работающие QR и mDNS: **95 проверок прошло**, Release-сборка + без ошибок и предупреждений. Прогон: `dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build -- --qr-window-smoke --mdns-smoke --live-read`. + Проверены декодирование QR независимой библиотекой, скрытое окно и очистка + просроченного QR, реальные объявления/goodbye mDNS отдельным слушателем на + этом же ПК. Снимок окна просмотрен. Реальная обложка: JPEG, 12068 байт. + Второе физическое устройство и сканирование телефоном пока не проверялись. + Далее — исторические результаты предыдущих этапов. + - Последний этап — заготовки подключения: Release-сборка без ошибок/предупреждений, **82 автоматические проверки прошли** (`dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build`). Проверены payload, адреса, истёкший код, descriptor и авторизация /v1/info. @@ -154,11 +164,13 @@ Windows Core Audio → системная громкость устройств При повреждении/недоступности хранилища или истечении сертификата агент завершается с ошибкой и не создаёт молча новую идентичность. Восстановление описано в PROTOCOL.md. - По умолчанию только loopback. `--lan` включает IPv4-сеть; брандмауэр не меняется. -- QR, mDNS, iPhone UI и интеграции iOS ещё не реализованы. +- QR и mDNS реализованы на Windows. iPhone UI и интеграции iOS ещё не реализованы. +- Адреса обновляются примерно раз в секунду; mDNS пересоздаётся при смене списка, + объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет. -Следующий шаг: этап 3 — QR-сопряжение (адрес, код, закрепляемый отпечаток), -затем Bonjour/mDNS и проверка с другого устройства. iPhone-клиент пока не начинать. -Заготовки готовы; точные границы и следующие шаги — в `CONNECTION-ROADMAP.md`. +Следующий шаг: проверка с другого устройства и устойчивости при смене сети, +затем tray/автозапуск/упаковка. iPhone-клиент пока не начинать. +Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`. Начать разработку iOS только после новой команды пользователя. ## Карта файлов @@ -176,6 +188,10 @@ Windows Core Audio → системная громкость устройств - `Network/AgentCertificate.cs`: создание сертификата TLS для первого запуска. - `PROTOCOL.md`: команды запуска и точный контракт будущего iPhone-клиента. - `CONNECTION-ROADMAP.md`: заготовки QR/DNS-SD и шаги будущего подключения. -- `Network/ConnectionInvitation.cs`: данные для QR, без генерации картинки. -- `Network/DiscoveryDescriptor.cs`: описание DNS-SD, без сетевой рассылки. +- `Network/ConnectionInvitation.cs`: проверка и формат данных QR. +- `Network/PairingQr.cs`: локальная генерация PNG (QRCoder). +- `PairingWindowHost.cs`: окно QR на отдельном STA-потоке, выбор адреса и обновления. +- `Network/LocalAddresses.cs`: подходящие домашние IPv4-адреса. +- `Network/DiscoveryDescriptor.cs`: публичные поля DNS-SD. +- `Network/MdnsPublisher.cs`: объявления mDNS, обновления адресов и goodbye. - `tests/`: исполняемые проверки без дополнительных тестовых NuGet-пакетов. diff --git a/PROTOCOL.md b/PROTOCOL.md index 791a854..3c60529 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -91,9 +91,10 @@ Authorization: Bearer ## Состояние -Подготовлен авторизованный `GET /v1/info` для идентификации агента и контракт -данных будущего QR/DNS-SD. Подробности — в [CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). -Клавиша `I` выводит payload; QR-картинка и автоматическое обнаружение пока не реализованы. +Авторизованный `GET /v1/info` идентифицирует агента. Клавиша `I` открывает +Windows-окно с QR; `B` создаёт новый код. При `--lan` агент объявляет сервис +`_musicbridge._tcp.local.` по mDNS. Подробности и ограничения — в +[CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). iOS-клиент пока не реализован. `GET /v1/state` возвращает последний снимок. События смены сессии, метаданных, воспроизведения и позиции Windows запускают обновление общего снимка для всех diff --git a/PairingWindowHost.cs b/PairingWindowHost.cs new file mode 100644 index 0000000..0debfac --- /dev/null +++ b/PairingWindowHost.cs @@ -0,0 +1,131 @@ +using MusicBridge.Agent.Network; + +namespace MusicBridge.Agent; + +internal sealed class PairingWindowHost(PairingService pairing, string fingerprint, Func endpoints, bool visible = true) : IDisposable +{ + private readonly object gate = new(); + private Thread? thread; + private Form? form; + private bool disposed; + public string? Error { get; private set; } + + public void Open() + { + lock (gate) + { + if (disposed) return; + if (thread?.IsAlive == true) + { + if (form is { IsHandleCreated: true } active) + { + try { active.BeginInvoke(() => { active.WindowState = FormWindowState.Normal; active.Activate(); }); } + catch (InvalidOperationException) { } + } + return; + } + thread = new Thread(Run) { IsBackground = true, Name = "MusicBridge QR" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + } + } + + private void Run() + { + try + { + using var window = new Form + { + Text = "MusicBridge — подключение iPhone", ClientSize = new Size(620, 740), + MinimumSize = new Size(440, 560), StartPosition = FormStartPosition.CenterScreen, + AutoScaleMode = AutoScaleMode.Dpi, ShowInTaskbar = visible, Opacity = visible ? 1 : 0 + }; + using var layout = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4, Padding = new Padding(16) }; + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 58)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 40)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 72)); + var title = new Label { Text = "Подключение к компьютеру\nВыбери адрес домашней сети", Dock = DockStyle.Fill }; + var selector = new ComboBox { Dock = DockStyle.Fill, DropDownStyle = ComboBoxStyle.DropDownList }; + var picture = new PictureBox { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Color.White }; + var status = new Label { Dock = DockStyle.Fill, TextAlign = ContentAlignment.MiddleCenter }; + layout.Controls.Add(title); layout.Controls.Add(selector); layout.Controls.Add(picture); layout.Controls.Add(status); + window.Controls.Add(layout); + string? renderedPayload = null; + void ClearImage() + { + var old = picture.Image; + picture.Image = null; + old?.Dispose(); + renderedPayload = null; + } + void RefreshQr() + { + try + { + var choices = endpoints().Select(u => u.GetLeftPart(UriPartial.Authority)).ToArray(); + if (!selector.Items.Cast().SequenceEqual(choices)) + { + var selected = selector.SelectedItem as string; + selector.Items.Clear(); + selector.Items.AddRange(choices); + if (choices.Length > 0) selector.SelectedIndex = Math.Max(0, Array.IndexOf(choices, selected)); + } + var now = DateTimeOffset.UtcNow; + var current = pairing.Current; + if (current is null || selector.SelectedItem is not string address) + { + ClearImage(); + status.Text = current is null ? "Сопряжение закрыто. Нажми B в консоли для нового кода." + : "Нет адреса домашней сети. Запусти агент с --lan."; + return; + } + var invitation = ConnectionInvitation.Create(new Uri(address), fingerprint, current, now); + var payload = invitation.ToQrPayload(); + if (renderedPayload != payload) + { + ClearImage(); + using var stream = new MemoryStream(PairingQr.Render(invitation)); + using var decoded = Image.FromStream(stream); + picture.Image = new Bitmap(decoded); + renderedPayload = payload; + } + status.Text = $"Код: {current.Code} • действует до {current.ExpiresAt.ToLocalTime():HH:mm:ss}\nСканирование предназначено для будущего приложения MusicBridge."; + } + catch (Exception ex) { ClearImage(); status.Text = $"QR недоступен: {ex.Message}"; } + } + using var timer = new System.Windows.Forms.Timer { Interval = 500 }; + timer.Tick += (_, _) => RefreshQr(); + window.Shown += (_, _) => + { + lock (gate) { if (disposed) { window.Close(); return; } } + RefreshQr(); timer.Start(); + }; + window.FormClosed += (_, _) => { timer.Stop(); ClearImage(); }; + lock (gate) + { + if (disposed) return; + form = window; + } + Application.Run(window); + } + catch (Exception ex) { Error = ex.Message; } + finally { lock (gate) form = null; } + } + + public void Dispose() + { + Thread? running; + lock (gate) + { + disposed = true; + running = thread; + if (form is { IsHandleCreated: true } active) + { + try { active.BeginInvoke(() => active.Close()); } + catch (InvalidOperationException) { } + } + } + running?.Join(TimeSpan.FromSeconds(2)); + } +} diff --git a/Program.cs b/Program.cs index 7fa159a..7556cd5 100644 --- a/Program.cs +++ b/Program.cs @@ -2,6 +2,7 @@ using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; +using MusicBridge.Agent; using MusicBridge.Agent.Media; using MusicBridge.Agent.Network; using Windows.Media.Control; @@ -39,16 +40,9 @@ try using var hostStopping = server.Stopping.Register(() => shutdown.Cancel()); var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); var addresses = new List { $"https://127.0.0.1:{port}" }; - if (lan) - { - try - { - addresses.AddRange(Dns.GetHostAddresses(Dns.GetHostName()) - .Where(ip => ip.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip)) - .Select(ip => $"https://{ip}:{port}")); - } - catch (SocketException) { } - } + Uri[] qrEndpoints = []; + using var mdns = new MdnsPublisher(fingerprint, port); + using var qrWindow = new PairingWindowHost(pairing, fingerprint, () => Volatile.Read(ref qrEndpoints)); var message = "Готов к управлению."; IReadOnlyList displayedDevices = []; var nextRefresh = DateTimeOffset.MinValue; @@ -73,13 +67,8 @@ try } else if (key == ConsoleKey.I) { - var invitationTime = DateTimeOffset.UtcNow; - var window = pairing.Current; - var endpoints = addresses.Select(a => new Uri(a)).Where(ConnectionInvitation.IsLocalEndpoint).ToArray(); - message = window is null ? "Сначала нажми B, чтобы открыть сопряжение." - : endpoints.Length == 0 ? "Для данных подключения запусти агент с --lan в домашней сети." - : string.Join(Environment.NewLine, endpoints.Select(endpoint => - $"Данные для будущего QR ({endpoint.Host}): {ConnectionInvitation.Create(endpoint, fingerprint, window, invitationTime).ToQrPayload()}")); + qrWindow.Open(); + message = "Окно QR открыто. Для нового кода нажми B."; } else if (key == ConsoleKey.B) { @@ -107,6 +96,23 @@ try } if (DateTimeOffset.UtcNow >= nextRefresh) { + if (lan) + { + try + { + var ips = LocalAddresses.Read(); + Volatile.Write(ref qrEndpoints, ips.Select(ip => new Uri($"https://{ip}:{port}")).ToArray()); + addresses = new List { $"https://127.0.0.1:{port}" }; + addresses.AddRange(qrEndpoints.Select(u => u.GetLeftPart(UriPartial.Authority))); + mdns.Refresh(ips); + } + catch (Exception ex) + { + Volatile.Write(ref qrEndpoints, []); + mdns.Refresh([]); + message = $"Не удалось обновить адреса: {ex.Message}"; + } + } if (!Console.IsOutputRedirected) Console.Clear(); var state = server.Latest; Console.WriteLine("MUSICBRIDGE\n"); @@ -126,6 +132,8 @@ try else Console.WriteLine($"Громкость недоступна: {state.VolumeError}"); Console.WriteLine($"\nПодключение: {string.Join(" | ", addresses)}"); Console.WriteLine(lan ? "Режим локальной сети (IPv4)." : "Режим только этого ПК; для Wi-Fi запусти с --lan."); + if (lan) Console.WriteLine(mdns.Status); + if (qrWindow.Error is { } qrError) Console.WriteLine($"Окно QR: {qrError}"); Console.WriteLine($"SHA-256 сертификата: {fingerprint}"); var window = pairing.Current; Console.WriteLine(window is null ? "Сопряжение закрыто. B — открыть заново." @@ -136,7 +144,7 @@ try Console.WriteLine($" {i + 1}. {displayedDevices[i].Name}"); Console.WriteLine("\nПробел: play/pause | N/P: следующий/предыдущий трек"); Console.WriteLine("←/→: ±10 с | ↑/↓: громкость ±5% | M: звук | B: сопряжение"); - Console.WriteLine("I: данные для будущего QR | Выход: Q или Ctrl+C"); + Console.WriteLine("I: открыть QR-код | Выход: Q или Ctrl+C"); Console.WriteLine($"\n{message}"); nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1); } diff --git a/tests/Program.cs b/tests/Program.cs index a8379f1..6f5e051 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -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().Single().Port == 18765, "DNS-SD advertises actual HTTPS port"); +Check(serviceProfile.Resources.OfType().Single().Address.Equals(IPAddress.Parse("192.168.1.10")), "DNS-SD contains selected LAN address"); +Check(serviceProfile.Resources.OfType().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().Single() + .Controls.OfType().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(TaskCreationOptions.RunContinuationsAsynchronously); + var gone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + listener.AnswerReceived += (_, e) => + { + foreach (var ptr in e.Message.Answers.OfType().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."); }