From 49d10a52426caed31c2e2d3519101ce4931c2aeb Mon Sep 17 00:00:00 2001 From: ares Date: Wed, 9 Sep 2026 10:01:39 +0300 Subject: [PATCH] Implement desktop tray interface with media controls and QR pairing; add related tests --- CONNECTION-ROADMAP.md | 3 +- Desktop/AgentWindow.cs | 162 +++++++++++++++++++++++++++++++++++++++++ Desktop/DesktopHost.cs | 84 +++++++++++++++++++++ PLAN.md | 15 +++- PROTOCOL.md | 21 ++++++ Program.cs | 27 ++++++- tests/Program.cs | 43 +++++++++++ 7 files changed, 350 insertions(+), 5 deletions(-) create mode 100644 Desktop/AgentWindow.cs create mode 100644 Desktop/DesktopHost.cs diff --git a/CONNECTION-ROADMAP.md b/CONNECTION-ROADMAP.md index 9c5893c..a3210bf 100644 --- a/CONNECTION-ROADMAP.md +++ b/CONNECTION-ROADMAP.md @@ -42,7 +42,8 @@ mDNS-слушатель на этом же ПК получил объявлен - [ ] Проверить смену Wi-Fi/IP, сон/пробуждение, несколько адаптеров и VPN. - [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу сертификата, а второй локальный экземпляр блокируется хранилищем. -- [ ] Tray, кнопки сопряжения/отзыва, автозапуск и упаковка Windows. +- [x] Tray и окно управления: `--tray --lan`, кнопки сопряжения/отзыва, управление музыкой. +- [ ] Автозапуск и упаковка Windows. - [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353. diff --git a/Desktop/AgentWindow.cs b/Desktop/AgentWindow.cs new file mode 100644 index 0000000..25c3a1b --- /dev/null +++ b/Desktop/AgentWindow.cs @@ -0,0 +1,162 @@ +using MusicBridge.Agent.Media; +using MusicBridge.Agent.Network; + +namespace MusicBridge.Agent.Desktop; + +internal sealed class AgentWindow : Form +{ + private readonly Func read; + private readonly IMediaService media; + private readonly PairingService pairing; + private readonly Func network; + private readonly Action openQr; + private readonly Label track = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; + private readonly Label position = new() { Dock = DockStyle.Fill }; + private readonly Label status = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; + private readonly Label connection = new() { Dock = DockStyle.Fill, AutoEllipsis = true }; + private readonly PictureBox cover = new() { Dock = DockStyle.Fill, SizeMode = PictureBoxSizeMode.Zoom, BackColor = Color.WhiteSmoke }; + private readonly TrackBar seek = new() { Name = "position", AccessibleName = "Позиция трека", Dock = DockStyle.Fill, Minimum = 0, Maximum = 1000, TickStyle = TickStyle.None }; + private readonly TrackBar volume = new() { Name = "volume", AccessibleName = "Системная громкость", Dock = DockStyle.Fill, Minimum = 0, Maximum = 100, TickFrequency = 10 }; + private readonly ListBox devices = new() { Dock = DockStyle.Fill, DisplayMember = nameof(TrustedDevice.Name) }; + private readonly Dictionary commands = []; + private readonly System.Windows.Forms.Timer timer = new() { Interval = 500 }; + private string? coverId; + private bool busy; + private bool seekDragging; + private bool volumeDragging; + private string? seekingTrack; + [System.ComponentModel.DesignerSerializationVisibility(System.ComponentModel.DesignerSerializationVisibility.Hidden)] + internal bool AllowClose { get; set; } + + public AgentWindow(Func read, IMediaService media, PairingService pairing, Func network, Action openQr) + { + this.read = read; this.media = media; this.pairing = pairing; this.network = network; this.openQr = openQr; + Text = "MusicBridge"; + Font = new Font("Segoe UI", 10); + ClientSize = new Size(720, 720); + MinimumSize = new Size(620, 680); + StartPosition = FormStartPosition.CenterScreen; + AutoScaleMode = AutoScaleMode.Dpi; + var root = new TableLayoutPanel { Dock = DockStyle.Fill, Padding = new Padding(20), ColumnCount = 1, RowCount = 9 }; + foreach (var height in new[] { 130, 28, 38, 46, 50, 50, 42, -1, 50 }) + root.RowStyles.Add(height < 0 ? new RowStyle(SizeType.Percent, 100) : new RowStyle(SizeType.Absolute, height)); + var heading = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2 }; + heading.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 150)); + heading.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); + track.Font = new Font("Segoe UI", 15); + track.Padding = new Padding(15, 12, 0, 0); + heading.Controls.Add(cover); heading.Controls.Add(track); + root.Controls.Add(heading); root.Controls.Add(position); root.Controls.Add(seek); + var controls = new FlowLayoutPanel { Dock = DockStyle.Fill }; + foreach (var item in new[] { ("previous", "Назад"), ("toggle", "Play / Pause"), ("next", "Далее"), ("toggleMute", "Без звука") }) + { + var button = new Button { Text = item.Item2, Name = item.Item1, Size = new Size(130, 36) }; + var command = item.Item1; + button.Click += async (_, _) => await SendAsync(new(command)); + commands.Add(command, button); controls.Controls.Add(button); + } + root.Controls.Add(controls); + root.Controls.Add(volume); + root.Controls.Add(connection); + var pairControls = new FlowLayoutPanel { Dock = DockStyle.Fill }; + var pairButton = new Button { Text = "Подключить iPhone", AutoSize = true, Height = 34 }; + pairButton.Click += (_, _) => { pairing.Open(); openQr(); status.Text = "Новый код действует 5 минут."; }; + var qrButton = new Button { Text = "Показать QR", AutoSize = true, Height = 34 }; + qrButton.Click += (_, _) => openQr(); + var revoke = new Button { Text = "Отозвать доступ", AutoSize = true, Height = 34 }; + revoke.Click += (_, _) => + { + if (devices.SelectedItem is not TrustedDevice device) { status.Text = "Выбери устройство в списке."; return; } + try { pairing.Revoke(device.Id); status.Text = $"Доступ отозван: {device.Name}"; RefreshState(); } + catch (Exception ex) { status.Text = $"Не удалось отозвать доступ: {ex.Message}"; } + }; + pairControls.Controls.AddRange([pairButton, qrButton, revoke]); + root.Controls.Add(pairControls); + var group = new GroupBox { Text = "Доверенные устройства", Dock = DockStyle.Fill }; + group.Controls.Add(devices); root.Controls.Add(group); root.Controls.Add(status); + Controls.Add(root); + volume.MouseDown += (_, _) => volumeDragging = true; + volume.MouseUp += async (_, _) => { volumeDragging = false; await SetVolumeAsync(); }; + volume.KeyUp += async (_, _) => await SetVolumeAsync(); + seek.MouseDown += (_, _) => { seekDragging = true; seekingTrack = TrackKey(read()); }; + seek.MouseUp += async (_, _) => { seekDragging = false; await SeekAsync(); }; + seek.KeyDown += (_, _) => { if (!seekDragging) seekingTrack = TrackKey(read()); seekDragging = true; }; + seek.KeyUp += async (_, _) => { seekDragging = false; await SeekAsync(); }; + timer.Tick += (_, _) => RefreshState(); + Shown += (_, _) => { RefreshState(); timer.Start(); }; + FormClosing += (_, e) => + { + if (!AllowClose && e.CloseReason == CloseReason.UserClosing) { e.Cancel = true; Hide(); } + }; + } + + private Task SetVolumeAsync() => SendAsync(new("volume", volume.Value / 100.0)); + private Task SeekAsync() + { + var state = read(); + if (seekingTrack != TrackKey(state)) { status.Text = "Трек сменился, перемотка отменена."; return Task.CompletedTask; } + return SendAsync(new("seek", state.MinSeekSeconds + (state.MaxSeekSeconds - state.MinSeekSeconds) * seek.Value / 1000.0)); + } + private static string TrackKey(MediaState state) => state.Source + "\n" + state.Title + "\n" + state.Artist; + + internal async Task SendAsync(MediaCommand command) + { + if (busy || IsDisposed) return; + busy = true; + RefreshState(); + try + { + var result = await media.ExecuteAsync(command, CancellationToken.None); + if (!IsDisposed) status.Text = result.Message; + } + catch (Exception ex) { if (!IsDisposed) status.Text = ex.Message; } + finally { busy = false; if (!IsDisposed) RefreshState(); } + } + + internal void RefreshState() + { + var state = read(); + track.Text = state.HasSession ? $"{state.Title}\n{state.Artist}\n{state.PlaybackStatus}" : "Ничего не играет\nОткрой плеер на компьютере"; + position.Text = $"{Format(state.PositionSeconds)} / {Format(state.EndSeconds)} Громкость: {(state.Volume is { } v ? $"{v:P0}" : "недоступна")}"; + commands["previous"].Enabled = !busy && state.Capabilities.Previous; + commands["toggle"].Enabled = !busy && state.Capabilities.Toggle; + commands["next"].Enabled = !busy && state.Capabilities.Next; + commands["toggleMute"].Enabled = !busy && state.Muted is not null; + commands["toggleMute"].Text = state.Muted == true ? "Включить звук" : "Без звука"; + volume.Enabled = !busy && state.Volume is not null; + seek.Enabled = !busy && state.Capabilities.Seek && state.MaxSeekSeconds > state.MinSeekSeconds; + if (!volumeDragging && state.Volume is { } level) volume.Value = Math.Clamp((int)Math.Round(level * 100), 0, 100); + if (!seekDragging && state.MaxSeekSeconds > state.MinSeekSeconds) + seek.Value = Math.Clamp((int)(1000 * (state.PositionSeconds - state.MinSeekSeconds) / (state.MaxSeekSeconds - state.MinSeekSeconds)), 0, 1000); + connection.Text = network(); + var saved = pairing.Devices; + if (!devices.Items.Cast().Select(d => d.Id).SequenceEqual(saved.Select(d => d.Id))) + { + var selected = (devices.SelectedItem as TrustedDevice)?.Id; + devices.Items.Clear(); devices.Items.AddRange(saved.Cast().ToArray()); + for (var i = 0; i < devices.Items.Count; i++) if (((TrustedDevice)devices.Items[i]).Id == selected) devices.SelectedIndex = i; + } + if (state.ArtworkId != coverId) + { + cover.Image?.Dispose(); cover.Image = null; coverId = state.ArtworkId; + try + { + if (coverId is { } id && media.GetArtwork(id) is { } artwork) + { + using var stream = new MemoryStream(artwork.Bytes); + using var original = Image.FromStream(stream); + if (original.Width <= 8192 && original.Height <= 8192) cover.Image = new Bitmap(original, new Size(150, 150)); + } + } + catch (Exception) { /* Missing or invalid cover must not stop playback controls. */ } + } + if (state.MediaError is not null || state.VolumeError is not null) status.Text = state.MediaError ?? state.VolumeError; + } + + private static string Format(double seconds) => TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(seconds >= 3600 ? @"h\:mm\:ss" : @"m\:ss"); + protected override void Dispose(bool disposing) + { + if (disposing) { timer.Dispose(); cover.Image?.Dispose(); } + base.Dispose(disposing); + } +} diff --git a/Desktop/DesktopHost.cs b/Desktop/DesktopHost.cs new file mode 100644 index 0000000..fda2b7b --- /dev/null +++ b/Desktop/DesktopHost.cs @@ -0,0 +1,84 @@ +using System.Runtime.InteropServices; +using MusicBridge.Agent.Media; +using MusicBridge.Agent.Network; + +namespace MusicBridge.Agent.Desktop; + +internal sealed class DesktopHost(Func read, IMediaService media, PairingService pairing, + Func network, Action showQr, Action exit, bool visible = true) : IDisposable +{ + private readonly object gate = new(); + private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private Thread? thread; + private AgentWindow? window; + private bool disposed; + + public Task StartAsync() + { + thread = new Thread(Run) { IsBackground = true, Name = "MusicBridge Desktop" }; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + return ready.Task; + } + + private void Run() + { + try + { + Application.EnableVisualStyles(); + using var view = new AgentWindow(read, media, pairing, network, showQr); + view.Opacity = visible ? 1 : 0; + view.ShowInTaskbar = visible; + using var menu = new ContextMenuStrip(); + void ShowWindow() { view.Show(); view.WindowState = FormWindowState.Normal; view.Activate(); } + menu.Items.Add("Открыть MusicBridge", null, (_, _) => ShowWindow()); + var toggle = menu.Items.Add("Play / Pause", null, async (_, _) => await view.SendAsync(new("toggle"))); + menu.Items.Add("Подключить iPhone", null, (_, _) => { pairing.Open(); showQr(); }); + menu.Items.Add(new ToolStripSeparator()); + menu.Items.Add("Завершить MusicBridge", null, (_, _) => { view.AllowClose = true; view.Close(); }); + using var icon = new NotifyIcon { Icon = SystemIcons.Application, Text = "MusicBridge", ContextMenuStrip = menu, Visible = visible }; + icon.DoubleClick += (_, _) => ShowWindow(); + using var timer = new System.Windows.Forms.Timer { Interval = 1000 }; + timer.Tick += (_, _) => + { + var state = read(); + toggle.Enabled = state.Capabilities.Toggle; + var text = state.HasSession ? $"MusicBridge — {state.Title}" : "MusicBridge — нет плеера"; + icon.Text = text.Length > 63 ? text[..63] : text; + }; + view.Shown += (_, _) => { timer.Start(); ready.TrySetResult(); }; + lock (gate) + { + if (disposed) { ready.TrySetCanceled(); return; } + window = view; + } + Application.Run(view); + icon.Visible = false; + } + catch (Exception ex) { ready.TrySetException(ex); } + finally + { + lock (gate) window = null; + exit(); + } + } + + public void Dispose() + { + lock (gate) + { + disposed = true; + if (window is { IsHandleCreated: true } view) + { + try { view.BeginInvoke(() => { view.AllowClose = true; view.Close(); }); } + catch (InvalidOperationException) { } + } + } + thread?.Join(TimeSpan.FromSeconds(2)); + } + + // Detach only this process; never hide or close the user's shared terminal window. + [DllImport("kernel32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool FreeConsole(); +} diff --git a/PLAN.md b/PLAN.md index c8f2a6a..ab54ba9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -80,6 +80,10 @@ Windows Core Audio → системная громкость устройств отключение Wi-Fi и несколько клиентов. - [ ] Tray-интерфейс, автозапуск, удобная установка; правила брандмауэра только для нужной локальной сети, без открытия доступа в интернет. +- [x] Трей и окно управления (`--tray`): медиакнопки, ползунки, QR, доверенные устройства. +- [x] Закрытие окна скрывает его; двойной щелчок по значку восстанавливает; + отдельный пункт меню завершает агент. Консоль остаётся отдельным режимом. +- [ ] Автозапуск и установщик (трей реализован, общий пункт выше ещё не завершён). ### 4. Нативное приложение iPhone — отложено пользователем @@ -108,6 +112,13 @@ Windows Core Audio → системная громкость устройств 2026-09-09: +- Последний этап — окно управления и трей: **102 проверки прошли**, Release-сборка + без ошибок/предупреждений. Прогон с `--desktop-smoke --qr-window-smoke --mdns-smoke --live-read`. + В скрытом окне проверены включение/отключение кнопок, отправка play/pause и + абсолютной громкости тестовому сервису, скрытие по крестику, продолжение работы + и остановка UI-потока. Снимок окна просмотрен. Реальную музыку тесты не меняли. + Ручное взаимодействие с видимым значком трея пока не проверялось. + - Последний этап — работающие QR и mDNS: **95 проверок прошло**, Release-сборка без ошибок и предупреждений. Прогон: `dotnet run --project tests/MusicBridge.Agent.Tests.csproj -c Release --no-build -- --qr-window-smoke --mdns-smoke --live-read`. Проверены декодирование QR независимой библиотекой, скрытое окно и очистка @@ -169,7 +180,7 @@ Windows Core Audio → системная громкость устройств объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет. Следующий шаг: проверка с другого устройства и устойчивости при смене сети, -затем tray/автозапуск/упаковка. iPhone-клиент пока не начинать. +автозапуск/упаковка. Трей уже реализован. iPhone-клиент пока не начинать. Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`. Начать разработку iOS только после новой команды пользователя. @@ -191,6 +202,8 @@ Windows Core Audio → системная громкость устройств - `Network/ConnectionInvitation.cs`: проверка и формат данных QR. - `Network/PairingQr.cs`: локальная генерация PNG (QRCoder). - `PairingWindowHost.cs`: окно QR на отдельном STA-потоке, выбор адреса и обновления. +- `Desktop/AgentWindow.cs`: окно управления, ползунки и доверенные устройства. +- `Desktop/DesktopHost.cs`: STA-поток, значок/меню трея и жизненный цикл окна. - `Network/LocalAddresses.cs`: подходящие домашние IPv4-адреса. - `Network/DiscoveryDescriptor.cs`: публичные поля DNS-SD. - `Network/MdnsPublisher.cs`: объявления mDNS, обновления адресов и goodbye. diff --git a/PROTOCOL.md b/PROTOCOL.md index 3c60529..c4d8040 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -4,6 +4,27 @@ ## Запуск +### Окно управления и трей + +```powershell +dotnet run -c Release -- --tray --lan +``` + +`--tray` открывает Windows-окно и значок в области уведомлений. Крестик скрывает +окно; двойной щелчок по значку снова его показывает. Для остановки выбрать +«Завершить MusicBridge» в меню значка. При старте агент отсоединяется от консоли, +не закрывая терминал пользователя. Без `--tray` сохраняется диагностическая консоль. + +Окно показывает трек/обложку, позицию и системную громкость. Поддерживает +play/pause, следующий/предыдущий трек, ползунки позиции/громкости, mute, +новое сопряжение, QR и отзыв доступа выбранного устройства. Недоступные команды +отключены. Без `--lan` агент остаётся доступен только с самого ПК. + +Чтобы пользоваться уже собранной версией, можно запускать +`bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`. +Пока это framework-dependent сборка: необходим .NET 10 Desktop Runtime и +ASP.NET Core Runtime. Автозапуск и установщик ещё не добавлены. + Из каталога репозитория: ```powershell diff --git a/Program.cs b/Program.cs index 7556cd5..ab64a8b 100644 --- a/Program.cs +++ b/Program.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using MusicBridge.Agent; +using MusicBridge.Agent.Desktop; using MusicBridge.Agent.Media; using MusicBridge.Agent.Network; using Windows.Media.Control; @@ -10,15 +11,17 @@ using Windows.Media.Control; Console.OutputEncoding = Encoding.UTF8; Console.Title = "MusicBridge Agent"; var lan = false; +var tray = false; var port = 8765; for (var i = 0; i < args.Length; i++) { if (args[i] == "--lan") lan = true; + else if (args[i] == "--tray") tray = 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("Запуск: dotnet run -- [--tray] [--lan] [--port 8765]"); Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет."); return; } @@ -43,12 +46,20 @@ try Uri[] qrEndpoints = []; using var mdns = new MdnsPublisher(fingerprint, port); using var qrWindow = new PairingWindowHost(pairing, fingerprint, () => Volatile.Read(ref qrEndpoints)); + string networkStatus = lan ? "Подключение к домашней сети…" : "Локальный режим. Для iPhone запусти с --lan."; + using var desktop = tray ? new DesktopHost(() => server.Latest, media, pairing, + () => Volatile.Read(ref networkStatus), qrWindow.Open, shutdown.Cancel) : null; + if (desktop is not null) + { + await desktop.StartAsync(); + DesktopHost.FreeConsole(); + } var message = "Готов к управлению."; IReadOnlyList displayedDevices = []; var nextRefresh = DateTimeOffset.MinValue; while (!shutdown.IsCancellationRequested) { - if (!Console.IsInputRedirected && Console.KeyAvailable) + if (!tray && !Console.IsInputRedirected && Console.KeyAvailable) { var key = Console.ReadKey(intercept: true).Key; if (key == ConsoleKey.Q) break; @@ -105,14 +116,22 @@ try addresses = new List { $"https://127.0.0.1:{port}" }; addresses.AddRange(qrEndpoints.Select(u => u.GetLeftPart(UriPartial.Authority))); mdns.Refresh(ips); + Volatile.Write(ref networkStatus, string.Join(" | ", qrEndpoints.Select(u => u.GetLeftPart(UriPartial.Authority))) + "\n" + mdns.Status); } catch (Exception ex) { Volatile.Write(ref qrEndpoints, []); mdns.Refresh([]); message = $"Не удалось обновить адреса: {ex.Message}"; + Volatile.Write(ref networkStatus, message); } } + if (tray) + { + nextRefresh = DateTimeOffset.UtcNow.AddSeconds(1); + await Task.Delay(50, shutdown.Token); + continue; + } if (!Console.IsOutputRedirected) Console.Clear(); var state = server.Latest; Console.WriteLine("MUSICBRIDGE\n"); @@ -154,7 +173,9 @@ try catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { } catch (Exception ex) { - Console.Error.WriteLine($"Не удалось запустить или продолжить работу агента: {ex.Message}"); + var error = $"Не удалось запустить или продолжить работу агента: {ex.Message}"; + if (tray) MessageBox.Show(error, "MusicBridge", MessageBoxButtons.OK, MessageBoxIcon.Error); + else Console.Error.WriteLine(error); Environment.ExitCode = 1; } diff --git a/tests/Program.cs b/tests/Program.cs index 6f5e051..fe589c8 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -345,6 +345,49 @@ if (args.Contains("--live-read")) Check(next.ArtworkId == id, "real cover cache survives progress refresh"); } } +if (args.Contains("--desktop-smoke")) +{ + var desktopMedia = new FakeMedia(); + var desktopPairing = new PairingService(); + var desktopState = new MediaState + { + HasSession = true, Title = "Проверка MusicBridge", Artist = "Тестовый плеер", PlaybackStatus = "Playing", + Volume = 0.5f, Muted = false, PositionSeconds = 45, EndSeconds = 180, MaxSeekSeconds = 180, + Capabilities = new(true, true, true, true, true, true) + }; + 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)) + { + 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(() => + { + view.RefreshState(); + using var frame = new Bitmap(view.Width, view.Height); + view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height)); + frame.Save(Path.Combine(AppContext.BaseDirectory, "desktop-smoke.png")); + var toggle = (Button)view.Controls.Find("toggle", true).Single(); + Check(toggle.Enabled, "desktop enables supported commands"); + toggle.PerformClick(); + Check(desktopMedia.Commands.Last().Type == "toggle", "desktop play button reaches shared media service"); + var slider = (TrackBar)view.Controls.Find("volume", true).Single(); + slider.Value = 80; + typeof(Control).GetMethod("OnKeyUp", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)! + .Invoke(slider, [new KeyEventArgs(Keys.Right)]); + Check(desktopMedia.Commands.Last() is { Type: "volume", Value: 0.8 }, "desktop volume slider sends absolute volume"); + desktopState = new MediaState(); + view.RefreshState(); + Check(!toggle.Enabled && !slider.Enabled, "desktop disables unavailable player and volume controls"); + view.Close(); + Check(!view.IsDisposed && !view.Visible, "closing desktop window hides it without stopping agent"); + }); + Check(!exited.Task.IsCompleted, "tray host stays alive while window is hidden"); + } + await exited.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Check(exited.Task.IsCompleted, "disposing tray host stops its message loop"); +} if (args.Contains("--qr-window-smoke")) { var uiClock = new TestClock(DateTimeOffset.UtcNow);