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
+53 -31
View File
@@ -1,41 +1,63 @@
# Заготовки подключения — iOS не начинать до команды пользователя # Подключение MusicBridge — Windows
## Уже есть Разработка iOS начинается только после отдельной команды пользователя.
- `ConnectionInvitation`: проверяет домашний IPv4 HTTPS-адрес, код/срок и SHA-256 ## Что работает
сертификата; создаёт строку `musicbridge://pair?data=<base64url UTF-8 JSON>`.
- Клавиша `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=<base64url UTF-8 JSON>`, поля `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; отображение в окне агента/трее. Без `--lan` mDNS выключен, а окно QR сообщает, что нужен адрес домашней сети.
- [ ] Закрывать/обновлять показанный QR при истечении кода, сопряжении или смене IP. Поиск не доказывает подлинность ПК: клиент всегда сверяет закреплённый TLS-сертификат.
- [ ] Выбор адаптера при нескольких адресах; сейчас для каждого свой payload.
- [ ] mDNS/DNS-SD publisher: PTR/SRV/TXT/A, порт HTTPS агента, без кодов и токенов. ## Что проверено
- [ ] Обработка конфликтов имён, смены адресов, сна/пробуждения и остановки сервиса.
- [ ] Проверка объявления со второго устройства. Сейчас multicast не отправляется. 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. - [ ] Tray, кнопки сопряжения/отзыва, автозапуск и упаковка Windows.
- [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия
Не заменять mDNS рассылкой секретов. Объявления сети не являются доказательством портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353.
подлинности ПК: проверять сохранённый отпечаток TLS перед отправкой токена.
## Порядок подключения будущего нативного клиента ## Порядок подключения будущего нативного клиента
1. Считать QR внутри приложения, проверить схему/версию, размер payload, 1. Считать QR внутри приложения; проверить схему/версию, размер payload, HTTPS-адрес,
HTTPS-адрес, отсутствие userinfo/параметров, формат отпечатка/кода и срок. формат отпечатка/кода и срок. Не использовать Safari как клиент.
2. Проверить TLS по отпечатку из QR **до** отправки POST `/v1/pair`. 2. Сверить сертификат с отпечатком из QR до POST `/v1/pair`.
3. Сохранить токен и отпечаток в Keychain; не сохранять код как постоянный секрет. 3. Сохранить токен и отпечаток в Keychain. Код сопряжения не является постоянным секретом.
4. Получить `/v1/info`, `/v1/state`, открыть WSS `/v1/events`. 4. Получить `/v1/info`, `/v1/state`, открыть WSS `/v1/events`.
5. При переподключении найти адрес по DNS-SD, сопоставить ID, проверить TLS; 5. При переподключении найти ПК по DNS-SD, сопоставить ID и снова проверить TLS.
при 401 запросить повторное сопряжение, не повторять команды автоматически. При 401 запросить новое сопряжение; не повторять команды автоматически.
Схема `musicbridge://` пока не зарегистрирована на iPhone; Safari ничего делать Схема `musicbridge://` пока не зарегистрирована на iPhone. Swift/Xcode-проект
с ней не должен. Swift, Xcode-проект, установка и интерфейс iPhone отложены. и приложение ещё не создавались.
Реализация использует [QRCoder](https://github.com/Shane32/QRCoder) и
[Makaretu.Dns.Multicast](https://github.com/richardschneider/net-mdns).
+1 -1
View File
@@ -46,7 +46,7 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable
} }
if (DateTimeOffset.UtcNow >= nextAnnouncement) 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 message.Answers.Add(new PTRRecord
{ {
Name = profile!.QualifiedServiceName, DomainName = profile.FullyQualifiedName, TTL = TimeSpan.FromSeconds(120) Name = profile!.QualifiedServiceName, DomainName = profile.FullyQualifiedName, TTL = TimeSpan.FromSeconds(120)
+24 -8
View File
@@ -69,10 +69,12 @@ Windows Core Audio → системная громкость устройств
- [x] Постоянные доверенные устройства, безопасное хранение хешей токенов, отзыв доступа. - [x] Постоянные доверенные устройства, безопасное хранение хешей токенов, отзыв доступа.
- [x] Постоянный сертификат, защищённое хранилище Windows DPAPI, блокировка второго агента. - [x] Постоянный сертификат, защищённое хранилище Windows DPAPI, блокировка второго агента.
- [ ] Обновление истекающего сертификата с явным повторным сопряжением. - [ ] Обновление истекающего сертификата с явным повторным сопряжением.
- [ ] QR-сопряжение с адресом и отпечатком сертификата. - [x] Локальный QR с адресом, кодом и отпечатком; Windows-окно по клавише I.
- [x] Обновление QR при смене адреса/кода; очистка при закрытии сопряжения.
- [x] Заготовки: payload для QR, проверка адреса/срока, клавиша I. - [x] Заготовки: payload для QR, проверка адреса/срока, клавиша I.
- [x] Заготовки: DNS-SD descriptor и авторизованный /v1/info; без multicast. - [x] Заготовки: DNS-SD descriptor и авторизованный /v1/info; без multicast.
- [ ] Bonjour/mDNS для поиска из нативного приложения. - [x] Объявление Bonjour/mDNS: PTR/SRV/TXT/A, повторные объявления и goodbye.
- [ ] Полноценное разрешение конфликтов имён mDNS (сейчас имя по хешу сертификата).
- [ ] Проверить подключение со второго устройства в домашней сети. - [ ] Проверить подключение со второго устройства в домашней сети.
- [ ] Проверить смену плеера/аудиоустройства, отсутствие плеера, сон/пробуждение, - [ ] Проверить смену плеера/аудиоустройства, отсутствие плеера, сон/пробуждение,
отключение Wi-Fi и несколько клиентов. отключение Wi-Fi и несколько клиентов.
@@ -106,6 +108,14 @@ Windows Core Audio → системная громкость устройств
2026-09-09: 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-сборка без ошибок/предупреждений, - Последний этап — заготовки подключения: Release-сборка без ошибок/предупреждений,
**82 автоматические проверки прошли** (`dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build`). **82 автоматические проверки прошли** (`dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build`).
Проверены payload, адреса, истёкший код, descriptor и авторизация /v1/info. Проверены payload, адреса, истёкший код, descriptor и авторизация /v1/info.
@@ -154,11 +164,13 @@ Windows Core Audio → системная громкость устройств
При повреждении/недоступности хранилища или истечении сертификата агент завершается При повреждении/недоступности хранилища или истечении сертификата агент завершается
с ошибкой и не создаёт молча новую идентичность. Восстановление описано в PROTOCOL.md. с ошибкой и не создаёт молча новую идентичность. Восстановление описано в PROTOCOL.md.
- По умолчанию только loopback. `--lan` включает IPv4-сеть; брандмауэр не меняется. - По умолчанию только loopback. `--lan` включает IPv4-сеть; брандмауэр не меняется.
- QR, mDNS, iPhone UI и интеграции iOS ещё не реализованы. - QR и mDNS реализованы на Windows. iPhone UI и интеграции iOS ещё не реализованы.
- Адреса обновляются примерно раз в секунду; mDNS пересоздаётся при смене списка,
объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет.
Следующий шаг: этап 3 — QR-сопряжение (адрес, код, закрепляемый отпечаток), Следующий шаг: проверка с другого устройства и устойчивости при смене сети,
затем Bonjour/mDNS и проверка с другого устройства. iPhone-клиент пока не начинать. затем tray/автозапуск/упаковка. iPhone-клиент пока не начинать.
Заготовки готовы; точные границы и следующие шаги — в `CONNECTION-ROADMAP.md`. Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`.
Начать разработку iOS только после новой команды пользователя. Начать разработку iOS только после новой команды пользователя.
## Карта файлов ## Карта файлов
@@ -176,6 +188,10 @@ Windows Core Audio → системная громкость устройств
- `Network/AgentCertificate.cs`: создание сертификата TLS для первого запуска. - `Network/AgentCertificate.cs`: создание сертификата TLS для первого запуска.
- `PROTOCOL.md`: команды запуска и точный контракт будущего iPhone-клиента. - `PROTOCOL.md`: команды запуска и точный контракт будущего iPhone-клиента.
- `CONNECTION-ROADMAP.md`: заготовки QR/DNS-SD и шаги будущего подключения. - `CONNECTION-ROADMAP.md`: заготовки QR/DNS-SD и шаги будущего подключения.
- `Network/ConnectionInvitation.cs`: данные для QR, без генерации картинки. - `Network/ConnectionInvitation.cs`: проверка и формат данных QR.
- `Network/DiscoveryDescriptor.cs`: описание DNS-SD, без сетевой рассылки. - `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-пакетов. - `tests/`: исполняемые проверки без дополнительных тестовых NuGet-пакетов.
+4 -3
View File
@@ -91,9 +91,10 @@ Authorization: Bearer <token>
## Состояние ## Состояние
Подготовлен авторизованный `GET /v1/info` для идентификации агента и контракт Авторизованный `GET /v1/info` идентифицирует агента. Клавиша `I` открывает
данных будущего QR/DNS-SD. Подробности — в [CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). Windows-окно с QR; `B` создаёт новый код. При `--lan` агент объявляет сервис
Клавиша `I` выводит payload; QR-картинка и автоматическое обнаружение пока не реализованы. `_musicbridge._tcp.local.` по mDNS. Подробности и ограничения — в
[CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). iOS-клиент пока не реализован.
`GET /v1/state` возвращает последний снимок. События смены сессии, метаданных, `GET /v1/state` возвращает последний снимок. События смены сессии, метаданных,
воспроизведения и позиции Windows запускают обновление общего снимка для всех воспроизведения и позиции Windows запускают обновление общего снимка для всех
+131
View File
@@ -0,0 +1,131 @@
using MusicBridge.Agent.Network;
namespace MusicBridge.Agent;
internal sealed class PairingWindowHost(PairingService pairing, string fingerprint, Func<Uri[]> 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<string>().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));
}
}
+26 -18
View File
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using MusicBridge.Agent;
using MusicBridge.Agent.Media; using MusicBridge.Agent.Media;
using MusicBridge.Agent.Network; using MusicBridge.Agent.Network;
using Windows.Media.Control; using Windows.Media.Control;
@@ -39,16 +40,9 @@ try
using var hostStopping = server.Stopping.Register(() => shutdown.Cancel()); using var hostStopping = server.Stopping.Register(() => shutdown.Cancel());
var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256); var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
var addresses = new List<string> { $"https://127.0.0.1:{port}" }; var addresses = new List<string> { $"https://127.0.0.1:{port}" };
if (lan) Uri[] qrEndpoints = [];
{ using var mdns = new MdnsPublisher(fingerprint, port);
try using var qrWindow = new PairingWindowHost(pairing, fingerprint, () => Volatile.Read(ref qrEndpoints));
{
addresses.AddRange(Dns.GetHostAddresses(Dns.GetHostName())
.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(ip))
.Select(ip => $"https://{ip}:{port}"));
}
catch (SocketException) { }
}
var message = "Готов к управлению."; var message = "Готов к управлению.";
IReadOnlyList<TrustedDevice> displayedDevices = []; IReadOnlyList<TrustedDevice> displayedDevices = [];
var nextRefresh = DateTimeOffset.MinValue; var nextRefresh = DateTimeOffset.MinValue;
@@ -73,13 +67,8 @@ try
} }
else if (key == ConsoleKey.I) else if (key == ConsoleKey.I)
{ {
var invitationTime = DateTimeOffset.UtcNow; qrWindow.Open();
var window = pairing.Current; message = "Окно QR открыто. Для нового кода нажми B.";
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()}"));
} }
else if (key == ConsoleKey.B) else if (key == ConsoleKey.B)
{ {
@@ -107,6 +96,23 @@ try
} }
if (DateTimeOffset.UtcNow >= nextRefresh) 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<string> { $"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(); if (!Console.IsOutputRedirected) Console.Clear();
var state = server.Latest; var state = server.Latest;
Console.WriteLine("MUSICBRIDGE\n"); Console.WriteLine("MUSICBRIDGE\n");
@@ -126,6 +132,8 @@ try
else Console.WriteLine($"Громкость недоступна: {state.VolumeError}"); else Console.WriteLine($"Громкость недоступна: {state.VolumeError}");
Console.WriteLine($"\nПодключение: {string.Join(" | ", addresses)}"); Console.WriteLine($"\nПодключение: {string.Join(" | ", addresses)}");
Console.WriteLine(lan ? "Режим локальной сети (IPv4)." : "Режим только этого ПК; для Wi-Fi запусти с --lan."); 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}"); Console.WriteLine($"SHA-256 сертификата: {fingerprint}");
var window = pairing.Current; var window = pairing.Current;
Console.WriteLine(window is null ? "Сопряжение закрыто. B — открыть заново." Console.WriteLine(window is null ? "Сопряжение закрыто. B — открыть заново."
@@ -136,7 +144,7 @@ try
Console.WriteLine($" {i + 1}. {displayedDevices[i].Name}"); Console.WriteLine($" {i + 1}. {displayedDevices[i].Name}");
Console.WriteLine("\nПробел: play/pause | N/P: следующий/предыдущий трек"); Console.WriteLine("\nПробел: play/pause | N/P: следующий/предыдущий трек");
Console.WriteLine("←/→: ±10 с | ↑/↓: громкость ±5% | M: звук | B: сопряжение"); Console.WriteLine("←/→: ±10 с | ↑/↓: громкость ±5% | M: звук | B: сопряжение");
Console.WriteLine("I: данные для будущего QR | Выход: Q или Ctrl+C"); Console.WriteLine("I: открыть QR-код | Выход: Q или Ctrl+C");
Console.WriteLine($"\n{message}"); Console.WriteLine($"\n{message}");
nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1); nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1);
} }
+82
View File
@@ -44,6 +44,31 @@ catch (ArgumentException) { expiredInvitationRejected = true; }
Check(expiredInvitationRejected, "expired pairing window cannot generate invitation"); Check(expiredInvitationRejected, "expired pairing window cannot generate invitation");
var descriptor = DiscoveryDescriptor.Create(new string('a', 64)); 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"); 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, 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(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"); 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"); 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."); Console.WriteLine($"All {checks} checks passed. No real playback or volume was changed.");
} }