Skip to content

Commit 80bb21c

Browse files
committed
Improve windows single line installer downloads script
Improve installation path selection Add PHP to the path after install
1 parent 861ecba commit 80bb21c

File tree

1 file changed

+250
-53
lines changed

1 file changed

+250
-53
lines changed

include/download-instructions/windows.ps1

Lines changed: 250 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,35 +3,56 @@
33
Downloads and sets up a specified PHP version on Windows.
44
55
.PARAMETER Version
6-
Major.minor or full version (e.g., 7.4 or 7.4.30).
6+
Major.minor or full version (e.g., 8.4 or 8.4.15).
77
8-
.PARAMETER Path
9-
Destination directory (defaults to C:\php<Version>).
8+
.PARAMETER Scope
9+
Auto (default), CurrentUser, AllUsers, or Custom.
10+
- Auto: AllUsers if elevated, otherwise CurrentUser.
11+
- AllUsers: Requires elevation, installs under Program Files (or Program Files (x86) for x86 arch).
12+
- CurrentUser: Installs under $env:LOCALAPPDATA.
13+
- Custom: Installs under -CustomPath (or prompts), adds to User PATH.
14+
15+
.PARAMETER CustomPath
16+
Directory for Scope=Custom. Versions are installed under this directory and a "current" link is created here.
1017
1118
.PARAMETER Arch
12-
Architecture: x64 or x86 (default: x64).
19+
x64 or x86 (default: x64).
1320
1421
.PARAMETER ThreadSafe
15-
ThreadSafe: download Thread Safe build (default: $False).
22+
Download Thread Safe build (default: $False).
1623
1724
.PARAMETER Timezone
1825
date.timezone string for php.ini (default: 'UTC').
26+
27+
.PARAMETER NoPrompt
28+
If set, does not prompt and uses provided parameters.
1929
#>
2030

2131
[CmdletBinding()]
2232
param(
2333
[Parameter(Mandatory = $true, Position=0)]
2434
[ValidatePattern('^\d+(\.\d+)?(\.\d+)?((alpha|beta|RC)\d*)?$')]
2535
[string]$Version,
36+
37+
[Parameter(Mandatory = $false)]
38+
[ValidateSet('Auto', 'CurrentUser', 'AllUsers', 'Custom')]
39+
[string]$Scope = 'Auto',
40+
41+
[Parameter(Mandatory = $false)]
42+
[string]$CustomPath,
43+
2644
[Parameter(Mandatory = $false, Position=1)]
27-
[string]$Path = "C:\php$Version",
28-
[Parameter(Mandatory = $false, Position=2)]
2945
[ValidateSet("x64", "x86")]
3046
[string]$Arch = "x64",
31-
[Parameter(Mandatory = $false, Position=3)]
47+
48+
[Parameter(Mandatory = $false, Position=2)]
3249
[bool]$ThreadSafe = $False,
33-
[Parameter(Mandatory = $false, Position=4)]
34-
[string]$Timezone = 'UTC'
50+
51+
[Parameter(Mandatory = $false, Position=3)]
52+
[string]$Timezone = 'UTC',
53+
54+
[Parameter(Mandatory = $false)]
55+
[switch]$NoPrompt
3556
)
3657

