Skip to content

Windows FMA - Power BI - #46284

Merged
harrisonravazzolo merged 16 commits into
mainfrom
adding-powerbi-win-fma
May 28, 2026
Merged

Windows FMA - Power BI#46284
harrisonravazzolo merged 16 commits into
mainfrom
adding-powerbi-win-fma

Conversation

@harrisonravazzolo

@harrisonravazzolo harrisonravazzolo commented May 27, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #

Checklist for submitter

If some of the following don't apply, delete the relevant line.

  • Changes file added for user-visible changes in changes/, orbit/changes/ or ee/fleetd-chrome/changes.
    See Changes files for more information.

  • Input data is properly validated, SELECT * is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters.

  • Timeouts are implemented and retries are limited to avoid infinite loops

  • If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes

Testing

For unreleased bug fixes in a release candidate, one of:

  • Confirmed that the fix is not expected to adversely impact load test results
  • Alerted the release DRI if additional load testing is needed

Database migrations

  • Checked schema for all modified table for columns that will auto-update timestamps during migration.
  • Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects.
  • Ensured the correct collation is explicitly set for character columns (COLLATE utf8mb4_unicode_ci).

New Fleet configuration settings

  • Setting(s) is/are explicitly excluded from GitOps

If you didn't check the box above, follow this checklist for GitOps-enabled settings:

  • Verified that the setting is exported via fleetctl generate-gitops
  • Verified the setting is documented in a separate PR to the GitOps documentation
  • Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional)
  • Verified that any relevant UI is disabled when GitOps mode is enabled

fleetd/orbit/Fleet Desktop

  • Verified compatibility with the latest released version of Fleet (see Must rule)
  • If the change applies to only one platform, confirmed that runtime.GOOS is used as needed to isolate changes
  • Verified that fleetd runs on macOS, Linux and Windows
  • Verified auto-update works from the released version of component to the new version (see tools/tuf/test)

Summary by CodeRabbit

  • New Features
    • Power BI Desktop (Windows) added to the app catalog with automated install/uninstall support, including graceful shutdown and robust cleanup of leftover installations and registry entries.
    • New package input definition for Windows package management to enable automated deployments.
    • Added a Power BI icon in the software management interface for easier identification.

Review Change Stack

@codecov

codecov Bot commented May 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 50.00000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 66.89%. Comparing base (a1d9146) to head (1773f84).

Files with missing lines Patch % Lines
...nd/pages/SoftwarePage/components/icons/PowerBi.tsx 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #46284      +/-   ##
==========================================
- Coverage   66.89%   66.89%   -0.01%     
==========================================
  Files        2783     2784       +1     
  Lines      221728   221730       +2     
  Branches    11258    11258              
==========================================
+ Hits       148330   148331       +1     
- Misses      59997    59998       +1     
  Partials    13401    13401              
Flag Coverage Δ
frontend 56.28% <50.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.SnxPJd	2026-05-28 00:30:56.804590883 +0000
+++ /tmp/new.s7kfKT	2026-05-28 00:30:56.804590883 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@harrisonravazzolo
harrisonravazzolo marked this pull request as ready for review May 28, 2026 00:43
@harrisonravazzolo
harrisonravazzolo requested a review from a team as a code owner May 28, 2026 00:43

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@harrisonravazzolo harrisonravazzolo changed the title powerbi fma Windows FMA - Power BI May 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.EUtWd0	2026-05-28 00:44:22.660631314 +0000
+++ /tmp/new.yOhC2e	2026-05-28 00:44:22.660631314 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.JfBHu2	2026-05-28 00:45:04.046379499 +0000
+++ /tmp/new.QkwGqK	2026-05-28 00:45:04.047379516 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8e1549ad-b5e0-4cad-b145-c7f3a3929d7b

📥 Commits

Reviewing files that changed from the base of the PR and between 8840318 and 1773f84.

📒 Files selected for processing (3)
  • ee/maintained-apps/inputs/winget/scripts/power_bi_uninstall.ps1
  • ee/maintained-apps/outputs/apps.json
  • frontend/pages/SoftwarePage/components/icons/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • ee/maintained-apps/outputs/apps.json
  • ee/maintained-apps/inputs/winget/scripts/power_bi_uninstall.ps1

