Skip to content

Add Windows FMA for Citrix Workspace App (LTSR) - #50903

Open
kitzy wants to merge 17 commits into
mainfrom
kitzy-add-fma-windows-citrix-workspace-ltsr
Open

Add Windows FMA for Citrix Workspace App (LTSR)#50903
kitzy wants to merge 17 commits into
mainfrom
kitzy-add-fma-windows-citrix-workspace-ltsr

Conversation

@kitzy

@kitzy kitzy commented Aug 10, 2026

Copy link
Copy Markdown
Member

Related issue: Resolves #50900

Summary

Adds a Windows Fleet-maintained app for Citrix Workspace App — LTSR (Long Term Service Release), sourced from winget's Citrix.Workspace.LTSR package (separate from the Current Release Citrix.Workspace package).

Kept as its own catalog entry/slug (citrix-workspace-ltsr/windows) rather than grouping with the existing macOS Homebrew "Citrix Workspace" entry, since that one tracks Current Release, not LTSR — same pattern as the existing Firefox / Firefox ESR split.

Verification notes

  • The Citrix bootstrapper .exe leaves a resident tray/self-service process running after install, so Start-Process -Wait never returns (it waits on the whole process tree). The install script polls Programs and Features for the app's own registry entry instead of trusting the installer process's exit code.
  • Confirmed by an actual CI validator run (not just docs): the entry that registers in Programs and Features is Citrix's bundled "ReceiverInside" component, DisplayName Citrix Workspace Inside, publisher Citrix Systems, Inc. — not the Citrix Workspace <marketing-version> naming community docs describe for older builds. Its DisplayVersion (e.g. 25.7.2000.9) is that component's own internal build number, independent of the outer installer package version (25.7.2000.2020) pulled from winget, with no documented mapping between the two. Added an existence-only version-check fallback in cmd/maintained-apps/validate/windows.go, scoped to this app's catalog name and Citrix's publisher, mirroring the existing Google Chrome / Microsoft Office Click-to-Run exemptions.
  • Uninstall/detection match on DisplayName prefix Citrix Workspace and publisher Citrix Systems, Inc. (matching the generated exists query), so an unrelated app can't be picked up. Also confirmed by CI, contradicting older community docs: the "Citrix Workspace Inside" entry's UninstallString is a standard MSI reference (MsiExec.exe /I{ProductCode}), not the TrolleyExpress.exe/CWAInstaller.exe bootstrapper uninstaller. The uninstall script resolves the ProductCode (registry key name, or the GUID in the UninstallString) and runs a clean msiexec /x {ProductCode} /qn /norestart, matching the existing foxit-pdf-editor/4k-video-downloader-plus precedent in this repo — with a fallback to re-running the entry's own exe if a future build isn't MSI-based. A missing uninstall entry is treated as already-uninstalled (exit 0), not a failure.
  • The exists-query prefix Citrix Workspace isn't scoped to LTSR specifically — Current Release registers under the same naming pattern. Not a problem today (no Windows Current Release FMA exists yet), but worth knowing if one is added later. Not adding a version-based discriminator for this, since LTSR vs. Current Release version numbers don't follow a documented, stable format difference (see PR discussion).
  • Reused the existing "Citrix Workspace" icon (same product logo) for the new citrix workspace ltsr icon-map key rather than generating a new one.

Checklist for submitter

  • 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

Testing

  • Added/updated automated tests — N/A, covered by existing cmd/maintained-apps / ingester test suites, which pass with this app added.
  • QA'd all new/changed functionality manually — validated via the FMA validator CI run (install → detect → uninstall) on an actual Windows Server 2025 runner; iterated through an install-hang fix, a version-mismatch fix, and an uninstall fix based on its real output.

Summary by CodeRabbit

Summary by CodeRabbit

  • New Features

    • Added Citrix Workspace LTSR version 25.7.2000.2020 to the Windows software catalog.
    • Added silent installation and uninstallation with reboot handling and completion verification.
    • Added software metadata, detection, categorization, and download integrity validation.
    • Added Citrix Workspace branding for the new software entry.
  • Bug Fixes

    • Improved detection to recognize installed Citrix Workspace versions published by Citrix Systems, Inc.

Citrix publishes LTSR as its own winget package (Citrix.Workspace.LTSR),
separate from the Current Release track. Kept as its own catalog entry/slug
rather than grouping with the existing macOS Homebrew Citrix Workspace entry,
since that one tracks Current Release, not LTSR.
@kitzy
kitzy requested a review from a team as a code owner August 10, 2026 19:56
Copilot AI lite review requested due to automatic review settings August 10, 2026 19:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds a new Windows Fleet-maintained app entry for Citrix Workspace App (LTSR) sourced from the winget package Citrix.Workspace.LTSR, including install/uninstall automation and UI icon mapping.

Changes:

  • Added a new winget input definition for citrix-workspace-ltsr/windows with custom PowerShell install/uninstall scripts.
  • Added the generated Windows output manifest for Citrix Workspace LTSR and registered the app in the maintained-apps catalog.
  • Mapped the new software icon key (citrix workspace ltsr) to reuse the existing Citrix Workspace icon.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
frontend/pages/SoftwarePage/components/icons/index.ts Adds an icon-map key for “citrix workspace ltsr”.
ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json New generated Windows maintained-app output (queries + installer URL + script refs).
ee/maintained-apps/outputs/apps.json Registers the new app entry in the maintained-apps catalog list.
ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 Adds custom uninstall logic that locates the uninstall entry and re-runs the uninstaller silently.
ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1 Adds custom silent install logic for the Citrix bootstrapper exe.
ee/maintained-apps/inputs/winget/citrix-workspace-ltsr.json Adds the new winget input definition for Citrix Workspace LTSR.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 Outdated
Comment thread ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 Outdated
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 68.65%. Comparing base (00bf00d) to head (2a11827).
⚠️ Report is 48 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #50903      +/-   ##
==========================================
+ Coverage   68.53%   68.65%   +0.12%     
==========================================
  Files        3980     3994      +14     
  Lines      256356   257543    +1187     
  Branches    13661    13805     +144     
==========================================
+ Hits       175691   176814    +1123     
+ Misses      65025    65006      -19     
- Partials    15640    15723      +83     
Flag Coverage Δ
backend 69.74% <ø> (+0.11%) ⬆️
frontend 62.85% <ø> (+0.11%) ⬆️

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

☔ View full report in Codecov by Harness.
📢 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.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Citrix Workspace LTSR as a Windows Fleet-maintained app. The change includes Winget metadata, generated application metadata, PowerShell installation and uninstallation scripts, catalog registration, validation handling, and a Software page icon alias. The app targets version 25.7.2000.2020 with detection queries, checksum verification, and Productivity categorization.

Possibly related issues

  • fleetdm/fleet issue 50900: Tracks adding Citrix Workspace LTSR as a maintained Windows app.

Possibly related PRs

  • fleetdm/fleet#49478: Adds a Windows maintained app with similar metadata and lifecycle scripts.
  • fleetdm/fleet#50016: Adds a Windows maintained app with Winget input, generated output, PowerShell scripts, catalog registration, and icon mapping.
  • fleetdm/fleet#50021: Adds a Windows maintained app with corresponding metadata, installation and uninstallation scripts, catalog entries, and icon mapping.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the addition of a Windows Fleet-maintained app for Citrix Workspace LTSR.