3758
Function Get-File {
@@ -52,19 +73,19 @@ Function Get-File {
5273
for ($i = 0; $i -lt $Retries; $i++) {
5374
try {
5475
if($OutFile -ne '') {
55-
Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec
76+
Invoke-WebRequest -Uri $Url -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing
5677
} else {
57-
Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec
78+
Invoke-WebRequest -Uri $Url -TimeoutSec $TimeoutSec -UseBasicParsing
5879
}
5980
break;
6081
} catch {
6182
if ($i -eq ($Retries - 1)) {
6283
if($FallbackUrl) {
6384
try {
6485
if($OutFile -ne '') {
65-
Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec
86+
Invoke-WebRequest -Uri $FallbackUrl -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing
6687
} else {
67-
Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec
88+
Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec $TimeoutSec -UseBasicParsing
6889
}
6990
} catch {
7091
throw "Failed to download the file from $Url and $FallbackUrl"
@@ -77,6 +98,87 @@ Function Get-File {
7798
}
7899
}
79100

101+
Function Test-IsAdmin {
102+
$p = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())
103+
return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
104+
}
105+
106+
Function ConvertTo-BoolOrDefault {
107+
param([string]$UserInput, [bool]$Default)
108+
if ([string]::IsNullOrWhiteSpace($UserInput)) { return $Default }
109+
switch -Regex ($UserInput.Trim().ToLowerInvariant()) {
110+
'^(1|true|t|y|yes)$' { return $true }
111+
'^(0|false|f|n|no)$' { return $false }
112+
default { return $Default }
113+
}
114+
}
115+
116+
Function Edit-PathForCompare([string]$p) {
117+
if ([string]::IsNullOrWhiteSpace($p)) { return '' }
118+
return ($p.Trim().Trim('"').TrimEnd('\')).ToLowerInvariant()
119+
}
120+
121+
Function Set-PathEntryFirst {
122+
param(
123+
[Parameter(Mandatory = $true)][ValidateSet('User','Machine')] [string]$Target,
124+
[Parameter(Mandatory = $true)][string]$Entry
125+
)
126+
127+
$entryNorm = Edit-PathForCompare $Entry
128+
129+
$existing = [Environment]::GetEnvironmentVariable('Path', $Target)
130+
if ($null -eq $existing) { $existing = '' }
131+
132+
$parts = @()
133+
foreach ($p in ($existing -split ';')) {
134+
if (-not [string]::IsNullOrWhiteSpace($p)) {
135+
if ((Edit-PathForCompare $p) -ne $entryNorm) { $parts += $p }
136+
}
137+
}
138+
$newParts = @($Entry) + $parts
139+
[Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), $Target)
140+
141+
$procParts = @()
142+
foreach ($p in ($env:Path -split ';')) {
143+
if (-not [string]::IsNullOrWhiteSpace($p)) {
144+
if ((Edit-PathForCompare $p) -ne $entryNorm) { $procParts += $p }
145+
}
146+
}
147+
$env:Path = ((@($Entry) + $procParts) -join ';')
148+
}
149+
150+
function Send-EnvironmentChangeBroadcast {
151+
try {
152+
$sig = @'
153+
using System;
154+
using System.Runtime.InteropServices;
155+
public static class NativeMethods {
156+
[DllImport("user32.dll", SetLastError=true, CharSet=CharSet.Auto)]
157+
public static extern IntPtr SendMessageTimeout(
158+
IntPtr hWnd, uint Msg, UIntPtr wParam, string lParam,
159+
uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);
160+
}
161+
'@
162+
Add-Type -TypeDefinition $sig -ErrorAction SilentlyContinue | Out-Null
163+
$HWND_BROADCAST = [IntPtr]0xffff
164+
$WM_SETTINGCHANGE = 0x001A
165+
$SMTO_ABORTIFHUNG = 0x0002
166+
[UIntPtr]$result = [UIntPtr]::Zero
167+
[NativeMethods]::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, "Environment", $SMTO_ABORTIFHUNG, 5000, [ref]$result) | Out-Null
168+
} catch { }
169+
}
170+
171+
Function Test-EmptyDir([string]$Dir) {
172+
if (-not (Test-Path -LiteralPath $Dir)) {
173+
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
174+
return
175+
}
176+
$items = Get-ChildItem -LiteralPath $Dir -Force -ErrorAction SilentlyContinue
177+
if ($items -and $items.Count -gt 0) {
178+
throw "The directory '$Dir' is not empty. Please choose another location."
179+
}
180+
}
181+
80182
Function Get-Semver {
81183
[CmdletBinding()]
82184
param(
@@ -85,19 +187,22 @@ Function Get-Semver {
85187
[ValidatePattern('^\d+\.\d+$')]
86188
[string]$Version
87189
)
88-
$releases = Get-File -Url "https://downloads.php.net/~windows/releases/releases.json" | ConvertFrom-Json
190+
191+
$jsonUrl = "https://downloads.php.net/~windows/releases/releases.json"
192+
$releases = ((Get-File -Url $jsonUrl).Content | ConvertFrom-Json)
193+
89194
$semver = $releases.$Version.version
90-
if($null -eq $semver) {
91-
$semver = (Get-File -Url "https://downloads.php.net/~windows/releases/archives").Links |
92-
Where-Object { $_.href -match "php-($Version.[0-9]+).*" } |
93-
ForEach-Object { $matches[1] } |
94-
Sort-Object { [System.Version]$_ } -Descending |
95-
Select-Object -First 1
96-
}
97-
if($null -eq $semver) {
98-
throw "Unsupported PHP version: $Version"
99-
}
100-
return $semver
195+
if ($null -ne $semver) { return [string]$semver }
196+
197+
$html = (Get-File -Url "https://downloads.php.net/~windows/releases/archives/").Content
198+
$rx = [regex]"php-($([regex]::Escape($Version))\.[0-9]+)"
199+
$found = $rx.Matches($html) | ForEach-Object { $_.Groups[1].Value } |
200+
Sort-Object { [version]$_ } -Descending |
201+
Select-Object -First 1
202+
203+
if ($null -ne $found) { return [string]$found }
204+
205+
throw "Unsupported PHP version series: $Version"
101206
}
102207

103208
Function Get-VSVersion {
@@ -160,7 +265,7 @@ Function Get-PhpFromUrl {
160265
$vs = Get-VSVersion $Version
161266
$ts = if ($ThreadSafe) { "ts" } else { "nts" }
162267
$zipName = if ($ThreadSafe) { "php-$Semver-Win32-$vs-$Arch.zip" } else { "php-$Semver-$ts-Win32-$vs-$Arch.zip" }
163-
$type = Get-ReleaseType $Version
268+
$type = Get-ReleaseType $Semver
164269

165270
$base = "https://downloads.php.net/~windows/$type"
166271
try {
@@ -175,49 +280,141 @@ Function Get-PhpFromUrl {
175280
}
176281

177282
$tempFile = [IO.Path]::ChangeExtension([IO.Path]::GetTempFileName(), '.zip')
283+
178284
try {
285+
$isAdmin = Test-IsAdmin
286+
287+
if (-not $NoPrompt) {
288+
Write-Host ""
289+
Write-Host "1) Would you like to install PHP for:"
290+
Write-Host " Enter 1 for Current user"
291+
Write-Host " Enter 2 for All users (requires admin elevation)"
292+
Write-Host " Enter 3 to install PHP at a custom path"
293+
Write-Host " Press Enter to choose automatically"
294+
$sel = Read-Host "Please enter [1-3]"
295+
switch ($sel) {
296+
'1' { $Scope = 'CurrentUser' }
297+
'2' { $Scope = 'AllUsers' }
298+
'3' { $Scope = 'Custom' }
299+
default { $Scope = 'Auto' }
300+
}
301+
302+
Write-Host ""
303+
Write-Host "2) What architecture would you like to install?"
304+
Write-Host " Enter x64 for 64-bit"
305+
Write-Host " Enter x86 for 32-bit"
306+
Write-Host " Press Enter to use default ($Arch)"
307+
$archSel = Read-Host "Please enter [x64/x86]"
308+
if (-not [string]::IsNullOrWhiteSpace($archSel) -and @('x64','x86') -contains $archSel.Trim()) {
309+
$Arch = $archSel.Trim()
310+
}
311+
312+
Write-Host ""
313+
Write-Host "3) What ThreadSafe option would you like to use?"
314+
Write-Host " Enter true for ThreadSafe"
315+
Write-Host " Enter false for Non-ThreadSafe"
316+
Write-Host " Press Enter to use default ($ThreadSafe)"
317+
$tsSel = Read-Host "Please enter [true/false]"
318+
$ThreadSafe = ConvertTo-BoolOrDefault -Input $tsSel -Default $ThreadSafe
319+
320+
Write-Host ""
321+
Write-Host "4) What timezone would you like to set in php.ini?"
322+
Write-Host " Press Enter to use default ($Timezone)"
323+
$tzSel = Read-Host "Please enter timezone"
324+
if (-not [string]::IsNullOrWhiteSpace($tzSel)) {
325+
$Timezone = $tzSel.Trim()
326+
}
327+
328+
if ($Scope -eq 'Custom') {
329+
$defaultCustom = if ($CustomPath) { $CustomPath } else { (Join-Path $env:LOCALAPPDATA 'Programs\PHP') }
330+
Write-Host ""
331+
Write-Host "5) Please enter the custom installation path."
332+
Write-Host " Press Enter to use default ($defaultCustom)"
333+
$cr = Read-Host "Please enter"
334+
$CustomPath = if ([string]::IsNullOrWhiteSpace($cr)) { $defaultCustom } else { $cr.Trim() }
335+
}
336+
}
337+
179338
if ($Version -match "^\d+\.\d+$") {
180339
$Semver = Get-Semver $Version
340+
$MajorMinor = $Version
181341
} else {
182342
$Semver = $Version
183-
$Semver -match '^(\d+\.\d+)' | Out-Null
184-
$Version = $Matches[1]
343+
if ($Semver -notmatch '^(\d+\.\d+)') { throw "Could not derive major.minor from Version '$Version'." }
344+
$MajorMinor = $Matches[1]
185345
}
186346

187-
if (-not (Test-Path $Path)) {
188-
try {
189-
New-Item -ItemType Directory -Path $Path -ErrorAction Stop | Out-Null
190-
} catch {
191-
throw "Failed to create directory $Path. $_"
347+
if ([version]$MajorMinor -lt [version]'5.5' -and $Arch -eq 'x64') {
348+
$Arch = 'x86'
349+
Write-Host "PHP series $MajorMinor does not support x64 on Windows. Using x86."
350+
}
351+
352+
$EffectiveScope = $Scope
353+
if ($Scope -eq 'Auto') {
354+
$EffectiveScope = if ($isAdmin) { 'AllUsers' } else { 'CurrentUser' }
355+
}
356+
357+
if ($EffectiveScope -eq 'AllUsers' -and -not $isAdmin) {
358+
throw "AllUsers install selected but this session is not elevated. Re-run as Administrator or choose CurrentUser/Custom."
359+
}
360+
361+
$installRootDirectory = switch ($EffectiveScope) {
362+
'CurrentUser' { Join-Path $env:LOCALAPPDATA 'Programs\PHP' }
363+
'AllUsers' {
364+
$pf = $env:ProgramFiles
365+
if ($Arch -eq 'x86' -and ${env:ProgramFiles(x86)}) { $pf = ${env:ProgramFiles(x86)} }
366+
Join-Path $pf 'PHP'
192367
}
193-
} else {
194-
$files = Get-ChildItem -Path $Path
195-
if ($files.Count -gt 0) {
196-
throw "The directory $Path is not empty. Please provide an empty directory."
368+
'Custom' {
369+
if ([string]::IsNullOrWhiteSpace($CustomPath)) { throw "Scope=Custom requires -CustomPath (or interactive input)." }
370+
[Environment]::ExpandEnvironmentVariables($CustomPath)
197371
}
372+
default { throw "Unexpected scope: $EffectiveScope" }
198373
}
199374

200-
if($Version -lt '5.5' -and $Arch -eq 'x64') {
201-
$Arch = 'x86'
202-
Write-Host "PHP version $Version does not support x64 architecture on Windows. Using x86 instead."
375+
if (-not (Test-Path -LiteralPath $installRootDirectory)) {
376+
New-Item -ItemType Directory -Path $installRootDirectory | Out-Null
203377
}
204378

205-
Write-Host "Downloading PHP $Semver to $Path"
206-
Get-PhpFromUrl $Version $Semver $Arch $ThreadSafe $tempFile
207-
Expand-Archive -Path $tempFile -DestinationPath $Path -Force -ErrorAction Stop
379+
$tsTag = if ($ThreadSafe) { 'ts' } else { 'nts' }
380+
$installDirectory = Join-Path (Join-Path (Join-Path $installRootDirectory $Semver) $tsTag) $Arch
381+
$currentLink = Join-Path $installRootDirectory 'current'
382+
383+
Test-EmptyDir $installDirectory
384+
385+
Write-Host "Downloading PHP $Semver ($Arch, $tsTag) -> $installDirectory"
386+
Get-PhpFromUrl $MajorMinor $Semver $Arch $ThreadSafe $tempFile
208387

209-
$phpIniProd = Join-Path $Path "php.ini-production"
388+
Expand-Archive -Path $tempFile -DestinationPath $installDirectory -Force -ErrorAction Stop
389+
390+
$phpIniProd = Join-Path $installDirectory "php.ini-production"
210391
if(-not(Test-Path $phpIniProd)) {
211-
$phpIniProd = Join-Path $Path "php.ini-recommended"
392+
$phpIniProd = Join-Path $installDirectory "php.ini-recommended"
212393
}
213-
$phpIni = Join-Path $Path "php.ini"
214-
Copy-Item $phpIniProd $phpIni -Force
215-
$extensionDir = Join-Path $Path "ext"
216-
(Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
217-
(Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
218-
(Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni
394+
$phpIni = Join-Path $installDirectory "php.ini"
395+
if (Test-Path $phpIniProd) {
396+
Copy-Item $phpIniProd $phpIni -Force
397+
398+
$extensionDir = Join-Path $installDirectory "ext"
399+
(Get-Content $phpIni) -replace '^extension_dir = "./"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
400+
(Get-Content $phpIni) -replace ';\s?extension_dir = "ext"', "extension_dir = `"$extensionDir`"" | Set-Content $phpIni
401+
(Get-Content $phpIni) -replace ';\s?date.timezone =', "date.timezone = `"$Timezone`"" | Set-Content $phpIni
402+
}
403+
404+
if (Test-Path -LiteralPath $currentLink) {
405+
Remove-Item -LiteralPath $currentLink -Force -Recurse
406+
}
407+
New-Item -ItemType Junction -Path $currentLink -Target $installDirectory | Out-Null
408+
409+
$pathTarget = if ($EffectiveScope -eq 'AllUsers') { 'Machine' } else { 'User' }
410+
Set-PathEntryFirst -Target $pathTarget -Entry $currentLink
411+
Send-EnvironmentChangeBroadcast
219412

220-
Write-Host "PHP $Semver downloaded to $Path"
413+
Write-Host ""
414+
Write-Host "Installed PHP ${Semver}: $installDirectory"
415+
Write-Host "It has been linked to $currentLink and added to PATH."
416+
Write-Host "Please restart any open Command Prompt/PowerShell windows or IDEs to pick up the new PATH."
417+
Write-Host "You can run 'php -v' to verify the installation in the new window."
221418
} catch {
222419
Write-Error $_
223420
Exit 1

0 commit comments

Comments
 (0)