Implement autostart functionality, ZIP packaging, and related installation scripts; update documentation and tests

This commit is contained in:
2026-09-09 18:27:32 +03:00
parent 49d10a5242
commit 369c0347a7
18 changed files with 368 additions and 13 deletions
+54 -1
View File
@@ -30,6 +30,19 @@ void Check(bool condition, string name)
}
var now = DateTimeOffset.UtcNow;
var startupExe = Path.Combine(AppContext.BaseDirectory, "MusicBridge.Agent.exe");
var startupRegistry = @"Software\MusicBridge\Tests\" + Guid.NewGuid().ToString("N");
try
{
var startup = new MusicBridge.Agent.Desktop.StartupSettings(startupExe, true, 18765, startupRegistry);
Check(startup.Command == $"\"{startupExe}\" --tray --minimized --lan --port 18765", "autostart preserves quoted path, mode and port");
Check(!startup.Enabled, "autostart starts disabled");
startup.SetEnabled(true);
Check(startup.Enabled, "autostart can be enabled in isolated registry key");
startup.SetEnabled(false);
Check(!startup.Enabled, "autostart can be disabled");
}
finally { Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(startupRegistry, false); }
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('_', '/');
@@ -329,6 +342,42 @@ await using (var eventServer = new RemoteServer(eventMedia, eventPairing, certif
}
Check(eventMedia.SubscriberCount == 0, "server unsubscribes from media events on shutdown");
var recoveryMedia = new FakeMedia { FailRead = true };
var recoveryPairing = new PairingService();
var recoveryToken = recoveryPairing.Pair(recoveryPairing.Open().Code).Token;
await using (var recoveryServer = new RemoteServer(recoveryMedia, recoveryPairing, certificate, false, 0, TimeSpan.FromMilliseconds(50)))
{
await recoveryServer.StartAsync();
Check(recoveryServer.Latest.MediaError is not null, "initial media failure does not prevent server startup");
recoveryMedia.FailRead = false;
recoveryMedia.Notify();
using var recoveredTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (recoveryServer.Latest.MediaError is not null) await Task.Delay(20, recoveredTimeout.Token);
Check(recoveryServer.Latest.HasSession, "polling recovers after initial media failure");
recoveryMedia.FailRead = true;
recoveryMedia.Notify();
while (recoveryServer.Latest.MediaError is null) await Task.Delay(20, recoveredTimeout.Token);
Check(!recoveryServer.Latest.HasSession && recoveryServer.Latest.ArtworkId is null, "read failure clears stale playing state");
recoveryMedia.FailRead = false;
recoveryMedia.Title = "Recovered track";
recoveryMedia.Notify();
while (recoveryServer.Latest.Title != "Recovered track") await Task.Delay(20, recoveredTimeout.Token);
Check(recoveryServer.Latest.MediaError is null, "polling survives later transient exception");
var address = new UriBuilder(recoveryServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri;
for (var i = 0; i < 12; i++)
{
using var clientSocket = new ClientWebSocket();
clientSocket.Options.SetRequestHeader("Authorization", $"Bearer {recoveryToken}");
clientSocket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint;
await clientSocket.ConnectAsync(address, recoveredTimeout.Token);
var data = new byte[8192];
await clientSocket.ReceiveAsync(data.AsMemory(), recoveredTimeout.Token);
clientSocket.Abort();
await Task.Delay(30, recoveredTimeout.Token);
}
Check(true, "abrupt WSS disconnects release slots for reconnection");
}
if (args.Contains("--live-read"))
{
var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
@@ -357,13 +406,15 @@ if (args.Contains("--desktop-smoke"))
};
var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using (var host = new MusicBridge.Agent.Desktop.DesktopHost(() => desktopState, desktopMedia, desktopPairing,
() => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false))
() => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false, minimized: true))
{
await host.StartAsync().WaitAsync(TimeSpan.FromSeconds(8));
var field = typeof(MusicBridge.Agent.Desktop.DesktopHost).GetField("window", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
var view = (MusicBridge.Agent.Desktop.AgentWindow)field.GetValue(host)!;
view.Invoke(() =>
{
Check(!view.Visible, "minimized startup leaves main window hidden");
view.Show();
view.RefreshState();
using var frame = new Bitmap(view.Width, view.Height);
view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height));
@@ -456,6 +507,7 @@ sealed class TestClock(DateTimeOffset now) : TimeProvider
sealed class FakeMedia : IMediaService
{
public volatile bool FailRead;
public event Action? Changed;
public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0;
public int ReadCount;
@@ -467,6 +519,7 @@ sealed class FakeMedia : IMediaService
public Task<MediaState> ReadAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReadCount);
if (FailRead) throw new IOException("Simulated player disconnection");
return Task.FromResult(new MediaState
{
HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow