Add MusicBridge protocol documentation and implement system volume control

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