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,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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user