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
+1
View File
@@ -1,2 +1,3 @@
**/bin/ **/bin/
**/obj/ **/obj/
dist/
+2 -1
View File
@@ -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
View File
@@ -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; }
+34
View File
@@ -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
View File
@@ -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`.
+5
View File
@@ -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
View File
@@ -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; }
+20 -2
View File
@@ -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
View File
@@ -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` включает
трей и скрывает главное окно при старте. Автозапуск по умолчанию выключен;
лучше включать его после установки в постоянную папку.
Из каталога репозитория: Из каталога репозитория:
+55
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
@echo off
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install.ps1"
if errorlevel 1 pause
+31
View File
@@ -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.'
+12
View File
@@ -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"
+16
View File
@@ -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.'
+24
View File
@@ -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.'
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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