Description check ✅ Passed The description explains the change, implementation details, related issue, validation results, and relevant checklist items.
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.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch kitzy-add-fma-windows-citrix-workspace-ltsr

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`:
- Around line 35-38: Update the missing-entry branch in the uninstall script to
treat an absent matching uninstall entry as successful: retain the informational
Write-Host message, but replace the failure exit status in the -not $selected
branch with a success status.

In `@ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json`:
- Around line 6-8: Update
ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json lines 6-8 to use
an LTSR-specific version or registry discriminator for detection and removal
queries. Update
ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1
lines 11-33 to select the same LTSR-specific registry entry, ensuring non-LTSR
Citrix Workspace installations are neither detected nor uninstalled.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: db332183-02a5-4b21-a4bd-84739f344737

📥 Commits

Reviewing files that changed from the base of the PR and between c41bd92 and 79c8ea0.

📒 Files selected for processing (6)
  • ee/maintained-apps/inputs/winget/citrix-workspace-ltsr.json
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1
  • ee/maintained-apps/outputs/apps.json
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json
  • frontend/pages/SoftwarePage/components/icons/index.ts

Comment thread ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 Outdated
Comment thread ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json
@allenhouchins
allenhouchins marked this pull request as draft August 10, 2026 20:30
…e LTSR

Start-Process -Wait blocked for the full 10-minute validator timeout because
the Citrix bootstrapper leaves a resident tray/self-service process running
as a child, so -Wait never returns even though the install itself completes.
Switch to polling Programs and Features for the app's own registry entry
instead of trusting the installer process's exit code.

Also scope both the install poll and the uninstall registry lookup to
publisher "Citrix Systems, Inc." (matching the generated exists query) so a
DisplayName-prefix match alone can't pick the wrong entry, and treat a
missing uninstall entry as already-uninstalled (exit 0) instead of failing.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install // 36e68d54 -> 9542c22e ===

--- /tmp/old.lKEiqN	2026-08-10 20:57:13.831644500 +0000
+++ /tmp/new.jcKzlo	2026-08-10 20:57:13.831644500 +0000
@@ -5,22 +5,55 @@
 # Custom): /silent runs without dialogs/prompts, /noreboot suppresses the
 # reboot prompt, and /AutoUpdateCheck=disabled keeps Citrix's own updater
 # from taking over version management.
+#
+# The Citrix bootstrapper leaves resident processes running after install
+# (e.g. its self-service/notification tray app), so Start-Process -Wait
+# never returns -- it waits on the whole process tree, not just the
+# installer itself. Start without -Wait and poll Programs and Features for
+# the app's own registry entry instead of trusting the installer process's
+# exit code.
+
+$softwareNameLike = "Citrix Workspace *"
+$paths = @(
+  'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
+  'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+)
+$timeoutSeconds = 480
+$pollIntervalSeconds = 10
+
+function Test-CitrixWorkspaceInstalled {
+  [array]$uninstallKeys = Get-ChildItem `
+      -Path $paths `
+      -ErrorAction SilentlyContinue |
+          ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+
+  foreach ($key in $uninstallKeys) {
+    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
+        -and $key.Publisher -eq "Citrix Systems, Inc.") {
+      return $true
+    }
+  }
+  return $false
+}
 
 try {
 
-$installProcess = Start-Process -FilePath "${env:INSTALLER_PATH}" `
+Start-Process -FilePath "${env:INSTALLER_PATH}" `
   -ArgumentList "/silent /noreboot /AutoUpdateCheck=disabled" `
-  -PassThru -Wait
-
-$exitCode = $installProcess.ExitCode
-Write-Host "Install exit code: $exitCode"
+  -PassThru | Out-Null
 
-# Treat msiexec-style reboot-required codes as success too.
-if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) {
-  Exit 0
+$elapsed = 0
+while ($elapsed -lt $timeoutSeconds) {
+  if (Test-CitrixWorkspaceInstalled) {
+    Write-Host "Citrix Workspace registered in Programs and Features after ${elapsed}s"
+    Exit 0
+  }
+  Start-Sleep -Seconds $pollIntervalSeconds
+  $elapsed += $pollIntervalSeconds
 }
 
-Exit $exitCode
+Write-Host "Timed out after ${timeoutSeconds}s waiting for Citrix Workspace to register"
+Exit 1
 
 } catch {
   Write-Host "Error: $_"

=== Uninstall // 9d1d208e -> f5031b25 ===

--- /tmp/old.QIsgqs	2026-08-10 20:57:13.855644349 +0000
+++ /tmp/new.3JYgk7	2026-08-10 20:57:13.855644349 +0000
@@ -26,15 +26,17 @@
 
 $selected = $null
 foreach ($key in $uninstallKeys) {
-    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike) {
+    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
+        -and $key.Publisher -eq "Citrix Systems, Inc.") {
         $selected = $key
         break
     }
 }
 
 if (-not $selected) {
+    # Already uninstalled (or never installed) -- nothing to do.
     Write-Host "Uninstall entry not found for $softwareNameLike"
-    Exit 1
+    Exit 0
 }
 
 $uninstallCommand = if ($selected.QuietUninstallString) {

CI validation showed the Citrix bootstrapper's Programs and Features entry
is its bundled "ReceiverInside" component (DisplayName "Citrix Workspace
Inside"), which carries its own internal build version (e.g. 25.7.2000.9)
distinct from the outer installer package version (25.7.2000.2020) pulled
from winget. There's no documented mapping between the two, so fall back to
an existence-only check once a genuine, publisher-scoped Citrix Workspace
entry is found -- same pattern already used for Google Chrome and Microsoft
Office's Click-to-Run versioning.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install // 36e68d54 -> 9542c22e ===

--- /tmp/old.AFjV3z	2026-08-10 21:16:47.410486949 +0000
+++ /tmp/new.6eC7GL	2026-08-10 21:16:47.411486943 +0000
@@ -5,22 +5,55 @@
 # Custom): /silent runs without dialogs/prompts, /noreboot suppresses the
 # reboot prompt, and /AutoUpdateCheck=disabled keeps Citrix's own updater
 # from taking over version management.
+#
+# The Citrix bootstrapper leaves resident processes running after install
+# (e.g. its self-service/notification tray app), so Start-Process -Wait
+# never returns -- it waits on the whole process tree, not just the
+# installer itself. Start without -Wait and poll Programs and Features for
+# the app's own registry entry instead of trusting the installer process's
+# exit code.
+
+$softwareNameLike = "Citrix Workspace *"
+$paths = @(
+  'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
+  'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
+)
+$timeoutSeconds = 480
+$pollIntervalSeconds = 10
+
+function Test-CitrixWorkspaceInstalled {
+  [array]$uninstallKeys = Get-ChildItem `
+      -Path $paths `
+      -ErrorAction SilentlyContinue |
+          ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+
+  foreach ($key in $uninstallKeys) {
+    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
+        -and $key.Publisher -eq "Citrix Systems, Inc.") {
+      return $true
+    }
+  }
+  return $false
+}
 
 try {
 
-$installProcess = Start-Process -FilePath "${env:INSTALLER_PATH}" `
+Start-Process -FilePath "${env:INSTALLER_PATH}" `
   -ArgumentList "/silent /noreboot /AutoUpdateCheck=disabled" `
-  -PassThru -Wait
-
-$exitCode = $installProcess.ExitCode
-Write-Host "Install exit code: $exitCode"
+  -PassThru | Out-Null
 
-# Treat msiexec-style reboot-required codes as success too.
-if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) {
-  Exit 0
+$elapsed = 0
+while ($elapsed -lt $timeoutSeconds) {
+  if (Test-CitrixWorkspaceInstalled) {
+    Write-Host "Citrix Workspace registered in Programs and Features after ${elapsed}s"
+    Exit 0
+  }
+  Start-Sleep -Seconds $pollIntervalSeconds
+  $elapsed += $pollIntervalSeconds
 }
 
-Exit $exitCode
+Write-Host "Timed out after ${timeoutSeconds}s waiting for Citrix Workspace to register"
+Exit 1
 
 } catch {
   Write-Host "Error: $_"

=== Uninstall // 9d1d208e -> f5031b25 ===

--- /tmp/old.4JruGp	2026-08-10 21:16:47.427486846 +0000
+++ /tmp/new.EPVLFM	2026-08-10 21:16:47.427486846 +0000
@@ -26,15 +26,17 @@
 
 $selected = $null
 foreach ($key in $uninstallKeys) {
-    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike) {
+    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
+        -and $key.Publisher -eq "Citrix Systems, Inc.") {
         $selected = $key
         break
     }
 }
 
 if (-not $selected) {
+    # Already uninstalled (or never installed) -- nothing to do.
     Write-Host "Uninstall entry not found for $softwareNameLike"
-    Exit 1
+    Exit 0
 }
 
 $uninstallCommand = if ($selected.QuietUninstallString) {

…eyExpress

CI showed the real UninstallString for "Citrix Workspace Inside" starts with
MsiExec.exe (the maintenance/repair form), not the TrolleyExpress.exe or
CWAInstaller.exe bootstrapper uninstaller community docs describe for this
DisplayName. Our previous parser discarded everything after "MsiExec.exe"
and then ran it with Citrix's own /uninstall /cleanup /silent switches,
which msiexec doesn't understand -- it sat for the full 10-minute timeout.

Detect the msiexec case, resolve the ProductCode from the registry key name
(or the UninstallString's GUID as a fallback), and run a clean
"msiexec /x {ProductCode} /qn /norestart", matching the existing
foxit-pdf-editor/4k-video-downloader-plus precedent in this repo. Keep the
exe-based fallback for the non-MSI shape in case a future build reverts to
the bootstrapper-owned uninstaller.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // f5031b25 -> fa9409d1 ===

--- /tmp/old.7qicLt	2026-08-10 21:46:54.410060906 +0000
+++ /tmp/new.ClPD2X	2026-08-10 21:46:54.410060906 +0000
@@ -1,12 +1,16 @@
 # Uninstall for Citrix Workspace App LTSR.
 #
-# The installer self-registers under Programs and Features as "Citrix
-# Workspace <version>" (e.g. "Citrix Workspace 2507"), with an UninstallString
-# that points at the vendor's own uninstaller (TrolleyExpress.exe on older
-# builds, CWAInstaller.exe on 2311.1+). We look up that entry, extract just
-# the executable path, and re-run it with the vendor-documented silent
-# uninstall switches rather than trusting whatever args are already in the
-# registry string.
+# The Programs and Features entry that's actually visible is Citrix's
+# bundled "ReceiverInside" component (DisplayName "Citrix Workspace Inside"),
+# whose UninstallString is a standard MSI reference (MsiExec.exe
+# /I{ProductCode}, the maintenance/repair form) -- not the TrolleyExpress.exe
+# or CWAInstaller.exe bootstrapper uninstaller some older community docs
+# describe for this DisplayName pattern. Resolve the ProductCode (registry
+# key name, or the first GUID in the UninstallString) and run a clean
+# "msiexec /x {ProductCode} /qn /norestart" -- never reuse the /I switch
+# from the registry string. If the selected entry isn't MSI-based, fall back
+# to re-running its own uninstaller exe with Citrix's documented silent
+# switches.
 
 $softwareNameLike = "Citrix Workspace *"
 
@@ -39,6 +43,8 @@
     Exit 0
 }
 
+Write-Host "Selected entry DisplayName: $($selected.DisplayName)"
+
 $uninstallCommand = if ($selected.QuietUninstallString) {
     $selected.QuietUninstallString
 } else {
@@ -50,25 +56,42 @@
     Exit 1
 }
 
-$exePath = ""
-if ($uninstallCommand -match '^\s*"([^"]+)"') {
-    # Quoted path
-    $exePath = $matches[1]
-} elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
-    # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
-    $exePath = $matches[1]
-} else {
-    Throw "Could not parse uninstaller path from: $uninstallCommand"
-}
+if ($uninstallCommand -match '(?i)msiexec') {
+    # MSI-based entry: resolve the ProductCode and run a clean uninstall,
+    # ignoring whatever switches are already in the registry string.
+    $productCode = $selected.PSChildName
+    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+        if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
+            $productCode = $matches[1]
+        }
+    }
+    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+        Throw "Could not determine ProductCode from: $uninstallCommand"
+    }
 
-# Vendor-documented silent uninstall switches.
-$uninstallArgs = "/uninstall /cleanup /silent"
+    Write-Host "Uninstalling product code: $productCode"
+    $process = Start-Process -FilePath "msiexec.exe" `
+        -ArgumentList "/x $productCode /qn /norestart" `
+        -PassThru -Wait
+} else {
+    # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run the
+    # vendor's own uninstaller with its documented silent switches.
+    $exePath = ""
+    if ($uninstallCommand -match '^\s*"([^"]+)"') {
+        # Quoted path
+        $exePath = $matches[1]
+    } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
+        # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
+        $exePath = $matches[1]
+    } else {
+        Throw "Could not parse uninstaller path from: $uninstallCommand"
+    }
 
-Write-Host "Selected entry DisplayName: $($selected.DisplayName)"
-Write-Host "Uninstall command: $exePath"
-Write-Host "Uninstall args: $uninstallArgs"
+    Write-Host "Uninstall command: $exePath"
+    $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
+        -PassThru -Wait
+}
 
-$process = Start-Process -FilePath $exePath -ArgumentList $uninstallArgs -PassThru -Wait
 $exitCode = $process.ExitCode
 Write-Host "Uninstall exit code: $exitCode"

@kitzy
kitzy marked this pull request as ready for review August 11, 2026 01:16
@kitzy
kitzy requested a review from a team as a code owner August 11, 2026 01:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`:
- Around line 73-75: Update both uninstaller invocations in
ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 at
lines 73-75 and 90-92: remove -Wait while retaining -PassThru, call
WaitForExit() with a defined timeout, terminate any process that exceeds it, and
fail immediately before reading ExitCode.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e28b92cf-882b-4b3e-94c3-21b0e8e738c8

📥 Commits

Reviewing files that changed from the base of the PR and between 79c8ea0 and e27694a.

📒 Files selected for processing (4)
  • cmd/maintained-apps/validate/windows.go
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

Comment thread ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 Outdated
Start-Process -Wait blocked indefinitely if the uninstaller hangs, which is
exactly what happened to the install script earlier in this PR. Switch both
the msiexec and exe-fallback branches to -PassThru + WaitForExit(timeout),
stopping and failing the process if it doesn't exit within 3 minutes instead
of reading a stale ExitCode.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // fa9409d1 -> 046e77f7 ===

--- /tmp/old.GBc5Jz	2026-08-11 01:36:31.995984239 +0000
+++ /tmp/new.8COYPr	2026-08-11 01:36:31.995984239 +0000
@@ -20,6 +20,7 @@
 )
 
 $exitCode = 0
+$timeoutSeconds = 180
 
 try {
 
@@ -72,7 +73,7 @@
     Write-Host "Uninstalling product code: $productCode"
     $process = Start-Process -FilePath "msiexec.exe" `
         -ArgumentList "/x $productCode /qn /norestart" `
-        -PassThru -Wait
+        -PassThru
 } else {
     # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run the
     # vendor's own uninstaller with its documented silent switches.
@@ -89,7 +90,14 @@
 
     Write-Host "Uninstall command: $exePath"
     $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
-        -PassThru -Wait
+        -PassThru
+}
+
+$exited = $process.WaitForExit($timeoutSeconds * 1000)
+if (-not $exited) {
+    Write-Host "Uninstaller did not exit within ${timeoutSeconds}s, stopping it."
+    Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+    Exit 1
 }
 
 $exitCode = $process.ExitCode

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // fa9409d1 -> 046e77f7 ===

--- /tmp/old.v76j3s	2026-08-11 01:55:47.044496521 +0000
+++ /tmp/new.4ayqxe	2026-08-11 01:55:47.044496521 +0000
@@ -20,6 +20,7 @@
 )
 
 $exitCode = 0
