Implement QR pairing window and mDNS announcements; update documentation and tests
This commit is contained in:
+53
-31
@@ -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; отображение в окне агента/трее.
|
||||
- [ ] Закрывать/обновлять показанный 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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-пакетов.
|
||||
|
||||
+4
-3
@@ -91,9 +91,10 @@ Authorization: Bearer <token>
|
||||
|
||||
## Состояние
|
||||
|
||||
Подготовлен авторизованный `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 запускают обновление общего снимка для всех
|
||||
|
||||
@@ -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
@@ -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<string> { $"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<TrustedDevice> 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<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();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user