# Toolbox 1.0.0 - built 2026-08-21 19:17 # Generated by build.ps1 - edit src\*.ps1 instead. #region 00-Preflight.ps1 # ============================================================================= # Toolbox :: preflight (run mode, elevation, apartment state, globals) # ============================================================================= $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $Toolbox = @{ Name = 'Toolbox' Version = '1.0.0' BuildDate = '2026-08-21' BaseUrl = 'https://tools.romansafranko.com'.TrimEnd('/') DataDir = (Join-Path $env:ProgramData 'Toolbox') Manifest = $null } # Local development override: $env:TOOLBOX_BASE = 'http://localhost:8080' if ($env:TOOLBOX_BASE) { $Toolbox.BaseUrl = $env:TOOLBOX_BASE.TrimEnd('/') } $Toolbox.BackupDir = Join-Path $Toolbox.DataDir 'backups' $Toolbox.LogFile = Join-Path $Toolbox.DataDir ('toolbox-{0}.log' -f (Get-Date -Format 'yyyy-MM-dd')) # --- run mode ---------------------------------------------------------------- # iex cannot take parameters, so the one-liner is configured with environment # variables instead: # # $env:TOOLBOX_PRESET='dev'; irm https://host | iex install a preset, no GUI # $env:TOOLBOX_APPS='vscode,git'; irm https://host | iex # $env:TOOLBOX_LIST='1'; irm https://host | iex print every id and stop # $env:TOOLBOX_LIST='short' just the names, grouped # $env:TOOLBOX_DRYRUN='1' log actions, change nothing # $env:TOOLBOX_ACTION='uninstall' reverse instead of apply $Toolbox.Preset = $env:TOOLBOX_PRESET $Toolbox.AppIds = $env:TOOLBOX_APPS $Toolbox.TweakIds = $env:TOOLBOX_TWEAKS $Toolbox.ScriptIds= $env:TOOLBOX_SCRIPTS $Toolbox.Action = if ($env:TOOLBOX_ACTION) { $env:TOOLBOX_ACTION.ToLower() } else { 'install' } $Toolbox.DryRun = ($env:TOOLBOX_DRYRUN -eq '1') $Toolbox.KeepOpen = ($env:TOOLBOX_KEEPOPEN -eq '1') # '1' or 'full' = ids, categories and install state; 'short' = names only. $Toolbox.ListStyle = if ($env:TOOLBOX_LIST) { $env:TOOLBOX_LIST.ToLower() } else { '' } $Toolbox.Mode = if ($Toolbox.ListStyle -and $Toolbox.ListStyle -ne '0') { 'list' } elseif ($Toolbox.Preset -or $Toolbox.AppIds -or $Toolbox.TweakIds -or $Toolbox.ScriptIds) { 'headless' } else { 'gui' } function Test-ToolboxAdmin { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator) } function Test-OneShotHost { # True when this host was started to run one command and quit # (powershell -Command / -File), false at an interactive prompt. Calling # exit in an interactive session would close the user's terminal. [Environment]::CommandLine -match '\s-(?:c|f|e|ec|Command|File|EncodedCommand)\b' } function Exit-Toolbox { param([int] $Code = 0) $global:LASTEXITCODE = $Code if (Test-OneShotHost) { exit $Code } } function Get-EnvCarryOver { # Start-Process -Verb RunAs goes through ShellExecute, and the elevated # child gets a FRESH environment block -- nothing set in the calling shell # survives. So every TOOLBOX_* variable is re-emitted into the command the # elevated process runs. $seen = @{} $lines = @("`$env:TOOLBOX_BASE='$($Toolbox.BaseUrl)'") $seen['TOOLBOX_BASE'] = $true foreach ($item in Get-ChildItem Env: | Where-Object { $_.Name -like 'TOOLBOX_*' }) { if ($seen[$item.Name]) { continue } $value = ('' + $item.Value).Replace("'", "''") $lines += "`$env:$($item.Name)='$value'" $seen[$item.Name] = $true } # A relaunched headless run owns its console window, so hold it open at the # end -- otherwise the window closes and takes the output with it. if ($Toolbox.Mode -ne 'gui' -and -not $seen['TOOLBOX_KEEPOPEN']) { $lines += "`$env:TOOLBOX_KEEPOPEN='1'" } $lines -join '; ' } function Restart-ToolboxElevated { # Re-launch the same entry point in an elevated PowerShell 5.1 host. $prefix = Get-EnvCarryOver if ($PSCommandPath) { $inner = "$prefix; & '$PSCommandPath'" } else { $inner = "$prefix; irm '$($Toolbox.BaseUrl)' | iex" } $exe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $sta = if ($Toolbox.Mode -eq 'gui') { ' -STA' } else { '' } $argl = "-NoProfile -ExecutionPolicy Bypass$sta -NoLogo -Command `"$($inner.Replace('"', '\"'))`"" try { if ($Toolbox.Mode -eq 'gui') { Start-Process -FilePath $exe -ArgumentList $argl -Verb RunAs | Out-Null return 0 } # Headless: wait, so the caller gets a real exit code back. $proc = Start-Process -FilePath $exe -ArgumentList $argl -Verb RunAs -PassThru -Wait return $proc.ExitCode } catch { Write-Host 'Elevation was cancelled. Toolbox needs Administrator to install software.' -ForegroundColor Yellow return 1 } } if ([Environment]::OSVersion.Platform -ne 'Win32NT') { Write-Host 'Toolbox only runs on Windows.' -ForegroundColor Red return } # Listing the catalog changes nothing, and a dry run does not either, so # neither needs Administrator. Only the GUI needs an STA thread. $needsElevation = ($Toolbox.Mode -ne 'list') -and (-not $Toolbox.DryRun) -and (-not (Test-ToolboxAdmin)) $needsSta = ($Toolbox.Mode -eq 'gui') -and ([Threading.Thread]::CurrentThread.GetApartmentState() -ne 'STA') if ($needsElevation -or $needsSta) { $why = @() if ($needsElevation) { $why += 'Administrator' } if ($needsSta) { $why += 'STA thread (WPF)' } Write-Host ("Relaunching Toolbox: needs {0}..." -f ($why -join ' + ')) -ForegroundColor Cyan $code = Restart-ToolboxElevated Exit-Toolbox $code return } New-Item -ItemType Directory -Path $Toolbox.DataDir -Force | Out-Null New-Item -ItemType Directory -Path $Toolbox.BackupDir -Force | Out-Null #endregion #region 10-Log.ps1 # ============================================================================= # Toolbox :: shared state + logging # ============================================================================= # $Sync is shared between the UI thread and the background worker runspace. # The worker never touches WPF objects -- it only enqueues log lines and # updates plain values that a DispatcherTimer on the UI thread polls. $Sync = [hashtable]::Synchronized(@{ LogQueue = (New-Object 'System.Collections.Concurrent.ConcurrentQueue[string]') Busy = $false Cancel = $false Progress = 0 Total = 0 Done = 0 Failed = 0 Status = 'Idle' LogFile = $Toolbox.LogFile }) function Write-Log { param( [Parameter(Mandatory)][string] $Message, [ValidateSet('info', 'ok', 'warn', 'err', 'step', 'dim')][string] $Level = 'info' ) $stamp = Get-Date -Format 'HH:mm:ss' $Sync.LogQueue.Enqueue("$Level|[$stamp] $Message") try { Add-Content -LiteralPath $Sync.LogFile -Value "[$stamp][$Level] $Message" -Encoding UTF8 } catch { } } function Set-Status { param([string] $Text) $Sync.Status = $Text } #endregion #region 20-Util.ps1 # ============================================================================= # Toolbox :: small utilities (used by both UI and worker) # ============================================================================= function Test-Cmd { param([Parameter(Mandatory)][string] $Name) [bool](Get-Command $Name -CommandType Application, Cmdlet, Function -ErrorAction SilentlyContinue) } function Remove-Bom { <# A UTF-8 BOM reaches us one of two ways: as U+FEFF when the server declared a utf-8 charset, or as the three characters its raw bytes decode to under latin-1 when it did not. Either one breaks ConvertFrom-Json and iex, so strip both. #> param([string] $Text) if (-not $Text) { return $Text } if ($Text[0] -eq [char]0xFEFF) { return $Text.Substring(1) } if ($Text.Length -ge 3 -and [int][char]$Text[0] -eq 239 -and [int][char]$Text[1] -eq 187 -and [int][char]$Text[2] -eq 191) { return $Text.Substring(3) } $Text } function Get-RemoteText { <# Fetches a text resource relative to the Toolbox base URL (or absolute). A BaseUrl that is not http(s) is treated as a directory and read off disk, so the catalog can be served from a folder -- dist\ during development, or a folder mapped into a VM -- with no web server. #> param([Parameter(Mandatory)][string] $Path) if ($Toolbox.BaseUrl -notmatch '^https?://' -and $Path -notmatch '^https?://') { return (Remove-Bom (Get-Content -LiteralPath (Join-Path $Toolbox.BaseUrl ($Path -replace '/', '\')) -Raw)) } $url = if ($Path -match '^https?://') { $Path } else { '{0}/{1}' -f $Toolbox.BaseUrl, $Path.TrimStart('/') } $url = '{0}{1}cb={2}' -f $url, $(if ($url.Contains('?')) { '&' } else { '?' }), [DateTime]::UtcNow.Ticks Remove-Bom (Invoke-WebRequest -Uri $url -UseBasicParsing -TimeoutSec 60).Content } function Invoke-Cli { <# Runs a console tool, streams its output into the log, returns the exit code. Output is redirected to temp files so large output can never deadlock us. #> param( [Parameter(Mandatory)][string] $FilePath, [string] $Arguments = '', [int[]] $SuccessCodes = @(0), [switch] $Quiet ) $out = [IO.Path]::GetTempFileName() $err = [IO.Path]::GetTempFileName() try { $p = Start-Process -FilePath $FilePath -ArgumentList $Arguments -NoNewWindow -Wait -PassThru ` -RedirectStandardOutput $out -RedirectStandardError $err $code = $p.ExitCode if (-not $Quiet) { foreach ($file in @($out, $err)) { Get-Content -LiteralPath $file -ErrorAction SilentlyContinue | Where-Object { $_ -and $_.Trim() } | Select-Object -Last 12 | ForEach-Object { Write-Log " $_" 'dim' } } } return [pscustomobject]@{ ExitCode = $code Success = ($SuccessCodes -contains $code) Output = ((Get-Content -LiteralPath $out -Raw -ErrorAction SilentlyContinue) + "`n" + (Get-Content -LiteralPath $err -Raw -ErrorAction SilentlyContinue)) } } finally { Remove-Item $out, $err -Force -ErrorAction SilentlyContinue } } function ConvertTo-Hashtable { # PS 5.1 has no -AsHashtable on ConvertFrom-Json. # NOTE: -is [psobject] is true for strings and numbers too, so the object # test has to be an exact type-name match or every string in the catalog # gets turned into a hashtable of its properties. param($InputObject) if ($null -eq $InputObject) { return $null } if ($InputObject.GetType().FullName -eq 'System.Management.Automation.PSCustomObject') { $ht = @{} foreach ($prop in $InputObject.PSObject.Properties) { $ht[$prop.Name] = ConvertTo-Hashtable $prop.Value } return $ht } if ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { # the leading comma stops a one-element list collapsing into a scalar return , @($InputObject | ForEach-Object { ConvertTo-Hashtable $_ }) } $InputObject } function Get-ItemProp { <# Safe property read off a hashtable-backed catalog item. #> param($Item, [string] $Name, $Default = $null) if ($Item -is [hashtable] -and $Item.ContainsKey($Name) -and $null -ne $Item[$Name]) { return $Item[$Name] } return $Default } #endregion #region 30-Manifest.ps1 # ============================================================================= # Toolbox :: manifest loading # ============================================================================= function Get-ToolboxManifest { Write-Host ("Fetching catalog from {0} ..." -f $Toolbox.BaseUrl) -ForegroundColor DarkGray $raw = Get-RemoteText 'manifest.json' $data = ConvertTo-Hashtable ($raw | ConvertFrom-Json) foreach ($key in 'apps', 'tweaks', 'scripts', 'presets') { if (-not $data.ContainsKey($key) -or $null -eq $data[$key]) { $data[$key] = @() } $data[$key] = @($data[$key]) } $data } function Group-ByCategory { # Returns an ordered list of @{ Name; Items }, categories A-Z with 'Other' # last. Deliberately NOT an [ordered]@{}: PowerShell resolves a string index # on OrderedDictionary against its int overload and throws. param([object[]] $Items) $names = @($Items | ForEach-Object { Get-ItemProp $_ 'category' 'Other' } | Select-Object -Unique | Sort-Object) $ordered = @($names | Where-Object { $_ -ne 'Other' }) + @($names | Where-Object { $_ -eq 'Other' }) $groups = @() foreach ($name in $ordered) { $groups += @{ Name = $name Items = @($Items | Where-Object { (Get-ItemProp $_ 'category' 'Other') -eq $name } | Sort-Object { Get-ItemProp $_ 'name' '' }) } } , $groups } #endregion #region 35-Installed.ps1 # ============================================================================= # Toolbox :: installed-app detection # ============================================================================= # One pass over Add/Remove Programs, then every catalog app is matched against # it. An app can override the match with a "detect" block: # # "detect": { "name": "^Node" } regex over the ARP name # "detect": { "command": "wt" } anything on PATH # "detect": { "path": "%ProgramFiles%/Foo/foo.exe" } # "detect": { "appx": "Microsoft.WindowsTerminal" } MSIX/Store, absent from ARP # # Without one, the app's own name is matched as a WHOLE WORD, so "Git" does not # report itself installed because "GitHub Desktop" is present. function Get-InstalledIndex { $keys = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $found = New-Object Collections.ArrayList foreach ($key in $keys) { foreach ($entry in (Get-ItemProperty -Path $key -ErrorAction SilentlyContinue)) { if (-not $entry.DisplayName) { continue } if ($entry.SystemComponent -eq 1) { continue } # updates, redists, plumbing [void]$found.Add([pscustomobject]@{ Name = ('' + $entry.DisplayName) Version = ('' + $entry.DisplayVersion) }) } } , $found } function Test-AppInstalled { param([hashtable] $App, $Index) $detect = Get-ItemProp $App 'detect' $null $cmd = Get-ItemProp $detect 'command' $null if ($cmd -and (Get-Command $cmd -ErrorAction SilentlyContinue)) { return @{ Installed = $true; Version = '' } } $file = Get-ItemProp $detect 'path' $null if ($file -and (Test-Path ([Environment]::ExpandEnvironmentVariables($file)) -ErrorAction SilentlyContinue)) { return @{ Installed = $true; Version = '' } } # Only pay for the Appx query when an entry actually asks for it. $appx = Get-ItemProp $detect 'appx' $null if ($appx) { $pkg = Get-AppxPackage -Name $appx -ErrorAction SilentlyContinue | Select-Object -First 1 if ($pkg) { return @{ Installed = $true; Version = ('' + $pkg.Version) } } } $pattern = Get-ItemProp $detect 'name' $null if (-not $pattern) { $name = '' + (Get-ItemProp $App 'name' '') if (-not $name) { return @{ Installed = $false; Version = '' } } $pattern = '\b' + [regex]::Escape($name) + '\b' } $hit = $Index | Where-Object { $_.Name -match $pattern } | Select-Object -First 1 if ($hit) { return @{ Installed = $true; Version = $hit.Version } } @{ Installed = $false; Version = '' } } function Update-InstalledState { <# Annotates every app in the manifest with 'installed' and 'installedVersion'. Returns a small summary for the caller to log. #> $watch = [Diagnostics.Stopwatch]::StartNew() $index = Get-InstalledIndex $apps = @($Toolbox.Manifest.apps) $count = 0 foreach ($app in $apps) { $state = Test-AppInstalled -App $app -Index $index $app['installed'] = $state.Installed $app['installedVersion'] = $state.Version if ($state.Installed) { $count++ } } $watch.Stop() @{ Installed = $count; Total = $apps.Count; Ms = $watch.ElapsedMilliseconds; Entries = @($index).Count } } #endregion #region 40-Providers.ps1 # ============================================================================= # Toolbox :: package-manager providers # ============================================================================= # Each provider exposes Install/Uninstall plus a bootstrap that installs the # manager itself on first use. To add a provider: write the two functions and # register the type name in Invoke-InstallStep (50-Actions.ps1). # --- winget ------------------------------------------------------------------ # winget returns HRESULT-style codes; these all mean "nothing to do, that's ok". $WingetOk = @(0, -1978335189, -1978335153, -1978335212, -1978334967, -1978335135) function Get-WingetPath { $cmd = Get-Command winget.exe -ErrorAction SilentlyContinue if ($cmd) { return $cmd.Source } # Elevated sessions sometimes lose the WindowsApps alias from PATH. $hit = Get-ChildItem "$env:ProgramFiles\WindowsApps" -Filter winget.exe -Recurse -ErrorAction SilentlyContinue | Sort-Object FullName -Descending | Select-Object -First 1 if ($hit) { return $hit.FullName } $null } function Initialize-Winget { if (Get-WingetPath) { return $true } Write-Log 'winget not found - installing App Installer...' 'warn' try { $tmp = Join-Path $env:TEMP 'toolbox-winget' New-Item -ItemType Directory -Path $tmp -Force | Out-Null $urls = @( 'https://aka.ms/Microsoft.VCLibs.x64.14.00.Desktop.appx', 'https://github.com/microsoft/winget-cli/releases/latest/download/Microsoft.DesktopAppInstaller_8wekyb3d8bbwe.msixbundle' ) foreach ($u in $urls) { $dest = Join-Path $tmp ([IO.Path]::GetFileName(([Uri]$u).LocalPath)) Invoke-WebRequest -Uri $u -OutFile $dest -UseBasicParsing Add-AppxPackage -Path $dest -ErrorAction SilentlyContinue } Start-Sleep -Seconds 2 if (Get-WingetPath) { Write-Log 'winget installed.' 'ok'; return $true } } catch { Write-Log "winget bootstrap failed: $($_.Exception.Message)" 'err' } $false } function Install-ViaWinget { param([string] $Id, [string] $ExtraArgs = '') if (-not (Initialize-Winget)) { return $false } $argl = "install --id $Id --exact --silent --accept-source-agreements --accept-package-agreements --disable-interactivity $ExtraArgs" $r = Invoke-Cli -FilePath (Get-WingetPath) -Arguments $argl -SuccessCodes $WingetOk if (-not $r.Success -and $r.Output -match 'already installed|No newer package') { return $true } $r.Success } function Uninstall-ViaWinget { param([string] $Id, [string] $ExtraArgs = '') $winget = Get-WingetPath if (-not $winget) { return $false } $argl = "uninstall --id $Id --exact --silent --accept-source-agreements --disable-interactivity $ExtraArgs" (Invoke-Cli -FilePath $winget -Arguments $argl -SuccessCodes $WingetOk).Success } # --- chocolatey -------------------------------------------------------------- function Initialize-Choco { if (Test-Cmd 'choco') { return $true } Write-Log 'Chocolatey not found - installing...' 'warn' try { Set-ExecutionPolicy Bypass -Scope Process -Force Invoke-Expression ((New-Object Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) $env:Path = [Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [Environment]::GetEnvironmentVariable('Path', 'User') if (Test-Cmd 'choco') { Write-Log 'Chocolatey installed.' 'ok'; return $true } } catch { Write-Log "Chocolatey bootstrap failed: $($_.Exception.Message)" 'err' } $false } function Install-ViaChoco { param([string] $Id, [string] $ExtraArgs = '') if (-not (Initialize-Choco)) { return $false } (Invoke-Cli -FilePath 'choco' -Arguments "install $Id -y --no-progress --limit-output $ExtraArgs" -SuccessCodes @(0, 1641, 3010)).Success } function Uninstall-ViaChoco { param([string] $Id, [string] $ExtraArgs = '') if (-not (Test-Cmd 'choco')) { return $false } (Invoke-Cli -FilePath 'choco' -Arguments "uninstall $Id -y --no-progress --limit-output $ExtraArgs" -SuccessCodes @(0, 1641, 3010)).Success } # --- scoop ------------------------------------------------------------------- # Scoop is a per-user manager and refuses to run elevated unless told to. function Get-ScoopShim { $shim = Join-Path $env:USERPROFILE 'scoop\shims\scoop.cmd' if (Test-Path $shim) { return $shim } if (Test-Cmd 'scoop') { return (Get-Command scoop).Source } $null } function Initialize-Scoop { if (Get-ScoopShim) { return $true } Write-Log 'Scoop not found - installing (admin mode)...' 'warn' try { $installer = Invoke-RestMethod 'https://get.scoop.sh' Invoke-Expression "& { $installer } -RunAsAdmin" if (Get-ScoopShim) { Write-Log 'Scoop installed.' 'ok'; return $true } } catch { Write-Log "Scoop bootstrap failed: $($_.Exception.Message)" 'err' } $false } function Install-ViaScoop { param([string] $Id, [string] $ExtraArgs = '') if (-not (Initialize-Scoop)) { return $false } (Invoke-Cli -FilePath (Get-ScoopShim) -Arguments "install $Id $ExtraArgs").Success } function Uninstall-ViaScoop { param([string] $Id, [string] $ExtraArgs = '') $shim = Get-ScoopShim if (-not $shim) { return $false } (Invoke-Cli -FilePath $shim -Arguments "uninstall $Id $ExtraArgs").Success } # --- direct download (msi / exe / msix / appx) ------------------------------- function Install-ViaDownload { param([string] $Url, [string] $ExtraArgs = '', [string] $FileName) if (-not $FileName) { $FileName = [IO.Path]::GetFileName(([Uri]$Url).LocalPath) } if (-not $FileName) { $FileName = 'toolbox-download.exe' } $dest = Join-Path $env:TEMP $FileName Write-Log " downloading $Url" 'dim' Invoke-WebRequest -Uri $Url -OutFile $dest -UseBasicParsing switch ([IO.Path]::GetExtension($dest).ToLower()) { '.msi' { return (Invoke-Cli -FilePath 'msiexec.exe' -Arguments "/i `"$dest`" /qn /norestart $ExtraArgs" -SuccessCodes @(0, 1641, 3010)).Success } '.msix' { Add-AppxPackage -Path $dest; return $true } '.appx' { Add-AppxPackage -Path $dest; return $true } '.zip' { $target = if ($ExtraArgs) { $ExtraArgs } else { Join-Path $env:LOCALAPPDATA ('Toolbox\' + [IO.Path]::GetFileNameWithoutExtension($dest)) } Expand-Archive -LiteralPath $dest -DestinationPath $target -Force Write-Log " extracted to $target" 'dim' return $true } default { # NSIS/Inno silent switch is the most common default for bare .exe installers. if (-not $ExtraArgs) { $ExtraArgs = '/S' } return (Invoke-Cli -FilePath $dest -Arguments $ExtraArgs -SuccessCodes @(0, 1641, 3010)).Success } } } #endregion #region 50-Actions.ps1 # ============================================================================= # Toolbox :: catalog actions (apps / tweaks / scripts) # ============================================================================= function Invoke-InstallStep { param([hashtable] $Step) $type = ('' + (Get-ItemProp $Step 'type' 'winget')).ToLower() $id = Get-ItemProp $Step 'id' '' $args = Get-ItemProp $Step 'args' '' switch ($type) { 'winget' { return Install-ViaWinget -Id $id -ExtraArgs $args } 'choco' { return Install-ViaChoco -Id $id -ExtraArgs $args } 'scoop' { return Install-ViaScoop -Id $id -ExtraArgs $args } 'download' { return Install-ViaDownload -Url (Get-ItemProp $Step 'url' '') -ExtraArgs $args -FileName (Get-ItemProp $Step 'file' $null) } 'script' { return Invoke-Payload -Path (Get-ItemProp $Step 'path' '') } default { Write-Log "unknown install type '$type'" 'err'; return $false } } } function Invoke-UninstallStep { param([hashtable] $Step) $type = ('' + (Get-ItemProp $Step 'type' 'winget')).ToLower() $id = Get-ItemProp $Step 'id' '' $args = Get-ItemProp $Step 'args' '' switch ($type) { 'winget' { return Uninstall-ViaWinget -Id $id -ExtraArgs $args } 'choco' { return Uninstall-ViaChoco -Id $id -ExtraArgs $args } 'scoop' { return Uninstall-ViaScoop -Id $id -ExtraArgs $args } 'script' { return Invoke-Payload -Path (Get-ItemProp $Step 'path' '') } default { Write-Log "cannot uninstall via '$type'" 'warn'; return $false } } } function Invoke-Payload { # Runs a .ps1 hosted next to the catalog on your server. param([Parameter(Mandatory)][string] $Path) Write-Log " running payload $Path" 'dim' $code = Get-RemoteText $Path $sb = [scriptblock]::Create($code) & $sb $true } function Install-CatalogApp { param([hashtable] $App) $name = Get-ItemProp $App 'name' (Get-ItemProp $App 'id' '?') $steps = @(Get-ItemProp $App 'install' @()) if (-not $steps) { Write-Log "$name has no install steps" 'err'; return $false } Write-Log "Installing $name" 'step' foreach ($step in $steps) { $type = Get-ItemProp $step 'type' 'winget' try { if (Invoke-InstallStep $step) { Write-Log "$name installed (via $type)" 'ok'; return $true } Write-Log "$name : $type failed, trying next source" 'warn' } catch { Write-Log "$name : $type errored - $($_.Exception.Message)" 'warn' } } Write-Log "$name FAILED - every source exhausted" 'err' $false } function Uninstall-CatalogApp { param([hashtable] $App) $name = Get-ItemProp $App 'name' (Get-ItemProp $App 'id' '?') # Fall back to the install steps: winget/choco/scoop uninstall by the same id. $steps = @(Get-ItemProp $App 'uninstall' (Get-ItemProp $App 'install' @())) Write-Log "Removing $name" 'step' foreach ($step in $steps) { try { if (Invoke-UninstallStep $step) { Write-Log "$name removed" 'ok'; return $true } } catch { Write-Log "$name : $($_.Exception.Message)" 'warn' } } Write-Log "$name could not be removed automatically" 'err' $false } #endregion #region 55-Tweaks.ps1 # ============================================================================= # Toolbox :: tweaks (registry / services / scheduled tasks / scripts) # ============================================================================= # Every tweak that touches the registry or a service writes the previous state # to %ProgramData%\Toolbox\backups\.json first, so "Undo" works even for # tweaks that never declared an explicit undo value. function Get-TweakBackupPath { param([string] $Id) Join-Path $Toolbox.BackupDir ("$Id.json") } function Save-TweakBackup { param([string] $Id, [object[]] $Entries) if (-not $Entries) { return } $file = Get-TweakBackupPath $Id if (Test-Path $file) { return } # keep the ORIGINAL state, not the last one $Entries | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $file -Encoding UTF8 } function Get-TweakBackup { <# Reads the recorded pre-tweak state. NB: do NOT wrap this in @(). ConvertTo-Hashtable returns an array that is itself array-wrapped, so @() leaves the whole list sitting in element zero -- every entry then reads as a non-hashtable and Get-ItemProp hands back defaults, which is an empty registry path. Single-entry backups survive that by accident, which is how it reached a real machine. #> param([string] $Id) $file = Get-TweakBackupPath $Id if (-not (Test-Path $file)) { return @() } ConvertTo-Hashtable ((Get-Content -LiteralPath $file -Raw) | ConvertFrom-Json) } function Set-RegValue { param([string] $Path, [string] $Name, [string] $Type = 'DWord', $Value) if (-not (Test-Path $Path)) { New-Item -Path $Path -Force | Out-Null } New-ItemProperty -Path $Path -Name $Name -PropertyType $Type -Value $Value -Force | Out-Null } function Get-RegSnapshot { param([string] $Path, [string] $Name) $snap = @{ kind = 'registry'; path = $Path; name = $Name; existed = $false; value = $null; type = 'DWord' } try { $item = Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop $snap.existed = $true $snap.value = $item.$Name $key = Get-Item -Path $Path $snap.type = ('' + $key.GetValueKind($Name)) } catch { } $snap } function Set-CatalogTweak { param([hashtable] $Tweak) $id = Get-ItemProp $Tweak 'id' '?' $name = Get-ItemProp $Tweak 'name' $id Write-Log "Applying tweak: $name" 'step' $backup = @() foreach ($reg in @(Get-ItemProp $Tweak 'registry' @())) { $path = Get-ItemProp $reg 'path' '' $key = Get-ItemProp $reg 'name' '' $backup += Get-RegSnapshot -Path $path -Name $key Set-RegValue -Path $path -Name $key -Type (Get-ItemProp $reg 'type' 'DWord') -Value (Get-ItemProp $reg 'value' 0) Write-Log " reg: $path\$key = $(Get-ItemProp $reg 'value' 0)" 'dim' } foreach ($svc in @(Get-ItemProp $Tweak 'services' @())) { $svcName = Get-ItemProp $svc 'name' '' $target = Get-ItemProp $svc 'startup' 'Disabled' $current = Get-Service -Name $svcName -ErrorAction SilentlyContinue if (-not $current) { Write-Log " service $svcName not present, skipped" 'dim'; continue } $backup += @{ kind = 'service'; name = $svcName; startup = ('' + $current.StartType) } if ($target -eq 'Disabled') { Stop-Service -Name $svcName -Force -ErrorAction SilentlyContinue } Set-Service -Name $svcName -StartupType $target -ErrorAction SilentlyContinue Write-Log " service: $svcName -> $target" 'dim' } foreach ($task in @(Get-ItemProp $Tweak 'tasks' @())) { try { $backup += @{ kind = 'task'; name = $task } Disable-ScheduledTask -TaskPath ([IO.Path]::GetDirectoryName($task) + '\') ` -TaskName ([IO.Path]::GetFileName($task)) -ErrorAction Stop | Out-Null Write-Log " task disabled: $task" 'dim' } catch { Write-Log " task $task not found" 'dim' } } Save-TweakBackup -Id $id -Entries $backup $apply = Get-ItemProp $Tweak 'apply' $null if ($apply) { Invoke-Payload -Path $apply | Out-Null } Write-Log "$name applied" 'ok' $true } function Undo-CatalogTweak { param([hashtable] $Tweak) $id = Get-ItemProp $Tweak 'id' '?' $name = Get-ItemProp $Tweak 'name' $id Write-Log "Reverting tweak: $name" 'step' $file = Get-TweakBackupPath $id $restored = 0 if (Test-Path $file) { foreach ($entry in (Get-TweakBackup $id)) { switch (Get-ItemProp $entry 'kind' 'registry') { 'registry' { $path = Get-ItemProp $entry 'path' '' $key = Get-ItemProp $entry 'name' '' if (Get-ItemProp $entry 'existed' $false) { Set-RegValue -Path $path -Name $key -Type (Get-ItemProp $entry 'type' 'DWord') -Value (Get-ItemProp $entry 'value' 0) Write-Log " restored $path\$key" 'dim' } else { Remove-ItemProperty -Path $path -Name $key -Force -ErrorAction SilentlyContinue Write-Log " removed $path\$key" 'dim' } $restored++ } 'service' { $svc = Get-ItemProp $entry 'name' '' Set-Service -Name $svc -StartupType (Get-ItemProp $entry 'startup' 'Manual') -ErrorAction SilentlyContinue Write-Log " restored service $svc" 'dim' $restored++ } 'task' { $t = Get-ItemProp $entry 'name' '' try { Enable-ScheduledTask -TaskPath ([IO.Path]::GetDirectoryName($t) + '\') ` -TaskName ([IO.Path]::GetFileName($t)) -ErrorAction Stop | Out-Null Write-Log " re-enabled task $t" 'dim' $restored++ } catch { } } } } Remove-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue } # The payload runs LAST, mirroring apply (declarative changes first, payload # after), so a refresh-style payload -- restarting Explorer, say -- sees the # values that were just restored rather than the ones being reverted. It also # has to run for payload-only tweaks, which never write a backup file at all. $undo = Get-ItemProp $Tweak 'undo' $null if ($undo) { Invoke-Payload -Path $undo | Out-Null } if (-not $restored -and -not $undo) { # Never applied, or already reverted. A no-op, not a failure. Write-Log "$name : no backup recorded, nothing to restore" 'warn' return $true } Write-Log "$name reverted ($restored item(s))" 'ok' $true } function Invoke-CatalogScript { param([hashtable] $Script) $name = Get-ItemProp $Script 'name' (Get-ItemProp $Script 'id' '?') Write-Log "Running: $name" 'step' Invoke-Payload -Path (Get-ItemProp $Script 'run' '') | Out-Null Write-Log "$name finished" 'ok' $true } #endregion #region 60-Worker.ps1 # ============================================================================= # Toolbox :: background worker # ============================================================================= # All installing happens in a separate STA runspace so the window keeps # repainting. The worker touches no WPF object -- it writes to $Sync only. $WorkerFunctions = @( 'Write-Log', 'Set-Status', 'Test-Cmd', 'Get-RemoteText', 'Invoke-Cli', 'ConvertTo-Hashtable', 'Get-ItemProp', 'Remove-Bom', 'Get-WingetPath', 'Initialize-Winget', 'Install-ViaWinget', 'Uninstall-ViaWinget', 'Initialize-Choco', 'Install-ViaChoco', 'Uninstall-ViaChoco', 'Get-ScoopShim', 'Initialize-Scoop', 'Install-ViaScoop', 'Uninstall-ViaScoop', 'Install-ViaDownload', 'Invoke-InstallStep', 'Invoke-UninstallStep', 'Invoke-Payload', 'Install-CatalogApp', 'Uninstall-CatalogApp', 'Get-TweakBackupPath', 'Get-TweakBackup', 'Save-TweakBackup', 'Set-RegValue', 'Get-RegSnapshot', 'Set-CatalogTweak', 'Undo-CatalogTweak', 'Invoke-CatalogScript' ) function Get-WorkerPrelude { ($WorkerFunctions | ForEach-Object { "function $_ {`n$((Get-Command $_ -CommandType Function).Definition)`n}" }) -join "`n" } $WorkerBody = { $ErrorActionPreference = 'Continue' $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $total = @($Plan).Count $index = 0 $failed = 0 Write-Log "--- starting $total task(s) ---" 'step' foreach ($action in $Plan) { if ($Sync.Cancel) { Write-Log 'Cancelled by user.' 'warn'; break } $index++ $Sync.Done = $index $Sync.Progress = [int]((($index - 1) / [Math]::Max($total, 1)) * 100) $label = if ($action.Item -is [hashtable]) { $action.Item['name'] } else { '' } $Sync.Status = "[$index/$total] $label" if ($Toolbox.DryRun) { Write-Log ('[dry run] would {0}: {1}' -f $action.Kind, $label) 'warn' continue } try { $ok = switch ($action.Kind) { 'install' { Install-CatalogApp $action.Item } 'uninstall' { Uninstall-CatalogApp $action.Item } 'tweak' { Set-CatalogTweak $action.Item } 'untweak' { Undo-CatalogTweak $action.Item } 'script' { Invoke-CatalogScript $action.Item } default { Write-Log "unknown action '$($action.Kind)'" 'err'; $false } } if (-not $ok) { $failed++; $Sync.Failed = $failed } } catch { $failed++ $Sync.Failed = $failed Write-Log "$label : $($_.Exception.Message)" 'err' } } $Sync.Progress = 100 $Sync.Status = 'Done' $Sync.Failed = $failed if ($failed -gt 0) { Write-Log "--- finished, $failed of $total failed ---" 'warn' } else { Write-Log "--- finished, all $total succeeded ---" 'ok' } $Sync.Busy = $false } function Start-ToolboxPlan { param([object[]] $Plan) if (-not $Plan -or $Plan.Count -eq 0) { Write-Log 'Nothing selected.' 'warn'; return $false } if ($Sync.Busy) { Write-Log 'Already running.' 'warn'; return $false } $Sync.Busy = $true; $Sync.Cancel = $false; $Sync.Progress = 0 $Sync.Total = $Plan.Count; $Sync.Done = 0; $Sync.Failed = 0 $rs = [runspacefactory]::CreateRunspace() $rs.ApartmentState = 'STA' $rs.ThreadOptions = 'ReuseThread' $rs.Open() $rs.SessionStateProxy.SetVariable('Sync', $Sync) $rs.SessionStateProxy.SetVariable('Toolbox', $Toolbox) $rs.SessionStateProxy.SetVariable('Plan', $Plan) $rs.SessionStateProxy.SetVariable('WingetOk', $WingetOk) $ps = [powershell]::Create() $ps.Runspace = $rs [void]$ps.AddScript((Get-WorkerPrelude) + "`n" + $WorkerBody.ToString()) $Sync.Shell = $ps $Sync.Handle = $ps.BeginInvoke() $true } function Stop-ToolboxPlan { $Sync.Cancel = $true Write-Log 'Cancel requested - finishing the current task first...' 'warn' } #endregion #region 70-Xaml.ps1 # ============================================================================= # Toolbox :: WPF layout # ============================================================================= $XamlA = @' '@ $XamlB = @'