+$timeoutSeconds = 180
 
 try {
 
@@ -72,7 +73,7 @@
     Write-Host "Uninstalling product code: $productCode"
     $process = Start-Process -FilePath "msiexec.exe" `
         -ArgumentList "/x $productCode /qn /norestart" `
-        -PassThru -Wait
+        -PassThru
 } else {
     # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run the
     # vendor's own uninstaller with its documented silent switches.
@@ -89,7 +90,14 @@
 
     Write-Host "Uninstall command: $exePath"
     $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
-        -PassThru -Wait
+        -PassThru
+}
+
+$exited = $process.WaitForExit($timeoutSeconds * 1000)
+if (-not $exited) {
+    Write-Host "Uninstaller did not exit within ${timeoutSeconds}s, stopping it."
+    Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+    Exit 1
 }
 
 $exitCode = $process.ExitCode

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1 (1)

27-30: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Do not treat suppressed registry-read failures as a successful uninstall.

When both registry reads fail, -ErrorAction SilentlyContinue produces no entries. $selected remains $null, and lines 41-44 exit with 0, even if Citrix remains installed. Preserve registry-read failures before treating a missing entry as success.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`
around lines 27 - 30, Update the registry lookup in the uninstall script around
$uninstallKeys to preserve and detect Get-ChildItem or Get-ItemProperty failures
instead of suppressing them as empty results. Ensure the uninstall flow
involving $selected returns a failure when registry reads fail, while still
treating a successfully completed lookup with no matching entry as an
already-uninstalled success.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In
`@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`:
- Around line 27-30: Update the registry lookup in the uninstall script around
$uninstallKeys to preserve and detect Get-ChildItem or Get-ItemProperty failures
instead of suppressing them as empty results. Ensure the uninstall flow
involving $selected returns a failure when registry reads fail, while still
treating a successfully completed lookup with no matching entry as an
already-uninstalled success.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 18214349-a7df-40e0-8376-75c6db35eed5

📥 Commits

Reviewing files that changed from the base of the PR and between e27694a and 4f9bf71.

📒 Files selected for processing (2)
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

@kitzy

kitzy commented Aug 11, 2026

Copy link
Copy Markdown
Member Author

Addressing CodeRabbit's outside-diff finding on citrix_workspace_ltsr_uninstall.ps1 (registry-read failures shouldn't be treated as "already uninstalled"): added a check that throws if Get-ChildItem fails on every uninstall registry path via -ErrorVariable, rather than silently falling through to exit 0 when the lookup itself failed rather than legitimately finding nothing.

🤖 Addressed by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // ca292dea -> f13cfbb2 ===

--- /tmp/old.wqpgqZ	2026-08-11 03:19:07.273075178 +0000
+++ /tmp/new.XnuJ13	2026-08-11 03:19:07.274075185 +0000
@@ -1,21 +1,26 @@
 # Uninstall for Citrix Workspace App LTSR.
 #
-# The Programs and Features entry that's actually visible is Citrix's
-# bundled "ReceiverInside" component (DisplayName "Citrix Workspace Inside"),
-# whose UninstallString is a standard MSI reference (MsiExec.exe
-# /I{ProductCode}, the maintenance/repair form) -- not the TrolleyExpress.exe
-# or CWAInstaller.exe bootstrapper uninstaller some older community docs
-# describe for this DisplayName pattern. Resolve the ProductCode (registry
-# key name, or the first GUID in the UninstallString) and run a clean
-# "msiexec /x {ProductCode} /qn /norestart" -- never reuse the /I switch
-# from the registry string. If the selected entry isn't MSI-based, fall back
-# to re-running its own uninstaller exe with Citrix's documented silent
+# The bootstrap registers multiple separate Programs and Features entries
+# (confirmed by CI: "Citrix Workspace Inside" plus a distinct "Citrix
+# Workspace(USB)" entry, and possibly others depending on selected
+# components) -- removing just the first one found still leaves the app
+# detectable. Enumerate every entry matching the "Citrix Workspace " prefix
+# and publisher, and uninstall each one.
+#
+# Most of these entries' UninstallStrings are standard MSI references
+# (MsiExec.exe /I{ProductCode}, the maintenance/repair form) -- not the
+# TrolleyExpress.exe or CWAInstaller.exe bootstrapper uninstaller some older
+# community docs describe for this DisplayName pattern. Resolve the
+# ProductCode (registry key name, or the first GUID in the UninstallString)
+# and run a clean "msiexec /x {ProductCode} /qn /norestart" -- never reuse
+# the /I switch from the registry string. If an entry isn't MSI-based, fall
+# back to re-running its own uninstaller exe with Citrix's documented silent
 # switches.
 #
-# The Citrix bootstrapper installs several components; other internal MSI
-# transactions can still hold the Windows Installer mutex right after our own
-# install script returns, so msiexec /x fails transiently with 1618
-# (ERROR_INSTALL_ALREADY_RUNNING). Retry a few times with a short delay.
+# The Citrix bootstrapper installs several components as separate MSI
+# transactions, so uninstalling one can transiently fail with 1618
+# (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
+# Retry a few times with a short delay.
 
 $softwareNameLike = "Citrix Workspace *"
 
@@ -24,7 +29,6 @@
   'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
 )
 
-$exitCode = 0
 $timeoutSeconds = 180
 $maxMsiAttempts = 5
 $msiRetryDelaySeconds = 15
@@ -40,105 +44,121 @@
     return $Process.ExitCode
 }
 
-try {
+function Uninstall-CitrixEntry {
+    param($Entry)
 
-[array]$uninstallKeys = Get-ChildItem `
-    -Path $paths `
-    -ErrorAction SilentlyContinue |
-        ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+    Write-Host "Uninstalling component: $($Entry.DisplayName)"
 
-$selected = $null
-foreach ($key in $uninstallKeys) {
-    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
-        -and $key.Publisher -eq "Citrix Systems, Inc.") {
-        $selected = $key
-        break
+    $uninstallCommand = if ($Entry.QuietUninstallString) {
+        $Entry.QuietUninstallString
+    } else {
+        $Entry.UninstallString
     }
-}
 
-if (-not $selected) {
-    # Already uninstalled (or never installed) -- nothing to do.
-    Write-Host "Uninstall entry not found for $softwareNameLike"
-    Exit 0
-}
+    if (-not $uninstallCommand) {
+        Write-Host "Entry has no UninstallString: $($Entry.DisplayName)"
+        return 1
+    }
+
+    if ($uninstallCommand -match '(?i)msiexec') {
+        # MSI-based entry: resolve the ProductCode and run a clean uninstall,
+        # ignoring whatever switches are already in the registry string.
+        $productCode = $Entry.PSChildName
+        if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+            if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
+                $productCode = $matches[1]
+            }
+        }
+        if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+            Write-Host "Could not determine ProductCode from: $uninstallCommand"
+            return 1
+        }
 
-Write-Host "Selected entry DisplayName: $($selected.DisplayName)"
+        Write-Host "Uninstalling product code: $productCode"
 
-$uninstallCommand = if ($selected.QuietUninstallString) {
-    $selected.QuietUninstallString
-} else {
-    $selected.UninstallString
-}
-
-if (-not $uninstallCommand) {
-    Write-Host "Selected entry has no UninstallString: $($selected.DisplayName)"
-    Exit 1
-}
-
-if ($uninstallCommand -match '(?i)msiexec') {
-    # MSI-based entry: resolve the ProductCode and run a clean uninstall,
-    # ignoring whatever switches are already in the registry string.
-    $productCode = $selected.PSChildName
-    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
-        if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
-            $productCode = $matches[1]
-        }
-    }
-    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
-        Throw "Could not determine ProductCode from: $uninstallCommand"
-    }
+        $exitCode = 1
+        for ($attempt = 1; $attempt -le $maxMsiAttempts; $attempt++) {
+            $process = Start-Process -FilePath "msiexec.exe" `
+                -ArgumentList "/x $productCode /qn /norestart" `
+                -PassThru
+
+            $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
+            if ($null -eq $exitCode) {
+                return 1
+            }
+            Write-Host "Uninstall exit code: $exitCode (attempt $attempt of $maxMsiAttempts)"
+
+            if ($exitCode -ne 1618) {
+                break
+            }
 
-    Write-Host "Uninstalling product code: $productCode"
+            Write-Host "Windows Installer busy (1618) -- another of Citrix's own component installs likely still holds the mutex. Retrying in ${msiRetryDelaySeconds}s."
+            Start-Sleep -Seconds $msiRetryDelaySeconds
+        }
+    } else {
+        # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run
+        # the vendor's own uninstaller with its documented silent switches.
+        $exePath = ""
+        if ($uninstallCommand -match '^\s*"([^"]+)"') {
+            # Quoted path
+            $exePath = $matches[1]
+        } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
+            # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
+            $exePath = $matches[1]
+        } else {
+            Write-Host "Could not parse uninstaller path from: $uninstallCommand"
+            return 1
+        }
 
-    for ($attempt = 1; $attempt -le $maxMsiAttempts; $attempt++) {
-        $process = Start-Process -FilePath "msiexec.exe" `
-            -ArgumentList "/x $productCode /qn /norestart" `
+        Write-Host "Uninstall command: $exePath"
+        $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
             -PassThru
 
         $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
         if ($null -eq $exitCode) {
-            Exit 1
+            return 1
         }
-        Write-Host "Uninstall exit code: $exitCode (attempt $attempt of $maxMsiAttempts)"
-
-        if ($exitCode -ne 1618) {
-            break
-        }
-
-        Write-Host "Windows Installer busy (1618) -- another of Citrix's own component installs likely still holds the mutex. Retrying in ${msiRetryDelaySeconds}s."
-        Start-Sleep -Seconds $msiRetryDelaySeconds
+        Write-Host "Uninstall exit code: $exitCode"
     }
-} else {
-    # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run the
-    # vendor's own uninstaller with its documented silent switches.
-    $exePath = ""
-    if ($uninstallCommand -match '^\s*"([^"]+)"') {
-        # Quoted path
-        $exePath = $matches[1]
-    } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
-        # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
-        $exePath = $matches[1]
-    } else {
-        Throw "Could not parse uninstaller path from: $uninstallCommand"
+
+    # Treat msiexec-style reboot-required codes as success too.
+    if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+        return 0
     }
+    return $exitCode
+}
 
-    Write-Host "Uninstall command: $exePath"
-    $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
-        -PassThru
+try {
 
-    $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
-    if ($null -eq $exitCode) {
-        Exit 1
-    }
-    Write-Host "Uninstall exit code: $exitCode"
+[array]$uninstallKeys = Get-ChildItem `
+    -Path $paths `
+    -ErrorAction SilentlyContinue -ErrorVariable readErrors |
+        ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+
+if (@($readErrors).Count -ge $paths.Count) {
+    Throw "Failed to read the registry uninstall keys: $($readErrors -join '; ')"
+}
+
+[array]$entries = $uninstallKeys | Where-Object {
+    $_.DisplayName -and $_.DisplayName -like $softwareNameLike `
+        -and $_.Publisher -eq "Citrix Systems, Inc."
 }
 
