<# LXS - Common library (Windows) Sourced by windows\lxs.ps1 and every sub-script. Mirrors linux/lib/common.sh: colors, UI helpers, loggers, spinner, guards (admin, disk, winget) and shared utilities (public IP, password generation, restore point, registry writes). Compatible with Windows PowerShell 5.1 and PowerShell 7+. Repo: https://git.hyko.cx/hykocx/lxs #> # ═══════════════════════════════════════════════════════════════════════════ # Console setup - UTF-8 output (box drawing chars) and VT sequences (colors). # Both are best-effort: a console that refuses either falls back to plain text. # ═══════════════════════════════════════════════════════════════════════════ $script:LxsColorEnabled = $false $script:LxsUnicodeEnabled = $false function Initialize-LxsConsole { [CmdletBinding()] param() try { [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false $script:LxsUnicodeEnabled = $true } catch { # Redirected output or a locked-down host - fall back to ASCII rules. $script:LxsUnicodeEnabled = $false } # PowerShell 7 renders VT natively; older hosts need ENABLE_VIRTUAL_TERMINAL_PROCESSING. if ($PSVersionTable.PSVersion.Major -ge 6) { $script:LxsColorEnabled = $true } else { try { if (-not ('Lxs.NativeConsole' -as [type])) { Add-Type -Namespace Lxs -Name NativeConsole -MemberDefinition @' [DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr GetStdHandle(int nStdHandle); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode); [DllImport("kernel32.dll", SetLastError = true)] public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode); '@ } $handle = [Lxs.NativeConsole]::GetStdHandle(-11) $mode = 0 if ([Lxs.NativeConsole]::GetConsoleMode($handle, [ref]$mode)) { if ([Lxs.NativeConsole]::SetConsoleMode($handle, $mode -bor 0x0004)) { $script:LxsColorEnabled = $true } } } catch { $script:LxsColorEnabled = $false } } if ($env:LXS_NO_COLOR) { $script:LxsColorEnabled = $false } $e = [char]0x1B if ($script:LxsColorEnabled) { $script:Red = "$e[38;2;255;64;64m" # errors, destructive actions $script:Cyan = "$e[38;2;0;229;255m" # accents, titles, OK, info $script:White = "$e[38;2;240;240;240m" # primary text $script:Gray = "$e[38;2;140;140;140m" # secondary text, separators $script:NC = "$e[0m" $script:Bold = "$e[1m" $script:Dim = "$e[2m" } else { $script:Red = ''; $script:Cyan = ''; $script:White = ''; $script:Gray = '' $script:NC = ''; $script:Bold = ''; $script:Dim = '' } } Initialize-LxsConsole # ═══════════════════════════════════════════════════════════════════════════ # UI helpers - title + horizontal rule, no closed box. Mirrors common.sh. # # Show-LxsBoxTop "TITLE" "RIGHT" -> TITLE [ RIGHT ] # ---------------------------------- # Show-LxsBoxMid "SECTION" -> - SECTION ------------------------ # Show-LxsBoxBottom -> ---------------------------------- # Show-LxsMenuItem "01" "LABEL" "desc" -> [01] LABEL // desc # Show-LxsPrompt -> > # ═══════════════════════════════════════════════════════════════════════════ function Get-LxsTermWidth { $cols = 80 try { $raw = $Host.UI.RawUI.WindowSize.Width if ($raw -gt 0) { $cols = $raw } } catch { $cols = 80 } if ($cols -gt 100) { $cols = 100 } if ($cols -lt 60) { $cols = 60 } return $cols } function Get-LxsRule { param([int]$Width = 0) if ($Width -le 0) { $Width = Get-LxsTermWidth } $char = if ($script:LxsUnicodeEnabled) { [char]0x2500 } else { '-' } return (New-Object string @($char, $Width)) } function Show-LxsBoxTop { param( [Parameter(Mandatory = $true)][string]$Title, [string]$Right = '' ) $cols = Get-LxsTermWidth if ($Right) { $padLen = $cols - 2 - $Title.Length - $Right.Length - 4 if ($padLen -lt 1) { $padLen = 1 } $pad = ' ' * $padLen Write-Host " $($script:Cyan)$($script:Bold)$Title$($script:NC)$pad$($script:Gray)[ $($script:Cyan)$Right$($script:Gray) ]$($script:NC)" } else { Write-Host " $($script:Cyan)$($script:Bold)$Title$($script:NC)" } Write-Host "$($script:Gray)$(Get-LxsRule $cols)$($script:NC)" } function Show-LxsBoxMid { param([Parameter(Mandatory = $true)][string]$Title) $cols = Get-LxsTermWidth $fillLen = $cols - $Title.Length - 4 if ($fillLen -lt 2) { $fillLen = 2 } Write-Host "$($script:Gray)$(Get-LxsRule 1) $($script:Cyan)$($script:Bold)$Title$($script:NC) $($script:Gray)$(Get-LxsRule $fillLen)$($script:NC)" } function Show-LxsBoxBottom { Write-Host "$($script:Gray)$(Get-LxsRule)$($script:NC)" } function Show-LxsSeparator { Write-Host "$($script:Gray)$(Get-LxsRule)$($script:NC)" } function Show-LxsTitle { param( [Parameter(Mandatory = $true)][string]$Title, [string]$Subtitle = '' ) Write-Host '' Show-LxsBoxTop -Title $Title -Right $Subtitle } # Render a menu line. -Exit colors the key red (used for BACK / quit entries). function Show-LxsMenuItem { param( [Parameter(Mandatory = $true)][string]$Key, [Parameter(Mandatory = $true)][string]$Label, [string]$Description = '', [switch]$Exit ) $keyColor = if ($Exit) { $script:Red } else { $script:Cyan } if ($Description) { $paddedLabel = $Label.PadRight(18) Write-Host " $keyColor[$Key]$($script:NC) $($script:White)$($script:Bold)$paddedLabel$($script:NC) $($script:Gray)// $Description$($script:NC)" } else { Write-Host " $keyColor[$Key]$($script:NC) $($script:White)$($script:Bold)$Label$($script:NC)" } } function Show-LxsPrompt { Write-Host " $($script:Cyan)>$($script:NC) " -NoNewline } # Read a menu selection. Returns the trimmed string (never $null). function Read-LxsChoice { Show-LxsPrompt $answer = Read-Host if ($null -eq $answer) { return '' } return $answer.Trim() } function Read-LxsEnter { param([string]$Message = 'Press Enter to continue...') Write-Host '' Read-Host -Prompt $Message | Out-Null } # ═══════════════════════════════════════════════════════════════════════════ # Loggers # ═══════════════════════════════════════════════════════════════════════════ function Write-LxsInfo { param([string]$Message) Write-Host "$($script:Cyan)[..]$($script:NC) $Message" } function Write-LxsOk { param([string]$Message) Write-Host "$($script:Cyan)[OK]$($script:NC) $Message" } function Write-LxsWarn { param([string]$Message) Write-Host "$($script:Cyan)[!!]$($script:NC) $Message" } function Write-LxsErr { param([string]$Message) Write-Host "$($script:Red)[KO]$($script:NC) $Message" } # ═══════════════════════════════════════════════════════════════════════════ # Option parsing # # Sub-scripts read $args by hand, exactly like the bash `for arg in "$@"` loop. # A param([switch]) block cannot be used: LXS invokes sub-scripts with a # splatted string[] (& $script @Arguments), and PowerShell binds every element # of a splatted array positionally - '-Yes' would land in $args as a value and # the switch would silently stay $false. # # $opts = Read-LxsFlags -Arguments $args -Known @('-Yes', '-y', '-Help') # if ($opts.Unknown.Count -gt 0) { ... } # $assumeYes = Test-LxsFlag $opts @('-Yes', '-y') # ═══════════════════════════════════════════════════════════════════════════ function Read-LxsFlags { param( [string[]]$Arguments = @(), [string[]]$Known = @() ) $present = @{} $unknown = @() foreach ($a in $Arguments) { if ([string]::IsNullOrWhiteSpace($a)) { continue } $match = $null foreach ($k in $Known) { if ($k -ieq $a) { $match = $k; break } } if ($match) { $present[$match.ToLower()] = $true } else { $unknown += $a } } return [pscustomobject]@{ Present = $present; Unknown = $unknown } } function Test-LxsFlag { param( [Parameter(Mandatory = $true)]$Parsed, [Parameter(Mandatory = $true)][string[]]$Name ) foreach ($n in $Name) { if ($Parsed.Present.ContainsKey($n.ToLower())) { return $true } } return $false } # Print the unknown options and return $true when there were any. function Show-LxsUnknownFlags { param([Parameter(Mandatory = $true)]$Parsed) if ($Parsed.Unknown.Count -eq 0) { return $false } foreach ($u in $Parsed.Unknown) { Write-LxsErr "Unknown option: $u" } return $true } # Yes/No confirmation. Default is No unless -DefaultYes is passed. function Confirm-LxsAction { param( [Parameter(Mandatory = $true)][string]$Question, [switch]$DefaultYes, [switch]$AssumeYes ) if ($AssumeYes) { return $true } $suffix = if ($DefaultYes) { '[Y/n]' } else { '[y/N]' } $answer = Read-Host -Prompt "$Question $suffix" if ([string]::IsNullOrWhiteSpace($answer)) { return [bool]$DefaultYes } return $answer.Trim().ToLower() -in @('y', 'yes', 'o', 'oui') } # ═══════════════════════════════════════════════════════════════════════════ # Progress helpers # # Invoke-LxsSpinner - external command, output captured to a log file, # animated spinner (mirrors run_spinner in common.sh). # Invoke-LxsStep - inline PowerShell work; prints [..] then [OK]/[KO]. # # Both return $true on success. Log file: $env:LXS_LOG_FILE or %TEMP%\lxs.log # ═══════════════════════════════════════════════════════════════════════════ function Get-LxsTempDir { if ($env:TEMP) { return $env:TEMP } return [IO.Path]::GetTempPath() } function Get-LxsLogFile { if ($env:LXS_LOG_FILE) { return $env:LXS_LOG_FILE } return (Join-Path (Get-LxsTempDir) 'lxs.log') } function Invoke-LxsSpinner { param( [Parameter(Mandatory = $true)][string]$Message, [Parameter(Mandatory = $true)][string]$FilePath, [string[]]$ArgumentList = @(), [int[]]$SuccessCodes = @(0) ) $log = Get-LxsLogFile $errLog = "$log.err" $spinner = '|/-\'.ToCharArray() $i = 0 Write-Host "$($script:Cyan)[..]$($script:NC) $Message" -NoNewline $startArgs = @{ FilePath = $FilePath NoNewWindow = $true PassThru = $true RedirectStandardOutput = $log RedirectStandardError = $errLog } if ($ArgumentList.Count -gt 0) { $startArgs['ArgumentList'] = $ArgumentList } try { $proc = Start-Process @startArgs } catch { Write-Host "`r$($script:Red)[KO]$($script:NC) $Message " Write-LxsErr $_.Exception.Message return $false } while (-not $proc.HasExited) { Write-Host "`r$($script:Cyan)[$($spinner[$i % 4]).]$($script:NC) $Message" -NoNewline $i++ Start-Sleep -Milliseconds 150 } $proc.WaitForExit() if (Test-Path $errLog) { Get-Content $errLog -ErrorAction SilentlyContinue | Add-Content $log -ErrorAction SilentlyContinue Remove-Item $errLog -Force -ErrorAction SilentlyContinue } if ($SuccessCodes -contains $proc.ExitCode) { Write-Host "`r$($script:Cyan)[OK]$($script:NC) $Message " return $true } Write-Host "`r$($script:Red)[KO]$($script:NC) $Message (exit $($proc.ExitCode)) " Write-Host "$($script:Gray) Log: $log$($script:NC)" return $false } function Invoke-LxsStep { param( [Parameter(Mandatory = $true)][string]$Message, [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock ) Write-Host "$($script:Cyan)[..]$($script:NC) $Message" -NoNewline try { & $ScriptBlock | Out-Null Write-Host "`r$($script:Cyan)[OK]$($script:NC) $Message " return $true } catch { Write-Host "`r$($script:Red)[KO]$($script:NC) $Message " Write-Host "$($script:Gray) $($_.Exception.Message)$($script:NC)" return $false } } # ═══════════════════════════════════════════════════════════════════════════ # Guards # ═══════════════════════════════════════════════════════════════════════════ function Test-LxsAdmin { try { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal $identity return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } # Re-launch the calling script elevated, preserving its arguments. Mirrors # require_root in common.sh. Call near the top of a sub-script: # Assert-LxsAdmin -ScriptPath $PSCommandPath -Arguments $args # Returns only when already elevated; otherwise it starts a new elevated # window and exits the current process. function Assert-LxsAdmin { param( [string]$ScriptPath = $PSCommandPath, [string[]]$Arguments = @() ) if (Test-LxsAdmin) { return } if (-not $ScriptPath -or -not (Test-Path $ScriptPath)) { Write-LxsErr 'Administrator rights are required. Re-run this from an elevated terminal.' exit 1 } Write-LxsWarn 'Administrator rights required; opening an elevated window...' $psExe = (Get-Process -Id $PID).Path if (-not $psExe) { $psExe = 'powershell.exe' } # The elevated window is a *new* console: without a trailing pause it would # close the moment the script ends and take all its output with it. $quote = { param($v) "'" + ("$v" -replace "'", "''") + "'" } $inner = "& $(& $quote $ScriptPath)" foreach ($a in $Arguments) { $inner += " $(& $quote $a)" } $inner += "; Write-Host ''; Read-Host 'Press Enter to close this window'" # Single pre-quoted string: Start-Process does not quote array elements, # and $inner contains spaces. It uses only single quotes internally, so # wrapping it in double quotes needs no further escaping. $cmdLine = '-NoProfile -ExecutionPolicy Bypass -Command "' + $inner + '"' try { Start-Process -FilePath $psExe -ArgumentList $cmdLine -Verb RunAs | Out-Null } catch { Write-LxsErr 'Elevation was refused.' exit 1 } Write-LxsInfo 'Continuing in the elevated window.' exit 0 } # Windows 10 1809 / Server 2019 (build 17763) is the documented floor. function Assert-LxsWindows { param([int]$MinimumBuild = 17763) # Not named $IsWindows: PowerShell 7 defines that as a read-only automatic # variable, and assigning to it throws on every call. $onWindows = $true if ($PSVersionTable.PSVersion.Major -ge 6) { $onWindows = [System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform( [System.Runtime.InteropServices.OSPlatform]::Windows) } if (-not $onWindows) { Write-LxsErr 'This script only runs on Windows. Use lxs.sh on Linux.' return $false } try { $build = [int](Get-CimInstance Win32_OperatingSystem).BuildNumber if ($build -lt $MinimumBuild) { Write-LxsWarn "Windows build $build is older than the supported floor ($MinimumBuild). Some tools may fail." } } catch { # Not fatal - CIM can be unavailable in stripped-down images. } return $true } # Mirrors require_disk_space: fail early rather than half-install. # Test-LxsDiskSpace -MinimumMB 1024 -Paths @('C:\') function Test-LxsDiskSpace { param( [int]$MinimumMB = 500, [string[]]$Paths = @($env:SystemDrive) ) $ok = $true $seen = @{} foreach ($path in $Paths) { if ([string]::IsNullOrWhiteSpace($path)) { continue } $root = $null try { $root = [System.IO.Path]::GetPathRoot((Resolve-Path $path -ErrorAction Stop).Path) } catch { $root = $null } if (-not $root) { $root = [System.IO.Path]::GetPathRoot($path) } if (-not $root) { Write-LxsErr "Cannot read disk usage for $path" $ok = $false continue } if ($seen.ContainsKey($root)) { continue } $seen[$root] = $true try { $drive = New-Object System.IO.DriveInfo $root $freeMB = [math]::Floor($drive.AvailableFreeSpace / 1MB) } catch { Write-LxsErr "Cannot read disk usage for $root" $ok = $false continue } if ($freeMB -lt $MinimumMB) { Write-LxsErr "Not enough disk space on ${root}: ${freeMB}MB free, ${MinimumMB}MB required." $ok = $false } } return $ok } # ═══════════════════════════════════════════════════════════════════════════ # winget helpers # ═══════════════════════════════════════════════════════════════════════════ function Test-LxsWinget { return [bool](Get-Command winget -ErrorAction SilentlyContinue) } function Assert-LxsWinget { if (Test-LxsWinget) { return $true } Write-LxsErr 'winget (App Installer) is not available on this machine.' Write-Host "$($script:Gray) Install it from the Microsoft Store ('App Installer') or from$($script:NC)" Write-Host "$($script:Gray) https://github.com/microsoft/winget-cli/releases, then re-run.$($script:NC)" return $false } # `winget list` is slow (a second or more per call), and a package menu asks # about a dozen ids at once - so the full listing is fetched once and reused. $script:LxsWingetListCache = $null function Get-LxsWingetListText { param([switch]$Refresh) if ($Refresh) { $script:LxsWingetListCache = $null } if ($null -ne $script:LxsWingetListCache) { return $script:LxsWingetListCache } try { $script:LxsWingetListCache = (& winget list --accept-source-agreements 2>&1 | Out-String) } catch { $script:LxsWingetListCache = '' } return $script:LxsWingetListCache } function Clear-LxsWingetListCache { $script:LxsWingetListCache = $null } function Test-LxsPackageInstalled { param([Parameter(Mandatory = $true)][string]$Id) $listing = Get-LxsWingetListText if (-not $listing) { return $false } return ($listing -match [regex]::Escape($Id)) } # Install one winget package. Idempotent: an already-installed package is # reported and skipped. Returns $true when the package ends up installed. function Install-LxsWingetPackage { param( [Parameter(Mandatory = $true)][string]$Id, [string]$Name = '', [switch]$Force ) if (-not $Name) { $Name = $Id } if (-not $Force -and (Test-LxsPackageInstalled -Id $Id)) { Write-LxsOk "$Name is already installed" return $true } # winget.exe is an App Execution Alias; resolve the real path so output # redirection in Invoke-LxsSpinner works reliably. $wingetPath = (Get-Command winget -ErrorAction SilentlyContinue).Source if (-not $wingetPath) { $wingetPath = 'winget.exe' } # 0 = installed. -1978335135 (0x8A150061) = "no applicable update", which # winget also returns when the package is already present at that version. $wingetArgs = @( 'install', '--id', $Id, '--exact', '--silent', '--accept-package-agreements', '--accept-source-agreements', '--disable-interactivity' ) $installed = Invoke-LxsSpinner -Message "Installing $Name" -FilePath $wingetPath ` -ArgumentList $wingetArgs -SuccessCodes @(0, -1978335135) Clear-LxsWingetListCache if (-not $installed) { Write-Host "$($script:Gray) Package id: $Id - check it with: winget search --id $Id$($script:NC)" } return $installed } # Install a list of @{ Id = '...'; Name = '...' } entries, reporting a summary. function Install-LxsWingetPackageSet { param( [Parameter(Mandatory = $true)][array]$Packages ) $failed = @() foreach ($pkg in $Packages) { if (-not (Install-LxsWingetPackage -Id $pkg.Id -Name $pkg.Name)) { $failed += $pkg.Name } } Write-Host '' if ($failed.Count -eq 0) { Write-LxsOk "All $($Packages.Count) package(s) are installed" } else { Write-LxsWarn "$($failed.Count) of $($Packages.Count) failed: $($failed -join ', ')" } return ($failed.Count -eq 0) } # Interactive package picker shared by every apps\ script: lists the bundle, # marks what is already installed, and installs "all" or the numbers picked. # Returns $true when the user actually installed something. function Invoke-LxsPackageMenu { param( [Parameter(Mandatory = $true)][string]$Title, [Parameter(Mandatory = $true)][array]$Packages, [string]$Note = '' ) if (-not (Assert-LxsWinget)) { return $false } Clear-Host Show-LxsBoxTop -Title $Title -Right 'WINGET' Write-Host '' if ($Note) { Write-Host "$($script:Gray)$Note$($script:NC)" Write-Host '' } Write-LxsInfo 'Checking what is already installed...' Get-LxsWingetListText -Refresh | Out-Null Write-Host '' for ($i = 0; $i -lt $Packages.Count; $i++) { $pkg = $Packages[$i] $state = if (Test-LxsPackageInstalled -Id $pkg.Id) { "$($script:Cyan)[installed]$($script:NC)" } else { '' } Write-Host (" $($script:Cyan)[{0,2}]$($script:NC) $($script:White)$($script:Bold){1,-24}$($script:NC) $($script:Gray){2,-34}$($script:NC) {3}" -f ` ($i + 1), $pkg.Name, $pkg.Id, $state) } Write-Host '' Write-Host "$($script:Gray)Enter the numbers to install, separated by commas (e.g. 1,3,5),$($script:NC)" Write-Host "$($script:Gray)or 'all' for everything above. Empty cancels.$($script:NC)" Write-Host '' $selection = Read-Host -Prompt 'Selection' if ([string]::IsNullOrWhiteSpace($selection)) { Write-LxsInfo 'Cancelled.' return $false } $targets = @() if ($selection.Trim() -ieq 'all') { $targets = $Packages } else { foreach ($part in ($selection -split ',')) { $part = $part.Trim() if ($part -notmatch '^\d+$') { Write-LxsErr "Not a number: $part"; return $false } $idx = [int]$part - 1 if ($idx -lt 0 -or $idx -ge $Packages.Count) { Write-LxsErr "Out of range: $part"; return $false } $targets += $Packages[$idx] } } Write-Host '' Show-LxsSeparator Write-Host '' Install-LxsWingetPackageSet -Packages $targets | Out-Null return $true } # ═══════════════════════════════════════════════════════════════════════════ # Misc utilities # ═══════════════════════════════════════════════════════════════════════════ function Get-LxsPublicIP { foreach ($url in @('https://ifconfig.me/ip', 'https://icanhazip.com', 'https://ipinfo.io/ip')) { try { $ip = (Invoke-RestMethod -Uri $url -TimeoutSec 5 -ErrorAction Stop) if ($ip) { return ("$ip").Trim() } } catch { continue } } try { $local = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } | Select-Object -First 1 -ExpandProperty IPAddress if ($local) { return $local } } catch { # no network } return 'unknown' } function New-LxsPassword { param([int]$Length = 32) $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' $bytes = New-Object byte[] $Length $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() try { $rng.GetBytes($bytes) } finally { $rng.Dispose() } $sb = New-Object System.Text.StringBuilder foreach ($b in $bytes) { [void]$sb.Append($chars[$b % $chars.Length]) } return $sb.ToString() } # Create a System Restore point, enabling System Protection first if needed. # Used as a safety net before debloat/hardening. Requires admin. function New-LxsRestorePoint { param([string]$Description = 'LXS checkpoint') if (-not (Test-LxsAdmin)) { Write-LxsErr 'A restore point requires administrator rights.' return $false } $drive = "$env:SystemDrive\" try { Enable-ComputerRestore -Drive $drive -ErrorAction Stop } catch { Write-LxsWarn "Could not enable System Protection on ${drive}: $($_.Exception.Message)" } # Windows rate-limits restore points to one per 24h unless this is relaxed. try { New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SystemRestore' ` -Name 'SystemRestorePointCreationFrequency' -Value 0 -PropertyType DWord -Force -ErrorAction Stop | Out-Null } catch { # Non-fatal: the checkpoint may be skipped if one was made recently. } try { Checkpoint-Computer -Description $Description -RestorePointType 'MODIFY_SETTINGS' -ErrorAction Stop Write-LxsOk "Restore point created: $Description" return $true } catch { Write-LxsErr "Restore point failed: $($_.Exception.Message)" return $false } } # Write a registry value, creating the key path when it does not exist. function Set-LxsRegistryValue { param( [Parameter(Mandatory = $true)][string]$Path, [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)]$Value, [ValidateSet('String', 'ExpandString', 'Binary', 'DWord', 'MultiString', 'QWord')] [string]$Type = 'DWord' ) try { if (-not (Test-Path $Path)) { New-Item -Path $Path -Force -ErrorAction Stop | Out-Null } New-ItemProperty -Path $Path -Name $Name -Value $Value -PropertyType $Type -Force -ErrorAction Stop | Out-Null return $true } catch { Write-LxsErr "Registry write failed ($Path\$Name): $($_.Exception.Message)" return $false } } # Append a directory to the *user* PATH (no admin needed). Idempotent. function Add-LxsUserPath { param([Parameter(Mandatory = $true)][string]$Directory) $current = [Environment]::GetEnvironmentVariable('Path', 'User') if ($null -eq $current) { $current = '' } # Windows paths are case-insensitive and a trailing backslash is # meaningless, so compare normalized - otherwise every run appends a # near-duplicate entry. $normalized = $Directory.TrimEnd('\') foreach ($entry in ($current -split ';')) { if ($entry -and ($entry.Trim().TrimEnd('\') -ieq $normalized)) { return $false } } $updated = if ($current.TrimEnd(';')) { "$($current.TrimEnd(';'));$Directory" } else { $Directory } [Environment]::SetEnvironmentVariable('Path', $updated, 'User') $env:Path = "$env:Path;$Directory" return $true } # Run a sibling script from the same directory. Prefers the local file # (installed layout or repo checkout), falls back to the remote raw URL. # Mirrors run_sibling() in linux/tools/index.sh. function Invoke-LxsSibling { param( [Parameter(Mandatory = $true)][string]$RelativePath, # e.g. tools\system-info.ps1 [string[]]$Arguments = @(), [string]$SelfDirectory = '' ) $name = Split-Path $RelativePath -Leaf if (-not $SelfDirectory) { $SelfDirectory = $PSScriptRoot } $local = Join-Path $SelfDirectory $name if (Test-Path $local) { $global:LASTEXITCODE = 0 # Out-Host, not a bare call: anything the child writes to the success # stream (native command output, for one) would otherwise be collected # into this function's return value alongside the exit code. & $local @Arguments | Out-Host return $LASTEXITCODE } $base = $env:LXS_RAW_PLATFORM_BASE if (-not $base) { $base = 'https://git.hyko.cx/hykocx/lxs/raw/branch/main/windows' } $url = "$base/$($RelativePath -replace '\\', '/')" $temp = Join-Path (Get-LxsTempDir) ("lxs.{0}.{1}.ps1" -f [IO.Path]::GetFileNameWithoutExtension($name), [guid]::NewGuid().ToString('N').Substring(0, 8)) Write-LxsInfo "Fetching $($script:Bold)$name$($script:NC)..." try { Invoke-WebRequest -Uri $url -OutFile $temp -UseBasicParsing -Headers @{ 'Cache-Control' = 'no-cache' } -ErrorAction Stop } catch { Write-LxsErr "Failed to download $RelativePath" Write-Host "$($script:Gray) URL: $url$($script:NC)" return 1 } Write-LxsOk 'Payload acquired' try { $global:LASTEXITCODE = 0 & $temp @Arguments | Out-Host return $LASTEXITCODE } finally { Remove-Item $temp -Force -ErrorAction SilentlyContinue } }