<# .SYNOPSIS Remove the ClickOnce app "Quality Management (g12361)" for the current user. .DESCRIPTION - Detects HKCU Uninstall entries and Start Menu .appref-ms for "Quality Management (g12361)". - If UninstallString exists (publisher enabled ARP), runs it. - Force-removes: Start Menu shortcut(s), app cache under %LOCALAPPDATA%\Apps\2.0, and the HKCU Uninstall key. - Dry-run by default. Use -Execute to apply changes. .EXITCODES 0 = Success (removed or nothing to do) 2 = Not found 4 = Partial cleanup .NOTES Run in the user's context (same user who installed the ClickOnce app). #> [CmdletBinding(SupportsShouldProcess)] param( [switch]$Execute, [switch]$PurgeAllClickOnceCache ) # ---- Constants ---- # Exact target $DisplayNameTarget = 'Quality Management (g12361)' # ---- Logging ---- function Write-Log { param([string]$Message,[string]$Level="INFO") $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" Write-Host "[$ts][$Level] $Message" } # ---- Helpers ---- function Get-ClickOnceUninstallEntries { $base = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' if (-not (Test-Path $base)) { return @() } Get-ChildItem $base | ForEach-Object { $k = $_.PsPath $p = Get-ItemProperty -Path $k -ErrorAction SilentlyContinue if ($p -and $p.DisplayName -and ($p.DisplayName -like "*$DisplayNameTarget*")) { $ver = try { [version]$p.DisplayVersion } catch { [version]'0.0.0.0' } [pscustomobject]@{ KeyPath = $k DisplayName = $p.DisplayName Publisher = $p.Publisher UninstallString = $p.UninstallString URLUpdateInfo = $p.URLUpdateInfo DisplayVersion = $p.DisplayVersion VerObj = $ver } } } } function Select-BestEntry { param($Entries) if (-not $Entries -or $Entries.Count -eq 0) { return $null } $Entries | Sort-Object ` @{ Expression = { $_.DisplayName.Length }; Descending = $true }, @{ Expression = { $_.VerObj }; Descending = $true } | Select-Object -First 1 } function Find-ApprefShortcut { $paths = @( "$env:APPDATA\Microsoft\Windows\Start Menu\Programs", "$env:ProgramData\Microsoft\Windows\Start Menu\Programs" ) | Select-Object -Unique foreach ($root in $paths) { if (-not (Test-Path $root)) { continue } # 1) Name match $byName = Get-ChildItem -Path $root -Recurse -Filter '*.appref-ms' -ErrorAction SilentlyContinue | Where-Object { $_.BaseName -like "*$DisplayNameTarget*" } | Select-Object -First 1 if ($byName) { return $byName } # 2) Content match (sometimes name differs) $byContent = Get-ChildItem -Path $root -Recurse -Filter '*.appref-ms' -ErrorAction SilentlyContinue | Where-Object { try { (Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop) -match [regex]::Escape($DisplayNameTarget) } catch { $false } } | Select-Object -First 1 if ($byContent) { return $byContent } } } function Read-ApprefDetails { param([string]$ApprefPath) $raw = Get-Content -LiteralPath $ApprefPath -Raw -ErrorAction SilentlyContinue if (-not $raw) { return $null } # Handles quoted/unquoted: # deploymentProvider url="https://example/app.application" OR without quotes $pattern = 'deploymentProvider\s+url\s*=\s*"?([^"\r\n]+)"?' $m = [regex]::Match($raw, $pattern, 'IgnoreCase') $url = if ($m.Success) { $m.Groups[1].Value } else { $null } [pscustomobject]@{ ApprefPath = $ApprefPath; DeploymentUrl = $url } } function Remove-ClickOnceCacheForApp { param([string]$NameHint, [string]$UrlHint) $cacheRoot = Join-Path $env:LOCALAPPDATA 'Apps\2.0' if (-not (Test-Path $cacheRoot)) { Write-Log "ClickOnce cache root not found: $cacheRoot" "WARN" return $true } $patterns = @() if ($NameHint) { $patterns += ($NameHint -replace '[^a-zA-Z0-9]+','') } if ($UrlHint) { try { $uri = [uri]$UrlHint; if ($uri.Host) { $patterns += ($uri.Host -replace '[^a-zA-Z0-9]+','') } } catch {} } if (-not $patterns -or $patterns.Count -eq 0) { $patterns = @('quality','management','g12361') } Write-Log "Scanning ClickOnce cache at $cacheRoot for patterns: $($patterns -join ', ')" $dirs = Get-ChildItem -Path $cacheRoot -Recurse -Directory -Force -ErrorAction SilentlyContinue $targets = New-Object System.Collections.Generic.HashSet[string] foreach ($d in $dirs) { $n = ($d.FullName -replace '[^a-zA-Z0-9]','') foreach ($p in $patterns) { if ($p -and $n -match [regex]::Escape($p)) { [void]$targets.Add($d.FullName); break } } } if ($targets.Count -eq 0) { Write-Log "No app-specific cache paths matched. (Cache structure varies by build.)" "WARN" return $true } $allOk = $true foreach ($t in $targets) { if (Test-Path $t) { if ($PSCmdlet.ShouldProcess($t, "Remove")) { try { Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction Stop; Write-Log "Removed $t" } catch { Write-Log "Failed to remove $t : $($_.Exception.Message)" "WARN"; $allOk = $false } } else { Write-Log "Would remove $t (WhatIf)" } } } return $allOk } function Invoke-RegisteredUninstall { param($Entry) if (-not $Entry.UninstallString) { return $true } # nothing to invoke; treat as neutral Write-Log "Attempting registered uninstall: $($Entry.UninstallString)" try { $file,$args = $null,$null if ($Entry.UninstallString.StartsWith('"')) { $end = $Entry.UninstallString.IndexOf('"',1) if ($end -lt 1) { throw "Malformed quoted path in UninstallString." } $file = $Entry.UninstallString.Substring(1, $end-1) $args = $Entry.UninstallString.Substring($end+1).Trim() } else { $parts = $Entry.UninstallString.Split(' ',2) $file = $parts[0] $args = if ($parts.Count -gt 1) { $parts[1] } else { "" } } if ($PSCmdlet.ShouldProcess($file, "Start-Process $args")) { $p = Start-Process -FilePath $file -ArgumentList $args -PassThru -WindowStyle Hidden $p.WaitForExit() Write-Log "Registered uninstall exited with code $($p.ExitCode)" return ($p.ExitCode -eq 0) } else { Write-Log "Would run registered uninstall (WhatIf)" return $true } } catch { Write-Log "Registered uninstall failed: $($_.Exception.Message)" "WARN" return $false } } # ---- Main ---- $partial = $false $notFound = $false $doIt = $Execute.IsPresent if (-not $doIt) { $WhatIfPreference = $true; Write-Log "Dry run. Pass -Execute to perform removal." "WARN" } Write-Log "Targeting ClickOnce app '$DisplayNameTarget' for user '$env:USERNAME'..." $entries = @(Get-ClickOnceUninstallEntries) $entry = Select-BestEntry -Entries $entries if (-not $entry) { Write-Log "No HKCU ARP entry matched '$DisplayNameTarget'." "WARN" } $shortcut = Find-ApprefShortcut $urlHint = $null if ($shortcut) { Write-Log "Found .appref-ms shortcut: $($shortcut.FullName)" $appref = Read-ApprefDetails -ApprefPath $shortcut.FullName if ($appref -and $appref.DeploymentUrl) { Write-Log "Deployment URL (hint for cache cleanup): $($appref.DeploymentUrl)" $urlHint = $appref.DeploymentUrl } } else { Write-Log "No .appref-ms shortcut found in Start Menu paths." "WARN" } if (-not $entry -and -not $shortcut) { Write-Log "No matching ClickOnce install detected." $notFound = $true } # 1) Graceful uninstall if available if ($entry) { Write-Log "HKCU Uninstall match: $($entry.DisplayName) Version: $($entry.DisplayVersion)" $ok = Invoke-RegisteredUninstall -Entry $entry if (-not $ok) { $partial = $true } } # 2) Force-remove: Start Menu shortcut(s), cache, ARP key if ($shortcut) { if ($PSCmdlet.ShouldProcess($shortcut.FullName, "Remove .appref-ms shortcut")) { try { Remove-Item -LiteralPath $shortcut.FullName -Force -ErrorAction Stop; Write-Log "Removed shortcut: $($shortcut.FullName)" } catch { Write-Log "Failed to remove shortcut: $($_.Exception.Message)" "WARN"; $partial = $true } } else { Write-Log "Would remove shortcut: $($shortcut.FullName) (WhatIf)" } } $cacheOk = Remove-ClickOnceCacheForApp -NameHint $DisplayNameTarget -UrlHint $urlHint if (-not $cacheOk) { $partial = $true } if ($entry) { if ($PSCmdlet.ShouldProcess($entry.KeyPath, "Remove HKCU Uninstall entry")) { try { Remove-Item -LiteralPath $entry.KeyPath -Recurse -Force -ErrorAction Stop; Write-Log "Removed HKCU uninstall entry." } catch { Write-Log "Failed to remove HKCU uninstall entry: $($_.Exception.Message)" "WARN"; $partial = $true } } else { Write-Log "Would remove HKCU uninstall entry at $($entry.KeyPath) (WhatIf)" } } # 3) Optional: purge entire user ClickOnce cache if ($PurgeAllClickOnceCache) { Write-Log "Purging entire ClickOnce cache for this user (dfshim)..." "WARN" if ($PSCmdlet.ShouldProcess("dfshim", "CleanOnlineAppCache")) { try { Start-Process -FilePath "rundll32.exe" -ArgumentList "dfshim.dll,ShArpMaintain CleanOnlineAppCache" -Wait -WindowStyle Hidden Write-Log "dfshim cache purge attempted." } catch { Write-Log "dfshim purge failed: $($_.Exception.Message)" "WARN"; $partial = $true } } else { Write-Log "Would purge dfshim cache (WhatIf)" } } Write-Log "Done." if ($partial) { exit 4 } elseif ($notFound) { exit 2 } else { exit 0 }