-# Treat msiexec-style reboot-required codes as success too.
-if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+if ($entries.Count -eq 0) {
+    # Already uninstalled (or never installed) -- nothing to do.
+    Write-Host "No entries found matching $softwareNameLike"
     Exit 0
 }
 
-Exit $exitCode
+$overallExitCode = 0
+foreach ($entry in $entries) {
+    $result = Uninstall-CitrixEntry -Entry $entry
+    if ($result -ne 0) {
+        $overallExitCode = $result
+    }
+}
+
+Exit $overallExitCode
 
 } catch {
     Write-Host "Error: $_"

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // ca292dea -> f13cfbb2 ===

--- /tmp/old.JiHfk8	2026-08-11 03:26:02.457296091 +0000
+++ /tmp/new.nNWNkO	2026-08-11 03:26:02.457296091 +0000
@@ -1,21 +1,26 @@
 # Uninstall for Citrix Workspace App LTSR.
 #
-# The Programs and Features entry that's actually visible is Citrix's
-# bundled "ReceiverInside" component (DisplayName "Citrix Workspace Inside"),
-# whose UninstallString is a standard MSI reference (MsiExec.exe
-# /I{ProductCode}, the maintenance/repair form) -- not the TrolleyExpress.exe
-# or CWAInstaller.exe bootstrapper uninstaller some older community docs
-# describe for this DisplayName pattern. Resolve the ProductCode (registry
-# key name, or the first GUID in the UninstallString) and run a clean
-# "msiexec /x {ProductCode} /qn /norestart" -- never reuse the /I switch
-# from the registry string. If the selected entry isn't MSI-based, fall back
-# to re-running its own uninstaller exe with Citrix's documented silent
+# The bootstrap registers multiple separate Programs and Features entries
+# (confirmed by CI: "Citrix Workspace Inside" plus a distinct "Citrix
+# Workspace(USB)" entry, and possibly others depending on selected
+# components) -- removing just the first one found still leaves the app
+# detectable. Enumerate every entry matching the "Citrix Workspace " prefix
+# and publisher, and uninstall each one.
+#
+# Most of these entries' UninstallStrings are standard MSI references
+# (MsiExec.exe /I{ProductCode}, the maintenance/repair form) -- not the
+# TrolleyExpress.exe or CWAInstaller.exe bootstrapper uninstaller some older
+# community docs describe for this DisplayName pattern. Resolve the
+# ProductCode (registry key name, or the first GUID in the UninstallString)
+# and run a clean "msiexec /x {ProductCode} /qn /norestart" -- never reuse
+# the /I switch from the registry string. If an entry isn't MSI-based, fall
+# back to re-running its own uninstaller exe with Citrix's documented silent
 # switches.
 #
-# The Citrix bootstrapper installs several components; other internal MSI
-# transactions can still hold the Windows Installer mutex right after our own
-# install script returns, so msiexec /x fails transiently with 1618
-# (ERROR_INSTALL_ALREADY_RUNNING). Retry a few times with a short delay.
+# The Citrix bootstrapper installs several components as separate MSI
+# transactions, so uninstalling one can transiently fail with 1618
+# (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
+# Retry a few times with a short delay.
 
 $softwareNameLike = "Citrix Workspace *"
 
@@ -24,7 +29,6 @@
   'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
 )
 
