<# : Common Apps Manager Redirecting to the install instructions. #> #Requires -Version 5.1 <# .SYNOPSIS WPF GUI to install, uninstall, and update common applications via WinGet. .DESCRIPTION Dark-themed, winutil-style companion to Install-Common-Apps-x64.ps1. Check the apps you want, then click Install, Uninstall, or Update. All WinGet work runs in a background runspace so the window stays responsive, output streams into the log panel, and everything is also written to a log file under %TEMP%\Winget-App-Installer. .EXAMPLE powershell.exe -NoProfile -ExecutionPolicy Bypass -File ".\Install-Common-Apps-GUI.ps1" #> [CmdletBinding()] param() Set-StrictMode -Version Latest $ErrorActionPreference = "Continue" # --------------------------------------------------------------------------- # STA guard and assemblies (WPF requires a single-threaded apartment) # --------------------------------------------------------------------------- # NOTE: 'exit' would close the whole console when this script is run via # 'iex' (e.g. 'irm | iex' or 'gc script.ps1 -Raw | iex'), because there # is no script file scope to exit from. $PSCommandPath is empty in that case, # so each early-exit below uses 'return' instead when running under iex. if ([Threading.Thread]::CurrentThread.ApartmentState -ne "STA") { if ([string]::IsNullOrEmpty($PSCommandPath)) { Write-Warning "WPF requires an STA session. Start PowerShell without -MTA and run this again." return } Start-Process powershell.exe -ArgumentList @( "-NoProfile", "-ExecutionPolicy", "Bypass", "-STA", "-File", "`"$PSCommandPath`"" ) exit 0 } Add-Type -AssemblyName PresentationFramework Add-Type -AssemblyName PresentationCore Add-Type -AssemblyName WindowsBase # --------------------------------------------------------------------------- # System checks # --------------------------------------------------------------------------- if (-not [Environment]::Is64BitOperatingSystem) { [Windows.MessageBox]::Show( "This tool is intended for a 64-bit Windows system.", "Common Apps Manager", "OK", "Error" ) | Out-Null if ($PSCommandPath) { exit 1 } else { return } } $WingetCommand = Get-Command winget.exe -ErrorAction SilentlyContinue if (-not $WingetCommand) { [Windows.MessageBox]::Show( "WinGet was not found.`n`nInstall or update Microsoft App Installer, then run this tool again.", "Common Apps Manager", "OK", "Error" ) | Out-Null if ($PSCommandPath) { exit 1 } else { return } } $CurrentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent() $Principal = New-Object Security.Principal.WindowsPrincipal($CurrentIdentity) $IsAdministrator = $Principal.IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator ) # --------------------------------------------------------------------------- # Shared state between the UI thread and the background runspace # --------------------------------------------------------------------------- # Must be global: the dispatcher closures (.GetNewClosure()) resolve $sync # through their module scope straight to the global scope, skipping the # script scope. Script-scoped, $sync would be $null inside those closures # when the script is invoked as '.\script.ps1'. $global:sync = [Hashtable]::Synchronized(@{}) $sync.Winget = $WingetCommand.Source $sync.IsAdministrator = $IsAdministrator $sync.ProcessRunning = $false $sync.InstalledMap = [Hashtable]::Synchronized(@{}) $sync.AppControls = @{} $sync.CategoryHeaders = @{} $sync.CategoryPanels = @{} $sync.ColumnCount = 3 $sync.ButtonsVertical = $false $sync.CurrentTask = $null # Exit code reported by a package's own installer/uninstaller, scraped from the # WinGet output. WinGet wraps it in a generic code of its own, so this is the # only number that says what actually went wrong. Pre-created so strict mode # never sees a missing key. $sync.LastInstallerExit = $null # Answer from a MessageBox the background runspace raised through the dispatcher. $sync.ConfirmResult = "No" $LogDirectory = Join-Path $env:TEMP "Winget-App-Installer" New-Item -Path $LogDirectory -ItemType Directory -Force | Out-Null $sync.LogFile = Join-Path $LogDirectory ( "GUI-{0}.log" -f (Get-Date -Format "yyyy-MM-dd_HH-mm-ss") ) # =========================================================================== # EDIT YOUR APP LIST HERE # --------------------------------------------------------------------------- # Name : label shown in the UI # Id : exact WinGet package ID (winget search ), or the DISM # feature name when Type is "Feature" # Source : "winget" or "msstore" (ignored for Type = "Feature") # Category : group header in the UI (apps are grouped in list order) # DetectNames : registry DisplayName wildcard patterns for detection # AppxPattern : Appx/Store package name pattern, or $null # Type : optional. Omit for normal WinGet packages. Use "Feature" for # Windows optional features installed through DISM instead of # WinGet (these require Administrator). # =========================================================================== $sync.Apps = @( [PSCustomObject]@{ Name = "Google Chrome"; Id = "Google.Chrome"; Source = "winget"; Category = "Browsers"; DetectNames = @("Google Chrome*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Brave Browser"; Id = "Brave.Brave"; Source = "winget"; Category = "Browsers"; DetectNames = @("Brave*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Waterfox"; Id = "Waterfox.Waterfox"; Source = "winget"; Category = "Browsers"; DetectNames = @("Waterfox*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Zen Browser"; Id = "Zen-Team.Zen-Browser"; Source = "winget"; Category = "Browsers"; DetectNames = @("Zen Browser*"); AppxPattern = $null } [PSCustomObject]@{ Name = "LibreWolf"; Id = "LibreWolf.LibreWolf"; Source = "winget"; Category = "Browsers"; DetectNames = @("LibreWolf*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Helium"; Id = "ImputNet.Helium"; Source = "winget"; Category = "Browsers"; DetectNames = @("Helium*"); AppxPattern = $null } [PSCustomObject]@{ Name = "WhatsApp"; Id = "9NKSQGP7F2NH"; Source = "msstore"; Category = "Communication"; DetectNames = @("WhatsApp*"); AppxPattern = "*WhatsApp*" } [PSCustomObject]@{ Name = "Telegram Desktop"; Id = "Telegram.TelegramDesktop"; Source = "winget"; Category = "Communication"; DetectNames = @("Telegram Desktop*"); AppxPattern = $null } [PSCustomObject]@{ Name = "AnyDesk"; Id = "AnyDesk.AnyDesk"; Source = "winget"; Category = "Remote Access"; DetectNames = @("AnyDesk*"); AppxPattern = $null } [PSCustomObject]@{ Name = "UltraViewer"; Id = "DucFabulous.UltraViewer"; Source = "winget"; Category = "Remote Access"; DetectNames = @("UltraViewer*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Dropbox"; Id = "Dropbox.Dropbox"; Source = "winget"; Category = "Cloud Storage"; DetectNames = @("Dropbox*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Google Drive"; Id = "Google.GoogleDrive"; Source = "winget"; Category = "Cloud Storage"; DetectNames = @("Google Drive*"); AppxPattern = $null } [PSCustomObject]@{ Name = "pCloud Drive"; Id = "pCloudAG.pCloudDrive"; Source = "winget"; Category = "Cloud Storage"; DetectNames = @("pCloud*", "pCloud Drive*"); AppxPattern = $null } # Adobe: skip Reader when Acrobat Pro or another Acrobat install already # exists, avoiding Adobe error 1603 conflicts. [PSCustomObject]@{ Name = "Adobe Acrobat Reader 64-bit"; Id = "Adobe.Acrobat.Reader.64-bit"; Source = "winget"; Category = "Documents & Editors"; DetectNames = @("Adobe Acrobat*", "Adobe Acrobat Reader*", "Adobe Reader*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Simplenote"; Id = "Automattic.Simplenote"; Source = "winget"; Category = "Documents & Editors"; DetectNames = @("Simplenote*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Sublime Text 4"; Id = "SublimeHQ.SublimeText.4"; Source = "winget"; Category = "Documents & Editors"; DetectNames = @("Sublime Text*"); AppxPattern = $null } [PSCustomObject]@{ Name = "7-Zip"; Id = "7zip.7zip"; Source = "winget"; Category = "Utilities"; DetectNames = @("7-Zip*"); AppxPattern = $null } [PSCustomObject]@{ Name = "WinRAR"; Id = "RARLab.WinRAR"; Source = "winget"; Category = "Utilities"; DetectNames = @("WinRAR*"); AppxPattern = $null } [PSCustomObject]@{ Name = "Lightshot"; Id = "Skillbrains.Lightshot"; Source = "winget"; Category = "Utilities"; DetectNames = @("Lightshot*"); AppxPattern = $null } [PSCustomObject]@{ Name = "LocalSend"; Id = "LocalSend.LocalSend"; Source = "winget"; Category = "Utilities"; DetectNames = @("LocalSend*"); AppxPattern = "*LocalSend*" } [PSCustomObject]@{ Name = "Files"; Id = "FilesCommunity.Files"; Source = "winget"; Category = "Utilities"; DetectNames = @("Files*"); AppxPattern = "*Files*" } [PSCustomObject]@{ Name = "Windows Terminal"; Id = "Microsoft.WindowsTerminal"; Source = "winget"; Category = "Utilities"; DetectNames = @("Windows Terminal*"); AppxPattern = "*WindowsTerminal*" } # Windows optional features: installed with DISM, not WinGet. .NET 3.5 is # the classic prompt older apps trigger ("An app on your PC needs the # following Windows feature"). Requires Administrator and downloads from # Windows Update. [PSCustomObject]@{ Name = ".NET Framework 3.5 (2.0/3.0)"; Id = "NetFx3"; Source = "feature"; Category = "Windows Features"; DetectNames = @(); AppxPattern = $null; Type = "Feature" } ) # --------------------------------------------------------------------------- # Detection helpers (ported from Install-Common-Apps-x64.ps1) # --------------------------------------------------------------------------- function Get-UninstallEntries { $RegistryPaths = @( "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*", "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" ) foreach ($RegistryPath in $RegistryPaths) { $Items = Get-ItemProperty -Path $RegistryPath -ErrorAction SilentlyContinue foreach ($Item in $Items) { $DisplayNameProperty = $Item.PSObject.Properties["DisplayName"] if ( $null -ne $DisplayNameProperty -and -not [string]::IsNullOrWhiteSpace([string]$DisplayNameProperty.Value) ) { $Item } } } } function Test-InstalledByRegistryName { param( [Parameter(Mandatory = $true)] [string[]]$Patterns ) $Entries = @(Get-UninstallEntries) foreach ($Pattern in $Patterns) { foreach ($Entry in $Entries) { $DisplayNameProperty = $Entry.PSObject.Properties["DisplayName"] if ( $null -ne $DisplayNameProperty -and [string]$DisplayNameProperty.Value -like $Pattern ) { return $true } } } return $false } function Get-AppType { param( [Parameter(Mandatory = $true)] [PSCustomObject]$App ) # Catalog entries omit Type for ordinary WinGet packages, and Set-StrictMode # makes a direct $App.Type read throw when the property is absent. $TypeProperty = $App.PSObject.Properties["Type"] if ($null -ne $TypeProperty -and -not [string]::IsNullOrWhiteSpace([string]$TypeProperty.Value)) { return [string]$TypeProperty.Value } return "Winget" } function Test-FeatureEnabled { param( [Parameter(Mandatory = $true)] [string]$FeatureName ) # Preferred check, but DISM online queries need elevation. try { $Feature = Get-WindowsOptionalFeature -Online -FeatureName $FeatureName -ErrorAction Stop return ($Feature.State -eq "Enabled") } catch { # Non-elevated fallback for .NET 3.5 specifically: the NDP registry key # is readable by standard users. if ($FeatureName -eq "NetFx3") { try { $Ndp = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\NET Framework Setup\NDP\v3.5" -ErrorAction Stop $InstallProperty = $Ndp.PSObject.Properties["Install"] if ($null -ne $InstallProperty -and [int]$InstallProperty.Value -eq 1) { return $true } } catch { # Key absent means the feature is not enabled. } } return $false } } function Test-InstalledByAppx { param( [AllowNull()] [string]$Pattern ) if ([string]::IsNullOrWhiteSpace($Pattern)) { return $false } try { return $null -ne ( Get-AppxPackage -Name $Pattern -ErrorAction SilentlyContinue | Select-Object -First 1 ) } catch { return $false } } # --------------------------------------------------------------------------- # GUI helpers (safe to call from the background runspace) # --------------------------------------------------------------------------- function Write-GuiLog { param( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string]$Message, [switch]$NoTimestamp ) if ($NoTimestamp) { $Line = $Message } else { $Line = "[{0}] {1}" -f (Get-Date -Format "HH:mm:ss"), $Message } try { Add-Content -Path $sync.LogFile -Value $Line -Encoding UTF8 -ErrorAction SilentlyContinue } catch { # File logging is best effort; the UI log panel still gets the line. } $sync.Form.Dispatcher.Invoke([action]{ $sync.LogBox.AppendText($Line + [Environment]::NewLine) $sync.LogBox.ScrollToEnd() }.GetNewClosure()) } function Set-UiBusy { param( [Parameter(Mandatory = $true)] [bool]$Busy, [string]$Status = "" ) $sync.ProcessRunning = $Busy $sync.Form.Dispatcher.Invoke([action]{ foreach ($ButtonName in @("InstallBtn", "UninstallBtn", "UpdateBtn", "UpdateAllBtn", "RefreshBtn", "CleanBtn")) { $sync[$ButtonName].IsEnabled = -not $Busy } if (-not [string]::IsNullOrEmpty($Status)) { $sync.StatusText.Text = $Status } }.GetNewClosure()) } function Set-AppStatus { param( [Parameter(Mandatory = $true)] [string]$Id, [Parameter(Mandatory = $true)] [ValidateSet("Installed", "NotInstalled", "Checking", "Working")] [string]$State ) if ($State -eq "Installed") { $sync.InstalledMap[$Id] = $true } if ($State -eq "NotInstalled") { $sync.InstalledMap[$Id] = $false } $sync.Form.Dispatcher.Invoke([action]{ $StatusControl = $sync.AppControls[$Id].Status switch ($State) { "Installed" { $StatusControl.Text = [char]0x25CF + " Installed" $StatusControl.Foreground = $sync.Brushes.Green } "NotInstalled" { $StatusControl.Text = [char]0x25CB + " Not installed" $StatusControl.Foreground = $sync.Brushes.Gray } "Checking" { $StatusControl.Text = "checking..." $StatusControl.Foreground = $sync.Brushes.Gray } "Working" { $StatusControl.Text = "working..." $StatusControl.Foreground = $sync.Brushes.Yellow } } }.GetNewClosure()) } # --------------------------------------------------------------------------- # Package workers (run inside the background runspace) # --------------------------------------------------------------------------- function Invoke-ProcessStreaming { param( [Parameter(Mandatory = $true)] [string]$FilePath, [Parameter(Mandatory = $true)] [string[]]$Arguments ) $LastLine = "" $sync.LastInstallerExit = $null & $FilePath @Arguments 2>&1 | ForEach-Object { $Line = $_.ToString() # Strip backspaces, carriage returns, and ANSI escape sequences used by # winget's progress spinner and DISM's progress bar. $Line = $Line -replace "`b", "" -replace "`r", "" $Line = $Line -replace "\x1b\[[0-9;]*[A-Za-z]", "" $Line = $Line.Trim() if ($Line -eq "") { return } # WinGet reports the package's own failure as e.g. "Uninstall failed # with exit code: 15" and then returns a generic code of its own. Keep # the inner number so the summary can name the real cause. if ($Line -match "exit code:\s*(-?\d+)") { $sync.LastInstallerExit = $Matches[1] } if ($Line -match "^[\-\\\|\/\s]+$") { return } # U+2588/2592/2591 are the block characters winget draws its progress # bar with. Written as regex escapes so this file stays pure ASCII and # survives being served over HTTP for 'irm | iex'. if ($Line -match "^[\u2588\u2592\u2591\s]") { return } if ($Line -match "^\[=*\s*\d+([.,]\d+)?%") { return } if ($Line -eq $LastLine) { return } $LastLine = $Line Write-GuiLog " $Line" -NoTimestamp } return $LASTEXITCODE } function Invoke-WingetStreaming { param( [Parameter(Mandatory = $true)] [string[]]$Arguments ) return (Invoke-ProcessStreaming -FilePath $sync.Winget -Arguments $Arguments) } function Update-InstalledStatus { Set-UiBusy $true "Checking installed applications..." Write-GuiLog "Checking installed applications..." foreach ($App in $sync.Apps) { Set-AppStatus -Id $App.Id -State "Checking" } # One bulk 'winget list' call instead of one call per app: much faster. $ListOutput = "" try { $ListOutput = ( & $sync.Winget list --accept-source-agreements --disable-interactivity 2>$null ) -join "`n" } catch { Write-GuiLog "WARNING: 'winget list' failed; falling back to registry detection only." } foreach ($App in $sync.Apps) { $Installed = $false if ((Get-AppType -App $App) -eq "Feature") { $Installed = Test-FeatureEnabled -FeatureName $App.Id } elseif ($ListOutput -match ("(?im)(^|\s){0}(\s|$)" -f [regex]::Escape($App.Id))) { $Installed = $true } elseif (@($App.DetectNames).Count -gt 0 -and (Test-InstalledByRegistryName -Patterns $App.DetectNames)) { $Installed = $true } elseif (Test-InstalledByAppx -Pattern $App.AppxPattern) { $Installed = $true } if ($Installed) { Set-AppStatus -Id $App.Id -State "Installed" } else { Set-AppStatus -Id $App.Id -State "NotInstalled" } } $InstalledCount = @($sync.Apps | Where-Object { $sync.InstalledMap[$_.Id] }).Count Write-GuiLog "Status check complete: $InstalledCount of $($sync.Apps.Count) apps installed." Set-UiBusy $false "Ready" } function Invoke-AppAction { $Action = $sync.PendingAction $Apps = @($sync.PendingApps) Set-UiBusy $true "$Action in progress..." $Succeeded = New-Object System.Collections.Generic.List[string] $Skipped = New-Object System.Collections.Generic.List[string] $Failed = New-Object System.Collections.Generic.List[string] $ScopeConflict = $false foreach ($App in $Apps) { $AppType = Get-AppType -App $App Write-GuiLog ("-" * 60) -NoTimestamp Write-GuiLog "$Action : $($App.Name) [$($App.Id)] ($($App.Source))" Set-UiBusy $true "$Action : $($App.Name)" if ($Action -eq "Install" -and $sync.InstalledMap[$App.Id]) { Write-GuiLog "$($App.Name) is already installed. Skipping." $Skipped.Add($App.Name) continue } if ($Action -eq "Update" -and -not $sync.InstalledMap[$App.Id]) { Write-GuiLog "$($App.Name) is not installed. Skipping update." $Skipped.Add($App.Name) continue } # ---- Windows optional features take the DISM path, not WinGet ---- if ($AppType -eq "Feature") { if ($Action -eq "Update") { Write-GuiLog "$($App.Name) is a Windows feature and has no separate update. Skipping." $Skipped.Add($App.Name) continue } if (-not $sync.IsAdministrator) { Write-GuiLog "ERROR: $($App.Name) requires Administrator. Restart this tool as Administrator." $Failed.Add("$($App.Name) (needs Administrator)") continue } Set-AppStatus -Id $App.Id -State "Working" if ($Action -eq "Install") { Write-GuiLog "Enabling Windows feature $($App.Id) (may download from Windows Update; this can take a few minutes)..." $FeatureArguments = @( "/online", "/enable-feature", "/featurename:$($App.Id)", "/all", "/norestart", "/quiet" ) } else { Write-GuiLog "Disabling Windows feature $($App.Id)..." $FeatureArguments = @( "/online", "/disable-feature", "/featurename:$($App.Id)", "/norestart", "/quiet" ) } $FeatureExit = Invoke-ProcessStreaming -FilePath "$env:SystemRoot\System32\dism.exe" -Arguments $FeatureArguments # 3010 means success but a reboot is required to complete. if ($FeatureExit -eq 0 -or $FeatureExit -eq 3010) { if ($FeatureExit -eq 3010) { Write-GuiLog "$($App.Name) : $Action completed. A RESTART IS REQUIRED to finish." } else { Write-GuiLog "$($App.Name) : $Action completed successfully." } $Succeeded.Add($App.Name) if ($Action -eq "Uninstall") { Set-AppStatus -Id $App.Id -State "NotInstalled" } else { Set-AppStatus -Id $App.Id -State "Installed" } } else { Write-GuiLog "WARNING: $($App.Name) failed with DISM exit code $FeatureExit." if ($App.Id -eq "NetFx3") { Write-GuiLog "NetFx3 note: if Windows Update is blocked by policy, enable it from Windows installation media instead:" Write-GuiLog " dism /online /enable-feature /featurename:NetFx3 /all /source:D:\sources\sxs /limitaccess" } $Failed.Add("$($App.Name) (DISM exit code $FeatureExit)") if ($sync.InstalledMap[$App.Id]) { Set-AppStatus -Id $App.Id -State "Installed" } else { Set-AppStatus -Id $App.Id -State "NotInstalled" } } continue } Set-AppStatus -Id $App.Id -State "Working" switch ($Action) { "Install" { $Arguments = @( "install", "--exact", "--id", $App.Id, "--source", $App.Source, "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity", "--silent" ) } "Uninstall" { # No --source here: uninstall targets the installed package. $Arguments = @( "uninstall", "--exact", "--id", $App.Id, "--accept-source-agreements", "--disable-interactivity", "--silent" ) } "Update" { $Arguments = @( "upgrade", "--exact", "--id", $App.Id, "--source", $App.Source, "--accept-package-agreements", "--accept-source-agreements", "--disable-interactivity", "--silent" ) } } $ExitCode = Invoke-WingetStreaming -Arguments $Arguments if ($ExitCode -eq 0) { Write-GuiLog "$($App.Name) : $Action completed successfully." $Succeeded.Add($App.Name) if ($Action -eq "Uninstall") { Set-AppStatus -Id $App.Id -State "NotInstalled" } else { Set-AppStatus -Id $App.Id -State "Installed" } } elseif ($ExitCode -eq -1978334963) { # MSI 1638: another version of this application is already installed. Write-GuiLog "$($App.Name) is already installed in another version. Skipping." $Skipped.Add($App.Name) Set-AppStatus -Id $App.Id -State "Installed" } elseif ($ExitCode -eq -1978335189) { # 0x8A15002B: no applicable update found. Write-GuiLog "$($App.Name) is already up to date." $Skipped.Add($App.Name) Set-AppStatus -Id $App.Id -State "Installed" } elseif ($ExitCode -eq -1978335107) { # 0x8A15007D: the package lives in this user's own profile, and an # elevated process runs as Administrator rather than as that user, # so WinGet refuses to touch it. Elevation is the cause, not the cure. Write-GuiLog "ERROR: $($App.Name) is installed for your user account, so WinGet will not $($Action.ToLower()) it from an Administrator session." $Failed.Add("$($App.Name) (needs a non-Administrator session)") $ScopeConflict = $true Set-AppStatus -Id $App.Id -State "Installed" } elseif ($ExitCode -eq -1978335184) { # 0x8A150030: WinGet handed off to the package's own uninstaller and # that failed. WinGet's code is the same every time, so the useful # number is the inner one scraped by Invoke-ProcessStreaming. if ($sync.LastInstallerExit) { Write-GuiLog "WARNING: $($App.Name) could not be removed - its own uninstaller returned exit code $($sync.LastInstallerExit)." } else { Write-GuiLog "WARNING: $($App.Name) could not be removed - its own uninstaller failed. See the lines above for its message." } Write-GuiLog " If the app no longer starts and its folder is gone, it was already removed and only a leftover" Write-GuiLog " Add/Remove Programs entry remains. Reinstalling it, then uninstalling again, clears that up." $Failed.Add("$($App.Name) (uninstaller failed)") Set-AppStatus -Id $App.Id -State "Installed" } else { Write-GuiLog "WARNING: $($App.Name) failed with WinGet exit code $ExitCode." if ($App.Id -eq "Adobe.Acrobat.Reader.64-bit") { Write-GuiLog "Adobe note: error 1603 commonly indicates an existing or conflicting Acrobat installation." } $Failed.Add("$($App.Name) (exit code $ExitCode)") if ($sync.InstalledMap[$App.Id]) { Set-AppStatus -Id $App.Id -State "Installed" } else { Set-AppStatus -Id $App.Id -State "NotInstalled" } } } Write-GuiLog ("=" * 60) -NoTimestamp Write-GuiLog "$Action summary - Succeeded: $($Succeeded.Count), Skipped: $($Skipped.Count), Failed: $($Failed.Count)" if ($Succeeded.Count -gt 0) { Write-GuiLog (" + " + ($Succeeded -join ", ")) -NoTimestamp } if ($Skipped.Count -gt 0) { Write-GuiLog (" = " + ($Skipped -join ", ")) -NoTimestamp } if ($Failed.Count -gt 0) { Write-GuiLog (" - " + ($Failed -join ", ")) -NoTimestamp } if ($ScopeConflict) { Write-GuiLog "Close this tool, open PowerShell WITHOUT 'Run as administrator', start it again, and retry those apps." } Write-GuiLog "Log file: $($sync.LogFile)" Set-UiBusy $false "Ready" } # --------------------------------------------------------------------------- # Leftover cleanup (runs inside the background runspace) # # An app removed by hand, or by an installer that did not finish the job, often # leaves its Add/Remove Programs entry behind. WinGet still lists it, this tool # still shows it as installed, and uninstalling fails because the real # uninstaller is already gone. The entry has to be deleted directly. # # Deleting registry keys is not undoable, so the rule here is deliberately # strict: an entry is a leftover only when it names a location on disk and that # location no longer exists. An entry we cannot prove anything about is left # alone rather than guessed at. # --------------------------------------------------------------------------- function Get-EntryValue { param( [Parameter(Mandatory = $true)] $Entry, [Parameter(Mandatory = $true)] [string]$Name ) # Registry entries omit properties freely and Set-StrictMode makes a direct # read throw, so every value goes through here. $Property = $Entry.PSObject.Properties[$Name] if ($null -eq $Property -or $null -eq $Property.Value) { return "" } return [string]$Property.Value } function Get-UninstallExePath { param( [AllowNull()] [string]$UninstallString ) if ([string]::IsNullOrWhiteSpace($UninstallString)) { return "" } $Text = $UninstallString.Trim() if ($Text.StartsWith('"')) { $Closing = $Text.IndexOf('"', 1) if ($Closing -gt 1) { return $Text.Substring(1, $Closing - 1) } return "" } # MsiExec /X{GUID} names a product code rather than a file, and msiexec.exe # itself always exists, so it proves nothing either way. if ($Text -match "^(?i)msiexec") { return "" } if ($Text -match "^(?[A-Za-z]:\\[^`"]+?\.exe)") { return $Matches.Path } return "" } function Get-AppLeftovers { $Entries = @(Get-UninstallEntries) $Leftovers = New-Object System.Collections.Generic.List[object] $SeenKeys = New-Object System.Collections.Generic.List[string] foreach ($App in $sync.Apps) { # Windows features are not registered in Add/Remove Programs. if ((Get-AppType -App $App) -eq "Feature") { continue } if (@($App.DetectNames).Count -eq 0) { continue } foreach ($Entry in $Entries) { $DisplayName = Get-EntryValue -Entry $Entry -Name "DisplayName" $Matched = $false foreach ($Pattern in $App.DetectNames) { if ($DisplayName -like $Pattern) { $Matched = $true break } } if (-not $Matched) { continue } $KeyPath = Get-EntryValue -Entry $Entry -Name "PSPath" if ($KeyPath -eq "" -or $SeenKeys.Contains($KeyPath)) { continue } $InstallLocation = (Get-EntryValue -Entry $Entry -Name "InstallLocation").Trim('"').TrimEnd('\') $ExePath = Get-UninstallExePath -UninstallString (Get-EntryValue -Entry $Entry -Name "UninstallString") $ProbePaths = New-Object System.Collections.Generic.List[string] if ($InstallLocation -ne "") { $ProbePaths.Add($InstallLocation) } if ($ExePath -ne "") { $ProbePaths.Add($ExePath) } # Nothing on disk to check means nothing to prove. Leave it. if ($ProbePaths.Count -eq 0) { continue } $StillThere = $false foreach ($ProbePath in $ProbePaths) { if (Test-Path -LiteralPath $ProbePath) { $StillThere = $true break } } if ($StillThere) { continue } $SeenKeys.Add($KeyPath) $Leftovers.Add([PSCustomObject]@{ Name = $App.Name DisplayName = $DisplayName KeyPath = $KeyPath InstallLocation = $InstallLocation MissingPath = $ProbePaths[0] }) } } return $Leftovers } function Remove-DeadShortcut { param( [Parameter(Mandatory = $true)] [PSCustomObject]$Leftover ) # Without a known install folder there is no safe way to tell this app's # shortcuts from anyone else's, so leave them all alone. if ($Leftover.InstallLocation -eq "") { return } $Root = $Leftover.InstallLocation try { $Shell = New-Object -ComObject WScript.Shell } catch { Write-GuiLog "WARNING: could not inspect shortcuts for $($Leftover.Name)." return } $Folders = @( (Join-Path $env:APPDATA "Microsoft\Windows\Start Menu\Programs"), (Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs"), [Environment]::GetFolderPath("Desktop"), (Join-Path $env:PUBLIC "Desktop") ) foreach ($Folder in $Folders) { if (-not (Test-Path -LiteralPath $Folder)) { continue } foreach ($Link in @(Get-ChildItem -LiteralPath $Folder -Recurse -Filter "*.lnk" -ErrorAction SilentlyContinue)) { $Target = "" try { $Target = [string]$Shell.CreateShortcut($Link.FullName).TargetPath } catch { continue } # Two conditions, both required: it points into the folder that # vanished, and its target really is gone. if ([string]::IsNullOrWhiteSpace($Target)) { continue } if (-not $Target.StartsWith($Root, [StringComparison]::OrdinalIgnoreCase)) { continue } if (Test-Path -LiteralPath $Target) { continue } try { Remove-Item -LiteralPath $Link.FullName -Force -ErrorAction Stop Write-GuiLog " Removed dead shortcut: $($Link.Name)" } catch { Write-GuiLog " WARNING: could not remove shortcut $($Link.FullName)." } } } try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($Shell) } catch { } } function Invoke-LeftoverCleanup { Set-UiBusy $true "Scanning for leftovers..." Write-GuiLog ("=" * 60) -NoTimestamp Write-GuiLog "Scanning for leftover Add/Remove Programs entries..." $Leftovers = @(Get-AppLeftovers) if ($Leftovers.Count -eq 0) { Write-GuiLog "No leftovers found. Everything listed as installed has its files in place." Set-UiBusy $false "Ready" return } foreach ($Leftover in $Leftovers) { Write-GuiLog "Leftover: $($Leftover.DisplayName) - registered as installed, but $($Leftover.MissingPath) does not exist." } $Summary = ($Leftovers | ForEach-Object { " - " + $_.DisplayName }) -join "`n" # MessageBox has to be raised on the UI thread; Invoke is synchronous, so # the answer is waiting in $sync by the time it returns. $sync.ConfirmResult = "No" $sync.Form.Dispatcher.Invoke([action]{ $sync.ConfirmResult = [Windows.MessageBox]::Show( ("These apps are listed as installed, but their files are gone:`n`n{0}`n`n" -f $Summary) + "Remove their Add/Remove Programs entries and any dead shortcuts?`n`n" + "This deletes registry entries and cannot be undone.", "Common Apps Manager", "YesNo", "Warning" ) }.GetNewClosure()) if ("$($sync.ConfirmResult)" -ne "Yes") { Write-GuiLog "Cleanup cancelled. Nothing was removed." Set-UiBusy $false "Ready" return } $Removed = 0 foreach ($Leftover in $Leftovers) { Set-UiBusy $true "Cleaning : $($Leftover.DisplayName)" try { Remove-Item -LiteralPath $Leftover.KeyPath -Recurse -Force -ErrorAction Stop Write-GuiLog "Removed the Add/Remove Programs entry for $($Leftover.DisplayName)." $Removed++ } catch { if ($Leftover.KeyPath -like "*HKEY_LOCAL_MACHINE*" -and -not $sync.IsAdministrator) { Write-GuiLog "ERROR: $($Leftover.DisplayName) is registered machine-wide. Restart this tool as Administrator to clean it." } else { Write-GuiLog "ERROR: could not remove the entry for $($Leftover.DisplayName): $($_.Exception.Message)" } continue } Remove-DeadShortcut -Leftover $Leftover } Write-GuiLog "Cleanup complete. Removed $Removed of $($Leftovers.Count) leftover entries." Update-InstalledStatus } # --------------------------------------------------------------------------- # Background runspace plumbing # --------------------------------------------------------------------------- $FunctionsToShare = @( "Get-UninstallEntries", "Test-InstalledByRegistryName", "Test-InstalledByAppx", "Get-AppType", "Test-FeatureEnabled", "Write-GuiLog", "Set-UiBusy", "Set-AppStatus", "Invoke-ProcessStreaming", "Invoke-WingetStreaming", "Update-InstalledStatus", "Invoke-AppAction", "Get-EntryValue", "Get-UninstallExePath", "Get-AppLeftovers", "Remove-DeadShortcut", "Invoke-LeftoverCleanup" ) $InitialSessionState = [InitialSessionState]::CreateDefault() foreach ($FunctionName in $FunctionsToShare) { $FunctionEntry = New-Object System.Management.Automation.Runspaces.SessionStateFunctionEntry( $FunctionName, (Get-Command $FunctionName).Definition ) $InitialSessionState.Commands.Add($FunctionEntry) } $InitialSessionState.Variables.Add(( New-Object System.Management.Automation.Runspaces.SessionStateVariableEntry( "sync", $sync, "Synchronized state shared with the UI thread" ) )) $sync.ISS = $InitialSessionState function Start-BackgroundTask { param( [Parameter(Mandatory = $true)] [string]$Command ) if ($sync.ProcessRunning) { return } # Dispose the previous task's runspace once it has finished. if ($null -ne $sync.CurrentTask -and $sync.CurrentTask.Handle.IsCompleted) { try { $sync.CurrentTask.PS.Dispose() $sync.CurrentTask.Runspace.Dispose() } catch { } $sync.CurrentTask = $null } $Runspace = [RunspaceFactory]::CreateRunspace($sync.ISS) $Runspace.ApartmentState = "STA" $Runspace.ThreadOptions = "ReuseThread" $Runspace.Open() $PowerShellInstance = [PowerShell]::Create() $PowerShellInstance.Runspace = $Runspace $WrappedCommand = @" try { $Command } catch { Write-GuiLog ("ERROR: " + `$_.Exception.Message) Set-UiBusy `$false "Error - see log" } "@ $PowerShellInstance.AddScript($WrappedCommand) | Out-Null $sync.CurrentTask = @{ PS = $PowerShellInstance Runspace = $Runspace Handle = $PowerShellInstance.BeginInvoke() } } # --------------------------------------------------------------------------- # XAML # --------------------------------------------------------------------------- $XamlString = @'