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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user