-$exitCode = 0
 $timeoutSeconds = 180
 $maxMsiAttempts = 5
 $msiRetryDelaySeconds = 15
@@ -40,105 +44,121 @@
     return $Process.ExitCode
 }
 
-try {
+function Uninstall-CitrixEntry {
+    param($Entry)
 
-[array]$uninstallKeys = Get-ChildItem `
-    -Path $paths `
-    -ErrorAction SilentlyContinue |
-        ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+    Write-Host "Uninstalling component: $($Entry.DisplayName)"
 
-$selected = $null
-foreach ($key in $uninstallKeys) {
-    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
-        -and $key.Publisher -eq "Citrix Systems, Inc.") {
-        $selected = $key
-        break
+    $uninstallCommand = if ($Entry.QuietUninstallString) {
+        $Entry.QuietUninstallString
+    } else {
+        $Entry.UninstallString
     }
-}
 
-if (-not $selected) {
-    # Already uninstalled (or never installed) -- nothing to do.
-    Write-Host "Uninstall entry not found for $softwareNameLike"
-    Exit 0
-}
+    if (-not $uninstallCommand) {
+        Write-Host "Entry has no UninstallString: $($Entry.DisplayName)"
+        return 1
+    }
+
+    if ($uninstallCommand -match '(?i)msiexec') {
+        # MSI-based entry: resolve the ProductCode and run a clean uninstall,
+        # ignoring whatever switches are already in the registry string.
+        $productCode = $Entry.PSChildName
+        if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+            if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
+                $productCode = $matches[1]
+            }
+        }
+        if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
+            Write-Host "Could not determine ProductCode from: $uninstallCommand"
+            return 1
+        }
 
-Write-Host "Selected entry DisplayName: $($selected.DisplayName)"
+        Write-Host "Uninstalling product code: $productCode"
 
-$uninstallCommand = if ($selected.QuietUninstallString) {
-    $selected.QuietUninstallString
-} else {
-    $selected.UninstallString
-}
-
-if (-not $uninstallCommand) {
-    Write-Host "Selected entry has no UninstallString: $($selected.DisplayName)"
-    Exit 1
-}
-
-if ($uninstallCommand -match '(?i)msiexec') {
-    # MSI-based entry: resolve the ProductCode and run a clean uninstall,
-    # ignoring whatever switches are already in the registry string.
-    $productCode = $selected.PSChildName
-    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
-        if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
-            $productCode = $matches[1]
-        }
-    }
-    if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
-        Throw "Could not determine ProductCode from: $uninstallCommand"
-    }
+        $exitCode = 1
+        for ($attempt = 1; $attempt -le $maxMsiAttempts; $attempt++) {
+            $process = Start-Process -FilePath "msiexec.exe" `
+                -ArgumentList "/x $productCode /qn /norestart" `
+                -PassThru
+
+            $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
+            if ($null -eq $exitCode) {
+                return 1
+            }
+            Write-Host "Uninstall exit code: $exitCode (attempt $attempt of $maxMsiAttempts)"
+
+            if ($exitCode -ne 1618) {
+                break
+            }
 
-    Write-Host "Uninstalling product code: $productCode"
+            Write-Host "Windows Installer busy (1618) -- another of Citrix's own component installs likely still holds the mutex. Retrying in ${msiRetryDelaySeconds}s."
+            Start-Sleep -Seconds $msiRetryDelaySeconds
+        }
+    } else {
+        # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run
+        # the vendor's own uninstaller with its documented silent switches.
+        $exePath = ""
+        if ($uninstallCommand -match '^\s*"([^"]+)"') {
+            # Quoted path
+            $exePath = $matches[1]
+        } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
+            # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
+            $exePath = $matches[1]
+        } else {
+            Write-Host "Could not parse uninstaller path from: $uninstallCommand"
+            return 1
+        }
 
-    for ($attempt = 1; $attempt -le $maxMsiAttempts; $attempt++) {
-        $process = Start-Process -FilePath "msiexec.exe" `
-            -ArgumentList "/x $productCode /qn /norestart" `
+        Write-Host "Uninstall command: $exePath"
+        $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
             -PassThru
 
         $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
         if ($null -eq $exitCode) {
-            Exit 1
+            return 1
         }
-        Write-Host "Uninstall exit code: $exitCode (attempt $attempt of $maxMsiAttempts)"
-
-        if ($exitCode -ne 1618) {
-            break
-        }
-
-        Write-Host "Windows Installer busy (1618) -- another of Citrix's own component installs likely still holds the mutex. Retrying in ${msiRetryDelaySeconds}s."
-        Start-Sleep -Seconds $msiRetryDelaySeconds
+        Write-Host "Uninstall exit code: $exitCode"
     }
-} else {
-    # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run the
-    # vendor's own uninstaller with its documented silent switches.
-    $exePath = ""
-    if ($uninstallCommand -match '^\s*"([^"]+)"') {
-        # Quoted path
-        $exePath = $matches[1]
-    } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
-        # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
-        $exePath = $matches[1]
-    } else {
-        Throw "Could not parse uninstaller path from: $uninstallCommand"
+
+    # Treat msiexec-style reboot-required codes as success too.
+    if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+        return 0
     }
+    return $exitCode
+}
 
-    Write-Host "Uninstall command: $exePath"
-    $process = Start-Process -FilePath $exePath -ArgumentList "/uninstall /cleanup /silent" `
-        -PassThru
+try {
 
-    $exitCode = Wait-ProcessBounded -Process $process -TimeoutSeconds $timeoutSeconds
-    if ($null -eq $exitCode) {
-        Exit 1
-    }
-    Write-Host "Uninstall exit code: $exitCode"
+[array]$uninstallKeys = Get-ChildItem `
+    -Path $paths `
+    -ErrorAction SilentlyContinue -ErrorVariable readErrors |
+        ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
+
+if (@($readErrors).Count -ge $paths.Count) {
+    Throw "Failed to read the registry uninstall keys: $($readErrors -join '; ')"
+}
+
+[array]$entries = $uninstallKeys | Where-Object {
+    $_.DisplayName -and $_.DisplayName -like $softwareNameLike `
+        -and $_.Publisher -eq "Citrix Systems, Inc."
 }
 
-# Treat msiexec-style reboot-required codes as success too.
-if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+if ($entries.Count -eq 0) {
+    # Already uninstalled (or never installed) -- nothing to do.
+    Write-Host "No entries found matching $softwareNameLike"
     Exit 0
 }
 
