Files
lxs/windows/lib/common.ps1
T

778 lines
31 KiB
PowerShell

<#
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
}
}