Walkthrough

This PR adds Power BI Desktop (x64) as a Fleet-managed Windows application. It introduces an installer script that runs the installer silently with EULA acceptance, a three-phase uninstaller handling WiX Burn bundles and leftover MSIs plus registry cleanup, a distribution manifest with installer URL and SHA-256, Winget input metadata, app catalog registration, and a frontend React SVG icon component with a "power bi" name-to-icon mapping.

Possibly related PRs

  • fleetdm/fleet#46252: Adds a Windows Winget app definition with corresponding PowerShell install/uninstall scripts (similar intake/output pattern).
  • fleetdm/fleet#46222: Adds a frontend icon and maintained-app metadata entries and updates SOFTWARE_NAME_TO_ICON_MAP (similar UI and registration changes).
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description contains only the bare template with all checkboxes unchecked and no actual implementation details, related issue, or meaningful content about the changes. Complete the description by filling in the related issue number and checking applicable boxes, then add a summary explaining what Power BI support was added for Windows FMA.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Windows FMA - Power BI' is directly related to the changeset, which adds Power BI support for Windows in the FMA (Fleet Managed Apps) system.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch adding-powerbi-win-fma

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.xStjSL	2026-05-28 02:11:34.467447886 +0000
+++ /tmp/new.nBe1Sv	2026-05-28 02:11:34.467447886 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.Goa12X	2026-05-28 02:12:11.385000979 +0000
+++ /tmp/new.oQ7Ksz	2026-05-28 02:12:11.385000979 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.JqgIms	2026-05-28 02:13:40.720414149 +0000
+++ /tmp/new.qvlGMw	2026-05-28 02:13:40.720414149 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

fleet-release
fleet-release previously approved these changes May 28, 2026
@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
fleet-release
fleet-release previously approved these changes May 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/power-bi/windows.json

=== Install Script (no changes) ===
=== Uninstall // ea2d4556 -> fa3b8ea6 ===

--- /tmp/old.0FTnb5	2026-05-28 18:05:33.805751466 +0000
+++ /tmp/new.QRNRjc	2026-05-28 18:05:33.805751466 +0000
@@ -1,109 +1,124 @@
-# Define acceptable/expected exit codes (0 = success, 3010/1641 = success, reboot required)
-$ExpectedExitCodes = @(0, 3010, 1641)
+# Power BI Desktop's EXE installer is a WiX "Burn" bundle. It registers TWO
+# uninstall entries:
+#   * "Microsoft PowerBI Desktop (x64)"  -> the bundle bootstrapper
+#       (...\Package Cache\{afa18d15-...}\PBIDesktopSetup_x64.exe)
+#   * "Microsoft Power BI Desktop (x64)" -> the MSI the bundle installed
+#       (MsiExec.exe /X{c7d2053f-...})
+#
+# Removing the MSI directly orphans the bundle: its uninstall then no-ops
+# (returns 0) and leaves the "Microsoft PowerBI Desktop (x64)" registration
+# behind, which is what Fleet's osquery-based validator keeps detecting.
+# Correct approach: uninstall via the BUNDLE first; it removes the MSI and its
+# own registration. Burn relaunches a cached copy of itself + spawns msiexec
+# asynchronously, so wait for those to finish.
 
