778 lines
31 KiB
PowerShell
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
|
|
}
|
|
}
|