diff --git a/README.md b/README.md index b98945e..9a639f4 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,15 @@ sendspin-cli vol 40 curl -fsSL https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_linux.sh | bash ``` -It removes the service and the installed files, and keeps your config and the player's -remembered state unless you pass `--purge`. +On Windows: + +```powershell +Invoke-WebRequest https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_windows.ps1 -OutFile uninstall_windows.ps1 +powershell -ExecutionPolicy Bypass -File .\uninstall_windows.ps1 +``` + +Each removes the service and the installed files, and keeps your config and the player's +remembered state unless you pass `--purge` (`-Purge` on Windows). ## Need more help? diff --git a/docs/wiki/Getting-Started-on-Windows.md b/docs/wiki/Getting-Started-on-Windows.md index 2aa646a..df6aeed 100644 --- a/docs/wiki/Getting-Started-on-Windows.md +++ b/docs/wiki/Getting-Started-on-Windows.md @@ -20,7 +20,7 @@ It prints what it is about to do, as you, and waits for you to say yes: ==> This is what it will do, as you, with no administrator rights install C:\Users\you\AppData\Local\Programs\sendspin-cli\ (sendspin-cli.exe and its README, LICENSE and config example) - config C:\Users\you\AppData\Roaming\sendspin-cli\config (from the example; every line in it is commented out) + config C:\Users\you\AppData\Roaming\sendspin-cli\config (from the example, setting the name you choose and the output you choose) PATH add C:\Users\you\AppData\Local\Programs\sendspin-cli to your user PATH, so 'sendspin-cli status' works anywhere task 'sendspin-cli' in Task Scheduler: start the player, without a window, when you log on, logging to C:\Users\you\AppData\Local\sendspin-cli\sendspin-cli.log; it checks every minute @@ -30,9 +30,27 @@ It prints what it is about to do, as you, and waits for you to say yes: Go ahead? [y/N] ``` -`-Yes` skips the prompt, and is required when there is no console to ask on. `-Version v0.5.0` -installs a specific release instead of the newest. `-Firewall` adds the firewall rule without -asking, though Windows still asks for administrator approval. +Then it asks two things, the name a server shows for the player and what it plays through: + +``` +==> What it plays through + + 0) Whatever Windows' default output is -- change it in Settings > System > Sound + 1) Speakers (USB DAC) (the default right now) + 2) Odyssey G95NC (NVIDIA High Definition Audio) + + Choose 0-2 [0]: +``` + +`0` follows Windows' default as you change it; a number pins that device, written to the +config as `output = portaudio:directsound:`. Neither is asked again once the config +sets it, so running the script again to upgrade asks nothing. + +`-Name kitchen` and `-Output "portaudio:directsound:Speakers (USB DAC)"` answer them up +front. `-Yes` skips every prompt, and is required when there is no console to ask on; with it, +only those two flags set anything. `-Version v0.5.0` installs a specific release instead of +the newest. `-Firewall` adds the firewall rule without asking, though Windows still asks for +administrator approval. ### What it actually does @@ -45,10 +63,11 @@ asking, though Windows still asks for administrator approval. already running from there is stopped cleanly first, so running the script again upgrades it. 5. **Copies the annotated example config** to `AppData\Roaming\sendspin-cli\config` if you - have none. Every line in it is commented out, so it chooses nothing: the player plays - through your **default output device**, and follows it when you change it. -6. **Adds the install folder to your user PATH**, so `sendspin-cli status` works in any new - terminal. + have none, then **asks for a name and an output** unless the config already sets an + output, and writes your answers into it. +6. **Adds the install folder to your user PATH**, so `sendspin-cli status` works in any + terminal opened afterwards. One already open keeps the old PATH, and VS Code's terminals + do until VS Code restarts, so the script prints the full path to use straight away. 7. **Registers a scheduled task** that runs `sendspin-cli -z` when you log on, with no window. The task also checks every minute: while the player runs, that check finds its pidfile held and does nothing, and if the player has stopped, it starts it again. @@ -142,22 +161,20 @@ rotated, so it only grows; it is safe to delete while the player is stopped. ## Uninstalling ```powershell -Disable-ScheduledTask sendspin-cli -taskkill /PID (Get-Content $env:LOCALAPPDATA\sendspin-cli\sendspin-cli.pid) -Unregister-ScheduledTask sendspin-cli -Confirm:$false -$dir = "$env:LOCALAPPDATA\Programs\sendspin-cli" -[Environment]::SetEnvironmentVariable('Path', (([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $dir }) -join ';'), 'User') -Remove-Item $dir -Recurse +Invoke-WebRequest https://raw.githubusercontent.com/Sendspin/sendspin-cpp-cli/main/scripts/uninstall_windows.ps1 -OutFile uninstall_windows.ps1 +powershell -ExecutionPolicy Bypass -File .\uninstall_windows.ps1 ``` -Your config, state and log are left where they are, so reinstalling keeps the player's -identity and pairings. Remove `%APPDATA%\sendspin-cli` and `%LOCALAPPDATA%\sendspin-cli` too -to forget it entirely; a server then sees a new, unpaired player. The firewall rule, if you -added it, goes from an administrator PowerShell: +It shows what it will remove and waits for you to say yes: the scheduled task, the running +player (stopped cleanly), the install folder, its PATH entry, and the firewall rule if you +added it, which brings up a UAC prompt. It also tidies the control-socket files players leave +in `%LOCALAPPDATA%`. -```powershell -Remove-NetFirewallRule -DisplayName sendspin-cli -``` +Your config and the player's remembered state (`%APPDATA%\sendspin-cli` and +`%LOCALAPPDATA%\sendspin-cli`) are kept by default, so reinstalling keeps the player's +identity and pairings; it asks whether to remove them too. `-Purge` removes them without +asking, after which a server sees a new, unpaired player. `-Yes` asks nothing, keeping them +unless `-Purge`. ## Without the script diff --git a/scripts/get_started_windows.ps1 b/scripts/get_started_windows.ps1 index b40ed24..4de8376 100644 --- a/scripts/get_started_windows.ps1 +++ b/scripts/get_started_windows.ps1 @@ -14,14 +14,21 @@ # limitations under the License. # # Installs a released sendspin-cli for this Windows user and starts it at every logon. -# Verifies SHA256SUMS, installs under LocalAppData, writes a config and registers a scheduled -# task. Nothing needs administrator rights except the optional firewall rule, asked for apart. +# Verifies SHA256SUMS, installs under LocalAppData, asks for a name and an output device, writes +# a config and registers a scheduled task. Nothing needs administrator rights except the +# optional firewall rule, asked for apart. uninstall_windows.ps1 removes it all again. # -# Usage: get_started_windows.ps1 [-Version ] [-Yes] [-Firewall] +# Usage: get_started_windows.ps1 [-Version ] [-Name ] [-Output ] [-Yes] +# [-Firewall] # -# -Version install this release instead of the latest, e.g. -Version v0.5.0 -# -Yes do not ask before installing. Required when there is no console to ask on -# -Firewall also add the firewall rule without asking (still needs a UAC prompt) +# -Version install this release instead of the latest, e.g. -Version v0.5.0 +# -Name the name a server shows for this player, instead of being asked. +# Ignored when the config already sets one +# -Output the output device, e.g. "portaudio:directsound:Speakers (USB DAC)", +# instead of being asked. Ignored when the config already sets one +# -Yes do not ask anything, taking the name and output only from the flags +# above. Required when there is no console to ask on +# -Firewall also add the firewall rule without asking (still needs a UAC prompt) # # Environment: # @@ -32,6 +39,8 @@ [CmdletBinding()] param( [string]$Version = '', + [string]$Name = '', + [string]$Output = '', [switch]$Yes, [switch]$Firewall, [switch]$Help @@ -79,6 +88,68 @@ function Confirm-Action([string]$Question) { return $answer -match '^(y|yes)$' } +# Does the config set this key, uncommented. +function Test-ConfigSets([string]$Key) { + if (-not (Test-Path -LiteralPath $Config)) { + return $false + } + $pattern = '^\s*' + [regex]::Escape($Key) + '\s*=' + return [bool]([IO.File]::ReadAllLines($Config) | Where-Object { $_ -match $pattern }) +} + +# Replaces the example's commented line for the key, else appends. Without a BOM: the player +# would read one as part of the first line and refuse the file. +function Set-ConfigKey([string]$Key, [string]$Value) { + $lines = [Collections.Generic.List[string]]::new([IO.File]::ReadAllLines($Config)) + $at = -1 + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i].StartsWith("#$Key = ")) { + $at = $i + break + } + } + if ($at -ge 0) { + $lines[$at] = "$Key = $Value" + } else { + $lines.Add("$Key = $Value") + } + [IO.File]::WriteAllLines($Config, $lines, (New-Object Text.UTF8Encoding $false)) +} + +# One entry per output device, from -l's DirectSound rows: full names, and Windows resamples. +function Get-OutputChoices { + # -l prints UTF-8, which PowerShell would read as the console's code page, turning a name + # with an umlaut in it into one no device has. + $encoding = [Console]::OutputEncoding + [Console]::OutputEncoding = New-Object Text.UTF8Encoding $false + try { + $listing = @(Invoke-Native { & $Binary -l }) + } finally { + [Console]::OutputEncoding = $encoding + } + $default = '' + $choices = @() + $inOutputs = $false + foreach ($line in $listing) { + if ($line -match '^PortAudio output devices') { + $inOutputs = $true + } elseif ($inOutputs -and $line -notmatch '^\s') { + break + } elseif ($inOutputs -and $line -match '^\s+\d+\s+(.+?)\s+mme\s+\d+ ch.*\(system default\)') { + # MME carries the default, cut to 31 characters. + $default = $Matches[1] + } elseif ($inOutputs -and $line -match '^\s+\d+\s+(.+?)\s+directsound\s+\d+ ch') { + # Primary Sound Driver is DirectSound's own follow-the-default, which choice 0 is. + if ($Matches[1] -ne 'Primary Sound Driver') { + $choices += $Matches[1] + } + } + } + return @($choices | ForEach-Object { + [pscustomobject]@{ Name = $_; IsDefault = ($default -and $_.StartsWith($default)) } + }) +} + if ($Help) { # The header's usage block, from '# Usage:' to the end of the comment. $inUsage = $false @@ -227,11 +298,48 @@ try { $HaveRule = $false } + # From the flag, asked, or neither; a key the config already sets is never asked or replaced. + $NameFrom = 'none' + $OutputFrom = 'none' + if (Test-ConfigSets 'output') { + if ($Output) { + Say " note: $Config already sets an output, so -Output is ignored" + } + } elseif ($Output) { + $OutputFrom = 'flag' + } elseif (-not $Yes) { + $OutputFrom = 'ask' + } + if (Test-ConfigSets 'name') { + if ($Name) { + Say " note: $Config already sets a name, so -Name is ignored" + } + } elseif ($Name) { + $NameFrom = 'flag' + } elseif ($OutputFrom -eq 'ask') { + # A config with an output is set up: upgrading it asks nothing. + $NameFrom = 'ask' + } + if ("$Name$Output" -match '[\r\n]') { + Fail '-Name and -Output are one line each' + } + + $Choosing = @() + if ($NameFrom -eq 'flag') { $Choosing += "name = $Name" } + if ($NameFrom -eq 'ask') { $Choosing += 'the name you choose' } + if ($OutputFrom -eq 'flag') { $Choosing += "output = $Output" } + if ($OutputFrom -eq 'ask') { $Choosing += 'the output you choose' } + $Setting = if ($Choosing) { ", setting $($Choosing -join ' and ')" } else { '' } + Step 'This is what it will do, as you, with no administrator rights' Say '' Say " install $InstallDir\ (sendspin-cli.exe and its README, LICENSE and config example)" - if ($SeedConfig) { + if ($SeedConfig -and $Choosing) { + Say " config $Config (from the example$Setting)" + } elseif ($SeedConfig) { Say " config $Config (from the example; every line in it is commented out)" + } elseif ($Choosing) { + Say " config $Config (exists$Setting; nothing else in it changes)" } else { Say " config $Config (exists; left as it is)" } @@ -290,6 +398,57 @@ try { Say " $Config (from the example; everything in it is commented out)" } + # Name and output + + if ($NameFrom -eq 'ask') { + Step 'Naming the player' + Say '' + Say ' This is what a Sendspin server shows it as.' + Say '' + $hostName = [Net.Dns]::GetHostName() + $answer = (Read-Host " Name [$hostName]").Trim() + $Name = if ($answer) { $answer } else { $hostName } + } + if ($OutputFrom -eq 'ask') { + Step 'What it plays through' + Say '' + $choices = @(Get-OutputChoices) + Say " 0) Whatever Windows' default output is -- change it in Settings > System > Sound" + for ($i = 0; $i -lt $choices.Count; $i++) { + Say (" {0}) {1}{2}" -f ($i + 1), $choices[$i].Name, + $(if ($choices[$i].IsDefault) { ' (the default right now)' } else { '' })) + } + Say '' + while ($true) { + $answer = (Read-Host " Choose 0-$($choices.Count) [0]").Trim() + if (-not $answer) { + $answer = '0' + } + # TryParse, so 00 is 0 and a number past Int32 is off the list rather than an error. + $number = 0 + if ($answer -match '^\d+$' -and [int]::TryParse($answer, [ref]$number) -and + $number -le $choices.Count) { + break + } + Say " '$answer' is not on the list" + } + $Output = if ($number -eq 0) { 'portaudio' } else { + "portaudio:directsound:$($choices[$number - 1].Name)" + } + } + if ($NameFrom -ne 'none' -or $OutputFrom -ne 'none') { + Step "Writing $Config" + Say '' + if ($NameFrom -ne 'none') { + Set-ConfigKey 'name' $Name + Say " name = $Name" + } + if ($OutputFrom -ne 'none') { + Set-ConfigKey 'output' $Output + Say " output = $Output" + } + } + if ($AddToPath) { $newPath = if ($UserPath) { "$UserPath;$InstallDir" } else { $InstallDir } [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') @@ -382,17 +541,20 @@ try { Step 'Next' Say '' - Say ' Ask it what it is doing (in a new terminal, so PATH is up to date):' + Say ' Ask it what it is doing:' Say '' - Say ' sendspin-cli status' + Say " & '$Binary' status" + Say '' + Say " Plain 'sendspin-cli status' works in terminals opened from now on. One already open," + Say " this one included, still has the old PATH; so do VS Code's until VS Code restarts." Say '' Say ' Pair it from your Sendspin server: when the server asks for a code, it is in the log.' Say '' Say " Get-Content '$LogFile' -Wait -Tail 20" Say '' Say " Change what it plays through, or anything else, in $Config --" - Say " 'sendspin-cli -l' lists the devices -- then stop it, and the task starts it again" - Say ' within a minute with the new settings:' + Say " & '$Binary' -l lists the devices -- then stop it, and the task starts it" + Say ' again within a minute with the new settings:' Say '' Say " taskkill /PID (Get-Content '$PidFile')" Say '' @@ -400,6 +562,8 @@ try { Say '' Say " Disable-ScheduledTask $TaskName; taskkill /PID (Get-Content '$PidFile')" Say '' + Say " To remove all of this again: https://github.com/$Repo#uninstall" + Say '' } finally { Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/scripts/uninstall_windows.ps1 b/scripts/uninstall_windows.ps1 new file mode 100644 index 0000000..ebe313c --- /dev/null +++ b/scripts/uninstall_windows.ps1 @@ -0,0 +1,243 @@ +# +# Copyright 2026 sendspin-cpp-cli Contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Removes what get_started_windows.ps1 installed: the scheduled task, the running player, the +# install folder, its PATH entry and the firewall rule. Config and remembered state are kept +# unless -Purge. Shows what it will do and asks first, unless -Yes. +# +# Usage: uninstall_windows.ps1 [-Purge] [-Yes] +# +# -Purge also remove what is otherwise kept: the config in AppData\Roaming\sendspin-cli, and +# the log and remembered state (identity, pairings, volume) in AppData\Local\sendspin-cli +# -Yes do not ask anything: do it as shown, keeping config and state unless -Purge. +# Required when there is no console to ask on. Removing the firewall rule still +# brings up a UAC prompt + +[CmdletBinding()] +param( + [switch]$Purge, + [switch]$Yes, + [switch]$Help +) + +$ErrorActionPreference = 'Stop' + +# Keep in step with get_started_windows.ps1. +$TaskName = 'sendspin-cli' +$FirewallRule = 'sendspin-cli' + +function Fail([string]$Message) { + Write-Host "uninstall_windows: FAIL: $Message" -ForegroundColor Red + exit 1 +} + +function Say([string]$Message = '') { + Write-Host $Message +} + +function Step([string]$Message) { + Write-Host '' + Write-Host "==> $Message" +} + +# Runs a native command, discarding its stderr. Windows PowerShell turns stderr into error +# records, which the script's 'Stop' preference would throw on. +function Invoke-Native([scriptblock]$Command) { + $ErrorActionPreference = 'Continue' + & $Command 2>$null +} + +function Test-CanAsk { + return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected +} + +function Confirm-Action([string]$Question) { + $answer = Read-Host "$Question [y/N]" + return $answer -match '^(y|yes)$' +} + +# Players running from the installed binary, not some other copy. +function Get-RunningPlayers { + return @(Get-CimInstance Win32_Process -Filter "Name = 'sendspin-cli.exe'" | + Where-Object { $_.ExecutablePath -eq $Binary }) +} + +if ($Help) { + # The header's usage block, from '# Usage:' to the end of the comment. + $inUsage = $false + foreach ($line in Get-Content -LiteralPath $PSCommandPath) { + if ($line -match '^# Usage:') { + $inUsage = $true + } + if ($inUsage) { + if ($line -notmatch '^#') { + break + } + Write-Host ($line -replace '^# ?', '') + } + } + exit 0 +} + +if ([Environment]::OSVersion.Platform -ne 'Win32NT') { + Fail 'Windows only. On Linux use scripts/uninstall_linux.sh' +} + +$LocalAppData = [Environment]::GetFolderPath('LocalApplicationData') +$RoamingAppData = [Environment]::GetFolderPath('ApplicationData') +$InstallDir = Join-Path $LocalAppData 'Programs\sendspin-cli' +$Binary = Join-Path $InstallDir 'sendspin-cli.exe' +$ConfigDir = Join-Path $RoamingAppData 'sendspin-cli' +$DataDir = Join-Path $LocalAppData 'sendspin-cli' + +# What is here + +$Task = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue +$Running = @(Get-RunningPlayers) +$HaveInstall = Test-Path -LiteralPath $InstallDir +$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User') +$OnPath = ($UserPath -split ';') -contains $InstallDir +$HaveRule = $false +try { + $HaveRule = [bool](Get-NetFirewallRule -DisplayName $FirewallRule -ErrorAction Stop) +} catch { + $HaveRule = $false +} +$Kept = @($ConfigDir, $DataDir | Where-Object { Test-Path -LiteralPath $_ }) +# What players leave per control port, e.g. sendspin-cli-8928.sock.lock. +$Leftovers = @(Get-ChildItem -LiteralPath $LocalAppData -Filter 'sendspin-cli-*.sock*' -Force -ErrorAction SilentlyContinue) + +if (-not $Purge -and -not $Yes -and $Kept -and (Test-CanAsk)) { + Step 'Your config and what the player remembered' + Say '' + $Kept | ForEach-Object { Say " $_" } + Say '' + Say " These are kept by default, so installing again keeps the player's identity and its" + Say ' pairings. Without them, a server sees a new, unpaired player.' + Say '' + $Purge = Confirm-Action ' Remove them too?' +} + +$Plan = @() +if ($Task) { + $Plan += "task unregister '$TaskName' from Task Scheduler" +} +foreach ($process in $Running) { + $Plan += "stop the running player (pid $($process.ProcessId))" +} +if ($HaveInstall) { + $Plan += "remove $InstallDir\" +} +if ($OnPath) { + $Plan += "PATH remove $InstallDir from your user PATH" +} +if ($HaveRule) { + $Plan += "firewall remove the '$FirewallRule' rule (Windows asks for administrator approval)" +} +if ($Purge) { + $Kept | ForEach-Object { $Plan += "remove $_\" } +} +if ($Leftovers) { + $Plan += "tidy $($Leftovers.Count) control-socket file(s) players left in $LocalAppData" +} + +if (-not $Plan) { + Say 'sendspin-cli is not installed here; there is nothing to remove.' + if ($Kept) { + Say "Left in place (-Purge removes them): $($Kept -join ', ')" + } + exit 0 +} + +Step 'This is what it will do' +Say '' +$Plan | ForEach-Object { Say " $_" } +Say '' +if (-not $Purge -and $Kept) { + Say "Kept: $($Kept -join ', ')" + Say '-Purge removes those as well.' +} + +if (-not $Yes) { + if (-not (Test-CanAsk)) { + Fail 'there is no console to confirm that with. Re-run with -Yes if you have read it and want it done; nothing was removed' + } + if (-not (Confirm-Action "`nGo ahead?")) { + Fail 'nothing was removed' + } +} + +Step 'Removing' +if ($Task) { + # Disabled first, so its once-a-minute check cannot start the player again. + Disable-ScheduledTask -TaskName $TaskName | Out-Null +} +# Asked again, not taken from the plan: the task may have started one while it was on screen. +foreach ($process in (Get-RunningPlayers)) { + Say " stopping the running player (pid $($process.ProcessId))" + # Without /F: its close handler says goodbye to the server first. + $target = $process.ProcessId + Invoke-Native { & taskkill.exe /PID $target } | Out-Null + Wait-Process -Id $target -Timeout 10 -ErrorAction SilentlyContinue + Stop-Process -Id $target -Force -ErrorAction SilentlyContinue +} +if ($Task) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false + Say " task '$TaskName' unregistered" +} +if ($OnPath) { + $remaining = ($UserPath -split ';' | Where-Object { $_ -and $_ -ne $InstallDir }) -join ';' + [Environment]::SetEnvironmentVariable('Path', $remaining, 'User') + Say " PATH: removed $InstallDir" +} +if ($HaveInstall) { + Remove-Item -LiteralPath $InstallDir -Recurse -Force + Say " removed $InstallDir" +} +if ($HaveRule) { + $command = "Remove-NetFirewallRule -DisplayName '$FirewallRule'" + try { + $elevated = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru ` + -ArgumentList '-NoProfile', '-NonInteractive', '-Command', $command + if ($elevated.ExitCode -eq 0) { + Say " removed the '$FirewallRule' firewall rule" + } else { + Say " The rule was not removed (exit $($elevated.ExitCode)); run this as administrator:" + Say " $command" + } + } catch { + Say ' Administrator approval was declined, so the firewall rule is still there. Run this' + Say ' from an administrator PowerShell to remove it:' + Say " $command" + } +} +if ($Purge) { + foreach ($path in $Kept) { + Remove-Item -LiteralPath $path -Recurse -Force + Say " removed $path" + } +} +if ($Leftovers) { + # A player still running, from anywhere, may be using one of them. + if (Get-Process -Name 'sendspin-cli' -ErrorAction SilentlyContinue) { + Say " another sendspin-cli is still running, so the control-socket files in $LocalAppData" + Say ' are left; run this again once it has stopped' + } else { + $Leftovers | Remove-Item -Force -ErrorAction SilentlyContinue + Say " tidied the control-socket files in $LocalAppData" + } +} +Say '' +Say 'Done.'