diff --git a/.gitignore b/.gitignore index 5967294..eb91332 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ **/bin/ **/obj/ +dist/ diff --git a/CONNECTION-ROADMAP.md b/CONNECTION-ROADMAP.md index a3210bf..589f488 100644 --- a/CONNECTION-ROADMAP.md +++ b/CONNECTION-ROADMAP.md @@ -43,7 +43,8 @@ mDNS-слушатель на этом же ПК получил объявлен - [ ] Полноценное разрешение конфликтов имён DNS-SD; сейчас имя строится по хешу сертификата, а второй локальный экземпляр блокируется хранилищем. - [x] Tray и окно управления: `--tray --lan`, кнопки сопряжения/отзыва, управление музыкой. -- [ ] Автозапуск и упаковка Windows. +- [x] Автозапуск из меню трея, самостоятельная ZIP-упаковка Windows x64, установка/удаление. +- [ ] Проверка реального входа в Windows с включённым автозапуском. - [ ] Подготовить правила брандмауэра для частной сети, без автоматического открытия портов сейчас. HTTPS использует выбранный TCP-порт, mDNS — UDP 5353. diff --git a/Desktop/DesktopHost.cs b/Desktop/DesktopHost.cs index fda2b7b..53f4c3a 100644 --- a/Desktop/DesktopHost.cs +++ b/Desktop/DesktopHost.cs @@ -5,7 +5,8 @@ using MusicBridge.Agent.Network; namespace MusicBridge.Agent.Desktop; internal sealed class DesktopHost(Func read, IMediaService media, PairingService pairing, - Func network, Action showQr, Action exit, bool visible = true) : IDisposable + Func network, Action showQr, Action exit, bool visible = true, + StartupSettings? startup = null, bool minimized = false) : IDisposable { private readonly object gate = new(); private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously); @@ -34,6 +35,18 @@ internal sealed class DesktopHost(Func read, IMediaService media, Pa 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(); }); + 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("Завершить MusicBridge", null, (_, _) => { view.AllowClose = true; view.Close(); }); using var icon = new NotifyIcon { Icon = SystemIcons.Application, Text = "MusicBridge", ContextMenuStrip = menu, Visible = visible }; @@ -46,7 +59,7 @@ internal sealed class DesktopHost(Func read, IMediaService media, Pa var text = state.HasSession ? $"MusicBridge — {state.Title}" : "MusicBridge — нет плеера"; 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) { if (disposed) { ready.TrySetCanceled(); return; } diff --git a/Desktop/StartupSettings.cs b/Desktop/StartupSettings.cs new file mode 100644 index 0000000..8e0d17b --- /dev/null +++ b/Desktop/StartupSettings.cs @@ -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}"; + } +} diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..aa97b2b --- /dev/null +++ b/INSTALL.md @@ -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`. diff --git a/Network/MdnsPublisher.cs b/Network/MdnsPublisher.cs index f024137..2563253 100644 --- a/Network/MdnsPublisher.cs +++ b/Network/MdnsPublisher.cs @@ -11,6 +11,7 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable private ServiceProfile? profile; private string addressesKey = ""; private DateTimeOffset nextAnnouncement; + private DateTimeOffset lastRefresh; public string Status { get; private set; } = "mDNS: ожидание локальной сети."; public static ServiceProfile CreateProfile(string fingerprint, int port, IPAddress[] addresses) @@ -28,6 +29,10 @@ internal sealed class MdnsPublisher(string fingerprint, int port) : IDisposable { 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())); if (discovery is null || key != addressesKey) { diff --git a/Network/RemoteServer.cs b/Network/RemoteServer.cs index 7bcd75e..87751fc 100644 --- a/Network/RemoteServer.cs +++ b/Network/RemoteServer.cs @@ -60,7 +60,10 @@ internal sealed class RemoteServer : IAsyncDisposable }); app = builder.Build(); 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) => { context.Response.Headers.CacheControl = "no-store"; @@ -116,7 +119,7 @@ internal sealed class RemoteServer : IAsyncDisposable public async Task StartAsync(CancellationToken cancellationToken = default) { media.Changed += RequestRefresh; - Volatile.Write(ref latest, await media.ReadAsync(cancellationToken)); + Volatile.Write(ref latest, await ReadSafeAsync(cancellationToken)); await app.StartAsync(cancellationToken); polling = PollAsync(stop.Token); } @@ -134,7 +137,7 @@ internal sealed class RemoteServer : IAsyncDisposable // Coalesce bursts of Windows events without accumulating work. await Task.Delay(25, cancellationToken); while (refresh.Reader.TryRead(out _)) { } - var state = await media.ReadAsync(cancellationToken); + var state = await ReadSafeAsync(cancellationToken); Volatile.Write(ref latest, state); Published?.Invoke(); } @@ -142,6 +145,16 @@ internal sealed class RemoteServer : IAsyncDisposable catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } } + private async Task 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. private async Task StreamAsync(HttpContext context) { @@ -195,7 +208,9 @@ internal sealed class RemoteServer : IAsyncDisposable while (pending.Reader.TryRead(out _)) { } if (!pairing.Authorize(token)) return; 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; } diff --git a/PLAN.md b/PLAN.md index ab54ba9..f0eb667 100644 --- a/PLAN.md +++ b/PLAN.md @@ -83,7 +83,9 @@ Windows Core Audio → системная громкость устройств - [x] Трей и окно управления (`--tray`): медиакнопки, ползунки, QR, доверенные устройства. - [x] Закрытие окна скрывает его; двойной щелчок по значку восстанавливает; отдельный пункт меню завершает агент. Консоль остаётся отдельным режимом. -- [ ] Автозапуск и установщик (трей реализован, общий пункт выше ещё не завершён). +- [x] Автозапуск текущего пользователя через меню трея; скрытый старт `--minimized`. +- [x] Самостоятельный Windows x64 пакет со встроенными Runtime, ZIP-установка, + ярлык в меню Пуск и скрипт удаления. Это не MSI и не подписанный установщик. ### 4. Нативное приложение iPhone — отложено пользователем @@ -112,6 +114,18 @@ Windows Core Audio → системная громкость устройств 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-сборка без ошибок/предупреждений. Прогон с `--desktop-smoke --qr-window-smoke --mdns-smoke --live-read`. В скрытом окне проверены включение/отключение кнопок, отправка play/pause и @@ -180,7 +194,8 @@ Windows Core Audio → системная громкость устройств объявляется на домашних IPv4-адаптерах с multicast. Автоизменения брандмауэра нет. Следующий шаг: проверка с другого устройства и устойчивости при смене сети, -автозапуск/упаковка. Трей уже реализован. iPhone-клиент пока не начинать. +проверка входа Windows с автозапуском и подготовка правил частного брандмауэра. +Трей, автозапуск и ZIP-упаковка реализованы. iPhone-клиент пока не начинать. Точные границы реализации и следующие шаги — в `CONNECTION-ROADMAP.md`. Начать разработку iOS только после новой команды пользователя. @@ -204,6 +219,9 @@ Windows Core Audio → системная громкость устройств - `PairingWindowHost.cs`: окно QR на отдельном STA-потоке, выбор адреса и обновления. - `Desktop/AgentWindow.cs`: окно управления, ползунки и доверенные устройства. - `Desktop/DesktopHost.cs`: STA-поток, значок/меню трея и жизненный цикл окна. +- `Desktop/StartupSettings.cs`: HKCU Run, экранированная команда автозапуска. +- `Packaging/`: сборка standalone ZIP, установка/удаление и изолированный smoke-тест. +- `INSTALL.md`: установка, автозапуск, обновление и удаление. - `Network/LocalAddresses.cs`: подходящие домашние IPv4-адреса. - `Network/DiscoveryDescriptor.cs`: публичные поля DNS-SD. - `Network/MdnsPublisher.cs`: объявления mDNS, обновления адресов и goodbye. diff --git a/PROTOCOL.md b/PROTOCOL.md index c4d8040..2f2530c 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -23,7 +23,13 @@ play/pause, следующий/предыдущий трек, ползунки Чтобы пользоваться уже собранной версией, можно запускать `bin\Release\net10.0-windows10.0.19041.0\MusicBridge.Agent.exe --tray --lan`. Пока это 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` включает +трей и скрывает главное окно при старте. Автозапуск по умолчанию выключен; +лучше включать его после установки в постоянную папку. Из каталога репозитория: diff --git a/Packaging/Firewall.ps1 b/Packaging/Firewall.ps1 new file mode 100644 index 0000000..31c888d --- /dev/null +++ b/Packaging/Firewall.ps1 @@ -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 +} diff --git a/Packaging/Install.cmd b/Packaging/Install.cmd new file mode 100644 index 0000000..0a22290 --- /dev/null +++ b/Packaging/Install.cmd @@ -0,0 +1,3 @@ +@echo off +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install.ps1" +if errorlevel 1 pause diff --git a/Packaging/Install.ps1 b/Packaging/Install.ps1 new file mode 100644 index 0000000..13be0ba --- /dev/null +++ b/Packaging/Install.ps1 @@ -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.' diff --git a/Packaging/Publish.ps1 b/Packaging/Publish.ps1 new file mode 100644 index 0000000..b7212d2 --- /dev/null +++ b/Packaging/Publish.ps1 @@ -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" diff --git a/Packaging/Test-Firewall.ps1 b/Packaging/Test-Firewall.ps1 new file mode 100644 index 0000000..8ef8800 --- /dev/null +++ b/Packaging/Test-Firewall.ps1 @@ -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.' diff --git a/Packaging/Test-Package.ps1 b/Packaging/Test-Package.ps1 new file mode 100644 index 0000000..9c75dcc --- /dev/null +++ b/Packaging/Test-Package.ps1 @@ -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.' diff --git a/Packaging/Uninstall.ps1 b/Packaging/Uninstall.ps1 new file mode 100644 index 0000000..a0f3607 --- /dev/null +++ b/Packaging/Uninstall.ps1 @@ -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.' diff --git a/Program.cs b/Program.cs index ab64a8b..1de250e 100644 --- a/Program.cs +++ b/Program.cs @@ -12,16 +12,18 @@ Console.OutputEncoding = Encoding.UTF8; Console.Title = "MusicBridge Agent"; var lan = false; var tray = false; +var minimized = 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] == "--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) port = parsed; else { - Console.WriteLine("Запуск: dotnet run -- [--tray] [--lan] [--port 8765]"); + Console.WriteLine("Запуск: dotnet run -- [--tray] [--minimized] [--lan] [--port 8765]"); Console.WriteLine("Без --lan соединения разрешены только с этого ПК. Веб-интерфейса нет."); return; } @@ -48,7 +50,8 @@ try 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; + () => 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) { await desktop.StartAsync(); diff --git a/tests/Program.cs b/tests/Program.cs index fe589c8..7ff101c 100644 --- a/tests/Program.cs +++ b/tests/Program.cs @@ -30,6 +30,19 @@ void Check(bool condition, string name) } var now = DateTimeOffset.UtcNow; +var startupExe = Path.Combine(AppContext.BaseDirectory, "MusicBridge.Agent.exe"); +var startupRegistry = @"Software\MusicBridge\Tests\" + Guid.NewGuid().ToString("N"); +try +{ + var startup = new MusicBridge.Agent.Desktop.StartupSettings(startupExe, true, 18765, startupRegistry); + Check(startup.Command == $"\"{startupExe}\" --tray --minimized --lan --port 18765", "autostart preserves quoted path, mode and port"); + Check(!startup.Enabled, "autostart starts disabled"); + startup.SetEnabled(true); + Check(startup.Enabled, "autostart can be enabled in isolated registry key"); + startup.SetEnabled(false); + Check(!startup.Enabled, "autostart can be disabled"); +} +finally { Microsoft.Win32.Registry.CurrentUser.DeleteSubKeyTree(startupRegistry, false); } var invitation = ConnectionInvitation.Create(new Uri("https://192.168.1.10:8765"), new string('A', 64), new("01234567", now.AddMinutes(5)), now); Check(invitation.Version == 1 && invitation.Code == "01234567", "invitation preserves leading zero in pairing code"); var encodedInvitation = invitation.ToQrPayload().Split("data=")[1].Replace('-', '+').Replace('_', '/'); @@ -329,6 +342,42 @@ await using (var eventServer = new RemoteServer(eventMedia, eventPairing, certif } Check(eventMedia.SubscriberCount == 0, "server unsubscribes from media events on shutdown"); +var recoveryMedia = new FakeMedia { FailRead = true }; +var recoveryPairing = new PairingService(); +var recoveryToken = recoveryPairing.Pair(recoveryPairing.Open().Code).Token; +await using (var recoveryServer = new RemoteServer(recoveryMedia, recoveryPairing, certificate, false, 0, TimeSpan.FromMilliseconds(50))) +{ + await recoveryServer.StartAsync(); + Check(recoveryServer.Latest.MediaError is not null, "initial media failure does not prevent server startup"); + recoveryMedia.FailRead = false; + recoveryMedia.Notify(); + using var recoveredTimeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + while (recoveryServer.Latest.MediaError is not null) await Task.Delay(20, recoveredTimeout.Token); + Check(recoveryServer.Latest.HasSession, "polling recovers after initial media failure"); + recoveryMedia.FailRead = true; + recoveryMedia.Notify(); + while (recoveryServer.Latest.MediaError is null) await Task.Delay(20, recoveredTimeout.Token); + Check(!recoveryServer.Latest.HasSession && recoveryServer.Latest.ArtworkId is null, "read failure clears stale playing state"); + recoveryMedia.FailRead = false; + recoveryMedia.Title = "Recovered track"; + recoveryMedia.Notify(); + while (recoveryServer.Latest.Title != "Recovered track") await Task.Delay(20, recoveredTimeout.Token); + Check(recoveryServer.Latest.MediaError is null, "polling survives later transient exception"); + var address = new UriBuilder(recoveryServer.Addresses.Single()) { Scheme = "wss", Path = "/v1/events" }.Uri; + for (var i = 0; i < 12; i++) + { + using var clientSocket = new ClientWebSocket(); + clientSocket.Options.SetRequestHeader("Authorization", $"Bearer {recoveryToken}"); + clientSocket.Options.RemoteCertificateValidationCallback = (_, cert, _, _) => cert?.GetCertHashString(HashAlgorithmName.SHA256) == fingerprint; + await clientSocket.ConnectAsync(address, recoveredTimeout.Token); + var data = new byte[8192]; + await clientSocket.ReceiveAsync(data.AsMemory(), recoveredTimeout.Token); + clientSocket.Abort(); + await Task.Delay(30, recoveredTimeout.Token); + } + Check(true, "abrupt WSS disconnects release slots for reconnection"); +} + if (args.Contains("--live-read")) { var manager = await Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); @@ -357,13 +406,15 @@ if (args.Contains("--desktop-smoke")) }; var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using (var host = new MusicBridge.Agent.Desktop.DesktopHost(() => desktopState, desktopMedia, desktopPairing, - () => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false)) + () => "Домашняя сеть • https://192.168.1.10:8765", () => { }, () => exited.TrySetResult(), visible: false, minimized: true)) { await host.StartAsync().WaitAsync(TimeSpan.FromSeconds(8)); var field = typeof(MusicBridge.Agent.Desktop.DesktopHost).GetField("window", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!; var view = (MusicBridge.Agent.Desktop.AgentWindow)field.GetValue(host)!; view.Invoke(() => { + Check(!view.Visible, "minimized startup leaves main window hidden"); + view.Show(); view.RefreshState(); using var frame = new Bitmap(view.Width, view.Height); view.DrawToBitmap(frame, new Rectangle(0, 0, frame.Width, frame.Height)); @@ -456,6 +507,7 @@ sealed class TestClock(DateTimeOffset now) : TimeProvider sealed class FakeMedia : IMediaService { + public volatile bool FailRead; public event Action? Changed; public int SubscriberCount => Changed?.GetInvocationList().Length ?? 0; public int ReadCount; @@ -467,6 +519,7 @@ sealed class FakeMedia : IMediaService public Task ReadAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref ReadCount); + if (FailRead) throw new IOException("Simulated player disconnection"); return Task.FromResult(new MediaState { HasSession = true, Title = Title, ArtworkId = Artwork.Current?.Id, Volume = 0.5f, Timestamp = DateTimeOffset.UtcNow