Implement desktop tray interface with media controls and QR pairing; add related tests

This commit is contained in:
2026-09-09 10:01:39 +03:00
parent 2da3c9544b
commit 49d10a5242
7 changed files with 350 additions and 5 deletions
+2 -1
View File
@@ -42,7 +42,8 @@ mDNS-слушатель на этом же ПК получил объявлен
- [ ] Проверить смену Wi-Fi/IP, сон/пробуждение, несколько адаптеров и VPN.
- [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу
сертификата, а второй локальный экземпляр блокируется хранилищем.
- [ ] Tray, кнопки сопряжения/отзыва, автозапуск и упаковка Windows.
- [x] Tray и окно управления: `--tray --lan`, кнопки сопряжения/отзыва, управление музыкой.
- [ ] Автозапуск и упаковка Windows.
- [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия
портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353.
+162
View File
@@ -0,0 +1,162 @@
using MusicBridge.Agent.Media;
using MusicBridge.Agent.Network;
namespace MusicBridge.Agent.Desktop;
internal sealed class AgentWindow : Form
{
private readonly Func<MediaState> read;
private readonly IMediaService media;
private readonly PairingService pairing;
private readonly Func<string> 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<string, Button> 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<MediaState> read, IMediaService media, PairingService pairing, Func<string> 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<TrustedDevice>().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<object>().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);
}
}
+84
View File
@@ -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<MediaState> read, IMediaService media, PairingService pairing,
Func<string> 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();
}
+14 -1
View File
@@ -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.
+21
View File
@@ -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
+24 -3
View File
@@ -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<TrustedDevice> 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<string> { $"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;
}
+43
View File
@@ -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);