<# .SYNOPSIS User-scope removal of the ClickOnce app "BPC Q-Pulse 7.1" without admin rights. .DESCRIPTION - Detects HKCU Uninstall and the user's Start Menu/Desktop .appref-ms for "BPC Q-Pulse 7.1". - If UninstallString exists (HKCU ARP), runs it (user-context). - Force-removes: user's Start Menu + user Desktop shortcut(s), user ClickOnce cache (%LOCALAPPDATA%\Apps\2.0), and HKCU Uninstall key(s) — all user-scope. - Purges ClickOnce Deployment hives in HKCU that reference the app/URL (user-scope). - Public Desktop shortcut is report-only (no delete) to avoid admin rights. - Dry-run by default; use -Execute to apply. - -DetectOnly returns 0 if nothing is found (inverse detect), 1 if found. .EXITCODES 0 = Success (removed or nothing to do) / DetectOnly: nothing found 1 = DetectOnly: something found 2 = Not found (normal flow) 4 = Partial cleanup #> [CmdletBinding(SupportsShouldProcess)] param( [switch]$Execute, [switch]$PurgeAllClickOnceCache, # user-safe, optional [switch]$DetectOnly, [string]$LogPath # passed in from SYSTEM script (e.g., C:\Windows\Logs\Software\UserCleanup-*.log) ) # --- Severity helper: map strings to PSADT numeric codes (1=Info, 2=Warning, 3=Error) --- function Convert-Severity { param( [Parameter(Mandatory=$true)] [string]$Level ) switch ($Level.ToUpperInvariant()) { 'INFORMATION' { return 1 } 'INFO' { return 1 } 'WARNING' { return 2 } 'ERROR' { return 3 } default { return 1 } # Fallback to Information } } # --- Minimal logger if PSADT isn't loaded in this user process --- # Note: This shim accepts the numeric Severity (UInt32) to match PSADT v4. if (-not (Get-Command Write-ADTLogEntry -ErrorAction SilentlyContinue)) { function Write-ADTLogEntry { param( [Parameter(Mandatory)][string]$Message, [Parameter(Mandatory)][uint32]$Severity # 1=Info, 2=Warning, 3=Error ) $sevLabel = switch ($Severity) { 1 { 'Information' } 2 { 'Warning' } 3 { 'Error' } default { 'Information' } } $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $line = "[$ts][$sevLabel] $Message" if ($LogPath) { try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 } catch { Write-Output $line } } else { Write-Output $line } } } # ---- Targets (user-scope only) ---- $DisplayNameTarget = 'BPC Q-Pulse 7.1' # For HKCU ARP & manifests $ShortcutTarget = 'BPC Q-Pulse 7.1.appref-ms' # Exact filename for Start Menu/Desktop # ---- Helpers (HKCU + user profile only) ---- function Get-ClickOnceUninstallEntries { $base = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall' if (-not (Test-Path $base)) { return @() } Get-ChildItem $base -ErrorAction SilentlyContinue | 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-UserStartMenuApprefShortcuts { $root = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' if (-not (Test-Path $root)) { return @() } $found = @() $hits = Get-ChildItem -Path $root -Recurse -Filter '*.appref-ms' -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $ShortcutTarget } if ($hits) { $found += $hits } # Fallback: content match for renamed shortcuts $all = Get-ChildItem -Path $root -Recurse -Filter '*.appref-ms' -ErrorAction SilentlyContinue foreach ($f in $all) { try { $raw = Get-Content -LiteralPath $f.FullName -Raw -ErrorAction Stop if ($raw -like "*$DisplayNameTarget*") { if (-not ($found.FullName -contains $f.FullName)) { $found += $f } } } catch {} } return $found } function Find-DesktopApprefShortcuts { # User desktops only + report-only Public Desktop (no delete) $desktops = @( (Join-Path $env:USERPROFILE 'Desktop') ) if ($env:OneDrive) { $desktops += (Join-Path $env:OneDrive 'Desktop') } try { $odDirs = Get-ChildItem -Path $env:USERPROFILE -Directory -Filter 'OneDrive*' -ErrorAction SilentlyContinue foreach ($d in $odDirs) { $desktops += (Join-Path $d.FullName 'Desktop') } } catch {} # Add Public Desktop only for reporting $publicDesktop = 'C:\Users\Public\Desktop' $desktopsForDelete = $desktops | Sort-Object -Unique $desktopsForReport = $desktopsForDelete + $publicDesktop $report = @() foreach ($desk in ($desktopsForReport | Sort-Object -Unique)) { if (-not (Test-Path $desk)) { continue } $hits = Get-ChildItem -LiteralPath $desk -Filter '*.appref-ms' -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $ShortcutTarget -or $_.Name -like "*$DisplayNameTarget*.appref-ms" } if ($hits) { $report += $hits } } # Return both sets (user-delete set separately calculated at call site) return [pscustomobject]@{ Report = $report DeleteCandidatesRootPaths = $desktopsForDelete } } function Read-ApprefDetails { param([string]$ApprefPath) $raw = Get-Content -LiteralPath $ApprefPath -Raw -ErrorAction SilentlyContinue if (-not $raw) { return $null } $m = [regex]::Match($raw, 'deploymentProvider\s+url\s*=\s*["'']?([^"''\r\n]+)["'']?', 'IgnoreCase') $url = if ($m.Success) { $m.Groups[1].Value } else { $null } [pscustomobject]@{ ApprefPath = $ApprefPath; DeploymentUrl = $url } } # Manifest-guided cache removal (user-scope) function Remove-ClickOnceCacheForApp { param( [string]$NameHint, [string]$UrlHint ) $cacheRoot = Join-Path $env:LOCALAPPDATA 'Apps\2.0' if (-not (Test-Path $cacheRoot)) { Write-ADTLogEntry -Message "ClickOnce cache root not found: $cacheRoot" -Severity (Convert-Severity 'Information') return $true } $hostToken = $null if ($UrlHint) { try { $u = [uri]$UrlHint if ($u.Host) { $hostToken = $u.Host.ToLowerInvariant() } } catch {} } $manifestPatterns = @('*.application','*.manifest') $topFoldersToDelete = New-Object System.Collections.Generic.HashSet[string] Write-ADTLogEntry -Message "Scanning manifests under $cacheRoot for app references…" -Severity (Convert-Severity 'Information') Get-ChildItem -Path $cacheRoot -Recurse -Include $manifestPatterns -File -Force -ErrorAction SilentlyContinue | ForEach-Object { $f = $_.FullName $raw = $null try { $raw = Get-Content -LiteralPath $f -Raw -ErrorAction Stop } catch { return } if (-not $raw) { return } $hit = $false if ($hostToken -and ($raw.ToLowerInvariant() -like "*$hostToken*")) { $hit = $true } elseif ($NameHint -and ($raw -like "*$NameHint*")) { $hit = $true } if ($hit) { # climb to first-level child of Apps\2.0 and remove that $parent = Split-Path $f -Parent while ($parent -and (Split-Path $parent -Parent) -and ((Split-Path $parent -Parent) -ne $cacheRoot)) { $parent = Split-Path $parent -Parent } if ($parent -and (Split-Path $parent -Parent) -eq $cacheRoot) { [void]$topFoldersToDelete.Add($parent) } else { [void]$topFoldersToDelete.Add((Split-Path $f -Parent)) } } } if ($topFoldersToDelete.Count -eq 0) { # Conservative fallback tokens, still user-scope only $fallbackTokens = @('bpc','qpulse','q','pulse','71') Write-ADTLogEntry -Message "No manifest-linked folders found; falling back to token match on folder names." -Severity (Convert-Severity 'Warning') Get-ChildItem -Path $cacheRoot -Recurse -Directory -Force -ErrorAction SilentlyContinue | ForEach-Object { $n = ($_.FullName -replace '[^a-zA-Z0-9]','').ToLowerInvariant() foreach ($t in $fallbackTokens) { if ($t -and $n -like "*$t*") { [void]$topFoldersToDelete.Add($_.FullName); break } } } } if ($topFoldersToDelete.Count -eq 0) { Write-ADTLogEntry -Message "No candidate ClickOnce cache folders matched." -Severity (Convert-Severity 'Warning') return $true } $ok = $true foreach ($t in $topFoldersToDelete) { if ($PSCmdlet.ShouldProcess($t, "Remove ClickOnce cache (top hashed folder)")) { try { Remove-Item -LiteralPath $t -Recurse -Force -ErrorAction Stop Write-ADTLogEntry -Message "Removed cache folder: $t" -Severity (Convert-Severity 'Information') } catch { Write-ADTLogEntry -Message "Failed to remove cache folder: $t : $($_.Exception.Message)" -Severity (Convert-Severity 'Warning') $ok = $false } } else { Write-ADTLogEntry -Message "Would remove cache folder: $t (WhatIf)" -Severity (Convert-Severity 'Information') } } return $ok } # Purge ClickOnce Deployment hives (HKCU only) function Remove-ClickOnceDeploymentRegistryRefs { param( [string]$NameHint, [string]$UrlHint ) $roots = @( 'HKCU:\Software\Classes\Software\Microsoft\Windows\CurrentVersion\Deployment', 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Deployment' ) $tokens = @() if ($NameHint) { $tokens += $NameHint } if ($UrlHint) { $tokens += $UrlHint } if ($tokens.Count -eq 0) { return $true } $removedAny = $false foreach ($root in $roots) { if (-not (Test-Path $root)) { continue } Write-ADTLogEntry -Message "Scanning ClickOnce deployment hive: $root" -Severity (Convert-Severity 'Information') $keys = Get-ChildItem -Path $root -Recurse -ErrorAction SilentlyContinue foreach ($k in $keys) { $hit = $false try { $props = Get-ItemProperty -Path $k.PSPath -ErrorAction SilentlyContinue if ($props) { foreach ($p in $props.PSObject.Properties) { $val = [string]$p.Value foreach ($t in $tokens) { if ($t -and $val -and ($val -like "*$t*")) { $hit = $true; break } } if ($hit) { break } } } } catch {} if ($hit) { if ($PSCmdlet.ShouldProcess($k.PSPath, "Remove ClickOnce deployment key (HKCU)")) { try { Remove-Item -LiteralPath $k.PSPath -Recurse -Force -ErrorAction Stop Write-ADTLogEntry -Message "Removed deployment key: $($k.PSPath)" -Severity (Convert-Severity 'Information') $removedAny = $true } catch { Write-ADTLogEntry -Message "Failed to remove deployment key: $($k.PSPath) : $($_.Exception.Message)" -Severity (Convert-Severity 'Warning') } } else { Write-ADTLogEntry -Message "Would remove deployment key: $($k.PSPath) (WhatIf)" -Severity (Convert-Severity 'Information') $removedAny = $true } } } } return $removedAny } function Invoke-RegisteredUninstall { param($Entry) if (-not $Entry.UninstallString) { return $true } Write-ADTLogEntry -Message "Attempting registered uninstall (user-context): $($Entry.UninstallString)" -Severity (Convert-Severity 'Information') try { $cmd = $Entry.UninstallString.Trim() $file = $null; $args = "" if ($cmd.StartsWith('"')) { $end = $cmd.IndexOf('"',1) if ($end -lt 1) { throw "Malformed quoted path in UninstallString." } $file = $cmd.Substring(1, $end-1) $args = $cmd.Substring($end+1).Trim() } else { $m = [regex]::Match($cmd, '^\S+') $file = $m.Value $args = $cmd.Substring($file.Length).Trim() } if ($PSCmdlet.ShouldProcess($file, "Start-Process $args")) { $p = Start-Process -FilePath $file -ArgumentList $args -PassThru -WindowStyle Hidden $p.WaitForExit() Write-ADTLogEntry -Message "Registered uninstall exited with code $($p.ExitCode)" -Severity (Convert-Severity 'Information') return ($p.ExitCode -eq 0) } else { Write-ADTLogEntry -Message "Would run registered uninstall (WhatIf)" -Severity (Convert-Severity 'Information') return $true } } catch { Write-ADTLogEntry -Message "Registered uninstall failed: $($_.Exception.Message)" -Severity (Convert-Severity 'Warning') return $false } } # ---- Main (user-scope only) ---- $partial = $false $notFound = $false if (-not $Execute) { $WhatIfPreference = $true; Write-ADTLogEntry -Message "Dry run. Pass -Execute to perform removal." -Severity (Convert-Severity 'Warning') } Write-ADTLogEntry -Message "Targeting ClickOnce app '$DisplayNameTarget' (shortcut '$ShortcutTarget') for user '$env:USERNAME'..." -Severity (Convert-Severity 'Information') $entries = @(Get-ClickOnceUninstallEntries) $bestEntry = Select-BestEntry -Entries $entries if (-not $bestEntry) { Write-ADTLogEntry -Message "No HKCU ARP entry matched '$DisplayNameTarget'." -Severity (Convert-Severity 'Information') } $userStartMenu = @(Find-UserStartMenuApprefShortcuts) if ($userStartMenu.Count -gt 0) { Write-ADTLogEntry -Message ("User Start Menu shortcut(s): " + ($userStartMenu.FullName -join '; ')) -Severity (Convert-Severity 'Information') } else { Write-ADTLogEntry -Message "No user Start Menu .appref-ms found for '$ShortcutTarget'." -Severity (Convert-Severity 'Information') } $deskObj = Find-DesktopApprefShortcuts $deskShortcuts = @($deskObj.Report) if ($deskShortcuts.Count -gt 0) { Write-ADTLogEntry -Message ("Desktop shortcuts (report-only; includes Public): " + ($deskShortcuts.FullName -join '; ')) -Severity (Convert-Severity 'Information') } # Deployment URL hint from any user Start Menu shortcut $urlHint = $null if ($userStartMenu.Count -gt 0) { $appref = Read-ApprefDetails -ApprefPath $userStartMenu[0].FullName if ($appref -and $appref.DeploymentUrl) { $urlHint = $appref.DeploymentUrl Write-ADTLogEntry -Message "Deployment URL hint: $urlHint" -Severity (Convert-Severity 'Information') } } # Inverse detect if (-not $bestEntry -and $userStartMenu.Count -eq 0 -and $deskShortcuts.Count -eq 0) { Write-ADTLogEntry -Message "No matching ClickOnce install or shortcuts detected (user-scope)." -Severity (Convert-Severity 'Information') if ($DetectOnly) { Write-ADTLogEntry -Message "DetectOnly: nothing found -> 0" -Severity (Convert-Severity 'Information'); exit 0 } $notFound = $true } elseif ($DetectOnly) { Write-ADTLogEntry -Message "DetectOnly: found something -> 1" -Severity (Convert-Severity 'Information') exit 1 } # Graceful uninstall (HKCU ARP) if ($bestEntry) { Write-ADTLogEntry -Message "HKCU Uninstall match: $($bestEntry.DisplayName) Version: $($bestEntry.DisplayVersion)" -Severity (Convert-Severity 'Information') if (-not (Invoke-RegisteredUninstall -Entry $bestEntry)) { $partial = $true } } # Remove user Start Menu shortcuts (user-scope) foreach ($sc in $userStartMenu) { if ($PSCmdlet.ShouldProcess($sc.FullName, "Remove user .appref-ms shortcut")) { try { Remove-Item -LiteralPath $sc.FullName -Force -ErrorAction Stop; Write-ADTLogEntry -Message "Removed: $($sc.FullName)" -Severity (Convert-Severity 'Information') } catch { Write-ADTLogEntry -Message "Failed to remove $($sc.FullName): $($_.Exception.Message)" -Severity (Convert-Severity 'Warning'); $partial = $true } } else { Write-ADTLogEntry -Message "Would remove: $($sc.FullName) (WhatIf)" -Severity (Convert-Severity 'Information') } } # Remove user Desktop shortcuts only under user-owned desktops (no Public Desktop) foreach ($desk in ($deskObj.DeleteCandidatesRootPaths | Sort-Object -Unique)) { if (-not (Test-Path $desk)) { continue } $hits = Get-ChildItem -LiteralPath $desk -Filter '*.appref-ms' -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -ieq $ShortcutTarget -or $_.Name -like "*$DisplayNameTarget*.appref-ms" } foreach ($h in $hits) { if ($PSCmdlet.ShouldProcess($h.FullName, "Remove user Desktop .appref-ms shortcut")) { try { Remove-Item -LiteralPath $h.FullName -Force -ErrorAction Stop; Write-ADTLogEntry -Message "Removed: $($h.FullName)" -Severity (Convert-Severity 'Information') } catch { Write-ADTLogEntry -Message "Failed to remove $($h.FullName): $($_.Exception.Message)" -Severity (Convert-Severity 'Warning'); $partial = $true } } else { Write-ADTLogEntry -Message "Would remove: $($h.FullName) (WhatIf)" -Severity (Convert-Severity 'Information') } } } # Remove ALL matching HKCU ARP entries $allArp = @(Get-ClickOnceUninstallEntries) foreach ($arp in $allArp) { if ($PSCmdlet.ShouldProcess($arp.KeyPath, "Remove HKCU Uninstall entry")) { try { Remove-Item -LiteralPath $arp.KeyPath -Recurse -Force -ErrorAction Stop; Write-ADTLogEntry -Message "Removed HKCU ARP: $($arp.DisplayName)" -Severity (Convert-Severity 'Information') } catch { Write-ADTLogEntry -Message "Failed to remove HKCU ARP at $($arp.KeyPath): $($_.Exception.Message)" -Severity (Convert-Severity 'Warning'); $partial = $true } } else { Write-ADTLogEntry -Message "Would remove HKCU ARP at $($arp.KeyPath) (WhatIf)" -Severity (Convert-Severity 'Information') } } # ClickOnce deployment hives cleanup (HKCU) $depCleaned = Remove-ClickOnceDeploymentRegistryRefs -NameHint $DisplayNameTarget -UrlHint $urlHint if (-not $depCleaned) { Write-ADTLogEntry -Message "No deployment keys matched (may already be clean)." -Severity (Convert-Severity 'Information') } # Cache cleanup (manifest-guided; %LOCALAPPDATA%\Apps\2.0) $cacheOk = Remove-ClickOnceCacheForApp -NameHint $DisplayNameTarget -UrlHint $urlHint if (-not $cacheOk) { $partial = $true } # Optional: dfshim cache purge (user-context safe) if ($PurgeAllClickOnceCache) { Write-ADTLogEntry -Message "Purging user ClickOnce cache via dfshim (user-context)..." -Severity (Convert-Severity 'Warning') if ($PSCmdlet.ShouldProcess("rundll32.exe", "dfshim.dll,ShArpMaintain CleanOnlineAppCache")) { try { Start-Process -FilePath "rundll32.exe" -ArgumentList "dfshim.dll,ShArpMaintain CleanOnlineAppCache" -Wait -WindowStyle Hidden Write-ADTLogEntry -Message "dfshim cache purge attempted." -Severity (Convert-Severity 'Information') } catch { Write-ADTLogEntry -Message "dfshim cache purge failed: $($_.Exception.Message)" -Severity (Convert-Severity 'Warning') $partial = $true } } else { Write-ADTLogEntry -Message "Would purge dfshim cache (WhatIf)" -Severity (Convert-Severity 'Information') } } Write-ADTLogEntry -Message ("Summary (user-scope): ARP entries removed=" + $allArp.Count + "; User StartMenu removed=" + $userStartMenu.Count + "; Desktop shortcuts reported (incl. Public)=" + $deskShortcuts.Count + "; Cache removal OK=" + $cacheOk) -Severity (Convert-Severity 'Information') if ($partial) { exit 4 } elseif ($notFound) { exit 2 } else { exit 0 }