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
+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;
}
}