-Exit $exitCode
+$overallExitCode = 0
+foreach ($entry in $entries) {
+    $result = Uninstall-CitrixEntry -Entry $entry
+    if ($result -ne 0) {
+        $overallExitCode = $result
+    }
+}
+
+Exit $overallExitCode
 
 } catch {
     Write-Host "Error: $_"

CI showed the previous fix (enumerate all matching entries) still left
"Citrix Workspace(USB)" installed. Root cause: the -like pattern was
"Citrix Workspace *" with a literal space before the wildcard, but that
entry's DisplayName has no space after "Workspace" ("Citrix
Workspace(USB)"), so it silently never matched the filter and was never
even attempted. Drop the space so the wildcard covers both shapes.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // f13cfbb2 -> 2a500494 ===

--- /tmp/old.dj5RhF	2026-08-11 03:33:33.387038954 +0000
+++ /tmp/new.Oy7wgw	2026-08-11 03:33:33.387038954 +0000
@@ -4,8 +4,9 @@
 # (confirmed by CI: "Citrix Workspace Inside" plus a distinct "Citrix
 # Workspace(USB)" entry, and possibly others depending on selected
 # components) -- removing just the first one found still leaves the app
-# detectable. Enumerate every entry matching the "Citrix Workspace " prefix
-# and publisher, and uninstall each one.
+# detectable. Enumerate every entry matching the "Citrix Workspace" prefix
+# and publisher, and uninstall each one. No space before the wildcard: some
+# entries (e.g. "Citrix Workspace(USB)") have no space after "Workspace".
 #
 # Most of these entries' UninstallStrings are standard MSI references
 # (MsiExec.exe /I{ProductCode}, the maintenance/repair form) -- not the
@@ -22,7 +23,7 @@
 # (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
 # Retry a few times with a short delay.
 
-$softwareNameLike = "Citrix Workspace *"
+$softwareNameLike = "Citrix Workspace*"
 
 $paths = @(
   'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',

…the first

CI kept showing "Citrix Workspace(USB)" still installed after uninstall, even
after fixing the uninstall script to enumerate every matching entry. The
install script was declaring success as soon as the FIRST component
("Citrix Workspace Inside") registered, but the bootstrap installs several
components as separate, sequential MSI transactions -- "Citrix
Workspace(USB)" was still mid-install (or not yet started) at that point.
The subsequent uninstall step only ever saw the entries that existed yet.

Require at least one entry registered AND no msiexec.exe process running,
stable across two consecutive 10s polls, before declaring the install done.
Also fixes the same missing-wildcard-space bug from the uninstall script fix
(this script's copy of the pattern had it too).
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install // 9542c22e -> 0f4f1460 ===

--- /tmp/old.AfXrxi	2026-08-11 03:44:06.055601237 +0000
+++ /tmp/new.EqlDQr	2026-08-11 03:44:06.055601237 +0000
@@ -9,17 +9,25 @@
 # The Citrix bootstrapper leaves resident processes running after install
 # (e.g. its self-service/notification tray app), so Start-Process -Wait
 # never returns -- it waits on the whole process tree, not just the
-# installer itself. Start without -Wait and poll Programs and Features for
-# the app's own registry entry instead of trusting the installer process's
-# exit code.
+# installer itself. Start without -Wait and poll Programs and Features
+# instead of trusting the installer process's exit code.
+#
+# The bootstrap installs several components as separate, sequential MSI
+# transactions (confirmed by CI: "Citrix Workspace Inside" registers before
+# "Citrix Workspace(USB)" does). Declaring success as soon as the FIRST
+# entry appears races the still-running later components -- our uninstall
+# script would then only find and remove whichever ones had registered so
+# far. Require at least one entry AND no msiexec.exe process running,
+# stable across two consecutive polls, before declaring the install done.
 
-$softwareNameLike = "Citrix Workspace *"
+$softwareNameLike = "Citrix Workspace*"
 $paths = @(
   'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
   'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
 )
 $timeoutSeconds = 480
 $pollIntervalSeconds = 10
+$requiredStableChecks = 2
 
 function Test-CitrixWorkspaceInstalled {
   [array]$uninstallKeys = Get-ChildItem `
@@ -43,16 +51,26 @@
   -PassThru | Out-Null
 
 $elapsed = 0
+$stableChecks = 0
 while ($elapsed -lt $timeoutSeconds) {
-  if (Test-CitrixWorkspaceInstalled) {
-    Write-Host "Citrix Workspace registered in Programs and Features after ${elapsed}s"
-    Exit 0
+  $registered = Test-CitrixWorkspaceInstalled
+  $msiexecIdle = -not (Get-Process -Name "msiexec" -ErrorAction SilentlyContinue)
+
+  if ($registered -and $msiexecIdle) {
+    $stableChecks++
+    if ($stableChecks -ge $requiredStableChecks) {
+      Write-Host "Citrix Workspace registered and no MSI transaction in flight after ${elapsed}s"
+      Exit 0
+    }
+  } else {
+    $stableChecks = 0
   }
+
   Start-Sleep -Seconds $pollIntervalSeconds
   $elapsed += $pollIntervalSeconds
 }
 
-Write-Host "Timed out after ${timeoutSeconds}s waiting for Citrix Workspace to register"
+Write-Host "Timed out after ${timeoutSeconds}s waiting for Citrix Workspace to finish installing"
 Exit 1
 
 } catch {

=== Uninstall Script (no changes) ===

CI showed the real shape now that the install script waits for all
components: 5 entries total, including a master "Citrix Workspace <version>"
entry whose UninstallString runs bootstrapperhelper.exe rather than msiexec.
Removing that master entry cascades and removes the individual MSI
sub-components (USB, SSON, Inside, DV) too. Since we snapshot every entry
before uninstalling any of them, the later msiexec /x calls for those
already-cascaded components correctly report 1605
(ERROR_UNKNOWN_PRODUCT, "not currently installed") -- that's the expected
outcome, not a failure.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // 2a500494 -> 955cb307 ===

--- /tmp/old.vMqfJC	2026-08-11 03:57:29.447361810 +0000
+++ /tmp/new.KzMWy9	2026-08-11 03:57:29.447361810 +0000
@@ -22,6 +22,14 @@
 # transactions, so uninstalling one can transiently fail with 1618
 # (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
 # Retry a few times with a short delay.
+#
+# One entry (DisplayName "Citrix Workspace <version>", UninstallString
+# bootstrapperhelper.exe rather than msiexec) is the master entry: removing
+# it cascades and removes the other MSI sub-components (USB, SSON, Inside,
+# DV, ...) too. Since every entry was already snapshotted before any
+# uninstall ran, a later msiexec /x for an already-cascaded component fails
+# with 1605 (ERROR_UNKNOWN_PRODUCT, "not currently installed") -- that's
+# success, not a failure.
 
 $softwareNameLike = "Citrix Workspace*"
 
@@ -122,8 +130,9 @@
         Write-Host "Uninstall exit code: $exitCode"
     }
 
-    # Treat msiexec-style reboot-required codes as success too.
-    if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+    # Treat msiexec-style reboot-required codes as success, and 1605 (product
+    # already removed, e.g. by another entry's cascade) as success too.
+    if ($exitCode -eq 3010 -or $exitCode -eq 1641 -or $exitCode -eq 1605) {
         return 0
     }
     return $exitCode

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install Script (no changes) ===
=== Uninstall // 2a500494 -> 955cb307 ===

--- /tmp/old.z4c9IR	2026-08-11 14:58:13.603832173 +0000
+++ /tmp/new.9q62jh	2026-08-11 14:58:13.604832182 +0000
@@ -22,6 +22,14 @@
 # transactions, so uninstalling one can transiently fail with 1618
 # (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
 # Retry a few times with a short delay.
+#
+# One entry (DisplayName "Citrix Workspace <version>", UninstallString
+# bootstrapperhelper.exe rather than msiexec) is the master entry: removing
+# it cascades and removes the other MSI sub-components (USB, SSON, Inside,
+# DV, ...) too. Since every entry was already snapshotted before any
+# uninstall ran, a later msiexec /x for an already-cascaded component fails
+# with 1605 (ERROR_UNKNOWN_PRODUCT, "not currently installed") -- that's
+# success, not a failure.
 
 $softwareNameLike = "Citrix Workspace*"
 
@@ -122,8 +130,9 @@
         Write-Host "Uninstall exit code: $exitCode"
     }
 
-    # Treat msiexec-style reboot-required codes as success too.
-    if ($exitCode -eq 3010 -or $exitCode -eq 1641) {
+    # Treat msiexec-style reboot-required codes as success, and 1605 (product
+    # already removed, e.g. by another entry's cascade) as success too.
+    if ($exitCode -eq 3010 -or $exitCode -eq 1641 -or $exitCode -eq 1605) {
         return 0
     }
     return $exitCode

@kitzy
kitzy marked this pull request as ready for review August 11, 2026 16:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1`:
- Line 23: Update the installation-detection logic using $softwareNameLike to
match the exact display name "Citrix Workspace Inside" instead of a wildcard,
while preserving the existing publisher check and related checks around lines
38-41.

In
`@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`:
- Line 34: Update the uninstall selection logic using $softwareNameLike so it
distinguishes Citrix Workspace LTSR from Current Release entries. Use a
track-specific registry value or validate the installed package identity before
proceeding, ensuring only the LTSR package is selected for uninstallation.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 468ea9ba-b69e-4802-90f3-4ef89ecb5e44

📥 Commits

Reviewing files that changed from the base of the PR and between 8165d7b and 21c952b.

📒 Files selected for processing (3)
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1
  • ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

Comment thread ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_install.ps1 Outdated
# with 1605 (ERROR_UNKNOWN_PRODUCT, "not currently installed") -- that's
# success, not a failure.

$softwareNameLike = "Citrix Workspace*"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate all Citrix maintained-app definitions and lifecycle scripts.
fd -i 'citrix' ee/maintained-apps | sort

# Compare registry matching and uninstall behavior across Citrix app variants.
rg -n -i -C 5 \
  'Citrix Workspace|softwareNameLike|DisplayName|Publisher|UninstallString' \
  ee/maintained-apps

Repository: fleetdm/fleet

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Citrix files ---'
fd -i 'citrix' ee/maintained-apps | sort

printf '%s\n' '--- Uninstall script ---'
sed -n '1,230p' ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1

printf '%s\n' '--- Citrix definitions and references ---'
rg -n -i -C 8 --glob '*.{yaml,yml,json,md,ps1}' \
  'Citrix|Workspace.*LTSR|Current Release|LTSR' \
  ee/maintained-apps | head -n 1200

Repository: fleetdm/fleet

Length of output: 50369


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Tracked Citrix paths ---'
git ls-files | rg -i 'citrix|workspace' | head -n 300

printf '%s\n' '--- Input definitions containing Citrix or Workspace ---'
rg -l -i --glob 'ee/maintained-apps/inputs/**' 'Citrix|Workspace' | sort | head -n 300

printf '%s\n' '--- Relevant input definitions ---'
while IFS= read -r file; do
  case "$file" in
    *.json|*.yaml|*.yml) printf '\n### %s\n' "$file"; cat -n "$file" ;;
  esac
done < <(rg -l -i --glob 'ee/maintained-apps/inputs/**' 'Citrix|Workspace' | sort | head -n 100)

printf '%s\n' '--- Windows output definitions for Citrix workspace variants ---'
for file in ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json ee/maintained-apps/outputs/citrix-workspace/windows.json; do
  if [ -f "$file" ]; then
    printf '\n### %s\n' "$file"
    python3 - "$file" <<'PY'
import json, sys
with open(sys.argv[1], encoding='utf-8') as f:
    data = json.load(f)
for version in data.get("versions", []):
    print(json.dumps({k: version.get(k) for k in ("version", "queries", "installer_url")}, indent=2))
PY
  fi
done

Repository: fleetdm/fleet

Length of output: 2823


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Citrix references outside generated outputs ---'
rg -n -i -C 5 --glob '!ee/maintained-apps/outputs/**' 'Citrix Workspace|citrix-workspace|LTSR|Current Release' . | head -n 1200

printf '%s\n' '--- Citrix input definitions ---'
for file in ee/maintained-apps/inputs/homebrew/citrix-workspace.json ee/maintained-apps/inputs/winget/citrix-workspace-ltsr.json; do
  printf '\n### %s\n' "$file"
  cat -n "$file"
done

printf '%s\n' '--- All generated Citrix output files ---'
find ee/maintained-apps/outputs -maxdepth 2 -type f -iname '*citrix*' -print | sort

Repository: fleetdm/fleet

Length of output: 50370


🌐 Web query:

Citrix Workspace app LTSR Current Release Windows Programs and Features registry DisplayName Publisher side-by-side installation

💡 Result:

Citrix Workspace app does not support side-by-side installation of multiple versions (e.g., LTSR and Current Release) on a single Windows machine [1][2]. Installing a new version typically upgrades or replaces the existing installation [3]. In Windows Programs and Features, the DisplayName and Publisher entries are generally consistent for both LTSR and Current Release (CR) builds, typically appearing as Citrix Workspace app [3]. Because the product uses identical registry paths and naming conventions, it is difficult to distinguish between LTSR and CR installations based solely on the standard Programs and Features registry keys (e.g., HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\CitrixOnlinePluginPackWeb) [3][4][5]. To identify if an installation is an LTSR release, administrators often rely on checking specific registry flags used for auto-update configuration rather than the display name itself [4][5]. Specifically, if an environment is configured for LTSR, the following registry value is commonly set to true [5]: HKLM\SOFTWARE\Citrix\ICA Client\AutoUpdate\Commandline Policy\LTSROnly = true or HKLM\SOFTWARE\Policies\Citrix\ICA Client\AutoUpdate\LTSROnly = true Because Citrix uses overlapping versioning schemes—where LTSR and CR tracks may have similar or even higher build numbers—the underlying DisplayVersion cannot be reliably used to distinguish between the two tracks without context [3]. Furthermore, installing a user-level instance alongside a system-level (administrator) instance is not recommended and can cause conflicts [2]. If you need to manage specific versions, it is recommended to control them through deployment policies (such as AutoUpdateStream) rather than attempting to maintain concurrent installations [6][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import fnmatch

pattern = "Citrix Workspace*"
publisher = "Citrix Systems, Inc."
entries = [
    ("Citrix Workspace 2402", publisher),
    ("Citrix Workspace Inside", publisher),
    ("Citrix Workspace(USB)", publisher),
    ("Citrix Workspace app", publisher),
    ("Citrix Workspace app", "Other publisher"),
]

matched = [
    (name, pub)
    for name, pub in entries
    if fnmatch.fnmatchcase(name.lower(), pattern.lower())
    and pub == publisher
]

print("pattern:", pattern)
print("publisher:", publisher)
print("matched:", matched)
print("current-release-shaped entry matched:",
      ("Citrix Workspace app", publisher) in matched)
PY

Repository: fleetdm/fleet

Length of output: 468


Restrict the selector to LTSR.

If Current Release is installed, $softwareNameLike = "Citrix Workspace*" matches its entries because Citrix uses the same Programs and Features name and publisher for both tracks. Use a track-specific registry value or validate the installed package identity before uninstalling.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ee/maintained-apps/inputs/winget/scripts/citrix_workspace_ltsr_uninstall.ps1`
at line 34, Update the uninstall selection logic using $softwareNameLike so it
distinguishes Citrix Workspace LTSR from Current Release entries. Use a
track-specific registry value or validate the installed package identity before
proceeding, ensuring only the LTSR package is selected for uninstallation.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Didn't implement a track-specific selector -- and notably, your own web research in this same comment reaches the same conclusion I did on the earlier round of this PR: "the DisplayVersion cannot be reliably used to distinguish between the two tracks" and "it is difficult to distinguish between LTSR and CR installations based solely on the standard Programs and Features registry keys." The LTSROnly policy registry value you found (HKLM\SOFTWARE\Citrix\ICA Client\AutoUpdate\...\LTSROnly) is an admin-configured auto-update policy, not installed-package identity -- it's usually unset, and if I gated on it the uninstall script would silently no-op on real LTSR installs that didn't have that GPO configured, which is worse than the current behavior. There's also still no Windows Current Release FMA in this catalog to actually collide with today. Leaving this thread open (not resolving) since it's a real tradeoff a human reviewer may want to weigh in on rather than something I should decide unilaterally.

🤖 Addressed by Claude Code

kitzy added 2 commits August 11, 2026 12:21
A wildcard match could let a leftover entry from an unrelated prior install
falsely satisfy the install script's completion check before this install
has actually registered anything. "Citrix Workspace Inside" is confirmed
(via CI) to always be one of the components this package installs, so match
it exactly. The uninstall script still needs the broad wildcard since it has
to enumerate and remove every component, not just detect one.
@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install // 0f4f1460 -> b4bd52f2 ===

--- /tmp/old.C3131o	2026-08-11 16:23:29.175951711 +0000
+++ /tmp/new.viF6Se	2026-08-11 16:23:29.175951711 +0000
@@ -17,10 +17,13 @@
 # "Citrix Workspace(USB)" does). Declaring success as soon as the FIRST
 # entry appears races the still-running later components -- our uninstall
 # script would then only find and remove whichever ones had registered so
-# far. Require at least one entry AND no msiexec.exe process running,
-# stable across two consecutive polls, before declaring the install done.
+# far. Require the core "Citrix Workspace Inside" entry AND no msiexec.exe
+# process running, stable across two consecutive polls, before declaring the
+# install done. Match that DisplayName exactly (not a "Citrix Workspace*"
+# wildcard) so a leftover entry from an unrelated prior install can't cause
+# a false-positive success before this install has actually registered.
 
-$softwareNameLike = "Citrix Workspace*"
+$softwareName = "Citrix Workspace Inside"
 $paths = @(
   'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall',
   'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall'
@@ -36,7 +39,7 @@
           ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }
 
   foreach ($key in $uninstallKeys) {
-    if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike `
+    if ($key.DisplayName -eq $softwareName `
         -and $key.Publisher -eq "Citrix Systems, Inc.") {
       return $true
     }

=== Uninstall Script (no changes) ===

RachelElysia
RachelElysia previously approved these changes Aug 12, 2026

@RachelElysia RachelElysia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

FE ✅

@allenhouchins

Copy link
Copy Markdown
Member

@kitzy we need to drastically cut down on the comments in scripts. These are customer facing. This has been added to the new-fma skill but waiting for it to be merged.

These scripts ship in the generated output manifests and run on customer
machines. Cut CI-run history, vendor-bug archaeology, and narrative about
prior revisions down to the operational essentials -- from 24/41 comment
lines to 6/12, in line with the repo median (5) and p90 (14).
@kitzy

kitzy commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Trimmed both scripts down (24/41 comment lines → 6/12, in line with the repo median/p90) — pushed in 2a11827. Kept only what's needed to understand a non-obvious switch or exit code at the callsite; the rest of the investigation history is in this PR's description and review thread.

🤖 Addressed by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

Script Diff Results

ee/maintained-apps/outputs/citrix-workspace-ltsr/windows.json

=== Install // b4bd52f2 -> ed1646b6 ===

--- /tmp/old.LCeYYH	2026-08-12 20:32:52.327246353 +0000
+++ /tmp/new.WCzSx4	2026-08-12 20:32:52.327246353 +0000
@@ -1,27 +1,10 @@
 # Learn more about .exe install scripts:
 # http://fleetdm.com/learn-more-about/exe-install-scripts
-#
-# Vendor-documented silent switches (winget InstallerSwitches: Silent +
-# Custom): /silent runs without dialogs/prompts, /noreboot suppresses the
-# reboot prompt, and /AutoUpdateCheck=disabled keeps Citrix's own updater
-# from taking over version management.
-#
-# The Citrix bootstrapper leaves resident processes running after install
-# (e.g. its self-service/notification tray app), so Start-Process -Wait
-# never returns -- it waits on the whole process tree, not just the
-# installer itself. Start without -Wait and poll Programs and Features
-# instead of trusting the installer process's exit code.
-#
-# The bootstrap installs several components as separate, sequential MSI
-# transactions (confirmed by CI: "Citrix Workspace Inside" registers before
-# "Citrix Workspace(USB)" does). Declaring success as soon as the FIRST
-# entry appears races the still-running later components -- our uninstall
-# script would then only find and remove whichever ones had registered so
-# far. Require the core "Citrix Workspace Inside" entry AND no msiexec.exe
-# process running, stable across two consecutive polls, before declaring the
-# install done. Match that DisplayName exactly (not a "Citrix Workspace*"
-# wildcard) so a leftover entry from an unrelated prior install can't cause
-# a false-positive success before this install has actually registered.
+
+# The installer leaves resident processes running and its bootstrap installs
+# several components as separate MSI transactions, so Start-Process -Wait
+# never returns reliably. Poll for the core entry instead, and wait for
+# msiexec to go idle so we don't race the later components.
 
 $softwareName = "Citrix Workspace Inside"
 $paths = @(

=== Uninstall // 955cb307 -> c05a6493 ===

--- /tmp/old.NFdo0D	2026-08-12 20:32:52.357246926 +0000
+++ /tmp/new.FFqKZz	2026-08-12 20:32:52.357246926 +0000
@@ -1,35 +1,7 @@
-# Uninstall for Citrix Workspace App LTSR.
-#
-# The bootstrap registers multiple separate Programs and Features entries
-# (confirmed by CI: "Citrix Workspace Inside" plus a distinct "Citrix
-# Workspace(USB)" entry, and possibly others depending on selected
-# components) -- removing just the first one found still leaves the app
-# detectable. Enumerate every entry matching the "Citrix Workspace" prefix
-# and publisher, and uninstall each one. No space before the wildcard: some
-# entries (e.g. "Citrix Workspace(USB)") have no space after "Workspace".
-#
-# Most of these entries' UninstallStrings are standard MSI references
-# (MsiExec.exe /I{ProductCode}, the maintenance/repair form) -- not the
-# TrolleyExpress.exe or CWAInstaller.exe bootstrapper uninstaller some older
-# community docs describe for this DisplayName pattern. Resolve the
-# ProductCode (registry key name, or the first GUID in the UninstallString)
-# and run a clean "msiexec /x {ProductCode} /qn /norestart" -- never reuse
-# the /I switch from the registry string. If an entry isn't MSI-based, fall
-# back to re-running its own uninstaller exe with Citrix's documented silent
-# switches.
-#
-# The Citrix bootstrapper installs several components as separate MSI
-# transactions, so uninstalling one can transiently fail with 1618
-# (ERROR_INSTALL_ALREADY_RUNNING) while another is still mid-transaction.
-# Retry a few times with a short delay.
-#
-# One entry (DisplayName "Citrix Workspace <version>", UninstallString
-# bootstrapperhelper.exe rather than msiexec) is the master entry: removing
-# it cascades and removes the other MSI sub-components (USB, SSON, Inside,
-# DV, ...) too. Since every entry was already snapshotted before any
-# uninstall ran, a later msiexec /x for an already-cascaded component fails
-# with 1605 (ERROR_UNKNOWN_PRODUCT, "not currently installed") -- that's
-# success, not a failure.
+# The bootstrap registers several separate Programs and Features entries, so
+# enumerate everything matching the "Citrix Workspace" prefix and publisher
+# and uninstall each one. No space before the wildcard: some entries (e.g.
+# "Citrix Workspace(USB)") have none after "Workspace".
 
 $softwareNameLike = "Citrix Workspace*"
 
@@ -70,8 +42,8 @@
     }
 
     if ($uninstallCommand -match '(?i)msiexec') {
-        # MSI-based entry: resolve the ProductCode and run a clean uninstall,
-        # ignoring whatever switches are already in the registry string.
+        # Resolve the ProductCode and run our own clean uninstall -- never
+        # reuse the /I (repair) switch already in the registry string.
         $productCode = $Entry.PSChildName
         if ($productCode -notmatch '^\{[0-9A-Fa-f-]+\}$') {
             if ($uninstallCommand -match '(\{[0-9A-Fa-f-]+\})') {
@@ -97,22 +69,20 @@
             }
             Write-Host "Uninstall exit code: $exitCode (attempt $attempt of $maxMsiAttempts)"
 
+            # 1618: another component's transaction still holds the Windows
+            # Installer mutex -- retry rather than fail.
             if ($exitCode -ne 1618) {
                 break
             }
-
-            Write-Host "Windows Installer busy (1618) -- another of Citrix's own component installs likely still holds the mutex. Retrying in ${msiRetryDelaySeconds}s."
             Start-Sleep -Seconds $msiRetryDelaySeconds
         }
     } else {
-        # Non-MSI entry (e.g. TrolleyExpress.exe / CWAInstaller.exe): re-run
-        # the vendor's own uninstaller with its documented silent switches.
+        # Non-MSI entry: re-run its own uninstaller with Citrix's documented
+        # silent switches.
         $exePath = ""
         if ($uninstallCommand -match '^\s*"([^"]+)"') {
-            # Quoted path
             $exePath = $matches[1]
         } elseif ($uninstallCommand -match '(?i)^\s*(.+?\.exe)') {
-            # Unquoted path that may contain spaces (e.g. "C:\Program Files (x86)\...")
             $exePath = $matches[1]
         } else {
             Write-Host "Could not parse uninstaller path from: $uninstallCommand"
@@ -130,8 +100,8 @@
         Write-Host "Uninstall exit code: $exitCode"
     }
 
-    # Treat msiexec-style reboot-required codes as success, and 1605 (product
-    # already removed, e.g. by another entry's cascade) as success too.
+    # 3010/1641: success pending reboot. 1605: already removed, e.g. by
+    # another entry's cascade uninstall. All count as success.
     if ($exitCode -eq 3010 -or $exitCode -eq 1641 -or $exitCode -eq 1605) {
         return 0
     }
@@ -155,7 +125,6 @@
 }
 
 if ($entries.Count -eq 0) {
-    # Already uninstalled (or never installed) -- nothing to do.
     Write-Host "No entries found matching $softwareNameLike"
     Exit 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.

New FMA: Citrix Workspace App (Windows, LTSR)

4 participants