Add MusicBridge protocol documentation and implement system volume control
- Created PROTOCOL.md to outline the MusicBridge protocol v1, detailing setup, TLS, pairing, state management, and command structure. - Implemented SystemVolume class for managing system audio levels, including methods for setting, reading, changing, and muting volume. - Added unit tests for the MusicBridge agent, covering pairing, identity storage, command validation, and media state management. - Included tests for real-time media reading from Windows and ensured proper handling of artwork caching and retrieval.
This commit is contained in:
+2
-2
@@ -1,2 +1,2 @@
|
||||
bin/*
|
||||
obj/*
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Заготовки подключения — 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
|
||||
|
||||
- [ ] Локальная генерация QR из payload; отображение в окне агента/трее.
|
||||
- [ ] Закрывать/обновлять показанный QR при истечении кода, сопряжении или смене IP.
|
||||
- [ ] Выбор адаптера при нескольких адресах; сейчас для каждого свой payload.
|
||||
- [ ] mDNS/DNS-SD publisher: PTR/SRV/TXT/A, порт HTTPS агента, без кодов и токенов.
|
||||
- [ ] Обработка конфликтов имён, смены адресов, сна/пробуждения и остановки сервиса.
|
||||
- [ ] Проверка объявления со второго устройства. Сейчас multicast не отправляется.
|
||||
- [ ] Tray, кнопки сопряжения/отзыва, автозапуск и упаковка Windows.
|
||||
|
||||
Не заменять mDNS рассылкой секретов. Объявления сети не являются доказательством
|
||||
подлинности ПК: проверять сохранённый отпечаток TLS перед отправкой токена.
|
||||
|
||||
## Порядок подключения будущего нативного клиента
|
||||
|
||||
1. Считать QR внутри приложения, проверить схему/версию, размер payload,
|
||||
HTTPS-адрес, отсутствие userinfo/параметров, формат отпечатка/кода и срок.
|
||||
2. Проверить TLS по отпечатку из QR **до** отправки POST `/v1/pair`.
|
||||
3. Сохранить токен и отпечаток в Keychain; не сохранять код как постоянный секрет.
|
||||
4. Получить `/v1/info`, `/v1/state`, открыть WSS `/v1/events`.
|
||||
5. При переподключении найти адрес по DNS-SD, сопоставить ID, проверить TLS;
|
||||
при 401 запросить повторное сопряжение, не повторять команды автоматически.
|
||||
|
||||
Схема `musicbridge://` пока не зарегистрирована на iPhone; Safari ничего делать
|
||||
с ней не должен. Swift, Xcode-проект, установка и интерфейс iPhone отложены.
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MusicBridge.Agent.Media;
|
||||
|
||||
internal sealed record Artwork(string Id, string ContentType, byte[] Bytes);
|
||||
|
||||
// Only the current cover is retained. IDs are content hashes, never file paths or URLs.
|
||||
internal sealed class ArtworkCache
|
||||
{
|
||||
public const int MaxBytes = 2 * 1024 * 1024;
|
||||
private Artwork? current;
|
||||
public Artwork? Current => Volatile.Read(ref current);
|
||||
public Artwork? Get(string id) => Current is { } artwork && artwork.Id == id ? artwork : null;
|
||||
public void Clear() => Volatile.Write(ref current, null);
|
||||
|
||||
public async Task<Artwork> LoadAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
Clear(); // A failed/new cover must not leave the previous track's image attached.
|
||||
using var output = new MemoryStream();
|
||||
var buffer = new byte[16 * 1024];
|
||||
while (true)
|
||||
{
|
||||
var count = await stream.ReadAsync(buffer, cancellationToken);
|
||||
if (count == 0) break;
|
||||
if (output.Length + count > MaxBytes) throw new InvalidDataException("Обложка больше 2 МиБ.");
|
||||
output.Write(buffer, 0, count);
|
||||
}
|
||||
var bytes = output.ToArray();
|
||||
var type = DetectType(bytes) ?? throw new InvalidDataException("Неподдерживаемый формат обложки.");
|
||||
var artwork = new Artwork(Convert.ToHexString(SHA256.HashData(bytes)), type, bytes);
|
||||
Volatile.Write(ref current, artwork);
|
||||
return artwork;
|
||||
}
|
||||
|
||||
private static string? DetectType(ReadOnlySpan<byte> data)
|
||||
{
|
||||
if (data.StartsWith(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 })) return "image/png";
|
||||
if (data.StartsWith(new byte[] { 255, 216, 255 })) return "image/jpeg";
|
||||
if (data.StartsWith("GIF87a"u8) || data.StartsWith("GIF89a"u8)) return "image/gif";
|
||||
if (data.Length >= 12 && data[..4].SequenceEqual("RIFF"u8) && data.Slice(8, 4).SequenceEqual("WEBP"u8)) return "image/webp";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace MusicBridge.Agent.Media;
|
||||
|
||||
internal sealed record MediaCommand(string Type, double? Value = null, string? Id = null);
|
||||
internal sealed record CommandResult(bool Success, string Code, string Message, string? Id = null);
|
||||
internal sealed record PlayerCapabilities(bool Play = false, bool Pause = false,
|
||||
bool Toggle = false, bool Next = false, bool Previous = false, bool Seek = false);
|
||||
|
||||
internal sealed record MediaState
|
||||
{
|
||||
public int ProtocolVersion { get; init; } = 1;
|
||||
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
|
||||
public bool HasSession { get; init; }
|
||||
public string Title { get; init; } = "";
|
||||
public string Artist { get; init; } = "";
|
||||
public string Source { get; init; } = "";
|
||||
public string PlaybackStatus { get; init; } = "Closed";
|
||||
public double PositionSeconds { get; init; }
|
||||
public double StartSeconds { get; init; }
|
||||
public double EndSeconds { get; init; }
|
||||
public double MinSeekSeconds { get; init; }
|
||||
public double MaxSeekSeconds { get; init; }
|
||||
public double PlaybackRate { get; init; } = 1;
|
||||
public PlayerCapabilities Capabilities { get; init; } = new();
|
||||
public float? Volume { get; init; }
|
||||
public bool? Muted { get; init; }
|
||||
public string? MediaError { get; init; }
|
||||
public string? VolumeError { get; init; }
|
||||
public string? ArtworkId { get; init; }
|
||||
public string? ArtworkError { get; init; }
|
||||
}
|
||||
|
||||
internal interface IMediaService
|
||||
{
|
||||
event Action? Changed;
|
||||
Artwork? GetArtwork(string id);
|
||||
Task<MediaState> ReadAsync(CancellationToken cancellationToken);
|
||||
Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal static class CommandValidation
|
||||
{
|
||||
public static CommandResult? Validate(MediaCommand command)
|
||||
{
|
||||
if (command.Id?.Length > 64) return Error("invalid_command", "Id длиннее 64 символов.");
|
||||
var valid = command.Type switch
|
||||
{
|
||||
"play" or "pause" or "toggle" or "next" or "previous" or "toggleMute" => command.Value is null,
|
||||
"seek" => command.Value is double s && double.IsFinite(s) && s >= 0 && s <= TimeSpan.MaxValue.TotalSeconds / 2,
|
||||
"seekRelative" => command.Value is double d && double.IsFinite(d) && Math.Abs(d) <= 3600,
|
||||
"volume" => command.Value is double v && double.IsFinite(v) && v >= 0 && v <= 1,
|
||||
"volumeRelative" => command.Value is double d && double.IsFinite(d) && Math.Abs(d) <= 1,
|
||||
_ => false
|
||||
};
|
||||
return valid ? null : Error("invalid_command", "Неизвестная команда или недопустимое значение.");
|
||||
}
|
||||
|
||||
public static CommandResult Error(string code, string message) => new(false, code, message);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
using Windows.Media.Control;
|
||||
|
||||
namespace MusicBridge.Agent.Media;
|
||||
|
||||
internal sealed class MediaService : IMediaService, IDisposable
|
||||
{
|
||||
private readonly GlobalSystemMediaTransportControlsSessionManager manager;
|
||||
private GlobalSystemMediaTransportControlsSession? observed;
|
||||
private GlobalSystemMediaTransportControlsSessionMediaProperties? metadata;
|
||||
private readonly ArtworkCache artwork = new();
|
||||
private long metadataRevision;
|
||||
private long loadedRevision = -1;
|
||||
private DateTimeOffset nextMetadataRefresh;
|
||||
private string? artworkError;
|
||||
private bool disposed;
|
||||
public event Action? Changed;
|
||||
public Artwork? GetArtwork(string id) => artwork.Get(id);
|
||||
|
||||
public MediaService(GlobalSystemMediaTransportControlsSessionManager manager)
|
||||
{
|
||||
this.manager = manager;
|
||||
manager.CurrentSessionChanged += OnCurrentSessionChanged;
|
||||
manager.SessionsChanged += OnSessionsChanged;
|
||||
}
|
||||
|
||||
// Console commands and remote clients must not race each other.
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
|
||||
public async Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var state = new MediaState();
|
||||
try
|
||||
{
|
||||
var session = manager.GetCurrentSession();
|
||||
Observe(session);
|
||||
if (session is not null)
|
||||
{
|
||||
var revision = Interlocked.Read(ref metadataRevision);
|
||||
if (metadata is null || loadedRevision != revision || DateTimeOffset.UtcNow >= nextMetadataRefresh)
|
||||
{
|
||||
metadata = null;
|
||||
artwork.Clear();
|
||||
metadata = await session.TryGetMediaPropertiesAsync().AsTask(cancellationToken);
|
||||
await LoadArtworkAsync(metadata, cancellationToken);
|
||||
loadedRevision = revision; // Events during this read remain dirty for the next read.
|
||||
nextMetadataRefresh = DateTimeOffset.UtcNow.AddSeconds(10);
|
||||
}
|
||||
var media = metadata;
|
||||
var playback = session.GetPlaybackInfo();
|
||||
var timeline = session.GetTimelineProperties();
|
||||
var range = GetSeekRange(timeline);
|
||||
var c = playback.Controls;
|
||||
state = state with
|
||||
{
|
||||
HasSession = true, Title = media.Title, Artist = media.Artist,
|
||||
Source = session.SourceAppUserModelId,
|
||||
ArtworkId = artwork.Current?.Id, ArtworkError = artworkError,
|
||||
PlaybackStatus = playback.PlaybackStatus.ToString(),
|
||||
PositionSeconds = GetPosition(timeline, playback),
|
||||
StartSeconds = timeline.StartTime.TotalSeconds, EndSeconds = timeline.EndTime.TotalSeconds,
|
||||
MinSeekSeconds = range.Min, MaxSeekSeconds = range.Max,
|
||||
PlaybackRate = double.IsFinite(playback.PlaybackRate ?? 1) ? playback.PlaybackRate ?? 1 : 1,
|
||||
Capabilities = new(c.IsPlayEnabled, c.IsPauseEnabled, c.IsPlayPauseToggleEnabled,
|
||||
c.IsNextEnabled, c.IsPreviousEnabled, c.IsPlaybackPositionEnabled && range.Max > range.Min)
|
||||
};
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata = null;
|
||||
artwork.Clear();
|
||||
state = state with { MediaError = ex.Message };
|
||||
}
|
||||
try
|
||||
{
|
||||
var volume = SystemVolume.Read();
|
||||
state = state with { Volume = volume.Level, Muted = volume.Muted };
|
||||
}
|
||||
catch (Exception ex) { state = state with { VolumeError = ex.Message }; }
|
||||
return state with { Timestamp = DateTimeOffset.UtcNow };
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var invalid = CommandValidation.Validate(command);
|
||||
if (invalid is not null) return invalid with { Id = command.Id };
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
return (await ExecuteCoreAsync(command)) with { Id = command.Id };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new(false, "operation_failed", ex.Message, command.Id);
|
||||
}
|
||||
finally { gate.Release(); Changed?.Invoke(); }
|
||||
}
|
||||
|
||||
private async Task LoadArtworkAsync(GlobalSystemMediaTransportControlsSessionMediaProperties properties, CancellationToken cancellationToken)
|
||||
{
|
||||
artworkError = null;
|
||||
if (properties.Thumbnail is null) return;
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
using var source = await properties.Thumbnail.OpenReadAsync().AsTask(timeout.Token);
|
||||
if (source.Size > ArtworkCache.MaxBytes) throw new InvalidDataException("Обложка больше 2 МиБ.");
|
||||
using var stream = source.AsStreamForRead();
|
||||
await artwork.LoadAsync(stream, timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||
catch (OperationCanceledException) { artworkError = "Время чтения обложки истекло."; }
|
||||
catch (Exception ex) { artworkError = ex.Message; }
|
||||
}
|
||||
|
||||
// Event callbacks only invalidate/notify; WinRT reads run under the shared gate.
|
||||
private void OnCurrentSessionChanged(GlobalSystemMediaTransportControlsSessionManager sender, CurrentSessionChangedEventArgs args) => Invalidate();
|
||||
private void OnSessionsChanged(GlobalSystemMediaTransportControlsSessionManager sender, SessionsChangedEventArgs args) => Invalidate();
|
||||
private void OnMediaChanged(GlobalSystemMediaTransportControlsSession sender, MediaPropertiesChangedEventArgs args) => Invalidate();
|
||||
private void OnPlaybackChanged(GlobalSystemMediaTransportControlsSession sender, PlaybackInfoChangedEventArgs args) => Changed?.Invoke();
|
||||
private void OnTimelineChanged(GlobalSystemMediaTransportControlsSession sender, TimelinePropertiesChangedEventArgs args) => Changed?.Invoke();
|
||||
private void Invalidate() { Interlocked.Increment(ref metadataRevision); Changed?.Invoke(); }
|
||||
|
||||
private void Observe(GlobalSystemMediaTransportControlsSession? session)
|
||||
{
|
||||
if (ReferenceEquals(observed, session)) return;
|
||||
if (observed is not null)
|
||||
{
|
||||
observed.MediaPropertiesChanged -= OnMediaChanged;
|
||||
observed.PlaybackInfoChanged -= OnPlaybackChanged;
|
||||
observed.TimelinePropertiesChanged -= OnTimelineChanged;
|
||||
}
|
||||
observed = session;
|
||||
metadata = null;
|
||||
artwork.Clear();
|
||||
artworkError = null;
|
||||
if (observed is not null)
|
||||
{
|
||||
observed.MediaPropertiesChanged += OnMediaChanged;
|
||||
observed.PlaybackInfoChanged += OnPlaybackChanged;
|
||||
observed.TimelinePropertiesChanged += OnTimelineChanged;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
manager.CurrentSessionChanged -= OnCurrentSessionChanged;
|
||||
manager.SessionsChanged -= OnSessionsChanged;
|
||||
Observe(null);
|
||||
// Owner stops server/read loop before disposing the media service.
|
||||
Changed = null;
|
||||
gate.Dispose();
|
||||
}
|
||||
|
||||
private async Task<CommandResult> ExecuteCoreAsync(MediaCommand command)
|
||||
{
|
||||
switch (command.Type)
|
||||
{
|
||||
case "volume": SystemVolume.Set((float)command.Value!.Value); return Ok();
|
||||
case "volumeRelative": SystemVolume.Change((float)command.Value!.Value); return Ok();
|
||||
case "toggleMute": SystemVolume.ToggleMute(); return Ok();
|
||||
}
|
||||
var session = manager.GetCurrentSession();
|
||||
if (session is null) return CommandValidation.Error("no_session", "Медиаплеер не найден.");
|
||||
var playback = session.GetPlaybackInfo();
|
||||
var controls = playback.Controls;
|
||||
bool accepted;
|
||||
switch (command.Type)
|
||||
{
|
||||
case "play":
|
||||
if (!controls.IsPlayEnabled) return Unsupported();
|
||||
accepted = await session.TryPlayAsync(); break;
|
||||
case "pause":
|
||||
if (!controls.IsPauseEnabled) return Unsupported();
|
||||
accepted = await session.TryPauseAsync(); break;
|
||||
case "toggle":
|
||||
if (!controls.IsPlayPauseToggleEnabled) return Unsupported();
|
||||
accepted = await session.TryTogglePlayPauseAsync(); break;
|
||||
case "next":
|
||||
if (!controls.IsNextEnabled) return Unsupported();
|
||||
accepted = await session.TrySkipNextAsync(); break;
|
||||
case "previous":
|
||||
if (!controls.IsPreviousEnabled) return Unsupported();
|
||||
accepted = await session.TrySkipPreviousAsync(); break;
|
||||
default:
|
||||
if (!controls.IsPlaybackPositionEnabled) return Unsupported();
|
||||
var timeline = session.GetTimelineProperties();
|
||||
var range = GetSeekRange(timeline);
|
||||
if (range.Max <= range.Min) return Unsupported();
|
||||
var target = command.Value!.Value;
|
||||
if (command.Type == "seekRelative") target += GetPosition(timeline, playback);
|
||||
target = Math.Clamp(target, range.Min, range.Max);
|
||||
accepted = await session.TryChangePlaybackPositionAsync(TimeSpan.FromSeconds(target).Ticks);
|
||||
break;
|
||||
}
|
||||
return accepted ? Ok() : CommandValidation.Error("rejected", "Плеер отклонил команду.");
|
||||
}
|
||||
|
||||
private static CommandResult Ok() => new(true, "ok", "Команда выполнена.");
|
||||
private static CommandResult Unsupported() => CommandValidation.Error("unsupported", "Команда сейчас недоступна у плеера.");
|
||||
private static (double Min, double Max) GetSeekRange(GlobalSystemMediaTransportControlsSessionTimelineProperties t)
|
||||
=> TimelineMath.SeekRange(t.StartTime.TotalSeconds, t.EndTime.TotalSeconds, t.MinSeekTime.TotalSeconds, t.MaxSeekTime.TotalSeconds);
|
||||
private static double GetPosition(GlobalSystemMediaTransportControlsSessionTimelineProperties t,
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackInfo p) => TimelineMath.Position(t.Position.TotalSeconds,
|
||||
t.StartTime.TotalSeconds, t.EndTime.TotalSeconds,
|
||||
p.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing,
|
||||
t.LastUpdatedTime, DateTimeOffset.UtcNow, p.PlaybackRate ?? 1);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MusicBridge.Agent.Media;
|
||||
|
||||
internal static class TimelineMath
|
||||
{
|
||||
public static double Position(double position, double start, double end, bool playing,
|
||||
DateTimeOffset updatedAt, DateTimeOffset now, double rate)
|
||||
{
|
||||
if (playing && updatedAt > DateTimeOffset.UnixEpoch && updatedAt <= now && double.IsFinite(rate))
|
||||
position += (now - updatedAt).TotalSeconds * rate;
|
||||
position = Math.Max(start, position);
|
||||
return end > start ? Math.Min(end, position) : position;
|
||||
}
|
||||
|
||||
public static (double Min, double Max) SeekRange(double start, double end, double min, double max)
|
||||
=> (Math.Max(start, min), end > start ? Math.Min(end, max) : max);
|
||||
}
|
||||
@@ -5,6 +5,18 @@
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<Compile Remove="tests/**/*.cs" />
|
||||
<InternalsVisibleTo Include="MusicBridge.Agent.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Makaretu.Dns.Multicast" Version="0.27.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class AgentCertificate
|
||||
{
|
||||
public static X509Certificate2 Create()
|
||||
{
|
||||
using var key = RSA.Create(2048);
|
||||
var request = new CertificateRequest("CN=MusicBridge Agent", key, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
|
||||
request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, true));
|
||||
request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, true));
|
||||
request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(
|
||||
new OidCollection { new("1.3.6.1.5.5.7.3.1") }, false));
|
||||
var names = new SubjectAlternativeNameBuilder();
|
||||
names.AddDnsName("localhost");
|
||||
names.AddIpAddress(IPAddress.Loopback);
|
||||
request.CertificateExtensions.Add(names.Build());
|
||||
using var certificate = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddMinutes(-5), DateTimeOffset.UtcNow.AddYears(5));
|
||||
// Reload so Schannel can use the private key independently of the RSA object.
|
||||
return X509CertificateLoader.LoadPkcs12(certificate.Export(X509ContentType.Pfx), null, X509KeyStorageFlags.Exportable);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// QR payload contract only. Rendering/scanning and the native client are separate steps.
|
||||
internal sealed record ConnectionInvitation(int Version, string Endpoint, string CertificateSha256,
|
||||
string Code, DateTimeOffset ExpiresAt)
|
||||
{
|
||||
public static ConnectionInvitation Create(Uri endpoint, string fingerprint, PairingWindow window, DateTimeOffset now)
|
||||
{
|
||||
if (!IsLocalEndpoint(endpoint)) throw new ArgumentException("Нужен HTTPS IPv4-адрес домашней сети без пути и параметров.");
|
||||
if (fingerprint.Length != 64 || !fingerprint.All(Uri.IsHexDigit)) throw new ArgumentException("Неверный отпечаток сертификата.");
|
||||
if (window.Code.Length != 8 || !window.Code.All(char.IsAsciiDigit) || window.ExpiresAt <= now)
|
||||
throw new ArgumentException("Окно сопряжения закрыто или код неверен.");
|
||||
return new(1, endpoint.GetLeftPart(UriPartial.Authority), fingerprint.ToUpperInvariant(), window.Code, window.ExpiresAt);
|
||||
}
|
||||
|
||||
public string ToQrPayload()
|
||||
{
|
||||
var json = JsonSerializer.SerializeToUtf8Bytes(this, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var data = Convert.ToBase64String(json).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
return "musicbridge://pair?data=" + data;
|
||||
}
|
||||
|
||||
public static bool IsLocalEndpoint(Uri endpoint)
|
||||
{
|
||||
if (!endpoint.IsAbsoluteUri || endpoint.Scheme != "https" || endpoint.UserInfo != ""
|
||||
|| endpoint.AbsolutePath != "/" || endpoint.Query != "" || endpoint.Fragment != ""
|
||||
|| !IPAddress.TryParse(endpoint.Host, out var ip)) return false;
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes.Length == 4 && (bytes[0] == 10 || bytes[0] == 172 && bytes[1] is >= 16 and <= 31
|
||||
|| bytes[0] == 192 && bytes[1] == 168);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// Data for a future DNS-SD publisher. Does not open sockets or advertise pairing secrets.
|
||||
internal sealed record DiscoveryDescriptor(int ProtocolVersion, string AgentId, string ServiceType, string InstanceName)
|
||||
{
|
||||
public static DiscoveryDescriptor Create(string certificateSha256)
|
||||
{
|
||||
if (certificateSha256.Length != 64 || !certificateSha256.All(Uri.IsHexDigit))
|
||||
throw new ArgumentException("Неверный идентификатор сертификата.");
|
||||
var id = certificateSha256.ToUpperInvariant();
|
||||
return new(1, id, "_musicbridge._tcp.local.", "MusicBridge-" + id[..12]);
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string> TxtRecords() => new Dictionary<string, string>
|
||||
{
|
||||
["v"] = ProtocolVersion.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
["id"] = AgentId
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal sealed record TrustedDevice(string Id, string Name, string TokenHash, DateTimeOffset CreatedAt);
|
||||
|
||||
internal sealed class IdentityStore : IDisposable
|
||||
{
|
||||
private sealed record Identity(int Version, byte[] Certificate, TrustedDevice[] Devices);
|
||||
private readonly FileStream owner;
|
||||
private readonly string path;
|
||||
private Identity identity = null!;
|
||||
public X509Certificate2 Certificate { get; private set; } = null!;
|
||||
public IReadOnlyList<TrustedDevice> Devices => identity.Devices;
|
||||
public static string DefaultDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MusicBridge");
|
||||
|
||||
public IdentityStore(string directory)
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
path = Path.Combine(directory, "identity.dat");
|
||||
// Prevent two agents from racing the certificate or overwriting trusted devices.
|
||||
owner = new FileStream(Path.Combine(directory, "identity.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
if (new FileInfo(path).Length > 1024 * 1024) throw new InvalidDataException("Хранилище слишком большое.");
|
||||
var clear = UserProtection.Unprotect(File.ReadAllBytes(path));
|
||||
try { identity = JsonSerializer.Deserialize<Identity>(clear) ?? throw new InvalidDataException("Пустое хранилище."); }
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
if (identity.Version != 1 || identity.Certificate is null || identity.Devices is null || identity.Devices.Length > 8
|
||||
|| identity.Devices.Any(d => d is null || !Guid.TryParseExact(d.Id, "N", out _) || string.IsNullOrWhiteSpace(d.Name)
|
||||
|| d.Name.Length > 64 || d.TokenHash is null || d.TokenHash.Length != 64 || !d.TokenHash.All(Uri.IsHexDigit))
|
||||
|| identity.Devices.Select(d => d.Id).Distinct().Count() != identity.Devices.Length)
|
||||
throw new InvalidDataException("Неверный формат хранилища.");
|
||||
Certificate = X509CertificateLoader.LoadPkcs12(identity.Certificate, null);
|
||||
if (!Certificate.HasPrivateKey || Certificate.NotAfter.ToUniversalTime() <= DateTime.UtcNow)
|
||||
throw new InvalidDataException("Сертификат недоступен или истёк. Хранилище сохранено без изменений.");
|
||||
}
|
||||
else
|
||||
{
|
||||
Certificate = AgentCertificate.Create();
|
||||
identity = new(1, Certificate.Export(X509ContentType.Pfx), []);
|
||||
Save(identity);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
Certificate?.Dispose();
|
||||
if (identity?.Certificate is { } bytes) CryptographicOperations.ZeroMemory(bytes);
|
||||
owner.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveDevices(TrustedDevice[] devices)
|
||||
{
|
||||
var updated = identity with { Devices = devices };
|
||||
Save(updated); // Commit to memory only after the protected file is safely replaced.
|
||||
identity = updated;
|
||||
}
|
||||
|
||||
private void Save(Identity value)
|
||||
{
|
||||
var clear = JsonSerializer.SerializeToUtf8Bytes(value);
|
||||
byte[] encrypted;
|
||||
try { encrypted = UserProtection.Protect(clear); }
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
var temporary = path + ".tmp";
|
||||
try
|
||||
{
|
||||
using (var file = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
file.Write(encrypted);
|
||||
file.Flush(flushToDisk: true);
|
||||
}
|
||||
File.Move(temporary, path, overwrite: true);
|
||||
}
|
||||
finally { if (File.Exists(temporary)) File.Delete(temporary); }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Certificate.Dispose();
|
||||
CryptographicOperations.ZeroMemory(identity.Certificate);
|
||||
owner.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Net;
|
||||
using System.Net.NetworkInformation;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class LocalAddresses
|
||||
{
|
||||
public static IPAddress[] Read() => NetworkInterface.GetAllNetworkInterfaces()
|
||||
.Where(nic => nic.OperationalStatus == OperationalStatus.Up && nic.SupportsMulticast
|
||||
&& nic.NetworkInterfaceType != NetworkInterfaceType.Loopback)
|
||||
.SelectMany(nic => nic.GetIPProperties().UnicastAddresses)
|
||||
.Select(a => a.Address)
|
||||
.Where(IsEligible).Distinct().OrderBy(a => a.ToString(), StringComparer.Ordinal).ToArray();
|
||||
|
||||
public static bool IsEligible(IPAddress ip) => ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
|
||||
&& ConnectionInvitation.IsLocalEndpoint(new Uri($"https://{ip}"));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net;
|
||||
using Makaretu.Dns;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// All lifecycle calls run on the console loop, not on network-change callback threads.
|
||||
internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable
|
||||
{
|
||||
private MulticastService? multicast;
|
||||
private ServiceDiscovery? discovery;
|
||||
private ServiceProfile? profile;
|
||||
private string addressesKey = "";
|
||||
private DateTimeOffset nextAnnouncement;
|
||||
public string Status { get; private set; } = "mDNS: ожидание локальной сети.";
|
||||
|
||||
public static ServiceProfile CreateProfile(string fingerprint, int port, IPAddress[] addresses)
|
||||
{
|
||||
if (port is < 1 or > 65535 || addresses.Length == 0 || addresses.Any(a => !LocalAddresses.IsEligible(a)))
|
||||
throw new ArgumentException("Неверный порт или адрес объявления mDNS.");
|
||||
var descriptor = DiscoveryDescriptor.Create(fingerprint);
|
||||
var result = new ServiceProfile(descriptor.InstanceName, "_musicbridge._tcp", (ushort)port, addresses);
|
||||
foreach (var pair in descriptor.TxtRecords()) result.AddProperty(pair.Key, pair.Value);
|
||||
foreach (var record in result.Resources) record.TTL = TimeSpan.FromSeconds(120);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Refresh(IPAddress[] addresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
var key = string.Join(",", addresses.Select(a => a.ToString()));
|
||||
if (discovery is null || key != addressesKey)
|
||||
{
|
||||
Stop();
|
||||
if (addresses.Length == 0) { Status = "mDNS: нет адреса домашней сети."; return; }
|
||||
multicast = new MulticastService(nics => nics.Where(nic => nic.GetIPProperties().UnicastAddresses.Any(a => addresses.Contains(a.Address))))
|
||||
{
|
||||
UseIpv4 = true, UseIpv6 = false
|
||||
};
|
||||
profile = CreateProfile(fingerprint, port, addresses);
|
||||
discovery = new ServiceDiscovery(multicast);
|
||||
discovery.Advertise(profile);
|
||||
multicast.Start();
|
||||
addressesKey = key;
|
||||
nextAnnouncement = DateTimeOffset.MinValue;
|
||||
}
|
||||
if (DateTimeOffset.UtcNow >= nextAnnouncement)
|
||||
{
|
||||
var message = new Message { QR = true, AA = true };
|
||||
message.Answers.Add(new PTRRecord
|
||||
{
|
||||
Name = profile!.QualifiedServiceName, DomainName = profile.FullyQualifiedName, TTL = TimeSpan.FromSeconds(120)
|
||||
});
|
||||
foreach (var record in profile.Resources) message.Answers.Add(record);
|
||||
multicast!.SendAnswer(message, checkDuplicate: false);
|
||||
nextAnnouncement = DateTimeOffset.UtcNow.AddSeconds(30);
|
||||
}
|
||||
Status = $"mDNS: объявляется {profile!.FullyQualifiedName}";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Stop();
|
||||
Status = $"mDNS недоступен: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private void Stop()
|
||||
{
|
||||
try { if (profile is not null) discovery?.Unadvertise(profile); }
|
||||
catch (Exception) { /* Best-effort goodbye when a network has disappeared. */ }
|
||||
discovery?.Dispose();
|
||||
multicast?.Dispose();
|
||||
discovery = null;
|
||||
multicast = null;
|
||||
profile = null;
|
||||
addressesKey = "";
|
||||
}
|
||||
|
||||
public void Dispose() => Stop();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using QRCoder;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal static class PairingQr
|
||||
{
|
||||
public static byte[] Render(ConnectionInvitation invitation)
|
||||
{
|
||||
using var data = QRCodeGenerator.GenerateQrCode(invitation.ToQrPayload(), QRCodeGenerator.ECCLevel.M);
|
||||
using var renderer = new PngByteQRCode(data);
|
||||
return renderer.GetGraphic(6);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal sealed record PairingWindow(string Code, DateTimeOffset ExpiresAt);
|
||||
internal sealed record PairingResult(bool Success, string? Token, string Code, string? DeviceId = null);
|
||||
|
||||
internal sealed class PairingService(TimeProvider? clock = null, IdentityStore? store = null)
|
||||
{
|
||||
private readonly TimeProvider clock = clock ?? TimeProvider.System;
|
||||
private readonly object gate = new();
|
||||
private TrustedDevice[] devices = store?.Devices.ToArray() ?? [];
|
||||
public event Action? TrustChanged;
|
||||
public IReadOnlyList<TrustedDevice> Devices { get { lock (gate) return devices.ToArray(); } }
|
||||
private PairingWindow? window;
|
||||
private int attempts;
|
||||
|
||||
public PairingWindow Open()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
attempts = 0;
|
||||
return window = new(RandomNumberGenerator.GetInt32(100_000_000).ToString("D8"),
|
||||
clock.GetUtcNow().AddMinutes(5));
|
||||
}
|
||||
}
|
||||
|
||||
public PairingWindow? Current
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
return window is not null && window.ExpiresAt > clock.GetUtcNow() && attempts < 10 ? window : null;
|
||||
}
|
||||
}
|
||||
|
||||
public PairingResult Pair(string? code, string? deviceName = null)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (window is null || window.ExpiresAt <= clock.GetUtcNow() || attempts >= 10)
|
||||
return new(false, null, "pairing_closed");
|
||||
attempts++;
|
||||
if (code is null || code.Length != 8 || !CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(code), Encoding.UTF8.GetBytes(window.Code)))
|
||||
return new(false, null, "invalid_code");
|
||||
if (deviceName is not null && (string.IsNullOrWhiteSpace(deviceName) || deviceName.Length > 64 || deviceName.Any(char.IsControl)))
|
||||
return new(false, null, "invalid_name");
|
||||
if (devices.Length >= 8) return new(false, null, "device_limit");
|
||||
var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
var device = new TrustedDevice(Guid.NewGuid().ToString("N"), deviceName?.Trim() ?? "iPhone",
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token))), clock.GetUtcNow());
|
||||
var updated = devices.Append(device).ToArray();
|
||||
try { store?.SaveDevices(updated); }
|
||||
catch (Exception) { return new(false, null, "storage_failed"); }
|
||||
devices = updated;
|
||||
window = null; // One successful pairing per locally opened window.
|
||||
return new(true, token, "ok", device.Id);
|
||||
}
|
||||
}
|
||||
|
||||
public bool Authorize(string? token)
|
||||
{
|
||||
if (token is null || token.Length != 64) return false;
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
lock (gate) return devices.Any(candidate => CryptographicOperations.FixedTimeEquals(Convert.FromHexString(candidate.TokenHash), hash));
|
||||
}
|
||||
|
||||
public bool Revoke(string id)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var updated = devices.Where(d => d.Id != id).ToArray();
|
||||
if (updated.Length == devices.Length) return false;
|
||||
store?.SaveDevices(updated);
|
||||
devices = updated;
|
||||
}
|
||||
TrustChanged?.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Net;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text.Json;
|
||||
using System.Threading.RateLimiting;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MusicBridge.Agent.Media;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
internal sealed class RemoteServer : IAsyncDisposable
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { MaxDepth = 8 };
|
||||
private readonly WebApplication app;
|
||||
private readonly IMediaService media;
|
||||
private readonly PairingService pairing;
|
||||
private readonly SemaphoreSlim clients = new(8, 8);
|
||||
private readonly CancellationTokenSource stop = new();
|
||||
private Task? polling;
|
||||
private MediaState latest = new();
|
||||
private readonly Channel<bool> refresh = CreateSignal();
|
||||
private readonly TimeSpan refreshInterval;
|
||||
private event Action? Published;
|
||||
|
||||
public MediaState Latest => Volatile.Read(ref latest);
|
||||
public IEnumerable<string> Addresses => app.Urls;
|
||||
public CancellationToken Stopping => app.Lifetime.ApplicationStopping;
|
||||
|
||||
public RemoteServer(IMediaService media, PairingService pairing, X509Certificate2 certificate, bool lan, int port,
|
||||
TimeSpan? refreshInterval = null)
|
||||
{
|
||||
this.media = media;
|
||||
this.pairing = pairing;
|
||||
this.refreshInterval = refreshInterval ?? TimeSpan.FromMilliseconds(500);
|
||||
var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { Args = [] });
|
||||
builder.Logging.ClearProviders(); // Never log pairing bodies or bearer tokens.
|
||||
builder.WebHost.ConfigureKestrel(options =>
|
||||
{
|
||||
options.Limits.MaxRequestBodySize = 4096;
|
||||
options.Limits.MaxConcurrentConnections = 32;
|
||||
options.Limits.MaxConcurrentUpgradedConnections = 8;
|
||||
options.Listen(lan ? IPAddress.Any : IPAddress.Loopback, port, endpoint => endpoint.UseHttps(certificate));
|
||||
});
|
||||
builder.Services.Configure<HostOptions>(options => options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(_ =>
|
||||
RateLimitPartition.GetFixedWindowLimiter("agent", _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 60, Window = TimeSpan.FromSeconds(1), QueueLimit = 0
|
||||
}));
|
||||
});
|
||||
app = builder.Build();
|
||||
app.UseRateLimiter();
|
||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(20) });
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.CacheControl = "no-store";
|
||||
// Native clients have no Origin. Browser pages are deliberately unsupported.
|
||||
if (context.Request.Headers.ContainsKey("Origin"))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
return;
|
||||
}
|
||||
if (context.Request.Path != "/v1/pair")
|
||||
{
|
||||
var header = context.Request.Headers.Authorization.ToString();
|
||||
if (!header.StartsWith("Bearer ", StringComparison.Ordinal) || !pairing.Authorize(header[7..]))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
}
|
||||
try { await next(context); }
|
||||
catch (JsonException)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsJsonAsync(new { code = "invalid_json" }, context.RequestAborted);
|
||||
}
|
||||
});
|
||||
app.MapPost("/v1/pair", async (HttpContext context) =>
|
||||
{
|
||||
var request = await JsonSerializer.DeserializeAsync<PairRequest>(context.Request.Body, JsonOptions, context.RequestAborted);
|
||||
var result = pairing.Pair(request?.Code, request?.DeviceName);
|
||||
return Results.Json(result, statusCode: result.Success ? 200 : result.Code == "storage_failed" ? 503 : 403);
|
||||
});
|
||||
app.MapGet("/v1/state", () => Latest);
|
||||
app.MapGet("/v1/info", () => DiscoveryDescriptor.Create(certificate.GetCertHashString(System.Security.Cryptography.HashAlgorithmName.SHA256)));
|
||||
app.MapGet("/v1/artwork/{id}", (string id, HttpContext context) =>
|
||||
{
|
||||
var image = media.GetArtwork(id);
|
||||
if (image is null) return Results.NotFound();
|
||||
context.Response.Headers.XContentTypeOptions = "nosniff";
|
||||
return Results.Bytes(image.Bytes, image.ContentType);
|
||||
});
|
||||
app.MapPost("/v1/command", async (HttpContext context) =>
|
||||
{
|
||||
var command = await JsonSerializer.DeserializeAsync<MediaCommand>(context.Request.Body, JsonOptions, context.RequestAborted);
|
||||
if (command is null) return Results.BadRequest(new { code = "invalid_command" });
|
||||
var invalid = CommandValidation.Validate(command);
|
||||
if (invalid is not null) return Results.BadRequest(invalid with { Id = command.Id });
|
||||
var result = await media.ExecuteAsync(command, context.RequestAborted);
|
||||
return Results.Json(result);
|
||||
});
|
||||
app.MapGet("/v1/events", StreamAsync);
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
media.Changed += RequestRefresh;
|
||||
Volatile.Write(ref latest, await media.ReadAsync(cancellationToken));
|
||||
await app.StartAsync(cancellationToken);
|
||||
polling = PollAsync(stop.Token);
|
||||
}
|
||||
|
||||
private async Task PollAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(refreshInterval);
|
||||
try { await refresh.Reader.ReadAsync(deadline.Token); }
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { }
|
||||
// Coalesce bursts of Windows events without accumulating work.
|
||||
await Task.Delay(25, cancellationToken);
|
||||
while (refresh.Reader.TryRead(out _)) { }
|
||||
var state = await media.ReadAsync(cancellationToken);
|
||||
Volatile.Write(ref latest, state);
|
||||
Published?.Invoke();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||
}
|
||||
|
||||
// WSS is a state stream; commands use HTTPS so each has its own response.
|
||||
private async Task StreamAsync(HttpContext context)
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
return;
|
||||
}
|
||||
if (!await clients.WaitAsync(0, context.RequestAborted))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(context.RequestAborted, stop.Token, Stopping);
|
||||
var token = context.Request.Headers.Authorization.ToString()[7..];
|
||||
var sender = SendStatesAsync(socket, token, lifetime.Token);
|
||||
var receiver = ReceiveCloseAsync(socket, lifetime.Token);
|
||||
await Task.WhenAny(sender, receiver);
|
||||
await lifetime.CancelAsync();
|
||||
try { await Task.WhenAll(sender, receiver); }
|
||||
catch (OperationCanceledException) { }
|
||||
catch (WebSocketException) { }
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
using var closeTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(2));
|
||||
try
|
||||
{
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, "State stream closed", closeTimeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (WebSocketException) { }
|
||||
}
|
||||
}
|
||||
finally { clients.Release(); }
|
||||
}
|
||||
|
||||
private async Task SendStatesAsync(WebSocket socket, string token, CancellationToken cancellationToken)
|
||||
{
|
||||
var pending = CreateSignal();
|
||||
void Signal() => pending.Writer.TryWrite(true);
|
||||
Published += Signal;
|
||||
pairing.TrustChanged += Signal;
|
||||
Signal();
|
||||
try
|
||||
{
|
||||
while (await pending.Reader.WaitToReadAsync(cancellationToken))
|
||||
{
|
||||
while (pending.Reader.TryRead(out _)) { }
|
||||
if (!pairing.Authorize(token)) return;
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(new { type = "state", state = Latest }, JsonOptions);
|
||||
await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, cancellationToken);
|
||||
}
|
||||
}
|
||||
finally { Published -= Signal; pairing.TrustChanged -= Signal; }
|
||||
}
|
||||
|
||||
private static Channel<bool> CreateSignal() => Channel.CreateBounded<bool>(new BoundedChannelOptions(1)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest, SingleReader = true, SingleWriter = false
|
||||
});
|
||||
private void RequestRefresh() => refresh.Writer.TryWrite(true);
|
||||
|
||||
private static async Task ReceiveCloseAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[1];
|
||||
// A receive observes disconnects. No commands or client payloads are accepted here.
|
||||
await socket.ReceiveAsync(buffer.AsMemory(), cancellationToken);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
media.Changed -= RequestRefresh;
|
||||
await stop.CancelAsync();
|
||||
if (polling is not null) await polling;
|
||||
await app.StopAsync();
|
||||
await app.DisposeAsync();
|
||||
stop.Dispose();
|
||||
clients.Dispose();
|
||||
}
|
||||
|
||||
private sealed record PairRequest(string? Code, string? DeviceName);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MusicBridge.Agent.Network;
|
||||
|
||||
// Windows DPAPI, scoped to the signed-in user. No UI or machine-wide key.
|
||||
internal static class UserProtection
|
||||
{
|
||||
public static byte[] Protect(byte[] bytes) => Transform(bytes, true);
|
||||
public static byte[] Unprotect(byte[] bytes) => Transform(bytes, false);
|
||||
|
||||
private static byte[] Transform(byte[] bytes, bool protect)
|
||||
{
|
||||
var input = new Blob { Size = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) };
|
||||
var output = new Blob();
|
||||
try
|
||||
{
|
||||
Marshal.Copy(bytes, 0, input.Data, bytes.Length);
|
||||
var ok = protect
|
||||
? CryptProtectData(ref input, null, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output)
|
||||
: CryptUnprotectData(ref input, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, 1, out output);
|
||||
if (!ok) throw new Win32Exception(Marshal.GetLastWin32Error(), "Не удалось открыть защищённое хранилище Windows.");
|
||||
var result = new byte[output.Size];
|
||||
Marshal.Copy(output.Data, result, 0, output.Size);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.Copy(new byte[input.Size], 0, input.Data, input.Size);
|
||||
Marshal.FreeHGlobal(input.Data);
|
||||
if (output.Data != IntPtr.Zero)
|
||||
{
|
||||
Marshal.Copy(new byte[output.Size], 0, output.Data, output.Size);
|
||||
LocalFree(output.Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct Blob { public int Size; public IntPtr Data; }
|
||||
[DllImport("crypt32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptProtectData(ref Blob input, string? description, IntPtr entropy, IntPtr reserved,
|
||||
IntPtr prompt, uint flags, out Blob output);
|
||||
[DllImport("crypt32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CryptUnprotectData(ref Blob input, IntPtr description, IntPtr entropy, IntPtr reserved,
|
||||
IntPtr prompt, uint flags, out Blob output);
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern IntPtr LocalFree(IntPtr memory);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
# MusicBridge — план проекта
|
||||
|
||||
Обновлено: 2026-09-09. Репозиторий находится в `MusicBridge.Agent/`.
|
||||
|
||||
## Цель
|
||||
|
||||
Личный пульт на **нативном iPhone-приложении** для музыки на Windows в одной
|
||||
Wi-Fi сети. Звук играет на ПК. Телефон показывает трек, исполнителя, обложку,
|
||||
позицию и позволяет управлять воспроизведением и системной громкостью ПК.
|
||||
|
||||
## Решения пользователя — не менять без его указания
|
||||
|
||||
- Windows + iPhone; своего Mac сейчас нет.
|
||||
- **Не делать Safari, PWA, веб-страницы или веб-приложение.** Клиент — Swift/SwiftUI.
|
||||
- iPhone-часть пока не начинать. Сначала довести Windows-агент и протокол.
|
||||
- Работать по этапам, самостоятельно редактировать файлы и проверять сборку.
|
||||
- Основные источники: Яндекс Музыка и SoundCloud в браузере, через текущую
|
||||
системную медиасессию Windows.
|
||||
- Нужны безопасное сопряжение, автоматическое переподключение, затем поиск ПК.
|
||||
- В будущем желательны Lock Screen, Dynamic Island и системные команды iOS.
|
||||
|
||||
## Границы возможностей
|
||||
|
||||
- «Любой звук» относится к системной громкости. Метаданные и команды зависят от
|
||||
того, что конкретный плеер передаёт в Windows Media Session.
|
||||
- Позиция вычисляется из Position + времени после LastUpdatedTime с учётом
|
||||
PlaybackRate. Нельзя считать Position постоянно обновляемым значением.
|
||||
- Не обещать работу перемотки или переключений без проверки возможностей плеера.
|
||||
- Для сборки/подписи нативного iOS-приложения потребуется доступ к macOS/Xcode;
|
||||
способ установки на личный iPhone нужно выбрать перед началом iOS-этапа.
|
||||
- Возможности iOS в фоне, Lock Screen и Dynamic Island исследовать отдельно.
|
||||
Не имитировать воспроизведение беззвучным аудио ради фоновой работы.
|
||||
- HTTPS/WebSocket внутри агента — транспорт для нативного клиента, не веб-интерфейс.
|
||||
|
||||
## Архитектура
|
||||
|
||||
Windows Media Session → общий MediaService → HTTPS/WSS → будущий SwiftUI-клиент.
|
||||
Windows Core Audio → системная громкость устройства вывода по умолчанию.
|
||||
Консоль и сеть используют одни команды и одни правила расчёта позиции.
|
||||
|
||||
## Этапы и TODO
|
||||
|
||||
### 1. Windows: чтение и локальное управление
|
||||
|
||||
- [x] Название, исполнитель, источник, состояние и длительность.
|
||||
- [x] Вычисление текущей позиции с учётом времени и скорости.
|
||||
- [x] Play/pause, следующий/предыдущий трек, перемотка ±10 секунд.
|
||||
- [x] Системная громкость ±5%, mute.
|
||||
- [x] Проверки поддержки команд и обработка отказов.
|
||||
- [x] Сборка исходной консольной версии: 0 ошибок, 0 предупреждений.
|
||||
- [x] Проверен запуск с реальным плеером и чтение громкости.
|
||||
- [ ] Полная ручная проверка кнопок в Яндекс Музыке и SoundCloud.
|
||||
|
||||
### 2. Windows: общий сервис и сетевой протокол
|
||||
|
||||
- [x] Вынести чтение/команды из Program.cs в общий сервис.
|
||||
- [x] Добавить абсолютную перемотку и установку громкости для ползунков iPhone.
|
||||
- [x] Версионированное JSON-состояние, возможности плеера, результаты команд.
|
||||
- [x] HTTPS для команд, WSS для обновлений состояния; без веб-интерфейса.
|
||||
- [x] Ограниченное по времени сопряжение по коду, аутентификация команд.
|
||||
- [x] Сертификат агента и отпечаток для будущего закрепления сертификата на iPhone.
|
||||
- [x] Проверки протокола, ошибочного ввода и доступа без авторизации.
|
||||
- [x] Обновить этот файл и документацию по фактическим результатам.
|
||||
|
||||
### 3. Довести агент перед iPhone
|
||||
|
||||
- [x] Обложка из медиасессии; ограничение размера, кеш и смена трека.
|
||||
- [x] Системные события Windows для быстрых обновлений; таймер для прогресса.
|
||||
- [x] Постоянные доверенные устройства, безопасное хранение хешей токенов, отзыв доступа.
|
||||
- [x] Постоянный сертификат, защищённое хранилище Windows DPAPI, блокировка второго агента.
|
||||
- [ ] Обновление истекающего сертификата с явным повторным сопряжением.
|
||||
- [ ] QR-сопряжение с адресом и отпечатком сертификата.
|
||||
- [x] Заготовки: payload для QR, проверка адреса/срока, клавиша I.
|
||||
- [x] Заготовки: DNS-SD descriptor и авторизованный /v1/info; без multicast.
|
||||
- [ ] Bonjour/mDNS для поиска из нативного приложения.
|
||||
- [ ] Проверить подключение со второго устройства в домашней сети.
|
||||
- [ ] Проверить смену плеера/аудиоустройства, отсутствие плеера, сон/пробуждение,
|
||||
отключение Wi-Fi и несколько клиентов.
|
||||
- [ ] Tray-интерфейс, автозапуск, удобная установка; правила брандмауэра только
|
||||
для нужной локальной сети, без открытия доступа в интернет.
|
||||
|
||||
### 4. Нативное приложение iPhone — отложено пользователем
|
||||
|
||||
- [ ] Согласовать macOS/Xcode, подпись и установку на личный iPhone.
|
||||
- [ ] SwiftUI: подключение, список компьютеров, сопряжение и сохранение в Keychain.
|
||||
- [ ] Обложка, трек, исполнитель, плавный прогресс, кнопки и ползунки.
|
||||
- [ ] Проверка сертификата ПК, переподключение и восстановление состояния.
|
||||
- [ ] Отключать недоступные команды, отображать ошибки и потерю связи.
|
||||
- [ ] Проверка на физическом iPhone.
|
||||
|
||||
### 5. Дополнительные возможности iOS
|
||||
|
||||
- [ ] Исследовать и проверить Live Activities / Dynamic Island / App Intents.
|
||||
- [ ] Зафиксировать реальные ограничения системного управления и фона.
|
||||
|
||||
## Как продолжать работу
|
||||
|
||||
1. Прочитать этот файл, затем текущие исходники и `git status`.
|
||||
2. Не переписывать уже работающие функции без необходимости.
|
||||
3. Выполнить конкретный этап, проверить сборку и соответствующие проверки.
|
||||
4. Отдельно указать: что проверено автоматически, что на реальной Windows,
|
||||
а что ещё требует телефона/ручной проверки.
|
||||
5. Обновить чекбоксы, результаты и следующий шаг здесь до завершения работы.
|
||||
|
||||
## Проверки и следующий шаг
|
||||
|
||||
2026-09-09:
|
||||
|
||||
- Последний этап — заготовки подключения: Release-сборка без ошибок/предупреждений,
|
||||
**82 автоматические проверки прошли** (`dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build`).
|
||||
Проверены payload, адреса, истёкший код, descriptor и авторизация /v1/info.
|
||||
Debug-сборку удерживал запущенный агент; его не останавливали. Новая сборка в Release.
|
||||
Реальная медиасессия в этом прогоне не проверялась; проверки ниже — с прошлого этапа.
|
||||
|
||||
- Сборка агента и тестового проекта: **0 ошибок, 0 предупреждений**.
|
||||
- `dotnet run --project tests/MusicBridge.Agent.Tests.csproj -- --live-read`:
|
||||
**74 проверки прошло** (71 общая + 3 с реальной медиасессией/обложкой).
|
||||
Проверены расчёт позиции, валидация, срок/одноразовость кода, блокировка после
|
||||
10 попыток, авторизация HTTPS/WSS, ошибки JSON и размер запроса, доставка
|
||||
абсолютной перемотки тестовому сервису, поток состояния и закрытие WebSocket.
|
||||
Добавлены проверки размера/формата обложки, отмены чтения, очистки старого кеша,
|
||||
авторизации скачивания и 404, доставки событий без ожидания таймера,
|
||||
объединения 1000 уведомлений и снятия подписки сервером при остановке.
|
||||
Проверены сохранение/повторное открытие сертификата и устройств, реальный HTTPS
|
||||
со восстановленным сертификатом/токеном, DPAPI, блокировка второго экземпляра,
|
||||
ошибки записи, отказ от подмены повреждённого хранилища, постоянный отзыв доступа,
|
||||
отключение открытого WSS и запрет HTTP-команд отозванным токеном.
|
||||
- В тестах добавлена обработка исключений верхнего уровня. Проверен
|
||||
`--test-failure`: текст `TEST FAILED` и выход 1 вместо необработанного исключения,
|
||||
вызвавшего ранее окно ошибки Windows. Тестовые файлы создаются отдельно от
|
||||
рабочего хранилища и удаляются после проверки.
|
||||
- Эти проверки не меняют реальную музыку/громкость. Ручная проверка команд
|
||||
конкретных плееров и физического iPhone всё ещё не выполнена.
|
||||
- Обновлённый агент запущен в обычном сеансе Windows на loopback-порту 18765:
|
||||
HTTPS-сервер стартовал, реальная медиасессия, состояние Paused, длительность
|
||||
и системная громкость прочитаны. После проверки тестовый запуск остановлен.
|
||||
- После добавления обложки в режиме `--live-read` прочитан реальный PNG размером
|
||||
438760 байт. Повторное чтение состояния сохранило тот же ID кешированной обложки.
|
||||
Подписки WinRT создаются успешно. Доставка событий проверена тестовым сервисом;
|
||||
ручная смена треков/плееров во время работы агента ещё требует проверки.
|
||||
- В песочнице Windows Media Session может быть недоступна. Не выдавать такую
|
||||
ошибку за отсутствие Windows API на машине пользователя.
|
||||
|
||||
Текущие ограничения сетевого этапа:
|
||||
|
||||
- Состояние обновляется по событиям Windows и резервному таймеру ~500 мс.
|
||||
Метаданные/обложка перечитываются по событию либо через 10 секунд.
|
||||
- В кеше только текущая обложка, максимум 2 МиБ; PNG/JPEG/GIF/WebP по сигнатуре,
|
||||
без декодирования на сервере. Скачивание требует авторизации.
|
||||
- Сертификат и хеши токенов сохраняются в `%LOCALAPPDATA%\MusicBridge\identity.dat`
|
||||
под Windows DPAPI текущего пользователя. При обычном перезапуске доверие сохраняется.
|
||||
В консоли `1`…`8` отзывают доступ устройства по номеру; `B` открывает сопряжение.
|
||||
- Сертификат действует 5 лет; автоматическое обновление ещё не реализовано.
|
||||
При повреждении/недоступности хранилища или истечении сертификата агент завершается
|
||||
с ошибкой и не создаёт молча новую идентичность. Восстановление описано в PROTOCOL.md.
|
||||
- По умолчанию только loopback. `--lan` включает IPv4-сеть; брандмауэр не меняется.
|
||||
- QR, mDNS, iPhone UI и интеграции iOS ещё не реализованы.
|
||||
|
||||
Следующий шаг: этап 3 — QR-сопряжение (адрес, код, закрепляемый отпечаток),
|
||||
затем Bonjour/mDNS и проверка с другого устройства. iPhone-клиент пока не начинать.
|
||||
Заготовки готовы; точные границы и следующие шаги — в `CONNECTION-ROADMAP.md`.
|
||||
Начать разработку iOS только после новой команды пользователя.
|
||||
|
||||
## Карта файлов
|
||||
|
||||
- `Program.cs`: запуск, аргументы, консольные клавиши и отображение состояния.
|
||||
- `Media/MediaService.cs`: чтение Windows Media Session и выполнение всех команд.
|
||||
- `Media/TimelineMath.cs`: вычисление позиции и границ перемотки.
|
||||
- `Media/Contracts.cs`: состояние, возможности, команды и их валидация.
|
||||
- `Media/ArtworkCache.cs`: ограниченное чтение обложки, определение типа и кеш по хешу.
|
||||
- `SystemVolume.cs`: Windows Core Audio, громкость устройства вывода по умолчанию.
|
||||
- `Network/RemoteServer.cs`: HTTPS API и обложки, WSS, события/таймер и лимиты.
|
||||
- `Network/PairingService.cs`: код сопряжения, доверенные устройства, проверка/отзыв токенов.
|
||||
- `Network/IdentityStore.cs`: постоянный сертификат и устройства, атомарная запись и блокировка.
|
||||
- `Network/UserProtection.cs`: Windows DPAPI текущего пользователя.
|
||||
- `Network/AgentCertificate.cs`: создание сертификата TLS для первого запуска.
|
||||
- `PROTOCOL.md`: команды запуска и точный контракт будущего iPhone-клиента.
|
||||
- `CONNECTION-ROADMAP.md`: заготовки QR/DNS-SD и шаги будущего подключения.
|
||||
- `Network/ConnectionInvitation.cs`: данные для QR, без генерации картинки.
|
||||
- `Network/DiscoveryDescriptor.cs`: описание DNS-SD, без сетевой рассылки.
|
||||
- `tests/`: исполняемые проверки без дополнительных тестовых NuGet-пакетов.
|
||||
+243
@@ -0,0 +1,243 @@
|
||||
# MusicBridge protocol v1
|
||||
|
||||
Транспорт для будущего **нативного iPhone-клиента**. HTML, Safari/PWA и веб-клиента нет.
|
||||
|
||||
## Запуск
|
||||
|
||||
Из каталога репозитория:
|
||||
|
||||
```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` для идентификации агента и контракт
|
||||
данных будущего QR/DNS-SD. Подробности — в [CONNECTION-ROADMAP.md](CONNECTION-ROADMAP.md).
|
||||
Клавиша `I` выводит payload; QR-картинка и автоматическое обнаружение пока не реализованы.
|
||||
|
||||
`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. После потери соединения клиент
|
||||
переподключается с задержкой и получает полный снимок. Медленный клиент получает
|
||||
последнее состояние; промежуточные снимки не накапливаются в неограниченной очереди.
|
||||
Запросы с 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).
|
||||
+146
-69
@@ -1,82 +1,159 @@
|
||||
using System.Text;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using MusicBridge.Agent.Media;
|
||||
using MusicBridge.Agent.Network;
|
||||
using Windows.Media.Control;
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
Console.Title = "MusicBridge Agent";
|
||||
|
||||
Console.WriteLine("Подключаемся к медиаплееру Windows...");
|
||||
|
||||
var manager =
|
||||
await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
||||
|
||||
while (true)
|
||||
var lan = false;
|
||||
var port = 8765;
|
||||
for (var i = 0; i < args.Length; i++)
|
||||
{
|
||||
try
|
||||
if (args[i] == "--lan") lan = true;
|
||||
else if (args[i] == "--port" && i + 1 < args.Length && int.TryParse(args[++i], out var parsed) && parsed is > 0 and <= 65535)
|
||||
port = parsed;
|
||||
else
|
||||
{
|
||||
var session = manager.GetCurrentSession();
|
||||
|
||||
if (session is null)
|
||||
{
|
||||
Console.Clear();
|
||||
Console.WriteLine("MUSICBRIDGE");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Медиаплеер не найден.");
|
||||
Console.WriteLine("Включи музыку в браузере или приложении.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var media = await session.TryGetMediaPropertiesAsync();
|
||||
var playback = session.GetPlaybackInfo();
|
||||
var timeline = session.GetTimelineProperties();
|
||||
var position = timeline.Position;
|
||||
var updatedAt = timeline.LastUpdatedTime;
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (playback.PlaybackStatus ==
|
||||
GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing
|
||||
&& updatedAt > DateTimeOffset.UnixEpoch
|
||||
&& updatedAt <= now)
|
||||
{
|
||||
var elapsed = (now - updatedAt).TotalSeconds;
|
||||
var rate = playback.PlaybackRate ?? 1.0;
|
||||
|
||||
position += TimeSpan.FromSeconds(elapsed * rate);
|
||||
}
|
||||
|
||||
if (position < timeline.StartTime)
|
||||
position = timeline.StartTime;
|
||||
|
||||
if (timeline.EndTime > timeline.StartTime
|
||||
&& position > timeline.EndTime)
|
||||
position = timeline.EndTime;
|
||||
|
||||
Console.Clear();
|
||||
Console.WriteLine("MUSICBRIDGE");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine($"Трек: {media.Title}");
|
||||
Console.WriteLine($"Исполнитель: {media.Artist}");
|
||||
Console.WriteLine($"Состояние: {playback.PlaybackStatus}");
|
||||
Console.WriteLine($"Позиция: {FormatTime(position)}");
|
||||
Console.WriteLine($"От плеера: {FormatTime(timeline.Position)}");
|
||||
Console.WriteLine($"Обновлено: {updatedAt.ToLocalTime():HH:mm:ss}");
|
||||
Console.WriteLine($"Конец трека: {FormatTime(timeline.EndTime)}");
|
||||
Console.WriteLine($"Источник: {session.SourceAppUserModelId}");
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Выход: Ctrl + C");
|
||||
Console.WriteLine("Запуск: dotnet run -- [--lan] [--port 8765]");
|
||||
Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет.");
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Не удалось прочитать плеер: {ex.Message}");
|
||||
}
|
||||
|
||||
await Task.Delay(1000);
|
||||
}
|
||||
|
||||
static string FormatTime(TimeSpan time)
|
||||
using var shutdown = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) => { e.Cancel = true; shutdown.Cancel(); };
|
||||
try
|
||||
{
|
||||
using var identity = new IdentityStore(IdentityStore.DefaultDirectory);
|
||||
Console.WriteLine("Подключаемся к медиаплееру Windows...");
|
||||
var manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
||||
using var media = new MediaService(manager);
|
||||
var pairing = new PairingService(store: identity);
|
||||
if (pairing.Devices.Count == 0) pairing.Open();
|
||||
var certificate = identity.Certificate;
|
||||
await using var server = new RemoteServer(media, pairing, certificate, lan, port);
|
||||
await server.StartAsync(shutdown.Token);
|
||||
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) { }
|
||||
}
|
||||
var message = "Готов к управлению.";
|
||||
IReadOnlyList<TrustedDevice> displayedDevices = [];
|
||||
var nextRefresh = DateTimeOffset.MinValue;
|
||||
while (!shutdown.IsCancellationRequested)
|
||||
{
|
||||
if (!Console.IsInputRedirected && Console.KeyAvailable)
|
||||
{
|
||||
var key = Console.ReadKey(intercept: true).Key;
|
||||
if (key == ConsoleKey.Q) break;
|
||||
if (key is >= ConsoleKey.D1 and <= ConsoleKey.D8)
|
||||
{
|
||||
var index = (int)key - (int)ConsoleKey.D1;
|
||||
if (index < displayedDevices.Count)
|
||||
{
|
||||
try
|
||||
{
|
||||
var device = displayedDevices[index];
|
||||
message = pairing.Revoke(device.Id) ? $"Доступ отозван: {device.Name}." : "Устройство уже отключено.";
|
||||
}
|
||||
catch (Exception ex) { message = $"Не удалось сохранить отзыв доступа: {ex.Message}"; }
|
||||
}
|
||||
}
|
||||
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()}"));
|
||||
}
|
||||
else if (key == ConsoleKey.B)
|
||||
{
|
||||
pairing.Open();
|
||||
message = "Новый код сопряжения действует 5 минут.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var command = key switch
|
||||
{
|
||||
ConsoleKey.Spacebar => new MediaCommand("toggle"),
|
||||
ConsoleKey.N => new MediaCommand("next"),
|
||||
ConsoleKey.P => new MediaCommand("previous"),
|
||||
ConsoleKey.LeftArrow => new MediaCommand("seekRelative", -10),
|
||||
ConsoleKey.RightArrow => new MediaCommand("seekRelative", 10),
|
||||
ConsoleKey.UpArrow => new MediaCommand("volumeRelative", 0.05),
|
||||
ConsoleKey.DownArrow => new MediaCommand("volumeRelative", -0.05),
|
||||
ConsoleKey.M => new MediaCommand("toggleMute"),
|
||||
_ => null
|
||||
};
|
||||
if (command is not null)
|
||||
message = (await media.ExecuteAsync(command, shutdown.Token)).Message;
|
||||
}
|
||||
nextRefresh = DateTimeOffset.MinValue;
|
||||
}
|
||||
if (DateTimeOffset.UtcNow >= nextRefresh)
|
||||
{
|
||||
if (!Console.IsOutputRedirected) Console.Clear();
|
||||
var state = server.Latest;
|
||||
Console.WriteLine("MUSICBRIDGE\n");
|
||||
if (state.HasSession)
|
||||
{
|
||||
Console.WriteLine($"Трек: {state.Title}");
|
||||
Console.WriteLine($"Исполнитель: {state.Artist}");
|
||||
Console.WriteLine($"Состояние: {state.PlaybackStatus}");
|
||||
Console.WriteLine($"Позиция: {FormatTime(state.PositionSeconds)} / {FormatTime(state.EndSeconds)}");
|
||||
Console.WriteLine($"Источник: {state.Source}");
|
||||
Console.WriteLine($"Обложка: {(state.ArtworkId is not null ? "доступна" : state.ArtworkError ?? "нет у плеера")}");
|
||||
}
|
||||
else Console.WriteLine("Медиаплеер не найден. Включи музыку в браузере или приложении.");
|
||||
if (state.MediaError is not null) Console.WriteLine($"Ошибка плеера: {state.MediaError}");
|
||||
if (state.Volume is float volume)
|
||||
Console.WriteLine($"Громкость: {volume:P0}{(state.Muted == true ? " (звук выключен)" : "")}");
|
||||
else Console.WriteLine($"Громкость недоступна: {state.VolumeError}");
|
||||
Console.WriteLine($"\nПодключение: {string.Join(" | ", addresses)}");
|
||||
Console.WriteLine(lan ? "Режим локальной сети (IPv4)." : "Режим только этого ПК; для Wi-Fi запусти с --lan.");
|
||||
Console.WriteLine($"SHA-256 сертификата: {fingerprint}");
|
||||
var window = pairing.Current;
|
||||
Console.WriteLine(window is null ? "Сопряжение закрыто. B — открыть заново."
|
||||
: $"Код сопряжения: {window.Code} (до {window.ExpiresAt.ToLocalTime():HH:mm:ss})");
|
||||
displayedDevices = pairing.Devices;
|
||||
Console.WriteLine($"Доверенные устройства: {displayedDevices.Count}/8 (клавиша с номером — отозвать доступ)");
|
||||
for (var i = 0; i < displayedDevices.Count; i++)
|
||||
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($"\n{message}");
|
||||
nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1);
|
||||
}
|
||||
await Task.Delay(50, shutdown.Token);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Не удалось запустить или продолжить работу агента: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
static string FormatTime(double seconds)
|
||||
{
|
||||
var time = TimeSpan.FromSeconds(seconds);
|
||||
return time.TotalHours >= 1
|
||||
? $"{(int)time.TotalHours}:{time.Minutes:00}:{time.Seconds:00}"
|
||||
: $"{(int)time.TotalMinutes}:{time.Seconds:00}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace MusicBridge.Agent;
|
||||
|
||||
// Obtain the current default output for each operation, including after device changes.
|
||||
internal static class SystemVolume
|
||||
{
|
||||
public static void Set(float level) => WithEndpoint(endpoint =>
|
||||
{
|
||||
if (!float.IsFinite(level) || level < 0 || level > 1)
|
||||
throw new ArgumentOutOfRangeException(nameof(level));
|
||||
var context = Guid.Empty;
|
||||
endpoint.SetMasterVolumeLevelScalar(level, ref context);
|
||||
return true;
|
||||
});
|
||||
|
||||
public static (float Level, bool Muted) Read() => WithEndpoint(endpoint =>
|
||||
{
|
||||
endpoint.GetMasterVolumeLevelScalar(out var level);
|
||||
endpoint.GetMute(out var muted);
|
||||
return (level, muted);
|
||||
});
|
||||
|
||||
public static void Change(float delta) => WithEndpoint(endpoint =>
|
||||
{
|
||||
endpoint.GetMasterVolumeLevelScalar(out var level);
|
||||
var context = Guid.Empty;
|
||||
endpoint.SetMasterVolumeLevelScalar(Math.Clamp(level + delta, 0f, 1f), ref context);
|
||||
return true;
|
||||
});
|
||||
|
||||
public static void ToggleMute() => WithEndpoint(endpoint =>
|
||||
{
|
||||
endpoint.GetMute(out var muted);
|
||||
var context = Guid.Empty;
|
||||
endpoint.SetMute(!muted, ref context);
|
||||
return true;
|
||||
});
|
||||
|
||||
private static T WithEndpoint<T>(Func<IAudioEndpointVolume, T> action)
|
||||
{
|
||||
var enumerator = (IMMDeviceEnumerator)new MMDeviceEnumerator();
|
||||
IMMDevice? device = null;
|
||||
object? volume = null;
|
||||
try
|
||||
{
|
||||
enumerator.GetDefaultAudioEndpoint(0, 1, out device); // Render, Multimedia
|
||||
var iid = typeof(IAudioEndpointVolume).GUID;
|
||||
device.Activate(ref iid, 23, IntPtr.Zero, out volume);
|
||||
return action((IAudioEndpointVolume)volume);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (volume is not null) Marshal.ReleaseComObject(volume);
|
||||
if (device is not null) Marshal.ReleaseComObject(device);
|
||||
Marshal.ReleaseComObject(enumerator);
|
||||
}
|
||||
}
|
||||
|
||||
[ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
|
||||
private class MMDeviceEnumerator { }
|
||||
|
||||
// COM declarations must preserve the native vtable order, including unused methods.
|
||||
// HRESULT failures are translated to exceptions by the COM interop runtime.
|
||||
[ComImport, Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDeviceEnumerator
|
||||
{
|
||||
void EnumAudioEndpoints(int flow, uint mask, out IntPtr devices);
|
||||
void GetDefaultAudioEndpoint(int flow, int role, out IMMDevice device);
|
||||
}
|
||||
|
||||
[ComImport, Guid("D666063F-1587-4E43-81F1-B948E807363F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IMMDevice
|
||||
{
|
||||
void Activate(ref Guid iid, uint context, IntPtr activation,
|
||||
[MarshalAs(UnmanagedType.IUnknown)] out object instance);
|
||||
}
|
||||
|
||||
[ComImport, Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IAudioEndpointVolume
|
||||
{
|
||||
void RegisterControlChangeNotify(IntPtr callback);
|
||||
void UnregisterControlChangeNotify(IntPtr callback);
|
||||
void GetChannelCount(out uint count);
|
||||
void SetMasterVolumeLevel(float level, ref Guid context);
|
||||
void SetMasterVolumeLevelScalar(float level, ref Guid context);
|
||||
void GetMasterVolumeLevel(out float level);
|
||||
void GetMasterVolumeLevelScalar(out float level);
|
||||
void SetChannelVolumeLevel(uint channel, float level, ref Guid context);
|
||||
void SetChannelVolumeLevelScalar(uint channel, float level, ref Guid context);
|
||||
void GetChannelVolumeLevel(uint channel, out float level);
|
||||
void GetChannelVolumeLevelScalar(uint channel, out float level);
|
||||
void SetMute([MarshalAs(UnmanagedType.Bool)] bool muted, ref Guid context);
|
||||
void GetMute([MarshalAs(UnmanagedType.Bool)] out bool muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../MusicBridge.Agent.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.11" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,355 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using MusicBridge.Agent.Media;
|
||||
using MusicBridge.Agent.Network;
|
||||
|
||||
try
|
||||
{
|
||||
await RunAsync(args);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"TEST FAILED: {ex.GetType().Name}: {ex.Message}");
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
static async Task RunAsync(string[] args)
|
||||
{
|
||||
if (args.Contains("--test-failure")) throw new InvalidOperationException("Controlled failure to verify console-only reporting.");
|
||||
var checks = 0;
|
||||
void Check(bool condition, string name)
|
||||
{
|
||||
if (!condition) throw new InvalidOperationException($"FAIL: {name}");
|
||||
checks++;
|
||||
Console.WriteLine($"PASS: {name}");
|
||||
}
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var invitation = ConnectionInvitation.Create(new Uri("https://192.168.1.10:8765"), new string('A', 64), new("01234567", now.AddMinutes(5)), now);
|
||||
Check(invitation.Version == 1 && invitation.Code == "01234567", "invitation preserves leading zero in pairing code");
|
||||
var encodedInvitation = invitation.ToQrPayload().Split("data=")[1].Replace('-', '+').Replace('_', '/');
|
||||
encodedInvitation = encodedInvitation.PadRight((encodedInvitation.Length + 3) / 4 * 4, '=');
|
||||
using (var decoded = JsonDocument.Parse(Convert.FromBase64String(encodedInvitation)))
|
||||
Check(decoded.RootElement.GetProperty("endpoint").GetString() == "https://192.168.1.10:8765", "QR payload contains versioned endpoint JSON");
|
||||
foreach (var endpoint in new[] { "https://127.0.0.1:8765", "http://192.168.1.2", "https://8.8.8.8", "https://192.168.1.2/?token=x", "https://user:pass@192.168.1.2" })
|
||||
Check(!ConnectionInvitation.IsLocalEndpoint(new Uri(endpoint)), "invitation rejects unsafe or unusable endpoint");
|
||||
var expiredInvitationRejected = false;
|
||||
try { ConnectionInvitation.Create(new Uri("https://10.0.0.1"), new string('A', 64), new("01234567", now), now); }
|
||||
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");
|
||||
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");
|
||||
Check(TimelineMath.Position(-5, 3, 200, false, now, now, 1) == 3, "position clamps to start");
|
||||
Check(TimelineMath.Position(20, 0, 200, true, now.AddSeconds(5), now, 1) == 20, "future update ignored");
|
||||
Check(TimelineMath.Position(20, 0, 200, true, DateTimeOffset.UnixEpoch, now, 1) == 20, "missing update ignored");
|
||||
Check(TimelineMath.SeekRange(10, 200, 20, 300) == (20, 200), "seek range intersects timeline");
|
||||
Check(TimelineMath.SeekRange(0, 0, 20, 100) == (20, 100), "live seek range without duration");
|
||||
foreach (var command in new[] { new MediaCommand("volume", -0.1), new("volume", 1.1), new("volume", double.NaN),
|
||||
new("seek", double.PositiveInfinity), new("seek", -1), new("seek"), new("next", 1), new("unknown"), new("pause", Id: new string('a', 65)) })
|
||||
Check(CommandValidation.Validate(command) is not null, $"reject invalid {command.Type}/{command.Value}");
|
||||
Check(CommandValidation.Validate(new("seek", 125.5)) is null, "absolute seek accepted");
|
||||
Check(CommandValidation.Validate(new("volume", 0)) is null, "zero volume accepted");
|
||||
Check(CommandValidation.Validate(new("volume", 1)) is null, "full volume accepted");
|
||||
|
||||
var clock = new TestClock(now);
|
||||
var trust = new PairingService(clock);
|
||||
Check(!trust.Pair("00000000").Success, "pairing closed initially");
|
||||
var expired = trust.Open();
|
||||
clock.Now = now.AddMinutes(6);
|
||||
Check(!trust.Pair(expired.Code).Success && trust.Current is null, "pairing code expires");
|
||||
var locked = trust.Open();
|
||||
for (var i = 0; i < 10; i++) trust.Pair("invalid");
|
||||
Check(!trust.Pair(locked.Code).Success, "ten failed attempts close pairing");
|
||||
var current = trust.Open();
|
||||
var paired = trust.Pair(current.Code);
|
||||
Check(paired.Success && trust.Authorize(paired.Token), "valid pairing authorizes token");
|
||||
Check(!trust.Pair(current.Code).Success, "pairing code is single use");
|
||||
Check(!trust.Authorize(new string('0', 64)), "wrong token rejected");
|
||||
Check(!new PairingService().Authorize(paired.Token), "isolated in-memory test services do not share trust");
|
||||
|
||||
var identityDirectory = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "MusicBridge-Test-" + Guid.NewGuid().ToString("N")));
|
||||
Directory.CreateDirectory(identityDirectory);
|
||||
try
|
||||
{
|
||||
string savedToken;
|
||||
string savedId;
|
||||
string savedFingerprint;
|
||||
var identityPath = Path.Combine(identityDirectory, "identity.dat");
|
||||
using (var identity = new IdentityStore(identityDirectory))
|
||||
{
|
||||
savedFingerprint = identity.Certificate.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
var persistent = new PairingService(store: identity);
|
||||
var newDevice = persistent.Pair(persistent.Open().Code, "My iPhone");
|
||||
Check(newDevice.Success && newDevice.DeviceId is not null, "persistent pairing returns stable device id");
|
||||
savedToken = newDevice.Token!;
|
||||
savedId = newDevice.DeviceId!;
|
||||
var clear = UserProtection.Unprotect(File.ReadAllBytes(identityPath));
|
||||
try
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(clear);
|
||||
Check(!text.Contains(savedToken) && text.Contains(persistent.Devices[0].TokenHash), "disk identity stores token hash, not bearer token");
|
||||
}
|
||||
finally { CryptographicOperations.ZeroMemory(clear); }
|
||||
var lockedOut = false;
|
||||
try { using var secondOwner = new IdentityStore(identityDirectory); }
|
||||
catch (IOException) { lockedOut = true; }
|
||||
Check(lockedOut, "second agent cannot overwrite active identity");
|
||||
}
|
||||
using (var reopened = new IdentityStore(identityDirectory))
|
||||
{
|
||||
Check(reopened.Certificate.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint, "certificate fingerprint survives restart");
|
||||
var persistent = new PairingService(store: reopened);
|
||||
Check(persistent.Authorize(savedToken) && persistent.Devices[0].Name == "My iPhone", "trusted token and device name survive restart");
|
||||
Check(persistent.Current is null, "restored trust does not open pairing window");
|
||||
await using (var restoredServer = new RemoteServer(new FakeMedia(), persistent, reopened.Certificate, false, 0))
|
||||
{
|
||||
await restoredServer.StartAsync();
|
||||
using var restoredHandler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == savedFingerprint
|
||||
};
|
||||
using var restoredClient = new HttpClient(restoredHandler)
|
||||
{
|
||||
BaseAddress = new Uri(restoredServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
restoredClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken);
|
||||
Check((await restoredClient.GetAsync("/v1/state")).IsSuccessStatusCode, "restored certificate and token work over real HTTPS");
|
||||
}
|
||||
var badName = persistent.Pair(persistent.Open().Code, "bad\nname");
|
||||
Check(!badName.Success && badName.Code == "invalid_name", "control characters in device name rejected");
|
||||
|
||||
// Force an atomic-write failure without touching the valid identity file.
|
||||
var blockedTemporary = identityPath + ".tmp";
|
||||
Directory.CreateDirectory(blockedTemporary);
|
||||
try
|
||||
{
|
||||
var failedPair = persistent.Pair(persistent.Open().Code, "Cannot save");
|
||||
Check(!failedPair.Success && failedPair.Code == "storage_failed" && persistent.Devices.Count == 1,
|
||||
"failed persistence never authorizes new device");
|
||||
var revokeFailed = false;
|
||||
try { persistent.Revoke(savedId); }
|
||||
catch (IOException) { revokeFailed = true; }
|
||||
catch (UnauthorizedAccessException) { revokeFailed = true; }
|
||||
Check(revokeFailed && persistent.Authorize(savedToken), "failed revocation preserves consistent active trust");
|
||||
}
|
||||
finally { Directory.Delete(blockedTemporary); }
|
||||
Check(persistent.Revoke(savedId) && !persistent.Authorize(savedToken), "revocation removes token authorization");
|
||||
}
|
||||
using (var reopened = new IdentityStore(identityDirectory))
|
||||
Check(!new PairingService(store: reopened).Authorize(savedToken), "revocation survives restart");
|
||||
|
||||
var corrupted = File.ReadAllBytes(identityPath);
|
||||
corrupted[^1] ^= 0xff;
|
||||
File.WriteAllBytes(identityPath, corrupted);
|
||||
var corruptionRejected = false;
|
||||
try { using var broken = new IdentityStore(identityDirectory); }
|
||||
catch (System.ComponentModel.Win32Exception) { corruptionRejected = true; }
|
||||
Check(corruptionRejected && File.ReadAllBytes(identityPath).SequenceEqual(corrupted),
|
||||
"corrupt identity is rejected without silent replacement");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Only files owned by this test in its newly generated directory are removed.
|
||||
foreach (var name in new[] { "identity.dat", "identity.lock", "identity.dat.tmp" })
|
||||
{
|
||||
var file = Path.Combine(identityDirectory, name);
|
||||
if (File.Exists(file)) File.Delete(file);
|
||||
}
|
||||
Directory.Delete(identityDirectory);
|
||||
}
|
||||
|
||||
var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=");
|
||||
var cache = new ArtworkCache();
|
||||
var cover = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None);
|
||||
Check(cover.ContentType == "image/png" && cover.Bytes.SequenceEqual(png), "cover preserves bytes and detects PNG");
|
||||
var repeated = await cache.LoadAsync(new MemoryStream(png), CancellationToken.None);
|
||||
Check(cover.Id == repeated.Id, "identical cover keeps content id");
|
||||
Check(cache.Get(cover.Id) is not null && cache.Get("unknown") is null, "cover lookup uses content id");
|
||||
foreach (var invalidImage in new[] { Array.Empty<byte>(), Encoding.UTF8.GetBytes("<svg></svg>"), new byte[ArtworkCache.MaxBytes + 1] })
|
||||
{
|
||||
await cache.LoadAsync(new MemoryStream(png), CancellationToken.None);
|
||||
var rejected = false;
|
||||
try { await cache.LoadAsync(new MemoryStream(invalidImage), CancellationToken.None); }
|
||||
catch (InvalidDataException) { rejected = true; }
|
||||
Check(rejected && cache.Current is null, "invalid or oversized artwork clears old cover");
|
||||
}
|
||||
using (var canceled = new CancellationTokenSource())
|
||||
{
|
||||
canceled.Cancel();
|
||||
var canceledRead = false;
|
||||
try { await cache.LoadAsync(new MemoryStream(png), canceled.Token); }
|
||||
catch (OperationCanceledException) { canceledRead = true; }
|
||||
Check(canceledRead && cache.Current is null, "artwork read supports cancellation");
|
||||
}
|
||||
|
||||
using var certificate = AgentCertificate.Create();
|
||||
var fingerprint = certificate.GetCertHashString(HashAlgorithmName.SHA256);
|
||||
var fake = new FakeMedia();
|
||||
await fake.Artwork.LoadAsync(new MemoryStream(png), CancellationToken.None);
|
||||
var pairing = new PairingService();
|
||||
var window = pairing.Open();
|
||||
await using var server = new RemoteServer(fake, pairing, certificate, false, 0);
|
||||
await server.StartAsync();
|
||||
using var handler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint
|
||||
};
|
||||
using var client = new HttpClient(handler) { BaseAddress = new Uri(server.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) };
|
||||
Check((await client.GetAsync("/v1/state")).StatusCode == HttpStatusCode.Unauthorized, "state requires authorization");
|
||||
Check((await client.GetAsync("/v1/info")).StatusCode == HttpStatusCode.Unauthorized, "agent info requires authorization");
|
||||
Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.Unauthorized, "artwork requires authorization");
|
||||
Check((await client.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized
|
||||
&& fake.Commands.Count == 0, "unauthorized command never reaches player");
|
||||
var wsUri = new UriBuilder(client.BaseAddress) { Scheme = "wss", Path = "/v1/events" }.Uri;
|
||||
using (var anonymous = new ClientWebSocket())
|
||||
{
|
||||
anonymous.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint;
|
||||
var denied = false;
|
||||
try { await anonymous.ConnectAsync(wsUri, CancellationToken.None); }
|
||||
catch (WebSocketException) { denied = true; }
|
||||
Check(denied, "WSS requires authorization");
|
||||
}
|
||||
Check((await client.PostAsJsonAsync("/v1/pair", new { code = "invalid" })).StatusCode == HttpStatusCode.Forbidden, "invalid pairing code denied over HTTPS");
|
||||
var response = await client.PostAsJsonAsync("/v1/pair", new { code = window.Code });
|
||||
var result = await response.Content.ReadFromJsonAsync<PairingResult>();
|
||||
Check(response.IsSuccessStatusCode && result?.Token is not null, "pair over pinned TLS");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", result!.Token);
|
||||
var info = await client.GetFromJsonAsync<DiscoveryDescriptor>("/v1/info");
|
||||
Check(info?.AgentId == fingerprint, "agent info matches pinned certificate");
|
||||
var state = await client.GetFromJsonAsync<MediaState>("/v1/state");
|
||||
Check(state?.ProtocolVersion == 1 && state.Title == "Test track", "authenticated state is structured");
|
||||
Check(state?.ArtworkId == cover.Id, "state references cover without embedding image bytes");
|
||||
var imageResponse = await client.GetAsync($"/v1/artwork/{cover.Id}");
|
||||
Check(imageResponse.IsSuccessStatusCode && imageResponse.Content.Headers.ContentType?.MediaType == "image/png"
|
||||
&& (await imageResponse.Content.ReadAsByteArrayAsync()).SequenceEqual(png), "authenticated cover download preserves MIME and bytes");
|
||||
Check((await client.GetAsync("/v1/artwork/missing")).StatusCode == HttpStatusCode.NotFound, "missing artwork returns 404");
|
||||
fake.Artwork.Clear();
|
||||
Check((await client.GetAsync($"/v1/artwork/{cover.Id}")).StatusCode == HttpStatusCode.NotFound, "old cover unavailable after track cache clears");
|
||||
var bad = await client.PostAsJsonAsync("/v1/command", new { type = "volume", value = 4 });
|
||||
Check(bad.StatusCode == HttpStatusCode.BadRequest && fake.Commands.Count == 0, "invalid remote volume cannot reach player");
|
||||
var malformed = await client.PostAsync("/v1/command", new StringContent("{", Encoding.UTF8, "application/json"));
|
||||
Check(malformed.StatusCode == HttpStatusCode.BadRequest, "malformed JSON gets 400");
|
||||
var tooLarge = await client.PostAsync("/v1/command", new StringContent(new string(' ', 5000), Encoding.UTF8, "application/json"));
|
||||
Check(tooLarge.StatusCode == HttpStatusCode.RequestEntityTooLarge, "oversized command rejected");
|
||||
var commandResponse = await client.PostAsJsonAsync("/v1/command", new MediaCommand("seek", 42.5, "seek-1"));
|
||||
var commandResult = await commandResponse.Content.ReadFromJsonAsync<CommandResult>();
|
||||
Check(commandResult is { Success: true, Id: "seek-1" } && fake.Commands.Single().Value == 42.5, "absolute seek and request id reach service");
|
||||
using (var origin = new HttpRequestMessage(HttpMethod.Get, "/v1/state"))
|
||||
{
|
||||
origin.Headers.Add("Origin", "https://example.com");
|
||||
Check((await client.SendAsync(origin)).StatusCode == HttpStatusCode.Forbidden, "browser origin rejected");
|
||||
}
|
||||
using (var socket = new ClientWebSocket())
|
||||
{
|
||||
socket.Options.SetRequestHeader("Authorization", $"Bearer {result.Token}");
|
||||
socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await socket.ConnectAsync(wsUri, timeout.Token);
|
||||
var buffer = new byte[8192];
|
||||
var first = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
|
||||
using var document = JsonDocument.Parse(buffer.AsMemory(0, first.Count));
|
||||
Check(first.EndOfMessage && document.RootElement.GetProperty("type").GetString() == "state", "WSS delivers initial state");
|
||||
var second = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
|
||||
Check(second.Count > 0, "WSS delivers subsequent state");
|
||||
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "test complete", timeout.Token);
|
||||
Check(socket.State == WebSocketState.Closed, "WSS closes cleanly");
|
||||
}
|
||||
// A long fallback interval proves delivery comes from the event, not the timer.
|
||||
var eventMedia = new FakeMedia();
|
||||
var eventPairing = new PairingService();
|
||||
var eventDevice = eventPairing.Pair(eventPairing.Open().Code);
|
||||
var eventToken = eventDevice.Token;
|
||||
await using (var eventServer = new RemoteServer(eventMedia, eventPairing, certificate, false, 0, TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
await eventServer.StartAsync();
|
||||
using var socket = new ClientWebSocket();
|
||||
socket.Options.SetRequestHeader("Authorization", $"Bearer {eventToken}");
|
||||
socket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint;
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||||
await socket.ConnectAsync(new UriBuilder(eventServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri, timeout.Token);
|
||||
var buffer = new byte[8192];
|
||||
await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
|
||||
eventMedia.Title = "Track after event";
|
||||
for (var i = 0; i < 1000; i++) eventMedia.Notify();
|
||||
var update = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
|
||||
using var document = JsonDocument.Parse(buffer.AsMemory(0, update.Count));
|
||||
Check(document.RootElement.GetProperty("state").GetProperty("title").GetString() == "Track after event",
|
||||
"media event reaches WSS before 30-second fallback timer");
|
||||
Check(eventMedia.ReadCount < 50, "event burst is coalesced");
|
||||
eventPairing.Revoke(eventDevice.DeviceId!);
|
||||
var disconnected = false;
|
||||
try
|
||||
{
|
||||
var close = await socket.ReceiveAsync(buffer.AsMemory(), timeout.Token);
|
||||
disconnected = close.MessageType == WebSocketMessageType.Close;
|
||||
}
|
||||
catch (WebSocketException) { disconnected = true; }
|
||||
Check(disconnected, "revoking trust disconnects an existing WSS stream");
|
||||
using var revokedHandler = new HttpClientHandler
|
||||
{
|
||||
ServerCertificateCustomValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint
|
||||
};
|
||||
using var revokedClient = new HttpClient(revokedHandler) { BaseAddress = new Uri(eventServer.Addresses.Single()), Timeout = TimeSpan.FromSeconds(5) };
|
||||
revokedClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", eventToken);
|
||||
Check((await revokedClient.PostAsJsonAsync("/v1/command", new { type = "next" })).StatusCode == HttpStatusCode.Unauthorized
|
||||
&& eventMedia.Commands.Count == 0, "revoked token cannot send HTTP commands");
|
||||
}
|
||||
Check(eventMedia.SubscriberCount == 0, "server unsubscribes from media events on shutdown");
|
||||
|
||||
if (args.Contains("--live-read"))
|
||||
{
|
||||
var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
||||
using var live = new MediaService(manager);
|
||||
var snapshot = await live.ReadAsync(CancellationToken.None);
|
||||
Check(snapshot.MediaError is null, "real Windows media read succeeds");
|
||||
Console.WriteLine($"Live read: session={snapshot.HasSession}, artwork={snapshot.ArtworkId is not null}, artworkError={snapshot.ArtworkError ?? "none"}");
|
||||
if (snapshot.ArtworkId is { } id)
|
||||
{
|
||||
var liveCover = live.GetArtwork(id);
|
||||
Check(liveCover is { Bytes.Length: > 0 }, "real cover bytes are cached");
|
||||
Console.WriteLine($"Live cover: {liveCover!.ContentType}, {liveCover.Bytes.Length} bytes.");
|
||||
var next = await live.ReadAsync(CancellationToken.None);
|
||||
Check(next.ArtworkId == id, "real cover cache survives progress refresh");
|
||||
}
|
||||
}
|
||||
Console.WriteLine($"All {checks} checks passed. No real playback or volume was changed.");
|
||||
}
|
||||
|
||||
sealed class TestClock(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
public DateTimeOffset Now { get; set; } = now;
|
||||
public override DateTimeOffset GetUtcNow() => Now;
|
||||
}
|
||||
|
||||
sealed class FakeMedia : IMediaService
|
||||
{
|
||||
public event Action? Changed;
|
||||
public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0;
|
||||
public int ReadCount;
|
||||
public readonly ArtworkCache Artwork = new();
|
||||
public Artwork? GetArtwork(string id) => Artwork.Get(id);
|
||||
public string Title { get; set; } = "Test track";
|
||||
public void Notify() => Changed?.Invoke();
|
||||
public List<MediaCommand> Commands { get; } = [];
|
||||
public Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Interlocked.Increment(ref ReadCount);
|
||||
return Task.FromResult(new MediaState
|
||||
{
|
||||
HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
public Task<CommandResult> ExecuteAsync(MediaCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
Commands.Add(command);
|
||||
return Task.FromResult(new CommandResult(true, "ok", "Test command", command.Id));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user