Files
aego/tools/release/windows/user-path.ps1

149 lines
7.2 KiB
PowerShell

param(
[ValidateSet('Install', 'Uninstall')]
[string]$Action,
[string]$InstallDir
)
$ErrorActionPreference = 'Stop'
# Preserve empty entries and delimiters; do not normalize the user's PATH.
function Add-AegoPath([AllowEmptyString()][string]$Current, [string]$Directory) {
if ([string]::IsNullOrEmpty($Directory) -or $Directory.Contains(';')) {
throw 'The installation directory cannot be empty or contain a semicolon.'
}
foreach ($part in $Current.Split([char]';')) {
if ([string]::Equals($part, $Directory, [StringComparison]::OrdinalIgnoreCase)) {
return [pscustomobject]@{ Value = $Current; Added = $false }
}
}
$updated = if ($Current.Length -eq 0) { $Directory } else { $Current + ';' + $Directory }
return [pscustomobject]@{ Value = $updated; Added = $true }
}
# Remove only one owned entry. In particular, a later duplicate is user data.
function Remove-AegoPath([AllowEmptyString()][string]$Current, [string]$Directory) {
$parts = @($Current.Split([char]';'))
$index = -1
for ($i = 0; $i -lt $parts.Count; $i++) {
if ([string]::Equals($parts[$i], $Directory, [StringComparison]::OrdinalIgnoreCase)) {
$index = $i
break
}
}
if ($index -lt 0) { return [pscustomobject]@{ Value = $Current; Removed = $false; RemainingEntries = $parts.Count } }
$rest = [System.Collections.Generic.List[string]]::new()
for ($i = 0; $i -lt $parts.Count; $i++) {
if ($i -ne $index) { $rest.Add($parts[$i]) }
}
return [pscustomobject]@{ Value = [string]::Join(';', $rest); Removed = $true; RemainingEntries = $rest.Count }
}
function Read-AegoUserPath($Key) {
$present = @($Key.GetValueNames()) -icontains 'Path'
if (-not $present) {
return [pscustomobject]@{ Present = $false; Value = ''; Kind = [Microsoft.Win32.RegistryValueKind]::ExpandString }
}
$kind = $Key.GetValueKind('Path')
if ($kind -ne [Microsoft.Win32.RegistryValueKind]::String -and $kind -ne [Microsoft.Win32.RegistryValueKind]::ExpandString) {
throw 'The user PATH has an unsupported registry type; it was not changed.'
}
$value = $Key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
return [pscustomobject]@{ Present = $true; Value = [string]$value; Kind = $kind }
}
function Set-AegoUserPath($Key, [string]$Value, $Kind, [bool]$Present) {
if ($Present) { $Key.SetValue('Path', $Value, $Kind) }
else { $Key.DeleteValue('Path', $false) }
}
function Invoke-AegoPathUpdate([ValidateSet('Install', 'Uninstall')][string]$Mode, [string]$Directory, $environment, $state) {
if ([string]::IsNullOrEmpty($Directory) -or $Directory.Contains(';')) {
throw 'The installation directory cannot be empty or contain a semicolon.'
}
$owned = $state.GetValue('PathOwned', 0) -eq 1
# Old installations and pre-existing PATH entries are unowned. Uninstall
# has nothing to change, even if PATH has an unfamiliar registry type.
if ($Mode -eq 'Uninstall' -and -not $owned) { return }
$current = Read-AegoUserPath $environment
$ownedDir = [string]$state.GetValue('PathDir', '')
$wasPresent = $state.GetValue('PathWasPresent', 0) -eq 1
if ($owned -and ([string]::IsNullOrEmpty($ownedDir) -or $ownedDir.Contains(';'))) {
throw 'The owned PATH directory is invalid; the user PATH was not changed.'
}
if ($owned -and $Mode -eq 'Uninstall' -and -not [string]::Equals($ownedDir, $Directory, [StringComparison]::OrdinalIgnoreCase)) {
throw 'The owned PATH entry belongs to a different installation; it was not changed.'
}
# A moved installation first releases the old entry it actually owned.
if ($owned -and ($Mode -eq 'Uninstall' -or -not [string]::Equals($ownedDir, $Directory, [StringComparison]::OrdinalIgnoreCase))) {
$removed = Remove-AegoPath $current.Value $ownedDir
if ($removed.Removed) {
# An empty entry added by the user still makes PATH present, even
# when its serialized value is empty after removing our entry.
$present = $removed.RemainingEntries -gt 0 -or $wasPresent
Set-AegoUserPath $environment $removed.Value $current.Kind $present
}
$state.SetValue('PathOwned', 0, [Microsoft.Win32.RegistryValueKind]::DWord)
$owned = $false
$current = Read-AegoUserPath $environment
}
if ($Mode -eq 'Install') {
$added = Add-AegoPath $current.Value $Directory
if ($added.Added) {
# If an update must add the entry again, the user removed it in the
# meantime. Capture the current state, not the original install's.
$state.SetValue('PathOwned', 0, [Microsoft.Win32.RegistryValueKind]::DWord)
$state.SetValue('PathWasPresent', [int]$current.Present, [Microsoft.Win32.RegistryValueKind]::DWord)
$state.SetValue('PathDir', $Directory, [Microsoft.Win32.RegistryValueKind]::String)
try {
Set-AegoUserPath $environment $added.Value $current.Kind $true
$state.SetValue('PathOwned', 1, [Microsoft.Win32.RegistryValueKind]::DWord)
} catch {
$failure = $_
# Do not overwrite a concurrent user edit if recording ownership
# fails; otherwise roll back our tentative PATH write.
try {
$state.SetValue('PathOwned', 0, [Microsoft.Win32.RegistryValueKind]::DWord)
$latest = Read-AegoUserPath $environment
if ($latest.Present -and $latest.Kind -eq $current.Kind -and $latest.Value -ceq $added.Value) {
Set-AegoUserPath $environment $current.Value $current.Kind $current.Present
} elseif ($latest.Present -ne $current.Present -or $latest.Kind -ne $current.Kind -or $latest.Value -cne $current.Value) {
throw 'PATH changed concurrently; the tentative entry was left untouched.'
}
} catch {
throw "Could not record PATH ownership or safely roll back: $($_.Exception.Message)"
}
throw $failure
}
} elseif (-not $owned) {
# This path predated Aego; uninstall must leave it alone.
$state.SetValue('PathDir', $Directory, [Microsoft.Win32.RegistryValueKind]::String)
$state.SetValue('PathOwned', 0, [Microsoft.Win32.RegistryValueKind]::DWord)
}
}
}
function Update-AegoUserPath([string]$Mode, [string]$Directory) {
$environment = $null
$state = $null
try {
$environment = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment')
$state = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Software\Aego')
Invoke-AegoPathUpdate $Mode $Directory $environment $state
} finally {
if ($null -ne $environment) { $environment.Dispose() }
if ($null -ne $state) { $state.Dispose() }
}
}
# Dot-sourcing exposes pure helpers to tests without touching the registry.
if ($Action) {
try {
Update-AegoUserPath $Action $InstallDir
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
}