diff --git a/include/download-instructions/windows.ps1 b/include/download-instructions/windows.ps1 index d6ac1bad52..a81aa16939 100644 --- a/include/download-instructions/windows.ps1 +++ b/include/download-instructions/windows.ps1 @@ -3,19 +3,27 @@ Downloads and sets up a specified PHP version on Windows. .PARAMETER Version -Major.minor or full version (e.g., 7.4 or 7.4.30). - -.PARAMETER Path -Destination directory (defaults to C:\php). +Major.minor or full version (e.g., 8.4 or 8.4.15). .PARAMETER Arch -Architecture: x64 or x86 (default: x64). +x64 or x86 (default: x64). .PARAMETER ThreadSafe -ThreadSafe: download Thread Safe build (default: $False). +Download Thread Safe build (default: $False). .PARAMETER Timezone date.timezone string for php.ini (default: 'UTC'). + +.PARAMETER Scope +Auto (default), CurrentUser, AllUsers, or Custom. +- Auto: AllUsers if elevated, otherwise CurrentUser. +- AllUsers: Requires elevation, installs under Program Files (or Program Files (x86) for x86 arch). +- CurrentUser: Installs under $env:LOCALAPPDATA. +- Custom: Installs under -CustomPath (or prompts), adds to User PATH. + +.PARAMETER CustomPath +Directory for Scope=Custom. Versions are installed under this directory and a "current" link is created here. + #> [CmdletBinding()] @@ -23,15 +31,23 @@ param( [Parameter(Mandatory = $true, Position=0)] [ValidatePattern('^\d+(\.\d+)?(\.\d+)?((alpha|beta|RC)\d*)?$')] [string]$Version, + [Parameter(Mandatory = $false, Position=1)] - [string]$Path = "C:\php$Version", - [Parameter(Mandatory = $false, Position=2)] [ValidateSet("x64", "x86")] [string]$Arch = "x64", - [Parameter(Mandatory = $false, Position=3)] + + [Parameter(Mandatory = $false, Position=2)] [bool]$ThreadSafe = $False, - [Parameter(Mandatory = $false, Position=4)] - [string]$Timezone = 'UTC' + + [Parameter(Mandatory = $false, Position=3)] + [string]$Timezone = 'UTC', + + [Parameter(Mandatory = $false)] + [ValidateSet('Auto', 'CurrentUser', 'AllUsers', 'Custom')] + [string]$Scope = 'Auto', + + [Parameter(Mandatory = $false)] + [string]$CustomPath ) Function Get-File { @@ -52,19 +68,20 @@ Function Get-File { for ($i = 0; $i -lt $Retries; $i++) { try { if($OutFile -ne '') { - Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec + Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + return } else { - Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec + return Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop } - break; } catch { if ($i -eq ($Retries - 1)) { if($FallbackUrl) { try { if($OutFile -ne '') { - Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec + Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + return } else { - Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec + return Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop } } catch { throw "Failed to download the file from $Url and $FallbackUrl" @@ -77,6 +94,87 @@ Function Get-File { } } +Function Test-IsAdmin { + $p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) + return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +Function ConvertTo-BoolOrDefault { + param([string]$UserInput, [bool]$Default) + if ([string]::IsNullOrWhiteSpace($UserInput)) { return $Default } + switch -Regex ($UserInput.Trim().ToLowerInvariant()) { + '^(1|true|t|y|yes)$' { return $true } + '^(0|false|f|n|no)$' { return $false } + default { return $Default } + } +} + +Function Edit-PathForCompare([string]$p) { + if ([string]::IsNullOrWhiteSpace($p)) { return '' } + return ($p.Trim().Trim('"').TrimEnd('\')).ToLowerInvariant() +} + +Function Set-PathEntryFirst { + param( + [Parameter(Mandatory = $true)][ValidateSet('User','Machine')] [string]$Target, + [Parameter(Mandatory = $true)][string]$Entry + ) + + $entryNorm = Edit-PathForCompare $Entry + + $existing = [Environment]::GetEnvironmentVariable('Path', $Target) + if ($null -eq $existing) { $existing = '' } + + $parts = @() + foreach ($p in ($existing -split ';')) { + if (-not [string]::IsNullOrWhiteSpace($p)) { + if ((Edit-PathForCompare $p) -ne $entryNorm) { $parts += $p } + } + } + $newParts = @($Entry) + $parts + [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), $Target) + + $procParts = @() + foreach ($p in ($env:Path -split ';')) { + if (-not [string]::IsNullOrWhiteSpace($p)) { + if ((Edit-PathForCompare $p) -ne $entryNorm) { $procParts += $p } + } + } + $env:Path = ((@($Entry) + $procParts) -join ';') +} + +function Send-EnvironmentChangeBroadcast { + try { + $sig = @' +using System; +using System.Runtime.InteropServices; +public static class NativeMethods { + [DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)] + public static extern IntPtr SendMessageTimeout( + IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam, + uint fuFlags, uint uTimeout, out UIntPtr lpdwResult); +} +'@ + Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue | Out-Null + $HWND_BROADCAST = [IntPtr]0xffff + $WM_SETTINGCHANGE = 0x001A + $SMTO_ABORTIFHUNG = 0x0002 + [UIntPtr]$result = [UIntPtr]::Zero + [NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", $SMTO_ABORTIFHUNG, 5000, [ref]$result) | Out-Null + } catch { } +} + +Function Test-EmptyDir([string]$Dir) { + if (-not (Test-Path -LiteralPath $Dir)) { + New-Item -ItemType Directory -Path $Dir -Force | Out-Null + return + } + $items = Get-ChildItem -LiteralPath $Dir -Force -ErrorAction SilentlyContinue + if ($items -and $items.Count -gt 0) { + throw "The directory '$Dir' is not empty. Please choose another location." + } +} + Function Get-Semver { [CmdletBinding()] param( @@ -85,19 +183,22 @@ Function Get-Semver { [ValidatePattern('^\d+\.\d+$')] [string]$Version ) - $releases = Get-File -Url "https://downloads.php.net/~windows/releases/releases.json" | ConvertFrom-Json + + $jsonUrl = "https://downloads.php.net/~windows/releases/releases.json" + $releases = ((Get-File -Url $jsonUrl).Content | ConvertFrom-Json) + $semver = $releases.$Version.version - if($null -eq $semver) { - $semver = (Get-File -Url "https://downloads.php.net/~windows/releases/archives").Links | - Where-Object { $_.href -match "php-($Version.[0-9]+).*" } | - ForEach-Object { $matches[1] } | - Sort-Object { [System.Version]$_ } -Descending | - Select-Object -First 1 - } - if($null -eq $semver) { - throw "Unsupported PHP version: $Version" - } - return $semver + if ($null -ne $semver) { return [string]$semver } + + $html = (Get-File -Url "https://downloads.php.net/~windows/releases/archives/").Content + $rx = [regex]"php-($([regex]::Escape($Version))\.[0-9]+)" + $found = $rx.Matches($html) | ForEach-Object { $_.Groups[1].Value } | + Sort-Object { [version]$_ } -Descending | + Select-Object -First 1 + + if ($null -ne $found) { return [string]$found } + + throw "Unsupported PHP version series: $Version" } Function Get-VSVersion { @@ -160,7 +261,7 @@ Function Get-PhpFromUrl { $vs = Get-VSVersion $Version $ts = if ($ThreadSafe) { "ts" } else { "nts" } $zipName = if ($ThreadSafe) { "php-$Semver-Win32-$vs-$Arch.zip" } else { "php-$Semver-$ts-Win32-$vs-$Arch.zip" } - $type = Get-ReleaseType $Version + $type = Get-ReleaseType $Semver $base = "https://downloads.php.net/~windows/$type" try { @@ -175,49 +276,147 @@ Function Get-PhpFromUrl { } $tempFile = [IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(), '.zip') + try { + $isAdmin = Test-IsAdmin + + if (-not $PSBoundParameters.ContainsKey('Arch')) { + Write-Host "" + Write-Host "What architecture would you like to install?" + Write-Host "Enter x64 for 64-bit" + Write-Host "Enter x86 for 32-bit" + Write-Host "Press Enter to use default ($Arch)" + $archSel = Read-Host "Please enter [x64/x86]" + if (-not [string]::IsNullOrWhiteSpace($archSel) -and @('x64','x86') -contains $archSel.Trim()) { + $Arch = $archSel.Trim() + } + } + + if (-not $PSBoundParameters.ContainsKey('ThreadSafe')) { + Write-Host "" + Write-Host "What ThreadSafe option would you like to use?" + Write-Host "Enter true for ThreadSafe" + Write-Host "Enter false for Non-ThreadSafe" + Write-Host "Press Enter to use default ($ThreadSafe)" + $tsSel = Read-Host "Please enter [true/false]" + $ThreadSafe = ConvertTo-BoolOrDefault -UserInput $tsSel -Default $ThreadSafe + } + + if (-not $PSBoundParameters.ContainsKey('Timezone')) { + Write-Host "" + Write-Host "What timezone would you like to set in php.ini?" + Write-Host "Press Enter to use default ($Timezone)" + $tzSel = Read-Host "Please enter timezone" + if (-not [string]::IsNullOrWhiteSpace($tzSel)) { + $Timezone = $tzSel.Trim() + } + } + + if (-not $PSBoundParameters.ContainsKey('Scope')) { + Write-Host "" + Write-Host "Would you like to install PHP for:" + Write-Host "Enter 1 for Current user" + Write-Host "Enter 2 for All users (requires admin elevation)" + Write-Host "Enter 3 to install PHP at a custom path" + Write-Host "Press Enter to choose automatically" + $sel = Read-Host "Please enter [1-3]" + switch ($sel) { + '1' { $Scope = 'CurrentUser' } + '2' { $Scope = 'AllUsers' } + '3' { $Scope = 'Custom' } + default { $Scope = 'Auto' } + } + } + + if ($Scope -eq 'Custom' -and -not $PSBoundParameters.ContainsKey('CustomPath')) { + $defaultCustom = if ($CustomPath) { $CustomPath } else { (Join-Path $env:LOCALAPPDATA 'Programs\PHP') } + Write-Host "" + Write-Host "Please enter the custom installation path." + Write-Host "Press Enter to use default ($defaultCustom)" + $cr = Read-Host "Please enter" + $CustomPath = if ([string]::IsNullOrWhiteSpace($cr)) { $defaultCustom } else { $cr.Trim() } + } + if ($Version -match "^\d+\.\d+$") { $Semver = Get-Semver $Version + $MajorMinor = $Version } else { $Semver = $Version - $Semver -match '^(\d+\.\d+)' | Out-Null - $Version = $Matches[1] + if ($Semver -notmatch '^(\d+\.\d+)') { throw "Could not derive major.minor from Version '$Version'." } + $MajorMinor = $Matches[1] } - if (-not (Test-Path $Path)) { - try { - New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null - } catch { - throw "Failed to create directory $Path. $_" + if ([version]$MajorMinor -lt [version]'5.5' -and $Arch -eq 'x64') { + $Arch = 'x86' + Write-Host "PHP series $MajorMinor does not support x64 on Windows. Using x86." + } + + $EffectiveScope = $Scope + if ($Scope -eq 'Auto') { + $EffectiveScope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' } + } + + if ($EffectiveScope -eq 'AllUsers' -and -not $isAdmin) { + throw "AllUsers install selected but this session is not elevated. Re-run as Administrator or choose CurrentUser/Custom." + } + + $installRootDirectory = switch ($EffectiveScope) { + 'CurrentUser' { Join-Path $env:LOCALAPPDATA 'Programs\PHP' } + 'AllUsers' { + $pf = $env:ProgramFiles + if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { $pf = ${env:ProgramFiles(x86)} } + Join-Path $pf 'PHP' } - } else { - $files = Get-ChildItem -Path $Path - if ($files.Count -gt 0) { - throw "The directory $Path is not empty. Please provide an empty directory." + 'Custom' { + if ([string]::IsNullOrWhiteSpace($CustomPath)) { throw "Scope=Custom requires -CustomPath (or interactive input)." } + [Environment]::ExpandEnvironmentVariables($CustomPath) } + default { throw "Unexpected scope: $EffectiveScope" } } - if($Version -lt '5.5' -and $Arch -eq 'x64') { - $Arch = 'x86' - Write-Host "PHP version $Version does not support x64 architecture on Windows. Using x86 instead." + if (-not (Test-Path -LiteralPath $installRootDirectory)) { + New-Item -ItemType Directory -Path $installRootDirectory | Out-Null } - Write-Host "Downloading PHP $Semver to $Path" - Get-PhpFromUrl $Version $Semver $Arch $ThreadSafe $tempFile - Expand-Archive -Path $tempFile -DestinationPath $Path -Force -ErrorAction Stop + $tsTag = if ($ThreadSafe) { 'ts' } else { 'nts' } + $installDirectory = Join-Path (Join-Path (Join-Path $installRootDirectory $Semver) $tsTag) $Arch + $currentLink = Join-Path $installRootDirectory 'current' + + Test-EmptyDir $installDirectory + + Write-Host "Downloading PHP $Semver ($Arch, $tsTag) -> $installDirectory" + Get-PhpFromUrl $MajorMinor $Semver $Arch $ThreadSafe $tempFile + + Expand-Archive -Path $tempFile -DestinationPath $installDirectory -Force -ErrorAction Stop - $phpIniProd = Join-Path $Path "php.ini-production" + $phpIniProd = Join-Path $installDirectory "php.ini-production" if(-not(Test-Path $phpIniProd)) { - $phpIniProd = Join-Path $Path "php.ini-recommended" + $phpIniProd = Join-Path $installDirectory "php.ini-recommended" } - $phpIni = Join-Path $Path "php.ini" - Copy-Item $phpIniProd $phpIni -Force - $extensionDir = Join-Path $Path "ext" - (Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni - (Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni - (Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni + $phpIni = Join-Path $installDirectory "php.ini" + if (Test-Path $phpIniProd) { + Copy-Item $phpIniProd $phpIni -Force + + $extensionDir = Join-Path $installDirectory "ext" + (Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni + (Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni + (Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni + } + + if (Test-Path -LiteralPath $currentLink) { + Remove-Item -LiteralPath $currentLink -Force -Recurse + } + New-Item -ItemType Junction -Path $currentLink -Target $installDirectory | Out-Null + + $pathTarget = if ($EffectiveScope -eq 'AllUsers') { 'Machine' } else { 'User' } + Set-PathEntryFirst -Target $pathTarget -Entry $currentLink + Send-EnvironmentChangeBroadcast - Write-Host "PHP $Semver downloaded to $Path" + Write-Host "" + Write-Host "Installed PHP ${Semver}: $installDirectory" + Write-Host "It has been linked to $currentLink and added to PATH." + Write-Host "Please restart any open Command Prompt/PowerShell windows or IDEs to pick up the new PATH." + Write-Host "You can run 'php -v' to verify the installation in the new window." } catch { Write-Error $_ Exit 1