-# Power BI Desktop registers in Programs and Features. Match on DisplayName since the
-# MSI product code (GUID) changes between versions.
-$softwareNameLike = "Microsoft Power BI Desktop*"
-$publisher        = "Microsoft Corporation"
-
-# Silent flag used only if the uninstaller turns out to be a plain EXE (not MsiExec)
-# and does not provide its own QuietUninstallString.
-$exeSilentArgs = "-q -norestart"
-
-$paths = @(
-    'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
-    'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+$ExpectedExitCodes = @(0, 1605, 1641, 3010)
+$exitCode = 0
+
+$installDirs = @(
+    (Join-Path $env:ProgramFiles 'Microsoft Power BI Desktop'),
+    (Join-Path ${env:ProgramFiles(x86)} 'Microsoft Power BI Desktop')
 )
 
-# Initialize exit code
-$exitCode = 0
+function Wait-ForProcessExit {
+    param([string[]]$Names, [int]$TimeoutSeconds = 240)
+    $elapsed = 0
+    while ($elapsed -lt $TimeoutSeconds) {
+        $running = $Names | Where-Object { Get-Process -Name $_ -ErrorAction SilentlyContinue }
+        if (-not $running) { break }
+        Start-Sleep -Seconds 3
+        $elapsed += 3
+    }
+}
 
-try {
-    # Locate the uninstall entry
-    $selected = $null
-    foreach ($p in $paths) {
-        $items = Get-ItemProperty "$p\*" -ErrorAction SilentlyContinue | Where-Object {
-            $_.DisplayName -and ($_.DisplayName -like $softwareNameLike) -and
-            ($publisher -eq "" -or $_.Publisher -eq $publisher)
+function Get-PowerBIEntries {
+    param([string[]]$Roots)
+    $list = @()
+    foreach ($root in $Roots) {
+        foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {
+            $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue
+            if (-not $key.DisplayName) { continue }
+            if (-not (($key.DisplayName -replace '\s', '').ToLower().Contains("powerbidesktop"))) { continue }
+            $list += [PSCustomObject]@{
+                DisplayName = $key.DisplayName
+                KeyPath     = $sub.PSPath
+                KeyName     = $sub.PSChildName
+                Command     = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }
+            }
         }
-        if ($items) { $selected = $items | Select-Object -First 1; break }
+    }
+    return $list
+}
+
+function Get-ExePath {
+    param([string]$Command)
+    if ($Command -match '"([^"]+\.exe)"') { return $Matches[1] }
+    if ($Command -match '(?i)([A-Z]:\\[^"]+?\.exe)') { return $Matches[1] }
+    return $null
+}
+
+try {
+    $roots = [System.Collections.Generic.List[string]]::new()
+    $roots.Add('HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall')
+    $roots.Add('HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall')
+    foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {
+        if ($hive.Name -match '_Classes$') { continue }
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall")
+        $roots.Add("Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall")
     }
 
-    if (-not $selected -or -not $selected.UninstallString) {
-        Write-Host "Uninstall entry not found for $softwareNameLike"
+    $entries = Get-PowerBIEntries -Roots $roots
+    if ($entries.Count -eq 0) {
+        Write-Host "No Power BI Desktop entries found (already removed)."
         Exit 0
     }
 
-    # Best-effort: stop running Power BI processes so the uninstaller doesn't fail on locked files
     foreach ($proc in @("PBIDesktop", "msmdsrv", "Microsoft.Mashup.Container")) {
         Stop-Process -Name $proc -Force -ErrorAction SilentlyContinue
     }
 
-    # Prefer QuietUninstallString (already includes silent switches) when present.
-    $uninstallCommand = if ($selected.QuietUninstallString) {
-        $selected.QuietUninstallString
-    } else {
-        $selected.UninstallString
-    }
-
-    $uninstallArgs = ""
-
-    if ($uninstallCommand -match "MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") {
-        # MSI-backed uninstall (the common case for the Power BI EXE installer)
-        $productCode = $Matches[1]
-        $uninstallArgs = "/X $productCode /qn /norestart"
-        $uninstallCommand = "MsiExec.exe"
-    } else {
-        # Plain EXE uninstaller. Split the quoted command from its args.
-        $splitArgs = $uninstallCommand.Split('"')
-        if ($splitArgs.Length -gt 1) {
-            if ($splitArgs.Length -eq 3) {
-                $uninstallArgs = $splitArgs[2].Trim()
-            } elseif ($splitArgs.Length -gt 3) {
-                Throw "Uninstall command contains multiple quoted strings. Please update the uninstall script.`nUninstall command: $uninstallCommand"
-            }
-            $uninstallCommand = $splitArgs[1]
-        }
-        # If the registry didn't supply silent switches, add ours.
-        if (-not $selected.QuietUninstallString) {
-            $uninstallArgs = "$uninstallArgs $exeSilentArgs".Trim()
+    # Phase 1: uninstall via the Burn bundle bootstrapper(s) FIRST.
+    foreach ($e in ($entries | Where-Object { $_.Command -match "(?i)PBIDesktopSetup.*\.exe" })) {
+        $exe = Get-ExePath $e.Command
+        if (-not $exe) { Write-Host "Could not parse bundle exe from: $($e.Command)"; continue }
+        if (-not (Test-Path -LiteralPath $exe)) { Write-Host "Bundle exe missing: $exe"; continue }
+
+        Write-Host "Uninstalling bundle: '$($e.DisplayName)'"
+        $p = Start-Process -FilePath $exe -ArgumentList "/uninstall /quiet /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+        Wait-ForProcessExit -Names @("PBIDesktopSetup_x64", "PBIDesktopSetup", "msiexec") -TimeoutSeconds 240
+    }
+
+    # Phase 2: remove any MSI entries the bundle didn't clean up.
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        $msiCode = $null
+        if ($e.Command -match "(?i)MsiExec\.exe\s+/[IX]\s*(\{[A-F0-9-]+\})") { $msiCode = $Matches[1] }
+        elseif ($e.KeyName -match "(?i)^\{[A-F0-9-]+\}$") { $msiCode = $e.KeyName }
+        if (-not $msiCode) { continue }
+
+        Write-Host "Removing leftover MSI: '$($e.DisplayName)' ($msiCode)"
+        $p = Start-Process -FilePath "MsiExec.exe" -ArgumentList "/X $msiCode /qn /norestart" -PassThru -Wait
+        Write-Host "  Exit code: $($p.ExitCode)"
+        Wait-ForProcessExit -Names @("msiexec") -TimeoutSeconds 180
+        if (($ExpectedExitCodes -notcontains $p.ExitCode) -and ($exitCode -eq 0)) { $exitCode = $p.ExitCode }
+    }
+
+    # Phase 3: safety net for stale registration when product files are gone.
+    $productGone = -not ($installDirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) })
+    foreach ($e in (Get-PowerBIEntries -Roots $roots)) {
+        if ($productGone) {
+            Write-Host "Removing orphaned registration: '$($e.DisplayName)' ($($e.KeyPath))"
+            Remove-Item -Path $e.KeyPath -Recurse -Force -ErrorAction SilentlyContinue
+        } else {
+            Write-Host "WARNING: entry still present and product files remain: '$($e.DisplayName)'"
+            if ($exitCode -eq 0) { $exitCode = 1 }
         }
     }
 
-    Write-Host "Uninstall command: $uninstallCommand"
-    Write-Host "Uninstall args: $uninstallArgs"
-
-    $processOptions = @{
-        FilePath    = $uninstallCommand
-        NoNewWindow = $true
-        PassThru    = $true
-        Wait        = $true
-    }
-    if ($uninstallArgs -ne '') {
-        $processOptions.ArgumentList = "$uninstallArgs"
-    }
-
-    # Start uninstall process
-    $process = Start-Process @processOptions
-    $exitCode = $process.ExitCode
-    Write-Host "Uninstall exit code: $exitCode"
-
-    # msiexec can return before the uninstall is fully complete; wait it out
-    $timeout = 120
-    $elapsed = 0
-    while ((Get-Process -Name "msiexec" -ErrorAction SilentlyContinue) -and ($elapsed -lt $timeout)) {
-        Start-Sleep -Seconds 2
-        $elapsed += 2
-    }
-
 } catch {
     Write-Host "Error: $_"
     $exitCode = 1
 }
 
-# Treat acceptable exit codes as success
-if ($ExpectedExitCodes -contains $exitCode) {
-    Exit 0
-} else {
-    Exit $exitCode
-}
+if ($ExpectedExitCodes -contains $exitCode) { Exit 0 } else { Exit $exitCode }

@coderabbitai

coderabbitai Bot commented May 28, 2026

Copy link
Copy Markdown
Contributor

Actionable comments posted: 0

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants