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; отображение в окне агента/трее.
|
Без `--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).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 запускают обновление общего снимка для всех
|
||||||
|
|||||||
@@ -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.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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user