From d5e827112a1ef8073216d7f72c122f75f338b4d8 Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 9 Sep 2026 01:05:52 +0300 Subject: [PATCH] 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. --- .gitignore | 4 +- CONNECTION-ROADMAP.md | 41 ++++ Media/ArtworkCache.cs | 43 ++++ Media/Contracts.cs | 58 +++++ Media/MediaService.cs | 217 ++++++++++++++++ Media/TimelineMath.cs | 16 ++ MusicBridge.Agent.csproj | 14 +- Network/AgentCertificate.cs | 25 ++ Network/ConnectionInvitation.cs | 35 +++ Network/DiscoveryDescriptor.cs | 19 ++ Network/IdentityStore.cs | 90 +++++++ Network/LocalAddresses.cs | 17 ++ Network/MdnsPublisher.cs | 80 ++++++ Network/PairingQr.cs | 13 + Network/PairingService.cs | 82 +++++++ Network/RemoteServer.cs | 229 +++++++++++++++++ Network/UserProtection.cs | 51 ++++ PLAN.md | 181 ++++++++++++++ PROTOCOL.md | 243 ++++++++++++++++++ Program.cs | 215 ++++++++++------ SystemVolume.cs | 96 ++++++++ tests/MusicBridge.Agent.Tests.csproj | 15 ++ tests/Program.cs | 355 +++++++++++++++++++++++++++ 23 files changed, 2067 insertions(+), 72 deletions(-) create mode 100644 CONNECTION-ROADMAP.md create mode 100644 Media/ArtworkCache.cs create mode 100644 Media/Contracts.cs create mode 100644 Media/MediaService.cs create mode 100644 Media/TimelineMath.cs create mode 100644 Network/AgentCertificate.cs create mode 100644 Network/ConnectionInvitation.cs create mode 100644 Network/DiscoveryDescriptor.cs create mode 100644 Network/IdentityStore.cs create mode 100644 Network/LocalAddresses.cs create mode 100644 Network/MdnsPublisher.cs create mode 100644 Network/PairingQr.cs create mode 100644 Network/PairingService.cs create mode 100644 Network/RemoteServer.cs create mode 100644 Network/UserProtection.cs create mode 100644 PLAN.md create mode 100644 PROTOCOL.md create mode 100644 SystemVolume.cs create mode 100644 tests/MusicBridge.Agent.Tests.csproj create mode 100644 tests/Program.cs diff --git a/.gitignore b/.gitignore index 3dea4ce..5967294 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ -bin/* -obj/* \ No newline at end of file +**/bin/ +**/obj/ diff --git a/CONNECTION-ROADMAP.md b/CONNECTION-ROADMAP.md new file mode 100644 index 0000000..b9293ac --- /dev/null +++ b/CONNECTION-ROADMAP.md @@ -0,0 +1,41 @@ +# Заготовки подключения — iOS не начинать до команды пользователя + +## Уже есть + +- `ConnectionInvitation`: проверяет домашний IPv4 HTTPS-адрес, код/срок и SHA-256 + сертификата; создаёт строку `musicbridge://pair?data=`. +- Клавиша `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 отложены. diff --git a/Media/ArtworkCache.cs b/Media/ArtworkCache.cs new file mode 100644 index 0000000..b22f853 --- /dev/null +++ b/Media/ArtworkCache.cs @@ -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 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 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; + } +} diff --git a/Media/Contracts.cs b/Media/Contracts.cs new file mode 100644 index 0000000..cb3108e --- /dev/null +++ b/Media/Contracts.cs @@ -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 ReadAsync(CancellationToken cancellationToken); + Task 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); +} diff --git a/Media/MediaService.cs b/Media/MediaService.cs new file mode 100644 index 0000000..e8550f0 --- /dev/null +++ b/Media/MediaService.cs @@ -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 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 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 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); +} diff --git a/Media/TimelineMath.cs b/Media/TimelineMath.cs new file mode 100644 index 0000000..d5a76c4 --- /dev/null +++ b/Media/TimelineMath.cs @@ -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); +} diff --git a/MusicBridge.Agent.csproj b/MusicBridge.Agent.csproj index ba23eb9..b47bd41 100644 --- a/MusicBridge.Agent.csproj +++ b/MusicBridge.Agent.csproj @@ -5,6 +5,18 @@ net10.0-windows10.0.19041.0 enable enable + true - \ No newline at end of file + + + + + + + + + + + + diff --git a/Network/AgentCertificate.cs b/Network/AgentCertificate.cs new file mode 100644 index 0000000..b8b7a32 --- /dev/null +++ b/Network/AgentCertificate.cs @@ -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); + } +} diff --git a/Network/ConnectionInvitation.cs b/Network/ConnectionInvitation.cs new file mode 100644 index 0000000..3e2d8d8 --- /dev/null +++ b/Network/ConnectionInvitation.cs @@ -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); + } +} diff --git a/Network/DiscoveryDescriptor.cs b/Network/DiscoveryDescriptor.cs new file mode 100644 index 0000000..f03d02c --- /dev/null +++ b/Network/DiscoveryDescriptor.cs @@ -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 TxtRecords() => new Dictionary + { + ["v"] = ProtocolVersion.ToString(System.Globalization.CultureInfo.InvariantCulture), + ["id"] = AgentId + }; +} diff --git a/Network/IdentityStore.cs b/Network/IdentityStore.cs new file mode 100644 index 0000000..486ee8f --- /dev/null +++ b/Network/IdentityStore.cs @@ -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 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(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(); + } +} diff --git a/Network/LocalAddresses.cs b/Network/LocalAddresses.cs new file mode 100644 index 0000000..0b8a954 --- /dev/null +++ b/Network/LocalAddresses.cs @@ -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}")); +} diff --git a/Network/MdnsPublisher.cs b/Network/MdnsPublisher.cs new file mode 100644 index 0000000..930c0b7 --- /dev/null +++ b/Network/MdnsPublisher.cs @@ -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(); +} diff --git a/Network/PairingQr.cs b/Network/PairingQr.cs new file mode 100644 index 0000000..b713936 --- /dev/null +++ b/Network/PairingQr.cs @@ -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); + } +} diff --git a/Network/PairingService.cs b/Network/PairingService.cs new file mode 100644 index 0000000..6ddc2e2 --- /dev/null +++ b/Network/PairingService.cs @@ -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 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; + } +} diff --git a/Network/RemoteServer.cs b/Network/RemoteServer.cs new file mode 100644 index 0000000..7bcd75e --- /dev/null +++ b/Network/RemoteServer.cs @@ -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 refresh = CreateSignal(); + private readonly TimeSpan refreshInterval; + private event Action? Published; + + public MediaState Latest => Volatile.Read(ref latest); + public IEnumerable 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(options => options.ShutdownTimeout = TimeSpan.FromSeconds(5)); + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + options.GlobalLimiter = PartitionedRateLimiter.Create(_ => + 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(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(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 CreateSignal() => Channel.CreateBounded(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); +} diff --git a/Network/UserProtection.cs b/Network/UserProtection.cs new file mode 100644 index 0000000..996d6e2 --- /dev/null +++ b/Network/UserProtection.cs @@ -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); +} diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..6dda1e6 --- /dev/null +++ b/PLAN.md @@ -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-пакетов. diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..791a854 --- /dev/null +++ b/PROTOCOL.md @@ -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":""}`. +Отказ 403: `success:false`, `token:null`, `code` равен `invalid_code`, +`pairing_closed`, `device_limit` или `invalid_name`. +Если сохранить устройство не удалось: HTTP 503, `code:"storage_failed"`, +токен не выдаётся и устройство не получает доступа. + +Все следующие запросы, включая WebSocket handshake, требуют заголовка: + +```http +Authorization: Bearer +``` + +## Хранение и восстановление + +Файл `%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": "", + "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). diff --git a/Program.cs b/Program.cs index 6b29d9e..7fa159a 100644 --- a/Program.cs +++ b/Program.cs @@ -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 { $"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 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}"; -} \ No newline at end of file +} diff --git a/SystemVolume.cs b/SystemVolume.cs new file mode 100644 index 0000000..e828826 --- /dev/null +++ b/SystemVolume.cs @@ -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(Func 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); + } +} diff --git a/tests/MusicBridge.Agent.Tests.csproj b/tests/MusicBridge.Agent.Tests.csproj new file mode 100644 index 0000000..944d536 --- /dev/null +++ b/tests/MusicBridge.Agent.Tests.csproj @@ -0,0 +1,15 @@ + + + Exe + net10.0-windows10.0.19041.0 + enable + enable + true + + + + + + + + diff --git a/tests/Program.cs b/tests/Program.cs new file mode 100644 index 0000000..a8379f1 --- /dev/null +++ b/tests/Program.cs @@ -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(), Encoding.UTF8.GetBytes(""), 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(); +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("/v1/info"); +Check(info?.AgentId == fingerprint, "agent info matches pinned certificate"); +var state = await client.GetFromJsonAsync("/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(); +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 Commands { get; } = []; + public Task 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 ExecuteAsync(MediaCommand command, CancellationToken cancellationToken) + { + Commands.Add(command); + return Task.FromResult(new CommandResult(true, "ok", "Test command", command.Id)); + } +}