Private/Find-Prerequisites.ps1
|
#Requires -Version 5.1 <# .SYNOPSIS Prerequisite detection for the installer. .DESCRIPTION Every detector returns a hashtable with at least: Found, Path, Version, Method, Reason VoiceMeeter and BeyondATC additionally return ExePath and ProcessName, because Start-BATCRelayBot launches them by executable and watches them by process name. Design rule: a candidate only counts as found once it has been *verified*. Earlier versions trusted the first path that existed, which is how the Windows Store stub at %LOCALAPPDATA%\Microsoft\WindowsApps\python.exe - a zero-byte launcher that opens the Store instead of running Python - was reported as a working interpreter, and how a manually installed Python registered only under HKLM was missed entirely. #> $script:MinPythonMajor = 3 $script:MinPythonMinor = 10 function Test-PythonCandidate { <# .SYNOPSIS Verifies that a path is a real, new-enough Python interpreter. .OUTPUTS Hashtable with Ok, Version, Reason. Never throws. #> param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path)) { return @{ Ok = $false; Version = $null; Reason = "no path" } } # Microsoft Store app-execution aliases are zero-byte reparse points. # Executing one opens the Store and blocks, so rule them out by path # before touching the filesystem at all - not even Test-Path should be # asked about a reparse point we will never use. if ($Path -like "*\WindowsApps\*") { return @{ Ok = $false; Version = $null; Reason = "Windows Store alias, not a real interpreter" } } if (-not (Test-Path $Path)) { return @{ Ok = $false; Version = $null; Reason = "path does not exist" } } try { if ((Get-Item $Path -ErrorAction Stop).Length -eq 0) { return @{ Ok = $false; Version = $null; Reason = "zero-byte stub" } } } catch { return @{ Ok = $false; Version = $null; Reason = "path not readable" } } try { $output = & $Path --version 2>&1 | Select-Object -First 1 } catch { return @{ Ok = $false; Version = $null; Reason = "interpreter could not be executed" } } $text = "$output" if ($text -notmatch 'Python\s+(\d+)\.(\d+)(\.(\d+))?') { return @{ Ok = $false; Version = $null; Reason = "did not report a Python version" } } $major = [int]$Matches[1] $minor = [int]$Matches[2] if ($major -lt $script:MinPythonMajor -or ($major -eq $script:MinPythonMajor -and $minor -lt $script:MinPythonMinor)) { return @{ Ok = $false Version = $text.Trim() Reason = "Python $major.$minor found, but $($script:MinPythonMajor).$($script:MinPythonMinor)+ is required" } } return @{ Ok = $true; Version = $text.Trim(); Reason = $null } } function Get-PythonCandidatePath { <# .SYNOPSIS Yields every plausible python.exe location, best-known first. .DESCRIPTION Ordered so that a deliberately installed interpreter wins over whatever happens to sit on PATH. #> [OutputType([string[]])] param() $candidates = New-Object System.Collections.Generic.List[string] function Add-Candidate { param([string]$Value) if (-not [string]::IsNullOrWhiteSpace($Value) -and -not $candidates.Contains($Value)) { $candidates.Add($Value) } } # 1. Per-user installs: the default for both python.org and winget. foreach ($root in @("$env:LOCALAPPDATA\Programs\Python", "$env:APPDATA\Python")) { try { Get-ChildItem -Path $root -Directory -ErrorAction SilentlyContinue | Sort-Object Name -Descending | ForEach-Object { Add-Candidate (Join-Path $_.FullName "python.exe") } } catch {} } # 2. Machine-wide installs ("Install for all users"). foreach ($root in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { if (-not $root) { continue } try { Get-ChildItem -Path $root -Directory -Filter "Python*" -ErrorAction SilentlyContinue | Sort-Object Name -Descending | ForEach-Object { Add-Candidate (Join-Path $_.FullName "python.exe") } } catch {} } # 3. Registry. HKLM matters as much as HKCU: an all-users install writes # only to HKLM, which pre-1.4.0 never looked at. foreach ($hive in @("HKLM:\Software\Python\PythonCore", "HKLM:\Software\Wow6432Node\Python\PythonCore", "HKCU:\Software\Python\PythonCore")) { try { Get-ChildItem $hive -ErrorAction Stop | Sort-Object PSChildName -Descending | ForEach-Object { $installPath = (Get-ItemProperty "$($_.PSPath)\InstallPath" -ErrorAction SilentlyContinue).'(default)' if ($installPath) { Add-Candidate (Join-Path $installPath "python.exe") } } } catch {} } # 4. The py launcher knows about interpreters in non-standard locations. try { $launcher = Get-Command py.exe -ErrorAction SilentlyContinue if ($launcher) { & $launcher.Source -0p 2>&1 | ForEach-Object { if ("$_" -match '([A-Za-z]:\\[^\s].*python\.exe)') { Add-Candidate $Matches[1] } } } } catch {} # 5. PATH, last: most likely to be the Store alias. try { Get-Command python.exe -All -ErrorAction SilentlyContinue | ForEach-Object { Add-Candidate $_.Source } } catch {} return $candidates.ToArray() } function Find-Python { [OutputType([hashtable])] param() $rejected = $null foreach ($candidate in (Get-PythonCandidatePath)) { $check = Test-PythonCandidate -Path $candidate if ($check.Ok) { return @{ Found = $true Path = $candidate Version = $check.Version Method = "Verified ($candidate)" Reason = $null } } # Remember a real-but-too-old interpreter so the user gets told why # their existing Python was not accepted. if (-not $rejected -and $check.Version) { $rejected = $check } } return @{ Found = $false Path = $null Version = $rejected.Version Method = $null Reason = if ($rejected) { $rejected.Reason } else { "no Python interpreter found" } } } function Test-FFmpegCandidate { <# .SYNOPSIS Verifies that a path is a runnable ffmpeg binary. #> param([string]$Path) if ([string]::IsNullOrWhiteSpace($Path) -or -not (Test-Path $Path)) { return @{ Ok = $false; Version = $null } } if ($Path -like "*\WindowsApps\*") { return @{ Ok = $false; Version = $null } } try { $output = & $Path -version 2>&1 | Select-Object -First 1 } catch { return @{ Ok = $false; Version = $null } } if ("$output" -match 'ffmpeg version\s+(\S+)') { return @{ Ok = $true; Version = $Matches[1] } } return @{ Ok = $false; Version = $null } } function Get-FFmpegCandidatePath { [OutputType([string[]])] param() $candidates = New-Object System.Collections.Generic.List[string] function Add-Candidate { param([string]$Value) if (-not [string]::IsNullOrWhiteSpace($Value) -and -not $candidates.Contains($Value)) { $candidates.Add($Value) } } # winget unpacks into a nested, version-named directory under AppData. foreach ($pattern in @( "$env:LOCALAPPDATA\Microsoft\WinGet\Packages\*FFmpeg*\*\bin\ffmpeg.exe", "$env:LOCALAPPDATA\Microsoft\WinGet\Packages\*FFmpeg*\bin\ffmpeg.exe", "$env:ProgramFiles\WinGet\Packages\*FFmpeg*\*\bin\ffmpeg.exe" )) { try { Get-ChildItem -Path $pattern -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | ForEach-Object { Add-Candidate $_.FullName } } catch {} } # Registry entries from installer-based builds. foreach ($hive in @("HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall")) { try { Get-ChildItem $hive -ErrorAction Stop | Get-ItemProperty -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*FFmpeg*" -and $_.InstallLocation } | ForEach-Object { Add-Candidate (Join-Path $_.InstallLocation "bin\ffmpeg.exe") Add-Candidate (Join-Path $_.InstallLocation "ffmpeg.exe") } } catch {} } # Conventional manual-extraction locations. foreach ($path in @( "$env:ProgramFiles\FFmpeg\bin\ffmpeg.exe", "${env:ProgramFiles(x86)}\FFmpeg\bin\ffmpeg.exe", "$env:LOCALAPPDATA\Programs\FFmpeg\bin\ffmpeg.exe", "C:\ffmpeg\bin\ffmpeg.exe" )) { Add-Candidate $path } try { Get-Command ffmpeg.exe -All -ErrorAction SilentlyContinue | ForEach-Object { Add-Candidate $_.Source } } catch {} return $candidates.ToArray() } function Find-FFmpeg { [OutputType([hashtable])] param() foreach ($candidate in (Get-FFmpegCandidatePath)) { $check = Test-FFmpegCandidate -Path $candidate if ($check.Ok) { return @{ Found = $true Path = $candidate Version = $check.Version Method = "Verified ($candidate)" Reason = $null } } } return @{ Found = $false Path = $null Version = $null Method = $null Reason = "no runnable ffmpeg.exe found" } } function Resolve-VoiceMeeterExecutable { <# .SYNOPSIS Picks which VoiceMeeter executable Start-BATCRelayBot should launch. .DESCRIPTION A running instance is the most reliable signal: at install time VoiceMeeter is usually running, because its virtual devices only appear in ffmpeg's device list while it is. Otherwise fall back to the highest edition present, preferring 64-bit - the VB installers place every UI they ship into the same directory, so "highest present" identifies the edition the user actually installed. #> [OutputType([hashtable])] param([string]$InstallDirectory) $knownProcesses = @( 'voicemeeter8x64', 'voicemeeter8', 'voicemeeterpro_x64', 'voicemeeterpro', 'voicemeeter_x64', 'voicemeeter' ) foreach ($name in $knownProcesses) { try { $proc = Get-Process -Name $name -ErrorAction SilentlyContinue | Select-Object -First 1 if ($proc -and $proc.Path) { return @{ ExePath = $proc.Path; ProcessName = $name } } } catch {} } if ([string]::IsNullOrWhiteSpace($InstallDirectory) -or -not (Test-Path $InstallDirectory)) { return @{ ExePath = $null; ProcessName = $null } } foreach ($name in $knownProcesses) { $exe = Join-Path $InstallDirectory "$name.exe" if (Test-Path $exe) { return @{ ExePath = $exe; ProcessName = $name } } } return @{ ExePath = $null; ProcessName = $null } } function Find-VoiceMeeter { [OutputType([hashtable])] param() $installDir = $null $version = "Unknown" $method = $null # Registry across all three hives that VB installers write to. foreach ($hive in @("HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall")) { if ($installDir) { break } try { $entry = Get-ChildItem $hive -ErrorAction Stop | Get-ItemProperty -ErrorAction SilentlyContinue | Where-Object { ($_.DisplayName -like "*Voicemeeter*" -or $_.DisplayName -like "*VB-Audio*") -and $_.InstallLocation -and (Test-Path $_.InstallLocation) } | Select-Object -First 1 if ($entry) { $installDir = $entry.InstallLocation $version = if ($entry.DisplayVersion) { $entry.DisplayVersion } else { "Unknown" } $method = "Registry" } } catch {} } # Official install locations. VB uses "VB\", not "VB-Audio\", and # ${env:ProgramFiles(x86)} needs braces because of the parentheses. if (-not $installDir) { foreach ($path in @( "${env:ProgramFiles(x86)}\VB\Voicemeeter", "$env:ProgramFiles\VB\Voicemeeter", "$env:ProgramFiles\VB\VBVoicemeeterVAIOs" )) { if ($path -and (Test-Path $path)) { $installDir = $path $method = "FileSystem" break } } } # A running instance locates a portable or non-standard install. $resolved = Resolve-VoiceMeeterExecutable -InstallDirectory $installDir if (-not $installDir -and $resolved.ExePath) { $installDir = Split-Path -Parent $resolved.ExePath $method = "Running process" } # Only the registry carries DisplayVersion, so an install found through the # filesystem reported "Unknown" and the status line showed a bare FOUND # while BeyondATC beside it showed its version. The executable knows. if ($version -eq "Unknown" -and $resolved.ExePath) { try { $fileVersion = (Get-Item $resolved.ExePath -ErrorAction Stop).VersionInfo.FileVersion # VB writes it as "1, 1, 2, 2". if ($fileVersion) { $version = ($fileVersion -replace '[,\s]+', '.') } } catch { # Keep "Unknown"; a missing version is not worth failing detection. } } if (-not $installDir) { return @{ Found = $false; Path = $null; ExePath = $null; ProcessName = $null Version = $null; Method = $null; Reason = "VoiceMeeter is not installed" } } if (-not $resolved.ExePath) { # Directory present but no known executable in it - treat as not # usable rather than reporting a success the launcher cannot act on. return @{ Found = $false; Path = $installDir; ExePath = $null; ProcessName = $null Version = $version; Method = $method Reason = "VoiceMeeter directory found at $installDir, but no VoiceMeeter executable in it" } } return @{ Found = $true Path = $installDir ExePath = $resolved.ExePath ProcessName = $resolved.ProcessName Version = $version Method = $method Reason = $null } } function Find-BeyondATC { [OutputType([hashtable])] param() $exePath = $null $installDir = $null $version = "Unknown" $method = $null try { $proc = Get-Process -Name "BeyondATC" -ErrorAction SilentlyContinue | Select-Object -First 1 if ($proc -and $proc.Path) { $exePath = $proc.Path $installDir = Split-Path -Parent $exePath $method = "Running process" } } catch {} if (-not $exePath) { foreach ($hive in @("HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall", "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall", "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall")) { if ($exePath) { break } try { $entry = Get-ChildItem $hive -ErrorAction Stop | Get-ItemProperty -ErrorAction SilentlyContinue | Where-Object { $_.DisplayName -like "*BeyondATC*" -and $_.InstallLocation } | Select-Object -First 1 if ($entry) { $candidate = Join-Path $entry.InstallLocation "BeyondATC.exe" if (Test-Path $candidate) { $exePath = $candidate $installDir = $entry.InstallLocation $version = if ($entry.DisplayVersion) { $entry.DisplayVersion } else { "Unknown" } $method = "Registry" } } } catch {} } } if (-not $exePath) { foreach ($dir in @("$env:ProgramFiles\BeyondATC", "${env:ProgramFiles(x86)}\BeyondATC")) { if (-not $dir) { continue } $candidate = Join-Path $dir "BeyondATC.exe" if (Test-Path $candidate) { $exePath = $candidate $installDir = $dir $method = "FileSystem" break } } } if ($exePath) { return @{ Found = $true; Path = $installDir; ExePath = $exePath; ProcessName = "BeyondATC" Version = $version; Method = $method; Reason = $null } } # The LocalLow config folder proves BeyondATC has run on this machine but # says nothing about where the executable lives. $localLow = "$env:USERPROFILE\AppData\LocalLow\Skirmish Mode Games, Inc\BeyondATC" if (Test-Path $localLow) { return @{ Found = $true; Path = $localLow; ExePath = $null; ProcessName = "BeyondATC" Version = "Unknown"; Method = "FileSystem (AppData LocalLow)" Reason = "configuration found, but BeyondATC.exe could not be located" } } return @{ Found = $false; Path = $null; ExePath = $null; ProcessName = $null Version = $null; Method = $null; Reason = "BeyondATC is not installed (optional)" } } Export-ModuleMember -Function @( 'Find-Python', 'Find-FFmpeg', 'Find-VoiceMeeter', 'Find-BeyondATC', 'Test-PythonCandidate', 'Test-FFmpegCandidate', 'Get-PythonCandidatePath', 'Get-FFmpegCandidatePath', 'Resolve-VoiceMeeterExecutable' ) |