277 lines
19 KiB
Markdown
277 lines
19 KiB
Markdown
|
|
# MusicBridge protocol v1
|
|||
|
|
|
|||
|
|
Транспорт для будущего **нативного iPhone-клиента**. HTML, Safari/PWA и веб-клиента нет.
|
|||
|
|
|
|||
|
|
## Запуск
|
|||
|
|
|
|||
|
|
### Окно управления и трей
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
dotnet run -c Release -- --tray --lan
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`--tray` открывает Windows-окно и значок в области уведомлений. Крестик скрывает
|
|||
|
|
окно; двойной щелчок по значку снова его показывает. Для остановки выбрать
|
|||
|
|
«Завершить MusicBridge» в меню значка. При старте агент отсоединяется от консоли,
|
|||
|
|
не закрывая терминал пользователя. Без `--tray` сохраняется диагностическая консоль.
|
|||
|
|
|
|||
|
|
Окно показывает трек/обложку, позицию и системную громкость. Поддерживает
|
|||
|
|
play/pause, следующий/предыдущий трек, ползунки позиции/громкости, mute,
|
|||
|
|
новое сопряжение, QR и отзыв доступа выбранного устройства. Недоступные команды
|
|||
|
|
отключены. Без `--lan` агент остаётся доступен только с самого ПК.
|
|||
|
|
|
|||
|
|
Чтобы пользоваться уже собранной версией, можно запускать
|
|||
|
|
`bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`.
|
|||
|
|
Пока это framework-dependent сборка: необходим .NET 10 Desktop Runtime и
|
|||
|
|
ASP.NET Core Runtime. Для установки без отдельных Runtime подготовлен
|
|||
|
|
`dist/MusicBridge-win-x64.zip`; см. [INSTALL.md](INSTALL.md).
|
|||
|
|
|
|||
|
|
Меню трея «Запускать вместе с Windows» включает/выключает автозапуск текущего
|
|||
|
|
пользователя. Сохраняются текущий путь, порт и режим сети. `--minimized` включает
|
|||
|
|
трей и скрывает главное окно при старте. Автозапуск по умолчанию выключен;
|
|||
|
|
лучше включать его после установки в постоянную папку.
|
|||
|
|
|
|||
|
|
Из каталога репозитория:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
dotnet run
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
По умолчанию агент принимает соединения только на `https://127.0.0.1:8765`.
|
|||
|
|
Для локальной сети:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
dotnet run -- --lan
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Другой порт: `dotnet run -- --lan --port 8766`.
|
|||
|
|
Консоль показывает IPv4-адреса ПК. При нескольких адаптерах нужен адрес домашней сети.
|
|||
|
|
Автоматического изменения брандмауэра нет. Если Windows запросит доступ к сети,
|
|||
|
|
для домашнего использования разрешать только частную сеть. Порт на роутере не открывать.
|
|||
|
|
|
|||
|
|
## TLS и сопряжение
|
|||
|
|
|
|||
|
|
- Только HTTPS/WSS. HTTP не поддерживается.
|
|||
|
|
- Агент создаёт самоподписанный сертификат при первом запуске и показывает SHA-256
|
|||
|
|
отпечаток всего DER-сертификата: 64 шестнадцатеричных символа.
|
|||
|
|
- Будущий нативный клиент должен сверять этот отпечаток **до отправки кода**,
|
|||
|
|
затем проверять его на каждом соединении. Пока отпечаток сверяют по экрану ПК;
|
|||
|
|
в следующем этапе он войдёт в QR. Отключение проверки всех сертификатов недопустимо.
|
|||
|
|
- Сертификат и доверенные устройства сохраняются между запусками. Новый код
|
|||
|
|
после обычного перезапуска не нужен: клиент использует сохранённый токен и отпечаток.
|
|||
|
|
- Восьмизначный код действует 5 минут и принимается один раз. Десять неудачных
|
|||
|
|
попыток закрывают окно сопряжения. Клавиша `B` на ПК открывает новое окно.
|
|||
|
|
- Максимум 8 доверенных устройств. В консоли показан нумерованный список;
|
|||
|
|
клавиши `1`…`8` отзывают доступ у соответствующего устройства. Его открытый WSS
|
|||
|
|
отключается, новые запросы получают 401. Уже принятые команды не отменяются.
|
|||
|
|
- Если устройства уже сохранены, при запуске окно сопряжения закрыто.
|
|||
|
|
Для нового телефона нажать `B`. При пустом списке первое окно открывается автоматически.
|
|||
|
|
- Bearer-токен не передавать в URL, не записывать в логи. В будущем хранить в Keychain.
|
|||
|
|
|
|||
|
|
Сопряжение:
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
POST /v1/pair
|
|||
|
|
Content-Type: application/json
|
|||
|
|
|
|||
|
|
{"code":"<код с экрана ПК>","deviceName":"Мой iPhone"}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
`deviceName` необязателен (по умолчанию `iPhone`), до 64 символов, без управляющих
|
|||
|
|
символов и не только пробелы. Название служит подписью в локальном списке,
|
|||
|
|
а не доказательством личности телефона.
|
|||
|
|
|
|||
|
|
Ответ 200: `{"success":true,"token":"<64 hex characters>","code":"ok","deviceId":"<id>"}`.
|
|||
|
|
Отказ 403: `success:false`, `token:null`, `code` равен `invalid_code`,
|
|||
|
|
`pairing_closed`, `device_limit` или `invalid_name`.
|
|||
|
|
Если сохранить устройство не удалось: HTTP 503, `code:"storage_failed"`,
|
|||
|
|
токен не выдаётся и устройство не получает доступа.
|
|||
|
|
|
|||
|
|
Все следующие запросы, включая WebSocket handshake, требуют заголовка:
|
|||
|
|
|
|||
|
|
```http
|
|||
|
|
Authorization: Bearer <token>
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
## Хранение и восстановление
|
|||
|
|
|
|||
|
|
Файл `%LOCALAPPDATA%\MusicBridge\identity.dat` содержит сертификат с закрытым
|
|||
|
|
ключом, ID/имена/даты сопряжения устройств и SHA-256 хеши токенов. Весь файл
|
|||
|
|
защищён Windows DPAPI текущего пользователя. Сами bearer-токены не сохраняются
|
|||
|
|
на ПК. DPAPI не защищает от программ, уже работающих от имени этого пользователя.
|
|||
|
|
|
|||
|
|
Запись выполняется через защищённый временный файл и замену основного файла.
|
|||
|
|
Ошибка записи не изменяет активное доверие. `identity.lock` блокирует одновременный
|
|||
|
|
запуск двух агентов с одним хранилищем; после закрытия агента блокировка снимается.
|
|||
|
|
Наличие самого файла блокировки после выхода нормально.
|
|||
|
|
|
|||
|
|
Повреждённое, недоступное или неизвестное по версии хранилище не заменяется
|
|||
|
|
автоматически: агент завершится с ошибкой в консоли. Сертификат выдан на 5 лет;
|
|||
|
|
истёкший сертификат также не заменяется молча, чтобы не нарушить закреплённый отпечаток.
|
|||
|
|
Автоматическое обновление сертификата — будущая задача.
|
|||
|
|
|
|||
|
|
Для осознанного полного сброса: остановить агент, сохранить резервную копию и
|
|||
|
|
переименовать `identity.dat`, затем запустить агент и заново сопрячь телефоны
|
|||
|
|
с новым отпечатком. Обычный перезапуск **не отзывает** доступ.
|
|||
|
|
Не переносить этот файл на другой компьютер в расчёте на сохранение подключения:
|
|||
|
|
он привязан к защите Windows текущего пользователя.
|
|||
|
|
|
|||
|
|
## Состояние
|
|||
|
|
|
|||
|
|
Авторизованный `GET /v1/info` идентифицирует агента. Клавиша `I` открывает
|
|||
|
|
Windows-окно с QR; `B` создаёт новый код. При `--lan` агент объявляет сервис
|
|||
|
|
`_musicbridge._tcp.local.` по mDNS. Подробности и ограничения — в
|
|||
|
|
[CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md). iOS-клиент пока не реализован.
|
|||
|
|
|
|||
|
|
`GET /v1/state` возвращает последний снимок. События смены сессии, метаданных,
|
|||
|
|
воспроизведения и позиции Windows запускают обновление общего снимка для всех
|
|||
|
|
клиентов. События объединяются в течение 25 мс. Резервный таймер примерно раз
|
|||
|
|
в 500 мс поддерживает прогресс и чтение системной громкости.
|
|||
|
|
Метаданные и обложка кешируются: перечитываются по событию либо раз в 10 секунд
|
|||
|
|
на случай пропущенного уведомления. При смене сессии подписки переключаются.
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{
|
|||
|
|
"protocolVersion": 1,
|
|||
|
|
"timestamp": "2026-09-09T00:00:00+00:00",
|
|||
|
|
"hasSession": true,
|
|||
|
|
"title": "Название",
|
|||
|
|
"artist": "Исполнитель",
|
|||
|
|
"source": "идентификатор приложения",
|
|||
|
|
"playbackStatus": "Playing",
|
|||
|
|
"positionSeconds": 42.5,
|
|||
|
|
"startSeconds": 0,
|
|||
|
|
"endSeconds": 180,
|
|||
|
|
"minSeekSeconds": 0,
|
|||
|
|
"maxSeekSeconds": 180,
|
|||
|
|
"playbackRate": 1,
|
|||
|
|
"capabilities": {
|
|||
|
|
"play": false, "pause": true, "toggle": true,
|
|||
|
|
"next": true, "previous": true, "seek": true
|
|||
|
|
},
|
|||
|
|
"volume": 0.5,
|
|||
|
|
"muted": false,
|
|||
|
|
"mediaError": null,
|
|||
|
|
"volumeError": null,
|
|||
|
|
"artworkId": "<SHA-256 обложки или null>",
|
|||
|
|
"artworkError": null
|
|||
|
|
}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Время — секунды от начала таймлайна плеера, включая дробную часть.
|
|||
|
|
Длительность — `endSeconds - startSeconds`, когда конец известен.
|
|||
|
|
`positionSeconds` уже учитывает время с последнего обновления Windows.
|
|||
|
|
Будущий клиент может плавно продолжать позицию между снимками при `Playing`,
|
|||
|
|
учитывая скорость; каждый следующий снимок корректирует локальный расчёт.
|
|||
|
|
|
|||
|
|
При отсутствии сессии `hasSession:false`, строки пустые, возможности выключены.
|
|||
|
|
Громкость доступна независимо от медиасессии; при ошибке аудиоустройства
|
|||
|
|
`volume` и `muted` равны `null`, а `volumeError` содержит причину.
|
|||
|
|
При ошибке чтения медиасессии заполняется `mediaError`.
|
|||
|
|
|
|||
|
|
## Обложка
|
|||
|
|
|
|||
|
|
`GET /v1/artwork/{artworkId}` требует того же Bearer-токена и возвращает бинарное
|
|||
|
|
изображение с Content-Type. В JSON и WebSocket передаётся только идентификатор,
|
|||
|
|
поэтому изображение не пересылается при каждом обновлении прогресса.
|
|||
|
|
|
|||
|
|
- `artworkId` — SHA-256 содержимого, 64 hex-символа; `null`, если обложки нет.
|
|||
|
|
- Клиент загружает обложку при изменении ID и может хранить её в своём кеше.
|
|||
|
|
- В агенте хранится только текущая обложка, не более 2 МиБ. Чтение ограничено
|
|||
|
|
двумя секундами; ошибка обложки не скрывает название и состояние трека.
|
|||
|
|
- Поддерживаются PNG, JPEG, GIF и WebP; тип определяется по сигнатуре. Сервер
|
|||
|
|
не декодирует изображение: клиенту нужно обрабатывать ошибки декодирования.
|
|||
|
|
- При смене трека, отсутствии или ошибке обложки предыдущая удаляется из кеша.
|
|||
|
|
Неизвестный/устаревший ID возвращает 404. При гонке со сменой трека клиент
|
|||
|
|
перечитывает состояние; при `artworkId:null` показывает заглушку.
|
|||
|
|
- `artworkError` содержит причину ошибки чтения; без ошибки — `null`.
|
|||
|
|
|
|||
|
|
## Команды
|
|||
|
|
|
|||
|
|
`POST /v1/command`, `Content-Type: application/json`:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"id":"seek-1","type":"seek","value":90.5}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
| type | value | Действие |
|
|||
|
|
|---|---|---|
|
|||
|
|
| play / pause | отсутствует | Начать / приостановить воспроизведение |
|
|||
|
|
| toggle | отсутствует | Play/pause |
|
|||
|
|
| next / previous | отсутствует | Следующий / предыдущий трек |
|
|||
|
|
| seek | секунды ≥ 0 | Абсолютная позиция, ограниченная диапазоном плеера |
|
|||
|
|
| seekRelative | секунды от −3600 до 3600 | Сдвиг от текущей вычисленной позиции |
|
|||
|
|
| volume | число 0…1 | Абсолютная системная громкость |
|
|||
|
|
| volumeRelative | число −1…1 | Сдвиг громкости с ограничением 0…1 |
|
|||
|
|
| toggleMute | отсутствует | Переключить mute основного устройства вывода |
|
|||
|
|
|
|||
|
|
Изменение громкости сохраняет текущее состояние mute.
|
|||
|
|
Результат: `{"success":true,"code":"ok","message":"…","id":"seek-1"}`.
|
|||
|
|
У медиакоманд успех означает принятие команды плеером; фактическое состояние
|
|||
|
|
подтверждается последующим снимком. Отказ не должен оптимистично менять UI.
|
|||
|
|
|
|||
|
|
`id` необязателен, максимум 64 символа. Он связывает ответ с запросом,
|
|||
|
|
**не является ключом идемпотентности**. Не повторять автоматически next, toggle
|
|||
|
|
или относительные команды после потери ответа. Сначала перечитать состояние.
|
|||
|
|
|
|||
|
|
Ошибки команды: `invalid_command`, `no_session`, `unsupported`, `rejected`,
|
|||
|
|
`operation_failed`. Некорректная команда/JSON — HTTP 400, тело больше 4096 байт —
|
|||
|
|
413, отсутствие/неверный токен — 401. Отказ плеера возвращается HTTP 200 с
|
|||
|
|
`success:false`, чтобы отличать транспорт от выполнения команды.
|
|||
|
|
Общий лимит HTTP-запросов: 60 в секунду, сверх лимита — 429.
|
|||
|
|
|
|||
|
|
## WebSocket
|
|||
|
|
|
|||
|
|
`wss://<адрес>:<порт>/v1/events`, с тем же Authorization.
|
|||
|
|
Сразу после подключения, по событиям Windows и при резервном обновлении
|
|||
|
|
примерно каждые 500 мс сервер присылает текстовый JSON:
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{"type":"state","state":{"protocolVersion":1,"…":"поля снимка выше"}}
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Поток только для состояния. Команды отправляются через HTTPS; каждый запрос
|
|||
|
|
имеет отдельный ответ. Передача клиентского payload в поток закрывает соединение.
|
|||
|
|
Поддерживается до 8 одновременных WebSocket. После потери соединения клиент
|
|||
|
|
переподключается с задержкой и получает полный снимок. Медленный клиент получает
|
|||
|
|
последнее состояние; промежуточные снимки не накапливаются в неограниченной очереди.
|
|||
|
|
Отправка снимка ограничена 5 секундами. Keepalive отправляется через 20 секунд,
|
|||
|
|
ожидание ответа ограничено 10 секундами; оборванные соединения освобождают слот.
|
|||
|
|
При ошибке чтения медиасессии сервер публикует пустое состояние с `mediaError`
|
|||
|
|
и продолжает опрос. После восстановления очередной снимок содержит свежие данные.
|
|||
|
|
На чтение передаётся отмена через 5 секунд.
|
|||
|
|
Запросы с Origin
|
|||
|
|
отклоняются: браузерные клиенты в проекте не поддерживаются.
|
|||
|
|
|
|||
|
|
## Проверка
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
dotnet build
|
|||
|
|
dotnet run --project tests/MusicBridge.Agent.Tests.csproj
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Проверки используют тестовый медиасервис и настоящий локальный HTTPS/WSS с
|
|||
|
|
проверкой отпечатка. Музыку и громкость пользователя они не меняют.
|
|||
|
|
|
|||
|
|
Дополнительно прочитать реальную медиасессию и обложку Windows:
|
|||
|
|
|
|||
|
|
```powershell
|
|||
|
|
dotnet run --project tests/MusicBridge.Agent.Tests.csproj -- --live-read
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Этот режим также не отправляет команд плееру. При отсутствии обложки он сообщает
|
|||
|
|
об этом, а проверки её содержимого пропускает.
|
|||
|
|
|
|||
|
|
Проверки постоянного доверия используют отдельный временный каталог и удаляют
|
|||
|
|
его после выполнения. Рабочее хранилище пользователя не затрагивается.
|
|||
|
|
Ошибки тестов выводятся как `TEST FAILED` с кодом завершения 1. Диагностический
|
|||
|
|
аргумент `--test-failure` проверяет этот путь контролируемым исключением.
|
|||
|
|
|
|||
|
|
Реализация транспорта опирается на документацию Microsoft:
|
|||
|
|
[Kestrel HTTPS](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints?view=aspnetcore-10.0)
|
|||
|
|
и [WebSocket](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/websockets?view=aspnetcore-10.0).
|
|||
|
|
Обновления таймлайна используют
|
|||
|
|
[событие Windows TimelinePropertiesChanged](https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession.timelinepropertieschanged).
|
|||
|
|
Защита хранилища использует
|
|||
|
|
[Windows CryptProtectData](https://learn.microsoft.com/en-us/windows/win32/api/dpapi/nf-dpapi-cryptprotectdata).
|