Implement autostart functionality, ZIP packaging, and related installation scripts; update documentation and tests
This commit is contained in:
@@ -1,2 +1,3 @@
|
|||||||
**/bin/
|
**/bin/
|
||||||
**/obj/
|
**/obj/
|
||||||
|
dist/
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ mDNS-слушатель на этом же ПК получил объявлен
|
|||||||
- [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу
|
- [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу
|
||||||
сертификата, а второй локальный экземпляр блокируется хранилищем.
|
сертификата, а второй локальный экземпляр блокируется хранилищем.
|
||||||
- [x] Tray и окно управления: `--tray --lan`, кнопки сопряжения/отзыва, управление музыкой.
|
- [x] Tray и окно управления: `--tray --lan`, кнопки сопряжения/отзыва, управление музыкой.
|
||||||
- [ ] Автозапуск и упаковка Windows.
|
- [x] Автозапуск из меню трея, самостоятельная ZIP-упаковка Windows x64, установка/удаление.
|
||||||
|
- [ ] Проверка реального входа в Windows с включённым автозапуском.
|
||||||
- [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия
|
- [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия
|
||||||
портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353.
|
портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353.
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -5,7 +5,8 @@ using MusicBridge.Agent.Network;
|
|||||||
namespace MusicBridge.Agent.Desktop;
|
namespace MusicBridge.Agent.Desktop;
|
||||||
|
|
||||||
internal sealed class DesktopHost(Func<MediaState> read, IMediaService media, PairingService pairing,
|
internal sealed class DesktopHost(Func<MediaState> read, IMediaService media, PairingService pairing,
|
||||||
Func<string> network, Action showQr, Action exit, bool visible = true) : IDisposable
|
Func<string> network, Action showQr, Action exit, bool visible = true,
|
||||||
|
StartupSettings? startup = null, bool minimized = false) : IDisposable
|
||||||
{
|
{
|
||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
@@ -34,6 +35,18 @@ internal sealed class DesktopHost(Func<MediaState> read, IMediaService media, Pa
|
|||||||
menu.Items.Add("Открыть MusicBridge", null, (_, _) => ShowWindow());
|
menu.Items.Add("Открыть MusicBridge", null, (_, _) => ShowWindow());
|
||||||
var toggle = menu.Items.Add("Play / Pause", null, async (_, _) => await view.SendAsync(new("toggle")));
|
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("Подключить iPhone", null, (_, _) => { pairing.Open(); showQr(); });
|
||||||
|
if (startup is not null)
|
||||||
|
{
|
||||||
|
var autoStart = new ToolStripMenuItem("Запускать вместе с Windows");
|
||||||
|
try { autoStart.Checked = startup.Enabled; }
|
||||||
|
catch (Exception) { autoStart.Enabled = false; }
|
||||||
|
autoStart.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
try { startup.SetEnabled(!startup.Enabled); autoStart.Checked = startup.Enabled; }
|
||||||
|
catch (Exception ex) { MessageBox.Show(view, ex.Message, "Автозапуск MusicBridge", MessageBoxButtons.OK, MessageBoxIcon.Warning); }
|
||||||
|
};
|
||||||
|
menu.Items.Add(autoStart);
|
||||||
|
}
|
||||||
menu.Items.Add(new ToolStripSeparator());
|
menu.Items.Add(new ToolStripSeparator());
|
||||||
menu.Items.Add("Завершить MusicBridge", null, (_, _) => { view.AllowClose = true; view.Close(); });
|
menu.Items.Add("Завершить MusicBridge", null, (_, _) => { view.AllowClose = true; view.Close(); });
|
||||||
using var icon = new NotifyIcon { Icon = SystemIcons.Application, Text = "MusicBridge", ContextMenuStrip = menu, Visible = visible };
|
using var icon = new NotifyIcon { Icon = SystemIcons.Application, Text = "MusicBridge", ContextMenuStrip = menu, Visible = visible };
|
||||||
@@ -46,7 +59,7 @@ internal sealed class DesktopHost(Func<MediaState> read, IMediaService media, Pa
|
|||||||
var text = state.HasSession ? $"MusicBridge — {state.Title}" : "MusicBridge — нет плеера";
|
var text = state.HasSession ? $"MusicBridge — {state.Title}" : "MusicBridge — нет плеера";
|
||||||
icon.Text = text.Length > 63 ? text[..63] : text;
|
icon.Text = text.Length > 63 ? text[..63] : text;
|
||||||
};
|
};
|
||||||
view.Shown += (_, _) => { timer.Start(); ready.TrySetResult(); };
|
view.Shown += (_, _) => { if (minimized) view.Hide(); timer.Start(); ready.TrySetResult(); };
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
if (disposed) { ready.TrySetCanceled(); return; }
|
if (disposed) { ready.TrySetCanceled(); return; }
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace MusicBridge.Agent.Desktop;
|
||||||
|
|
||||||
|
internal sealed class StartupSettings(string executable, bool lan, int port,
|
||||||
|
string registryPath = @"Software\Microsoft\Windows\CurrentVersion\Run")
|
||||||
|
{
|
||||||
|
private const string ValueName = "MusicBridge.Agent";
|
||||||
|
public string Command => BuildCommand(executable, lan, port);
|
||||||
|
public bool Enabled
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
using var key = Registry.CurrentUser.OpenSubKey(registryPath);
|
||||||
|
return string.Equals(key?.GetValue(ValueName) as string, Command, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetEnabled(bool enabled)
|
||||||
|
{
|
||||||
|
if (enabled && !File.Exists(executable)) throw new FileNotFoundException("Сначала установи или опубликуй MusicBridge.", executable);
|
||||||
|
using var key = Registry.CurrentUser.CreateSubKey(registryPath);
|
||||||
|
if (enabled) key.SetValue(ValueName, Command, RegistryValueKind.String);
|
||||||
|
else key.DeleteValue(ValueName, throwOnMissingValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static string BuildCommand(string executable, bool lan, int port)
|
||||||
|
{
|
||||||
|
if (!Path.IsPathFullyQualified(executable) || executable.Contains('"') || executable.Any(char.IsControl)
|
||||||
|
|| !Path.GetFileName(executable).Equals("MusicBridge.Agent.exe", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| port is < 1 or > 65535) throw new ArgumentException("Неверный путь или порт автозапуска.");
|
||||||
|
return $"\"{executable}\" --tray --minimized{(lan ? " --lan" : "")} --port {port}";
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -0,0 +1,43 @@
|
|||||||
|
# Установка MusicBridge для Windows x64
|
||||||
|
|
||||||
|
1. Распаковать `MusicBridge-win-x64.zip` целиком.
|
||||||
|
2. Запустить `Install.cmd`. Права администратора не нужны.
|
||||||
|
3. Открыть MusicBridge через меню «Пуск».
|
||||||
|
|
||||||
|
Пакет содержит .NET Runtime: SDK и отдельная установка .NET не нужны.
|
||||||
|
Это локальный ZIP-установщик со скриптом, не MSI и не подписанный установщик.
|
||||||
|
`Install.cmd` разрешает выполнение только запущенного скрипта в этом процессе;
|
||||||
|
постоянная политика PowerShell не меняется.
|
||||||
|
|
||||||
|
Приложение устанавливается в `%LOCALAPPDATA%\Programs\MusicBridge`.
|
||||||
|
Ярлык запускает `--tray --lan`. Крестик скрывает окно, меню трея завершает агент.
|
||||||
|
|
||||||
|
## Автозапуск
|
||||||
|
|
||||||
|
В меню значка трея включить «Запускать вместе с Windows». По умолчанию он выключен.
|
||||||
|
Настройка относится только к текущему пользователю (HKCU Run). Сохраняются путь
|
||||||
|
исполняемого файла, режим локальной сети и порт текущего запуска. Агент открывается
|
||||||
|
в трее без главного окна (`--minimized`). Повторное нажатие выключает автозапуск.
|
||||||
|
Включать его желательно после установки, чтобы путь к приложению был постоянным.
|
||||||
|
|
||||||
|
## Обновление и удаление
|
||||||
|
|
||||||
|
Перед повторной установкой завершить агент через меню трея. Установщик не
|
||||||
|
завершает процессы автоматически и не трогает хранилище сопряжения.
|
||||||
|
|
||||||
|
Для удаления завершить агент и выполнить:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\MusicBridge\Uninstall.ps1"
|
||||||
|
```
|
||||||
|
|
||||||
|
Удаляются установленная папка, соответствующий ярлык и запись автозапуска.
|
||||||
|
Сертификат и доверенные устройства в `%LOCALAPPDATA%\MusicBridge` сохраняются.
|
||||||
|
Правила брандмауэра установщик не меняет. Для подключения из домашней сети
|
||||||
|
потребуется разрешённый доступ к TCP-порту агента и UDP 5353 в частной сети.
|
||||||
|
|
||||||
|
## Для разработчика
|
||||||
|
|
||||||
|
Собрать пакет: `powershell -NoProfile -File Packaging/Publish.ps1`.
|
||||||
|
Результат: `dist/MusicBridge-win-x64.zip`. Для проверки установки в изоляции
|
||||||
|
можно передать `Install.ps1 -InstallDirectory <пустая тестовая папка> -NoShortcut`.
|
||||||
@@ -11,6 +11,7 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable
|
|||||||
private ServiceProfile? profile;
|
private ServiceProfile? profile;
|
||||||
private string addressesKey = "";
|
private string addressesKey = "";
|
||||||
private DateTimeOffset nextAnnouncement;
|
private DateTimeOffset nextAnnouncement;
|
||||||
|
private DateTimeOffset lastRefresh;
|
||||||
public string Status { get; private set; } = "mDNS: ожидание локальной сети.";
|
public string Status { get; private set; } = "mDNS: ожидание локальной сети.";
|
||||||
|
|
||||||
public static ServiceProfile CreateProfile(string fingerprint, int port, IPAddress[] addresses)
|
public static ServiceProfile CreateProfile(string fingerprint, int port, IPAddress[] addresses)
|
||||||
@@ -28,6 +29,10 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Rebind after a long pause (sleep/resume), even if Windows kept the same IP.
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (lastRefresh != default && (now - lastRefresh > TimeSpan.FromSeconds(20) || now < lastRefresh)) Stop();
|
||||||
|
lastRefresh = now;
|
||||||
var key = string.Join(",", addresses.Select(a => a.ToString()));
|
var key = string.Join(",", addresses.Select(a => a.ToString()));
|
||||||
if (discovery is null || key != addressesKey)
|
if (discovery is null || key != addressesKey)
|
||||||
{
|
{
|
||||||
|
|||||||
+19
-4
@@ -60,7 +60,10 @@ internal sealed class RemoteServer : IAsyncDisposable
|
|||||||
});
|
});
|
||||||
app = builder.Build();
|
app = builder.Build();
|
||||||
app.UseRateLimiter();
|
app.UseRateLimiter();
|
||||||
app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(20) });
|
app.UseWebSockets(new WebSocketOptions
|
||||||
|
{
|
||||||
|
KeepAliveInterval = TimeSpan.FromSeconds(20), KeepAliveTimeout = TimeSpan.FromSeconds(10)
|
||||||
|
});
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
context.Response.Headers.CacheControl = "no-store";
|
context.Response.Headers.CacheControl = "no-store";
|
||||||
@@ -116,7 +119,7 @@ internal sealed class RemoteServer : IAsyncDisposable
|
|||||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
media.Changed += RequestRefresh;
|
media.Changed += RequestRefresh;
|
||||||
Volatile.Write(ref latest, await media.ReadAsync(cancellationToken));
|
Volatile.Write(ref latest, await ReadSafeAsync(cancellationToken));
|
||||||
await app.StartAsync(cancellationToken);
|
await app.StartAsync(cancellationToken);
|
||||||
polling = PollAsync(stop.Token);
|
polling = PollAsync(stop.Token);
|
||||||
}
|
}
|
||||||
@@ -134,7 +137,7 @@ internal sealed class RemoteServer : IAsyncDisposable
|
|||||||
// Coalesce bursts of Windows events without accumulating work.
|
// Coalesce bursts of Windows events without accumulating work.
|
||||||
await Task.Delay(25, cancellationToken);
|
await Task.Delay(25, cancellationToken);
|
||||||
while (refresh.Reader.TryRead(out _)) { }
|
while (refresh.Reader.TryRead(out _)) { }
|
||||||
var state = await media.ReadAsync(cancellationToken);
|
var state = await ReadSafeAsync(cancellationToken);
|
||||||
Volatile.Write(ref latest, state);
|
Volatile.Write(ref latest, state);
|
||||||
Published?.Invoke();
|
Published?.Invoke();
|
||||||
}
|
}
|
||||||
@@ -142,6 +145,16 @@ internal sealed class RemoteServer : IAsyncDisposable
|
|||||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<MediaState> ReadSafeAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
try { return await media.ReadAsync(timeout.Token); }
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
|
||||||
|
catch (OperationCanceledException) { return new MediaState { MediaError = "Плеер не ответил вовремя. Повторяем подключение." }; }
|
||||||
|
catch (Exception ex) { return new MediaState { MediaError = $"Плеер временно недоступен: {ex.Message}" }; }
|
||||||
|
}
|
||||||
|
|
||||||
// WSS is a state stream; commands use HTTPS so each has its own response.
|
// WSS is a state stream; commands use HTTPS so each has its own response.
|
||||||
private async Task StreamAsync(HttpContext context)
|
private async Task StreamAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
@@ -195,7 +208,9 @@ internal sealed class RemoteServer : IAsyncDisposable
|
|||||||
while (pending.Reader.TryRead(out _)) { }
|
while (pending.Reader.TryRead(out _)) { }
|
||||||
if (!pairing.Authorize(token)) return;
|
if (!pairing.Authorize(token)) return;
|
||||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(new { type = "state", state = Latest }, JsonOptions);
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(new { type = "state", state = Latest }, JsonOptions);
|
||||||
await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, cancellationToken);
|
using var sendTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
sendTimeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||||
|
await socket.SendAsync(bytes.AsMemory(), WebSocketMessageType.Text, true, sendTimeout.Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally { Published -= Signal; pairing.TrustChanged -= Signal; }
|
finally { Published -= Signal; pairing.TrustChanged -= Signal; }
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ Windows Core Audio → системная громкость устройств
|
|||||||
- [x] Трей и окно управления (`--tray`): медиакнопки, ползунки, QR, доверенные устройства.
|
- [x] Трей и окно управления (`--tray`): медиакнопки, ползунки, QR, доверенные устройства.
|
||||||
- [x] Закрытие окна скрывает его; двойной щелчок по значку восстанавливает;
|
- [x] Закрытие окна скрывает его; двойной щелчок по значку восстанавливает;
|
||||||
отдельный пункт меню завершает агент. Консоль остаётся отдельным режимом.
|
отдельный пункт меню завершает агент. Консоль остаётся отдельным режимом.
|
||||||
- [ ] Автозапуск и установщик (трей реализован, общий пункт выше ещё не завершён).
|
- [x] Автозапуск текущего пользователя через меню трея; скрытый старт `--minimized`.
|
||||||
|
- [x] Самостоятельный Windows x64 пакет со встроенными Runtime, ZIP-установка,
|
||||||
|
ярлык в меню Пуск и скрипт удаления. Это не MSI и не подписанный установщик.
|
||||||
|
|
||||||
### 4. Нативное приложение iPhone — отложено пользователем
|
### 4. Нативное приложение iPhone — отложено пользователем
|
||||||
|
|
||||||
@@ -112,6 +114,18 @@ Windows Core Audio → системная громкость устройств
|
|||||||
|
|
||||||
2026-09-09:
|
2026-09-09:
|
||||||
|
|
||||||
|
- Последний этап — автозапуск и упаковка: **107 проверок прошло** с
|
||||||
|
`-c Packaging --no-build -- --desktop-smoke --qr-window-smoke --mdns-smoke --live-read`.
|
||||||
|
Проверены запись/удаление тестовой записи автозапуска и скрытый старт окна.
|
||||||
|
Тестовая конфигурация Packaging использована, потому что Release exe занят
|
||||||
|
работающим агентом; его не останавливали. Сборка тестов без ошибок/предупреждений.
|
||||||
|
- Собран `dist/MusicBridge-win-x64.zip` (~65 МиБ), Release win-x64 self-contained.
|
||||||
|
`Packaging/Test-Package.ps1` проверил установку в отдельную папку без ярлыка,
|
||||||
|
запуск со встроенным Runtime, повторную установку, запрет обновления занятого
|
||||||
|
exe и удаление. Рабочий автозапуск и хранилище пользователя не менялись.
|
||||||
|
Перезагрузка Windows и реальное срабатывание автозапуска при входе ещё не проверены.
|
||||||
|
Инструкции — в `INSTALL.md`.
|
||||||
|
|
||||||
- Последний этап — окно управления и трей: **102 проверки прошли**, Release-сборка
|
- Последний этап — окно управления и трей: **102 проверки прошли**, Release-сборка
|
||||||
без ошибок/предупреждений. Прогон с `--desktop-smoke --qr-window-smoke --mdns-smoke --live-read`.
|
без ошибок/предупреждений. Прогон с `--desktop-smoke --qr-window-smoke --mdns-smoke --live-read`.
|
||||||
В скрытом окне проверены включение/отключение кнопок, отправка play/pause и
|
В скрытом окне проверены включение/отключение кнопок, отправка play/pause и
|
||||||
@@ -180,7 +194,8 @@ Windows Core Audio → системная громкость устройств
|
|||||||
объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет.
|
объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет.
|
||||||
|
|
||||||
Следующий шаг: проверка с другого устройства и устойчивости при смене сети,
|
Следующий шаг: проверка с другого устройства и устойчивости при смене сети,
|
||||||
автозапуск/упаковка. Трей уже реализован. iPhone-клиент пока не начинать.
|
проверка входа Windows с автозапуском и подготовка правил частного брандмауэра.
|
||||||
|
Трей, автозапуск и ZIP-упаковка реализованы. iPhone-клиент пока не начинать.
|
||||||
Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`.
|
Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`.
|
||||||
Начать разработку iOS только после новой команды пользователя.
|
Начать разработку iOS только после новой команды пользователя.
|
||||||
|
|
||||||
@@ -204,6 +219,9 @@ Windows Core Audio → системная громкость устройств
|
|||||||
- `PairingWindowHost.cs`: окно QR на отдельном STA-потоке, выбор адреса и обновления.
|
- `PairingWindowHost.cs`: окно QR на отдельном STA-потоке, выбор адреса и обновления.
|
||||||
- `Desktop/AgentWindow.cs`: окно управления, ползунки и доверенные устройства.
|
- `Desktop/AgentWindow.cs`: окно управления, ползунки и доверенные устройства.
|
||||||
- `Desktop/DesktopHost.cs`: STA-поток, значок/меню трея и жизненный цикл окна.
|
- `Desktop/DesktopHost.cs`: STA-поток, значок/меню трея и жизненный цикл окна.
|
||||||
|
- `Desktop/StartupSettings.cs`: HKCU Run, экранированная команда автозапуска.
|
||||||
|
- `Packaging/`: сборка standalone ZIP, установка/удаление и изолированный smoke-тест.
|
||||||
|
- `INSTALL.md`: установка, автозапуск, обновление и удаление.
|
||||||
- `Network/LocalAddresses.cs`: подходящие домашние IPv4-адреса.
|
- `Network/LocalAddresses.cs`: подходящие домашние IPv4-адреса.
|
||||||
- `Network/DiscoveryDescriptor.cs`: публичные поля DNS-SD.
|
- `Network/DiscoveryDescriptor.cs`: публичные поля DNS-SD.
|
||||||
- `Network/MdnsPublisher.cs`: объявления mDNS, обновления адресов и goodbye.
|
- `Network/MdnsPublisher.cs`: объявления mDNS, обновления адресов и goodbye.
|
||||||
|
|||||||
+7
-1
@@ -23,7 +23,13 @@ play/pause, следующий/предыдущий трек, ползунки
|
|||||||
Чтобы пользоваться уже собранной версией, можно запускать
|
Чтобы пользоваться уже собранной версией, можно запускать
|
||||||
`bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`.
|
`bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`.
|
||||||
Пока это framework-dependent сборка: необходим .NET 10 Desktop Runtime и
|
Пока это framework-dependent сборка: необходим .NET 10 Desktop Runtime и
|
||||||
ASP.NET Core Runtime. Автозапуск и установщик ещё не добавлены.
|
ASP.NET Core Runtime. Для установки без отдельных Runtime подготовлен
|
||||||
|
`dist/MusicBridge-win-x64.zip`; см. [INSTALL.md](INSTALL.md).
|
||||||
|
|
||||||
|
Меню трея «Запускать вместе с Windows» включает/выключает автозапуск текущего
|
||||||
|
пользователя. Сохраняются текущий путь, порт и режим сети. `--minimized` включает
|
||||||
|
трей и скрывает главное окно при старте. Автозапуск по умолчанию выключен;
|
||||||
|
лучше включать его после установки в постоянную папку.
|
||||||
|
|
||||||
Из каталога репозитория:
|
Из каталога репозитория:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
[CmdletBinding(SupportsShouldProcess = $true)]
|
||||||
|
param(
|
||||||
|
[ValidateSet('Preview','Apply','Remove')][string]$Action = 'Preview',
|
||||||
|
[string]$ProgramPath = (Join-Path $env:LOCALAPPDATA 'Programs\MusicBridge\MusicBridge.Agent.exe'),
|
||||||
|
[ValidateRange(1,65535)][int]$Port = 8765
|
||||||
|
)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
if (![IO.Path]::IsPathRooted($ProgramPath)) { throw 'ProgramPath must be absolute.' }
|
||||||
|
$exe = [IO.Path]::GetFullPath($ProgramPath)
|
||||||
|
if ([IO.Path]::GetFileName($exe) -ne 'MusicBridge.Agent.exe') { throw 'Select MusicBridge.Agent.exe.' }
|
||||||
|
if ($Action -ne 'Remove' -and !(Test-Path -LiteralPath $exe -PathType Leaf)) { throw 'Install or publish the executable first.' }
|
||||||
|
$sha = [Security.Cryptography.SHA256]::Create()
|
||||||
|
try { $id = ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($exe.ToUpperInvariant())))).Replace('-','').Substring(0,16) }
|
||||||
|
finally { $sha.Dispose() }
|
||||||
|
$definitions = @(
|
||||||
|
@{Name="MusicBridge-$id-HTTPS"; DisplayName='MusicBridge HTTPS (private LAN)'; Protocol='TCP'; LocalPort=$Port},
|
||||||
|
@{Name="MusicBridge-$id-mDNS"; DisplayName='MusicBridge mDNS (private LAN)'; Protocol='UDP'; LocalPort=5353}
|
||||||
|
)
|
||||||
|
foreach ($definition in $definitions) {
|
||||||
|
$definition.Program = $exe
|
||||||
|
$definition.Profile = 'Private'
|
||||||
|
$definition.Direction = 'Inbound'
|
||||||
|
$definition.Action = 'Allow'
|
||||||
|
$definition.RemoteAddress = 'LocalSubnet'
|
||||||
|
$definition.EdgeTraversalPolicy = 'Block'
|
||||||
|
$definition.Enabled = 'True'
|
||||||
|
}
|
||||||
|
if ($Action -eq 'Preview') { $definitions | ForEach-Object { [pscustomobject]$_ }; return }
|
||||||
|
$principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
|
||||||
|
if (!$WhatIfPreference -and !$principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||||
|
throw 'Open PowerShell as Administrator to apply or remove these rules. Preview requires no elevation.'
|
||||||
|
}
|
||||||
|
foreach ($definition in $definitions) {
|
||||||
|
if (!$PSCmdlet.ShouldProcess($definition.Name, $Action)) { continue }
|
||||||
|
$existing = Get-NetFirewallRule -Name $definition.Name -ErrorAction SilentlyContinue
|
||||||
|
if ($existing) {
|
||||||
|
$application = $existing | Get-NetFirewallApplicationFilter
|
||||||
|
if ($application.Program -ne $exe -or $existing.Group -ne 'MusicBridge managed network access') {
|
||||||
|
throw 'A conflicting rule exists; refusing to modify it.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($Action -eq 'Remove') {
|
||||||
|
if ($existing) { $existing | Remove-NetFirewallRule }
|
||||||
|
} elseif ($existing) {
|
||||||
|
$update = $definition.Clone()
|
||||||
|
$update.Remove('DisplayName')
|
||||||
|
Set-NetFirewallRule @update | Out-Null
|
||||||
|
} else {
|
||||||
|
New-NetFirewallRule @definition -Group 'MusicBridge managed network access' | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$WhatIfPreference) {
|
||||||
|
Get-NetFirewallRule -Name ($definitions.Name) -ErrorAction SilentlyContinue |
|
||||||
|
Select-Object Name,Enabled,Profile,Direction,Action
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@echo off
|
||||||
|
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install.ps1"
|
||||||
|
if errorlevel 1 pause
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
param([string]$InstallDirectory = (Join-Path $env:LOCALAPPDATA 'Programs\MusicBridge'), [switch]$NoShortcut)
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$target = [IO.Path]::GetFullPath($InstallDirectory).TrimEnd('\')
|
||||||
|
$source = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot 'app'))
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $source 'MusicBridge.Agent.exe'))) { throw 'Package is incomplete: app\MusicBridge.Agent.exe is missing.' }
|
||||||
|
if ($target -eq $source -or $target.StartsWith($source + '\', [StringComparison]::OrdinalIgnoreCase)) { throw 'Choose an install directory outside the package app directory.' }
|
||||||
|
$parent = Split-Path $target -Parent
|
||||||
|
if (!$parent -or $target -eq [IO.Path]::GetPathRoot($target)) { throw 'Unsafe install directory.' }
|
||||||
|
New-Item -ItemType Directory -Path $target -Force | Out-Null
|
||||||
|
$marker = Join-Path $target '.musicbridge-install'
|
||||||
|
if ((Get-ChildItem -LiteralPath $target -Force | Measure-Object).Count -gt 0 -and !(Test-Path -LiteralPath $marker)) { throw 'Directory is not a MusicBridge installation. Choose an empty directory.' }
|
||||||
|
$exe = Join-Path $target 'MusicBridge.Agent.exe'
|
||||||
|
if (Test-Path -LiteralPath $exe) {
|
||||||
|
try { $probe = [IO.File]::Open($exe, 'Open', 'ReadWrite', 'None'); $probe.Dispose() }
|
||||||
|
catch { throw 'Close MusicBridge from its tray menu before updating.' }
|
||||||
|
}
|
||||||
|
# Copy without deleting unrelated files. Existing user identity lives outside this directory.
|
||||||
|
Copy-Item -Path (Join-Path $source '*') -Destination $target -Recurse -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Uninstall.ps1') -Destination $target -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Firewall.ps1') -Destination $target -Force
|
||||||
|
Set-Content -LiteralPath $marker -Value 'MusicBridge per-user installation v1' -Encoding UTF8
|
||||||
|
if (!$NoShortcut) {
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
$shortcut = $shell.CreateShortcut((Join-Path ([Environment]::GetFolderPath('Programs')) 'MusicBridge.lnk'))
|
||||||
|
$shortcut.TargetPath = $exe
|
||||||
|
$shortcut.Arguments = '--tray --lan'
|
||||||
|
$shortcut.WorkingDirectory = $target
|
||||||
|
$shortcut.Save()
|
||||||
|
}
|
||||||
|
Write-Host "Installed: $exe"
|
||||||
|
Write-Host 'Open MusicBridge from Start. Autostart can be enabled from the tray menu.'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
param([string]$Configuration = 'Release')
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$project = Split-Path $PSScriptRoot -Parent
|
||||||
|
$output = Join-Path $project 'dist\MusicBridge-win-x64'
|
||||||
|
New-Item -ItemType Directory -Path $output -Force | Out-Null
|
||||||
|
& dotnet publish (Join-Path $project 'MusicBridge.Agent.csproj') -c $Configuration -r win-x64 --self-contained true -p:PublishSingleFile=false -o (Join-Path $output 'app')
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Publish failed.' }
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Install.ps1'),(Join-Path $PSScriptRoot 'Install.cmd'),(Join-Path $PSScriptRoot 'Uninstall.ps1') -Destination $output -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $project 'INSTALL.md') -Destination $output -Force
|
||||||
|
Copy-Item -LiteralPath (Join-Path $PSScriptRoot 'Firewall.ps1') -Destination $output -Force
|
||||||
|
Compress-Archive -Path (Join-Path $output '*') -DestinationPath (Join-Path $project 'dist\MusicBridge-win-x64.zip') -Force
|
||||||
|
Write-Host "Package: $project\dist\MusicBridge-win-x64.zip"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$project = Split-Path $PSScriptRoot -Parent
|
||||||
|
$exe = [IO.Path]::GetFullPath((Join-Path $project 'dist\MusicBridge-win-x64\app\MusicBridge.Agent.exe'))
|
||||||
|
$script = Join-Path $PSScriptRoot 'Firewall.ps1'
|
||||||
|
$rules = @(& $script -Action Preview -ProgramPath $exe -Port 18765)
|
||||||
|
if ($rules.Count -ne 2) { throw 'Expected two rules.' }
|
||||||
|
foreach ($rule in $rules) {
|
||||||
|
if ($rule.Profile -ne 'Private' -or $rule.RemoteAddress -ne 'LocalSubnet' -or $rule.Program -ne $exe -or
|
||||||
|
$rule.Direction -ne 'Inbound' -or $rule.Action -ne 'Allow' -or $rule.EdgeTraversalPolicy -ne 'Block') { throw 'Rule scope is too broad.' }
|
||||||
|
}
|
||||||
|
if (($rules | Where-Object Protocol -eq TCP).LocalPort -ne 18765) { throw 'HTTPS port mismatch.' }
|
||||||
|
if (($rules | Where-Object Protocol -eq UDP).LocalPort -ne 5353) { throw 'mDNS port mismatch.' }
|
||||||
|
$again = @(& $script -Action Preview -ProgramPath $exe -Port 8765)
|
||||||
|
if (($rules.Name -join ',') -ne ($again.Name -join ',')) { throw 'Changing port would leave old rule names behind.' }
|
||||||
|
& $script -Action Apply -ProgramPath $exe -WhatIf
|
||||||
|
Write-Host 'PASS: private profile, local subnet, executable binding, ports, stable names and dry-run. No firewall changes.'
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$project = Split-Path $PSScriptRoot -Parent
|
||||||
|
$package = Join-Path $project 'dist\MusicBridge-win-x64'
|
||||||
|
$testRoot = [IO.Path]::GetFullPath((Join-Path $project ('dist\install-smoke-' + [guid]::NewGuid().ToString('N'))))
|
||||||
|
$allowed = [IO.Path]::GetFullPath((Join-Path $project 'dist')) + '\'
|
||||||
|
if (!$testRoot.StartsWith($allowed, [StringComparison]::OrdinalIgnoreCase)) { throw 'Unsafe test path.' }
|
||||||
|
& (Join-Path $package 'Install.ps1') -InstallDirectory $testRoot -NoShortcut
|
||||||
|
$exe = Join-Path $testRoot 'MusicBridge.Agent.exe'
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $testRoot 'coreclr.dll'))) { throw 'Self-contained runtime is missing.' }
|
||||||
|
$config = Get-Content -LiteralPath (Join-Path $testRoot 'MusicBridge.Agent.runtimeconfig.json') -Raw | ConvertFrom-Json
|
||||||
|
if (!$config.runtimeOptions.includedFrameworks) { throw 'Package unexpectedly depends on globally installed runtimes.' }
|
||||||
|
& $exe --help
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'Installed executable failed to start.' }
|
||||||
|
& (Join-Path $package 'Install.ps1') -InstallDirectory $testRoot -NoShortcut
|
||||||
|
$probe = [IO.File]::Open($exe, 'Open', 'Read', 'None')
|
||||||
|
try {
|
||||||
|
$blocked = $false
|
||||||
|
try { & (Join-Path $package 'Install.ps1') -InstallDirectory $testRoot -NoShortcut }
|
||||||
|
catch { $blocked = $true }
|
||||||
|
if (!$blocked) { throw 'Installer did not reject locked executable.' }
|
||||||
|
} finally { $probe.Dispose() }
|
||||||
|
& (Join-Path $testRoot 'Uninstall.ps1')
|
||||||
|
if (Test-Path -LiteralPath $testRoot) { throw 'Uninstall did not remove test directory.' }
|
||||||
|
Write-Host 'PASS: install, self-contained launch, update, locked-file protection, uninstall.'
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$target = [IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\')
|
||||||
|
if (!(Test-Path -LiteralPath (Join-Path $target '.musicbridge-install'))) { throw 'Installation marker is missing; refusing deletion.' }
|
||||||
|
if ($target -eq [IO.Path]::GetPathRoot($target)) { throw 'Unsafe directory.' }
|
||||||
|
$exe = Join-Path $target 'MusicBridge.Agent.exe'
|
||||||
|
if (Test-Path -LiteralPath $exe) {
|
||||||
|
try { $probe = [IO.File]::Open($exe, 'Open', 'ReadWrite', 'None'); $probe.Dispose() }
|
||||||
|
catch { throw 'Close MusicBridge from its tray menu first.' }
|
||||||
|
}
|
||||||
|
$run = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run'
|
||||||
|
$command = (Get-ItemProperty -LiteralPath $run -Name 'MusicBridge.Agent' -ErrorAction SilentlyContinue).'MusicBridge.Agent'
|
||||||
|
if ($command -and $command.StartsWith('"' + $exe + '"', [StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
Remove-ItemProperty -LiteralPath $run -Name 'MusicBridge.Agent'
|
||||||
|
}
|
||||||
|
$link = Join-Path ([Environment]::GetFolderPath('Programs')) 'MusicBridge.lnk'
|
||||||
|
if (Test-Path -LiteralPath $link) {
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
if ($shell.CreateShortcut($link).TargetPath -eq $exe) { Remove-Item -LiteralPath $link }
|
||||||
|
}
|
||||||
|
# Delete only the verified installed directory, preserving %LOCALAPPDATA%\MusicBridge identity.
|
||||||
|
Remove-Item -LiteralPath $target -Recurse -Force
|
||||||
|
Write-Host 'MusicBridge removed. Pairing identity was preserved.'
|
||||||
+5
-2
@@ -12,16 +12,18 @@ Console.OutputEncoding = Encoding.UTF8;
|
|||||||
Console.Title = "MusicBridge Agent";
|
Console.Title = "MusicBridge Agent";
|
||||||
var lan = false;
|
var lan = false;
|
||||||
var tray = false;
|
var tray = false;
|
||||||
|
var minimized = false;
|
||||||
var port = 8765;
|
var port = 8765;
|
||||||
for (var i = 0; i < args.Length; i++)
|
for (var i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
if (args[i] == "--lan") lan = true;
|
if (args[i] == "--lan") lan = true;
|
||||||
else if (args[i] == "--tray") tray = true;
|
else if (args[i] == "--tray") tray = true;
|
||||||
|
else if (args[i] == "--minimized") { minimized = true; tray = true; }
|
||||||
else if (args[i] == "--port" && i + 1 < args.Length && int.TryParse(args[++i], out var parsed) && parsed is > 0 and <= 65535)
|
else if (args[i] == "--port" && i + 1 < args.Length && int.TryParse(args[++i], out var parsed) && parsed is > 0 and <= 65535)
|
||||||
port = parsed;
|
port = parsed;
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("Запуск: dotnet run -- [--tray] [--lan] [--port 8765]");
|
Console.WriteLine("Запуск: dotnet run -- [--tray] [--minimized] [--lan] [--port 8765]");
|
||||||
Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет.");
|
Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -48,7 +50,8 @@ try
|
|||||||
using var qrWindow = new PairingWindowHost(pairing, fingerprint, () => Volatile.Read(ref qrEndpoints));
|
using var qrWindow = new PairingWindowHost(pairing, fingerprint, () => Volatile.Read(ref qrEndpoints));
|
||||||
string networkStatus = lan ? "Подключение к домашней сети…" : "Локальный режим. Для iPhone запусти с --lan.";
|
string networkStatus = lan ? "Подключение к домашней сети…" : "Локальный режим. Для iPhone запусти с --lan.";
|
||||||
using var desktop = tray ? new DesktopHost(() => server.Latest, media, pairing,
|
using var desktop = tray ? new DesktopHost(() => server.Latest, media, pairing,
|
||||||
() => Volatile.Read(ref networkStatus), qrWindow.Open, shutdown.Cancel) : null;
|
() => Volatile.Read(ref networkStatus), qrWindow.Open, shutdown.Cancel,
|
||||||
|
startup: new StartupSettings(Path.Combine(AppContext.BaseDirectory, "MusicBridge.Agent.exe"), lan, port), minimized: minimized) : null;
|
||||||
if (desktop is not null)
|
if (desktop is not null)
|
||||||
{
|
{
|
||||||
await desktop.StartAsync();
|
await desktop.StartAsync();
|
||||||
|
|||||||
+54
-1
@@ -30,6 +30,19 @@ void Check(bool condition, string name)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTimeOffset.UtcNow;
|
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);
|
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");
|
Check(invitation.Version == 1 && invitation.Code == "01234567", "invitation preserves leading zero in pairing code");
|
||||||
var encodedInvitation = invitation.ToQrPayload().Split("data=")[1].Replace('-', '+').Replace('_', '/');
|
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");
|
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"))
|
if (args.Contains("--live-read"))
|
||||||
{
|
{
|
||||||
var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync();
|
||||||
@@ -357,13 +406,15 @@ if (args.Contains("--desktop-smoke"))
|
|||||||
};
|
};
|
||||||
var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
using (var host = new MusicBridge.Agent.Desktop.DesktopHost(() => desktopState, desktopMedia, desktopPairing,
|
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));
|
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 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)!;
|
var view = (MusicBridge.Agent.Desktop.AgentWindow)field.GetValue(host)!;
|
||||||
view.Invoke(() =>
|
view.Invoke(() =>
|
||||||
{
|
{
|
||||||
|
Check(!view.Visible, "minimized startup leaves main window hidden");
|
||||||
|
view.Show();
|
||||||
view.RefreshState();
|
view.RefreshState();
|
||||||
using var frame = new Bitmap(view.Width, view.Height);
|
using var frame = new Bitmap(view.Width, view.Height);
|
||||||
view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.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
|
sealed class FakeMedia : IMediaService
|
||||||
{
|
{
|
||||||
|
public volatile bool FailRead;
|
||||||
public event Action? Changed;
|
public event Action? Changed;
|
||||||
public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0;
|
public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0;
|
||||||
public int ReadCount;
|
public int ReadCount;
|
||||||
@@ -467,6 +519,7 @@ sealed class FakeMedia : IMediaService
|
|||||||
public Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
public Task<MediaState> ReadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref ReadCount);
|
Interlocked.Increment(ref ReadCount);
|
||||||
|
if (FailRead) throw new IOException("Simulated player disconnection");
|
||||||
return Task.FromResult(new MediaState
|
return Task.FromResult(new MediaState
|
||||||
{
|
{
|
||||||
HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow
|
HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow
|
||||||
|
|||||||
Reference in New Issue
Block a user