From 51404a115e1a713dcef935c96979c0830f1c1181 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 16:55:20 -0500 Subject: [PATCH 1/8] Fix Windows FMA per-user vs per-machine install scope (#48248) Windows apps can install per-user (HKCU, %LOCALAPPDATA%) or per-machine (HKLM, Program Files). The FMA patch policy is scope-blind (osquery's programs table reads both), so when a host had a copy at one scope and the FMA installed at the other, a duplicate was created, the stale copy lingered unmanaged, and the policy never reported patched. Foundation: - Ingester now trusts the input's installer_scope when the winget manifest is silent on scope (previously panicked). Exact matching still selects the right variant for apps that declare per-installer scope (PowerToys, GIMP). - Set installer_scope on the 9 previously-unset winget inputs, verified from each installer's PE requestedExecutionLevel / MSI ALLUSERS / winget Scope. - Added TestInputInstallerScopeIsSet enforcing scope is set on all inputs, plus ingester tests for the scope-fallback and scope-mismatch paths. - Documented the never-scope-narrow guardrail in the FMA authoring guide (README) and the new-fma skill. Reference remediation (Pattern A): - PowerToys and GIMP install scripts remove any stale per-user copy before installing the per-machine copy. Because Fleet runs scripts as SYSTEM (where HKCU is SYSTEM's own hive), they enumerate HKEY_USERS per-user hives. Removal is best-effort: it never aborts the machine install and never goes false-green. Detection queries remain scope-blind. Live Windows-host validation of the Pattern A scripts is still pending (see issue acceptance criteria). --- .claude/skills/new-fma/SKILL.md | 4 +- changes/48248-windows-fma-install-scope | 1 + ee/maintained-apps/README.md | 19 ++- .../ingesters/winget/ingester.go | 11 +- .../ingesters/winget/ingester_test.go | 46 +++++++ .../ingesters/winget/input_scope_test.go | 51 ++++++++ .../inputs/winget/descript.json | 2 +- .../inputs/winget/electrum.json | 2 +- .../winget/microsoft-dotnet-runtime-10.json | 2 +- .../winget/microsoft-dotnet-runtime-8.json | 2 +- .../inputs/winget/notesnook.json | 2 +- .../inputs/winget/ocenaudio.json | 2 +- .../inputs/winget/protopie.json | 2 +- .../inputs/winget/requestly.json | 2 +- .../inputs/winget/scripts/gimp_install.ps1 | 102 +++++++++++++++- .../winget/scripts/powertoys_install.ps1 | 113 +++++++++++++++++- .../inputs/winget/standard-notes.json | 2 +- ee/maintained-apps/outputs/gimp/windows.json | 6 +- .../outputs/powertoys/windows.json | 4 +- 19 files changed, 355 insertions(+), 20 deletions(-) create mode 100644 changes/48248-windows-fma-install-scope create mode 100644 ee/maintained-apps/ingesters/winget/input_scope_test.go diff --git a/.claude/skills/new-fma/SKILL.md b/.claude/skills/new-fma/SKILL.md index 5ac73be5e66..d25b0faac58 100644 --- a/.claude/skills/new-fma/SKILL.md +++ b/.claude/skills/new-fma/SKILL.md @@ -129,7 +129,7 @@ go run cmd/maintained-apps/main.go --slug="/" --debug | `program_publisher` (winget) | Overrides the exists-query publisher when registry Publisher ≠ winget locale Publisher. | | `fuzzy_match_name` (winget) | `true` → `name LIKE ' %'`. A string → `name LIKE ''` verbatim (e.g. `"Mozilla Firefox % ESR %"`, `"IntelliJ IDEA 20%"`). | | `exists_query` (winget) | Replaces the generated exists query verbatim. The patched query is DERIVED from it (appends `AND version_compare(...) < 0`). | -| `installer_scope` | Must match the winget manifest's Scope — you can't pick machine if only user exists. | +| `installer_scope` | **Required, `machine` or `user`.** Must match the scope the installer actually uses. If the winget manifest declares distinct per-user/per-machine installers (e.g. PowerToys, GIMP), you must pick one it offers. If winget is *silent* on scope (common for NSIS/Inno/burn), the ingester trusts your value — so verify it from the installer's requested execution level (`requireAdministrator`/`highestAvailable` ⇒ machine; `asInvoker` ⇒ user) or MSI `ALLUSERS`, don't guess. Enforced non-empty by `TestInputInstallerScopeIsSet`. | `patch_policy_path` exists in the input struct but is **dead code** (unused since the patched query became auto-generated). Don't use it; there is no patched-query override other than shaping `exists_query` or a hard-coded per-app branch in the ingester (Docker Desktop precedent). @@ -154,6 +154,8 @@ if ($u -match '^\s*"([^"]+)"\s*(.*)$') { # quoted **5. Scope / SYSTEM context.** Fleet runs installs as SYSTEM (elevated). A per-user installer lands in the SYSTEM profile (useless). Force machine-wide with the installer's all-users switch (`ALLUSERS=1`/`2`, `/ALLUSERS`, `G2MINSTALLFORALLUSERS=1`). A per-user uninstaller likewise can't be reached from a SYSTEM-context script (its ARP entry is in the logged-in user's HKCU). +**5a. Duplicate/stale copies across scopes — never scope-narrow detection ([#48248](https://github.com/fleetdm/fleet/issues/48248)).** `programs` reads both `HKLM` and per-user hives, so the `exists` query (and the patched query derived from it) matches an app at *either* scope. If a host has the app at one scope and the FMA installs at the other, a second copy appears, the stale copy lingers unmanaged, and the scope-blind policy keeps reporting not-patched. **Do NOT fix this by narrowing the query to one hive** — that makes the policy go false-green (reports patched while an unmanaged, unpatched copy remains). Fix it in remediation: the install script removes any *other-scope* copy before installing one canonical copy at the target scope (Pattern A; see PowerToys/GIMP uninstall scripts enumerating both hives), or upgrades the existing scope's copy in place (Pattern B). Flag cross-scope data-loss risk in the PR. Keep `installer_scope`, the scripts, and the detection query consistent. Full guidance: `ee/maintained-apps/README.md` → "Install scope vs. detection". + **6. Multi-version / sibling products sharing a DisplayName.** - Corretto 21 and 25 both register as `Amazon Corretto (x64)` — pin each with `exists_query ... AND version LIKE '.%'`. - IntelliJ Ultimate's DisplayName `IntelliJ IDEA ` also matches Community's `IntelliJ IDEA Community Edition ` — exclude siblings in `exists_query` (`AND name NOT LIKE 'IntelliJ IDEA Community%'`) or use a custom `fuzzy_match_name` pattern. diff --git a/changes/48248-windows-fma-install-scope b/changes/48248-windows-fma-install-scope new file mode 100644 index 00000000000..2c69465f9bd --- /dev/null +++ b/changes/48248-windows-fma-install-scope @@ -0,0 +1 @@ +- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. The PowerToys and GIMP install scripts now remove any per-user copy before installing the managed per-machine copy, and all Windows Fleet-maintained apps now declare an explicit install scope. diff --git a/ee/maintained-apps/README.md b/ee/maintained-apps/README.md index 6b4046d1f08..4e48d9c5886 100644 --- a/ee/maintained-apps/README.md +++ b/ee/maintained-apps/README.md @@ -119,12 +119,29 @@ go run cmd/maintained-apps/main.go --slug="box-drive/windows" --debug | `slug` | string | **Required.** Identifies the app/platform combination (e.g., `box-drive/windows`). Used to name manifest files and reference the app in [Fleet's best practice GitOps](https://fleetdm.com/docs/configuration/yaml-files#fleet-maintained-apps). Format: `/`, where app name is filesystem-friendly and platform is `darwin`. | | `installer_arch` | string | **Required.** `x64` or `x86` (most apps use `x64`). | | `installer_type` | string | **Required.** `exe`, `msi`, or `msix` (file type, not vendor tech like "wix") | -| `installer_scope` | string | **Required.** `machine` or `user` (prefer `machine` for managed installs) | +| `installer_scope` | string | **Required.** `machine` or `user`. Prefer `machine` for managed installs, but it must match the scope the installer actually uses (see [Install scope vs. detection](#install-scope-vs-detection-per-user-vs-per-machine)). Verify with the installer's requested execution level (`requireAdministrator`/`highestAvailable` ⇒ machine; `asInvoker` ⇒ user) or the winget manifest `Scope`; don't guess. | | `default_categories` | string | **Required.** Default categories for self-service if none are specified. Valid values: `Browsers`, `Communication`, `Developer Tools`, `Productivity`. | | `install_script_path` | string | Filepath to a custom install script (`.ps1`). Overrides the generated install script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates install scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide PowerShell scripts that run the installer file directly. Fleet stores the installer and sends it to the host at install time; your script must execute it using the `INSTALLER_PATH` environment variable. | | `uninstall_script_path` | string | Filepath to a custom uninstall script (`.ps1`). Overrides the generated uninstall script. Script must be placed in `inputs/winget/scripts/`. For `.msi` apps, the ingestor automatically generates uninstall scripts. Do not add scripts unless you need to override the generated behavior. For `.exe` apps, you must provide a script to uninstall the app. Scripts for `.exe` apps are vendor-specific. Use the vendor’s documented silent uninstall switch or the registered UninstallString (if available), ensuring the script runs silently and returns the installer’s exit code. | | `fuzzy_match_name` | boolean | If the `unique_identifier` doesn't match the `DisplayName`, use `fuzzy_match_name` to specify that Fleet uses "fuzzy matching" to match the Fleet-maintained app and the inventoried software. For example, for Pritunl, the `unique_identifier` is "Pritunl" and the inventories software's `DisplayName` is "Pritunl Client". With `fuzzy_match_name` set to true, Pritunl app will be matched to the inventories software. | +#### Install scope vs. detection (per-user vs. per-machine) + +Windows apps can be installed **per-user** (`HKCU`, `%LOCALAPPDATA%`) or **per-machine** (`HKLM`, `Program Files`), and some ship both. A Fleet-maintained app's patch policy is its `exists` detection query with a version comparison appended, so **detection scope and "is it patched?" scope are the same predicate.** The `programs` osquery table reads both the machine and per-user registry hives, so the default `exists` query matches an app at *either* scope. + +This creates a trap when a host has the app at one scope and the FMA installs at the other: a second copy is installed, the stale copy is left behind unmanaged, and because the scope-blind query still sees the stale copy, the policy never reports patched and installs may keep retrying. Reported for Slack, PowerToys, and GIMP (see [#48248](https://github.com/fleetdm/fleet/issues/48248)). + +Follow these guardrails: + +- **Never scope-narrow the detection query.** Do not "fix" duplicate copies by restricting the `exists` query to one hive (e.g. system-only). That makes the policy go **false-green**: it stops seeing the stale copy and reports patched while an unmanaged, unpatched copy remains on the device. The hard requirement is that the policy must **never** report "patched" while a stale copy remains at *any* scope. Keep the `exists`/`patched` query scope-blind. +- **Fix scope in remediation, not the query.** Set `installer_scope` to the scope the installer actually uses (verify it — see the schema note), and make the install/uninstall scripts converge the device on a single canonical copy: + - **Pattern A — remove-and-replace (default):** the install script first removes any *other-scope* copy, then installs one copy at the target scope. The scope-blind policy then truthfully goes green. (PowerToys and GIMP install scripts enumerate both `HKLM` and `HKCU` to do this.) + - **Pattern B — update-in-place per scope:** detect which scope the existing copy lives in and upgrade *that* one. More data-safe; per-user installs from the SYSTEM context are the hard case (run in the logged-on user's session via a scheduled task — see the Slack MSIX script). +- **Keep installer ↔ detection ↔ uninstaller consistent.** `installer_scope`, the install/uninstall scripts, and the detection query must agree about the app. Avoid over-narrow `name = '…'` matches that could miss a scope variant; use `fuzzy_match_name` or a custom `exists_query` where a package's `DisplayName` differs by scope. +- **Data preservation is a separate guarantee from "no false-green."** A same-scope upgrade preserves data normally. Cross-scope removal (Pattern A) can lose data: config in `%APPDATA%`/`HKCU` for the same packaging is usually **preserved**, Squirrel/Electron local session/cache may be **partially** lost, and cross-packaging to MSIX generally **resets** data. Call this out in the PR when it applies. + +`installer_scope` is enforced to be `machine` or `user` for every winget input by `TestInputInstallerScopeIsSet`. + #### Windows troubleshooting - App not found in Fleet UI: ensure `apps.json` was updated by the generator and your override URL is correct diff --git a/ee/maintained-apps/ingesters/winget/ingester.go b/ee/maintained-apps/ingesters/winget/ingester.go index c02ab8bdbf7..c0aa8602645 100644 --- a/ee/maintained-apps/ingesters/winget/ingester.go +++ b/ee/maintained-apps/ingesters/winget/ingester.go @@ -299,9 +299,18 @@ func (i *wingetIngester) ingestOne(ctx context.Context, input inputApp) (*mainta installer.InstallerLocale = "" } + // Scope disambiguates packages that ship distinct per-user and per-machine + // installers (e.g. PowerToys, GIMP): those declare Scope on each installer, so + // exact matching selects the right variant. When the winget manifest is silent + // about scope (derived scope == ""), there is nothing to disambiguate, so we + // trust the input's installer_scope as authoritative. This lets an app set + // installer_scope for correctness/detection even though winget doesn't declare + // it, without failing to find an installer. + scopeMatches := scope == input.InstallerScope || scope == "" + // Check if this installer matches our criteria matches := installer.Architecture == input.InstallerArch && - scope == input.InstallerScope && + scopeMatches && installer.InstallerLocale == input.InstallerLocale && installerType == input.InstallerType diff --git a/ee/maintained-apps/ingesters/winget/ingester_test.go b/ee/maintained-apps/ingesters/winget/ingester_test.go index 35a3bfffdc2..5dc12496e5d 100644 --- a/ee/maintained-apps/ingesters/winget/ingester_test.go +++ b/ee/maintained-apps/ingesters/winget/ingester_test.go @@ -456,6 +456,52 @@ func TestIngestValidations(t *testing.T) { upgradeCode: "{ABCDEF}", }, }, + { + // Many winget manifests (e.g. NSIS/Inno/burn apps like descript, + // electrum, protopie) don't declare a Scope. The input's + // installer_scope must still be honored so scope can be set for + // correctness without failing to find an installer. + name: "scope honored when winget manifest omits scope", + wantErr: "", + inputApp: inputApp{ + Name: "Foo", + UniqueIdentifier: "Foo", + PackageIdentifier: "Foo", + InstallerArch: "x64", + Slug: "foo/windows", + InstallScriptPath: path.Join(tempDir, "install_script.ps1"), + UninstallScriptPath: path.Join(tempDir, "uninstall_script.ps1"), + InstallerType: "exe", + InstallerScope: "machine", + }, + cfg: serverConfig{ + installerType: "exe", + installerScope: "", // winget silent on scope + installerArch: "x64", + }, + }, + { + // When winget DOES declare a scope, an input scope that disagrees + // must not silently select the wrong-scope installer. + name: "scope mismatch fails when winget declares scope", + wantErr: "failed to find installer for app", + inputApp: inputApp{ + Name: "Foo", + UniqueIdentifier: "Foo", + PackageIdentifier: "Foo", + InstallerArch: "x64", + Slug: "foo/windows", + InstallScriptPath: path.Join(tempDir, "install_script.ps1"), + UninstallScriptPath: path.Join(tempDir, "uninstall_script.ps1"), + InstallerType: "exe", + InstallerScope: "machine", + }, + cfg: serverConfig{ + installerType: "exe", + installerScope: "user", // winget declares user, input wants machine + installerArch: "x64", + }, + }, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { diff --git a/ee/maintained-apps/ingesters/winget/input_scope_test.go b/ee/maintained-apps/ingesters/winget/input_scope_test.go new file mode 100644 index 00000000000..854157b162c --- /dev/null +++ b/ee/maintained-apps/ingesters/winget/input_scope_test.go @@ -0,0 +1,51 @@ +package winget + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wingetInputsDir is the real inputs directory relative to this package. +const wingetInputsDir = "../../inputs/winget" + +// TestInputInstallerScopeIsSet enforces that every winget input declares a valid +// installer_scope ("machine" or "user"). +// +// Scope must be explicit because Windows apps can install per-user (HKCU, +// %LOCALAPPDATA%) or per-machine (HKLM, Program Files). An unset scope leaves the +// ingester guessing and, more importantly, prevents remediation scripts from +// knowing which scope's copy to remove/upgrade — the root cause of duplicate/stale +// copies (see https://github.com/fleetdm/fleet/issues/48248). +// +// The detection ("exists") query must stay scope-blind; scope correctness lives in +// the installer_scope field and the install/uninstall scripts, never by narrowing +// the query. See the FMA authoring guide. +func TestInputInstallerScopeIsSet(t *testing.T) { + entries, err := os.ReadDir(wingetInputsDir) + require.NoError(t, err) + + checked := 0 + for _, e := range entries { + if e.IsDir() || filepath.Ext(e.Name()) != ".json" { + continue + } + + path := filepath.Join(wingetInputsDir, e.Name()) + b, err := os.ReadFile(path) + require.NoErrorf(t, err, "reading %s", e.Name()) + + var input inputApp + require.NoErrorf(t, json.Unmarshal(b, &input), "unmarshaling %s", e.Name()) + + assert.Containsf(t, []string{machineScope, userScope}, input.InstallerScope, + "%s: installer_scope must be %q or %q, got %q", e.Name(), machineScope, userScope, input.InstallerScope) + checked++ + } + + require.NotZero(t, checked, "expected to check at least one winget input file") +} diff --git a/ee/maintained-apps/inputs/winget/descript.json b/ee/maintained-apps/inputs/winget/descript.json index d340da58521..5e9eb187655 100644 --- a/ee/maintained-apps/inputs/winget/descript.json +++ b/ee/maintained-apps/inputs/winget/descript.json @@ -6,7 +6,7 @@ "fuzzy_match_name": true, "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "user", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/descript_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/descript_uninstall.ps1", "default_categories": [ diff --git a/ee/maintained-apps/inputs/winget/electrum.json b/ee/maintained-apps/inputs/winget/electrum.json index cfde124d1d1..adcdf8e36c0 100644 --- a/ee/maintained-apps/inputs/winget/electrum.json +++ b/ee/maintained-apps/inputs/winget/electrum.json @@ -5,7 +5,7 @@ "unique_identifier": "Electrum", "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "machine", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/electrum_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/electrum_uninstall.ps1", "default_categories": [ diff --git a/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-10.json b/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-10.json index 1ed8804561f..9a691b99d6b 100644 --- a/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-10.json +++ b/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-10.json @@ -6,7 +6,7 @@ "exists_query": "SELECT 1 FROM programs WHERE name LIKE 'Microsoft .NET Runtime - 10.%' AND name LIKE '%(x64)' AND publisher = 'Microsoft Corporation';", "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "machine", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_uninstall.ps1", "default_categories": ["Developer tools"] diff --git a/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-8.json b/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-8.json index b90f7cb3383..d7cb5a1242c 100644 --- a/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-8.json +++ b/ee/maintained-apps/inputs/winget/microsoft-dotnet-runtime-8.json @@ -6,7 +6,7 @@ "exists_query": "SELECT 1 FROM programs WHERE name LIKE 'Microsoft .NET Runtime - 8.%' AND name LIKE '%(x64)' AND publisher = 'Microsoft Corporation';", "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "machine", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft_dotnet_runtime_uninstall.ps1", "default_categories": ["Developer tools"] diff --git a/ee/maintained-apps/inputs/winget/notesnook.json b/ee/maintained-apps/inputs/winget/notesnook.json index 121eaff3a6f..27918a18709 100644 --- a/ee/maintained-apps/inputs/winget/notesnook.json +++ b/ee/maintained-apps/inputs/winget/notesnook.json @@ -6,7 +6,7 @@ "fuzzy_match_name": true, "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "user", "default_categories": ["Productivity"], "install_script_path": "ee/maintained-apps/inputs/winget/scripts/notesnook_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/notesnook_uninstall.ps1" diff --git a/ee/maintained-apps/inputs/winget/ocenaudio.json b/ee/maintained-apps/inputs/winget/ocenaudio.json index 64122ea6a82..b008b2cb5b5 100644 --- a/ee/maintained-apps/inputs/winget/ocenaudio.json +++ b/ee/maintained-apps/inputs/winget/ocenaudio.json @@ -5,7 +5,7 @@ "unique_identifier": "ocenaudio", "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "machine", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/ocenaudio_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/ocenaudio_uninstall.ps1", "default_categories": ["Productivity"] diff --git a/ee/maintained-apps/inputs/winget/protopie.json b/ee/maintained-apps/inputs/winget/protopie.json index f8cead183e7..515e11b5aa1 100644 --- a/ee/maintained-apps/inputs/winget/protopie.json +++ b/ee/maintained-apps/inputs/winget/protopie.json @@ -5,7 +5,7 @@ "unique_identifier": "ProtoPie", "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "machine", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/protopie_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/protopie_uninstall.ps1", "default_categories": ["Productivity"] diff --git a/ee/maintained-apps/inputs/winget/requestly.json b/ee/maintained-apps/inputs/winget/requestly.json index d51d2dc06f9..a44d548c78f 100644 --- a/ee/maintained-apps/inputs/winget/requestly.json +++ b/ee/maintained-apps/inputs/winget/requestly.json @@ -6,7 +6,7 @@ "fuzzy_match_name": true, "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "user", "default_categories": ["Productivity"], "install_script_path": "ee/maintained-apps/inputs/winget/scripts/requestly_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/requestly_uninstall.ps1" diff --git a/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 index f60988c146f..bc272f2bc1f 100644 --- a/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 @@ -1,5 +1,103 @@ -# Learn more about .exe install scripts: -# http://fleetdm.com/learn-more-about/exe-install-scripts +# GIMP is managed by Fleet as a PER-MACHINE install (Inno Setup /ALLUSERS). +# GIMP also offers a per-user install, so a host may already have a stale per-user +# copy. Fleet's patch policy is scope-blind (osquery's "programs" table reads HKLM +# + every loaded user hive), so a lingering per-user copy keeps the policy red and +# leaves two copies on disk. +# +# Pattern A (remove-and-replace): before installing the machine copy, remove any +# per-user copy so the device converges on a single canonical copy. The machine +# installer upgrades an existing machine copy in place, so same-scope data is +# preserved; only the cross-scope (per-user) copy is removed. +# See https://github.com/fleetdm/fleet/issues/48248. +# +# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own +# hive — NOT the logged-on user's. Per-user copies must be found under +# HKEY_USERS\, which is what Remove-OtherScopeCopies does below. +# Removal is best-effort: it never aborts the machine install, and a copy that +# survives keeps the (truthful) scope-blind policy red rather than false-green. + +# Match GIMP 3.x only (the FMA targets GIMP.GIMP.3); avoids touching GIMP 2. +$displayNameLike = "GIMP 3*" + +function Get-UninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } elseif ($Command -match '^\s*(\S+)\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } + return $null +} + +function Remove-OtherScopeCopies { + param([Parameter(Mandatory = $true)][string]$DisplayNameLike) + + # Per-user uninstall registrations live in the logged-on users' hives. + $roots = @() + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + + $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { + Write-Host "Per-user copy '$($key.DisplayName)' has no uninstall string; skipping." + continue + } + + $parsed = Get-UninstallExeAndArgs $command + if (-not $parsed) { + Write-Host "Could not parse uninstall string for '$($key.DisplayName)': $command" + continue + } + + $exe = $parsed.Exe + $uninstallArgs = $parsed.Args + + # GIMP uses an Inno Setup uninstaller; ensure a silent uninstall. + if ($uninstallArgs -notmatch '(?i)/VERYSILENT') { $uninstallArgs = ("$uninstallArgs /VERYSILENT").Trim() } + if ($uninstallArgs -notmatch '(?i)/SUPPRESSMSGBOXES') { $uninstallArgs = ("$uninstallArgs /SUPPRESSMSGBOXES").Trim() } + if ($uninstallArgs -notmatch '(?i)/NORESTART') { $uninstallArgs = ("$uninstallArgs /NORESTART").Trim() } + + if (-not (Test-Path -LiteralPath $exe)) { + Write-Host "Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe" + continue + } + + Write-Host "Removing per-user copy: '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uninstallArgs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs } + $p = Start-Process @opts + Write-Host " Per-user uninstall exit code: $($p.ExitCode)" + } catch { + # Best effort: never fail the machine install because of other-scope cleanup. + Write-Host " WARNING: failed to remove per-user copy: $_" + } + } + } +} + +try { + Remove-OtherScopeCopies -DisplayNameLike $displayNameLike +} catch { + # Cleanup is best-effort; proceed to install regardless. + Write-Host "Warning during per-user cleanup: $_" +} $exeFilePath = "${env:INSTALLER_PATH}" diff --git a/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 index 5f8b8d50358..8acf884a358 100644 --- a/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 @@ -1,6 +1,117 @@ -$exeFilePath = "${env:INSTALLER_PATH}" +# PowerToys is managed by Fleet as a PER-MACHINE (HKLM) install. Windows also +# ships a per-user installer (PowerToysUserSetup), so a host may already have a +# stale per-user copy. Fleet's patch policy is scope-blind (osquery's "programs" +# table reads HKLM + every loaded user hive), so a lingering per-user copy keeps +# the policy red and leaves two copies on disk. +# +# Pattern A (remove-and-replace): before installing the machine copy, remove any +# per-user copy so the device converges on a single canonical copy. The machine +# installer upgrades an existing machine copy in place, so same-scope data is +# preserved; only the cross-scope (per-user) copy is removed. +# See https://github.com/fleetdm/fleet/issues/48248. +# +# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own +# hive — NOT the logged-on user's. Per-user copies must be found under +# HKEY_USERS\, which is what Remove-OtherScopeCopies does below. +# Removal is best-effort: it never aborts the machine install, and a copy that +# survives keeps the (truthful) scope-blind policy red rather than false-green. + $ExpectedExitCodes = @(0, 1641, 3010, 1223) +function Get-UninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } elseif ($Command -match '^\s*(\S+)\s*(.*)$') { + return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } + } + return $null +} + +function Remove-OtherScopeCopies { + param( + [Parameter(Mandatory = $true)][string]$DisplayNameLike, + [string]$PublisherLike = '' + ) + + # Per-user uninstall registrations live in the logged-on users' hives. + $roots = @() + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { + Write-Host "Per-user copy '$($key.DisplayName)' has no uninstall string; skipping." + continue + } + + $parsed = Get-UninstallExeAndArgs $command + if (-not $parsed) { + Write-Host "Could not parse uninstall string for '$($key.DisplayName)': $command" + continue + } + + $exe = $parsed.Exe + $uninstallArgs = $parsed.Args + + # PowerToys installers are WiX Burn bundles. Ensure a quiet uninstall. + if ($exe -match '(?i)msiexec') { + $uninstallArgs = $uninstallArgs -replace '(?i)/i(\{)', '/x$1' + if ($uninstallArgs -notmatch '(?i)/x') { $uninstallArgs = ("/x $uninstallArgs").Trim() } + if ($uninstallArgs -notmatch '(?i)/qn') { $uninstallArgs = ("$uninstallArgs /qn").Trim() } + if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = ("$uninstallArgs /norestart").Trim() } + } else { + if ($uninstallArgs -notmatch '(?i)/uninstall') { $uninstallArgs = ("$uninstallArgs /uninstall").Trim() } + if ($uninstallArgs -notmatch '(?i)/quiet') { $uninstallArgs = ("$uninstallArgs /quiet").Trim() } + if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = ("$uninstallArgs /norestart").Trim() } + } + + if (-not ($exe -match '(?i)msiexec') -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe" + continue + } + + Write-Host "Removing per-user copy: '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uninstallArgs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs } + $p = Start-Process @opts + Write-Host " Per-user uninstall exit code: $($p.ExitCode)" + } catch { + # Best effort: never fail the machine install because of other-scope cleanup. + Write-Host " WARNING: failed to remove per-user copy: $_" + } + } + } +} + +try { + Stop-Process -Name "PowerToys" -Force -ErrorAction SilentlyContinue + Remove-OtherScopeCopies -DisplayNameLike "PowerToys*" -PublisherLike "Microsoft Corporation*" +} catch { + # Cleanup is best-effort; proceed to install regardless. + Write-Host "Warning during per-user cleanup: $_" +} + +$exeFilePath = "${env:INSTALLER_PATH}" + try { $processOptions = @{ diff --git a/ee/maintained-apps/inputs/winget/standard-notes.json b/ee/maintained-apps/inputs/winget/standard-notes.json index a026d962d76..08f629155ac 100644 --- a/ee/maintained-apps/inputs/winget/standard-notes.json +++ b/ee/maintained-apps/inputs/winget/standard-notes.json @@ -6,7 +6,7 @@ "fuzzy_match_name": true, "installer_arch": "x64", "installer_type": "exe", - "installer_scope": "", + "installer_scope": "user", "install_script_path": "ee/maintained-apps/inputs/winget/scripts/standard-notes_install.ps1", "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/standard-notes_uninstall.ps1", "default_categories": ["Security"] diff --git a/ee/maintained-apps/outputs/gimp/windows.json b/ee/maintained-apps/outputs/gimp/windows.json index 5a8523ce349..505cef27e5b 100644 --- a/ee/maintained-apps/outputs/gimp/windows.json +++ b/ee/maintained-apps/outputs/gimp/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'GIMP %' AND publisher = 'The GIMP Team' AND version_compare(version, '3.2.4.0') < 0);" }, "installer_url": "https://download.gimp.org/gimp/v3.2/windows/gimp-3.2.4-setup.exe", - "install_script_ref": "7e5269bc", + "install_script_ref": "72113c10", "uninstall_script_ref": "79fb181d", "sha256": "ec31d757dd82831d201ffcf47ffeac4175df739e0c02d5122e89aeeadfb988cc", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "79fb181d": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"GIMP\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"GIMP 3*\"\n\n# Inno Setup installers require /VERYSILENT flag for silent uninstall\n$uninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" /SILENT\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n", - "7e5269bc": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Inno Setup installer with /ALLUSERS for machine-scope installation\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /ALLUSERS\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "72113c10": "# GIMP is managed by Fleet as a PER-MACHINE install (Inno Setup /ALLUSERS).\n# GIMP also offers a per-user install, so a host may already have a stale per-user\n# copy. Fleet's patch policy is scope-blind (osquery's \"programs\" table reads HKLM\n# + every loaded user hive), so a lingering per-user copy keeps the policy red and\n# leaves two copies on disk.\n#\n# Pattern A (remove-and-replace): before installing the machine copy, remove any\n# per-user copy so the device converges on a single canonical copy. The machine\n# installer upgrades an existing machine copy in place, so same-scope data is\n# preserved; only the cross-scope (per-user) copy is removed.\n# See https://github.com/fleetdm/fleet/issues/48248.\n#\n# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own\n# hive — NOT the logged-on user's. Per-user copies must be found under\n# HKEY_USERS\\, which is what Remove-OtherScopeCopies does below.\n# Removal is best-effort: it never aborts the machine install, and a copy that\n# survives keeps the (truthful) scope-blind policy red rather than false-green.\n\n# Match GIMP 3.x only (the FMA targets GIMP.GIMP.3); avoids touching GIMP 2.\n$displayNameLike = \"GIMP 3*\"\n\nfunction Get-UninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n }\n return $null\n}\n\nfunction Remove-OtherScopeCopies {\n param([Parameter(Mandatory = $true)][string]$DisplayNameLike)\n\n # Per-user uninstall registrations live in the logged-on users' hives.\n $roots = @()\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n\n $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) {\n Write-Host \"Per-user copy '$($key.DisplayName)' has no uninstall string; skipping.\"\n continue\n }\n\n $parsed = Get-UninstallExeAndArgs $command\n if (-not $parsed) {\n Write-Host \"Could not parse uninstall string for '$($key.DisplayName)': $command\"\n continue\n }\n\n $exe = $parsed.Exe\n $uninstallArgs = $parsed.Args\n\n # GIMP uses an Inno Setup uninstaller; ensure a silent uninstall.\n if ($uninstallArgs -notmatch '(?i)/VERYSILENT') { $uninstallArgs = (\"$uninstallArgs /VERYSILENT\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/SUPPRESSMSGBOXES') { $uninstallArgs = (\"$uninstallArgs /SUPPRESSMSGBOXES\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/NORESTART') { $uninstallArgs = (\"$uninstallArgs /NORESTART\").Trim() }\n\n if (-not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe\"\n continue\n }\n\n Write-Host \"Removing per-user copy: '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uninstallArgs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs }\n $p = Start-Process @opts\n Write-Host \" Per-user uninstall exit code: $($p.ExitCode)\"\n } catch {\n # Best effort: never fail the machine install because of other-scope cleanup.\n Write-Host \" WARNING: failed to remove per-user copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-OtherScopeCopies -DisplayNameLike $displayNameLike\n} catch {\n # Cleanup is best-effort; proceed to install regardless.\n Write-Host \"Warning during per-user cleanup: $_\"\n}\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Inno Setup installer with /ALLUSERS for machine-scope installation\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /ALLUSERS\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "79fb181d": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"GIMP\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"GIMP 3*\"\n\n# Inno Setup installers require /VERYSILENT flag for silent uninstall\n$uninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" /SILENT\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" } } diff --git a/ee/maintained-apps/outputs/powertoys/windows.json b/ee/maintained-apps/outputs/powertoys/windows.json index b425ce86d02..3a9cc3629f5 100644 --- a/ee/maintained-apps/outputs/powertoys/windows.json +++ b/ee/maintained-apps/outputs/powertoys/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'PowerToys %' AND publisher = 'Microsoft Corporation' AND version_compare(version, '0.100.2') < 0);" }, "installer_url": "https://github.com/microsoft/PowerToys/releases/download/v0.100.2/PowerToysSetup-0.100.2-x64.exe", - "install_script_ref": "1a5ce2ca", + "install_script_ref": "733f8df6", "uninstall_script_ref": "a97a3cf8", "sha256": "73c04aac8052420111fe5cdc0098ec8415d87cbdbd42de253e9af959781cbf9e", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "1a5ce2ca": "$exeFilePath = \"${env:INSTALLER_PATH}\"\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/install /quiet /norestart\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "733f8df6": "# PowerToys is managed by Fleet as a PER-MACHINE (HKLM) install. Windows also\n# ships a per-user installer (PowerToysUserSetup), so a host may already have a\n# stale per-user copy. Fleet's patch policy is scope-blind (osquery's \"programs\"\n# table reads HKLM + every loaded user hive), so a lingering per-user copy keeps\n# the policy red and leaves two copies on disk.\n#\n# Pattern A (remove-and-replace): before installing the machine copy, remove any\n# per-user copy so the device converges on a single canonical copy. The machine\n# installer upgrades an existing machine copy in place, so same-scope data is\n# preserved; only the cross-scope (per-user) copy is removed.\n# See https://github.com/fleetdm/fleet/issues/48248.\n#\n# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own\n# hive — NOT the logged-on user's. Per-user copies must be found under\n# HKEY_USERS\\, which is what Remove-OtherScopeCopies does below.\n# Removal is best-effort: it never aborts the machine install, and a copy that\n# survives keeps the (truthful) scope-blind policy red rather than false-green.\n\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\nfunction Get-UninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n }\n return $null\n}\n\nfunction Remove-OtherScopeCopies {\n param(\n [Parameter(Mandatory = $true)][string]$DisplayNameLike,\n [string]$PublisherLike = ''\n )\n\n # Per-user uninstall registrations live in the logged-on users' hives.\n $roots = @()\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) {\n Write-Host \"Per-user copy '$($key.DisplayName)' has no uninstall string; skipping.\"\n continue\n }\n\n $parsed = Get-UninstallExeAndArgs $command\n if (-not $parsed) {\n Write-Host \"Could not parse uninstall string for '$($key.DisplayName)': $command\"\n continue\n }\n\n $exe = $parsed.Exe\n $uninstallArgs = $parsed.Args\n\n # PowerToys installers are WiX Burn bundles. Ensure a quiet uninstall.\n if ($exe -match '(?i)msiexec') {\n $uninstallArgs = $uninstallArgs -replace '(?i)/i(\\{)', '/x$1'\n if ($uninstallArgs -notmatch '(?i)/x') { $uninstallArgs = (\"/x $uninstallArgs\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/qn') { $uninstallArgs = (\"$uninstallArgs /qn\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = (\"$uninstallArgs /norestart\").Trim() }\n } else {\n if ($uninstallArgs -notmatch '(?i)/uninstall') { $uninstallArgs = (\"$uninstallArgs /uninstall\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/quiet') { $uninstallArgs = (\"$uninstallArgs /quiet\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = (\"$uninstallArgs /norestart\").Trim() }\n }\n\n if (-not ($exe -match '(?i)msiexec') -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe\"\n continue\n }\n\n Write-Host \"Removing per-user copy: '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uninstallArgs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs }\n $p = Start-Process @opts\n Write-Host \" Per-user uninstall exit code: $($p.ExitCode)\"\n } catch {\n # Best effort: never fail the machine install because of other-scope cleanup.\n Write-Host \" WARNING: failed to remove per-user copy: $_\"\n }\n }\n }\n}\n\ntry {\n Stop-Process -Name \"PowerToys\" -Force -ErrorAction SilentlyContinue\n Remove-OtherScopeCopies -DisplayNameLike \"PowerToys*\" -PublisherLike \"Microsoft Corporation*\"\n} catch {\n # Cleanup is best-effort; proceed to install regardless.\n Write-Host \"Warning during per-user cleanup: $_\"\n}\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/install /quiet /norestart\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "a97a3cf8": "$displayNameLike = \"PowerToys*\"\n$publisherLike = \"Microsoft Corporation*\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\n# A WiX Burn bundle and its child MSI packages can all share the same DisplayName.\n# The bundle is the entry that exposes a QuietUninstallString / an .exe bootstrapper\n# UninstallString; child MSIs only expose \"MsiExec.exe /I{GUID}\". Prefer the bundle\n# so the whole product is removed (uninstalling a single child MSI would not).\n$candidates = @()\nforeach ($p in $paths) {\n $candidates += Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -like $displayNameLike -and $_.Publisher -like $publisherLike\n }\n}\n\nif (-not $candidates -or $candidates.Count -eq 0) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\n$entry = $candidates | Where-Object { $_.QuietUninstallString } | Select-Object -First 1\nif (-not $entry) {\n $entry = $candidates | Where-Object { $_.UninstallString -and $_.UninstallString -notmatch '(?i)msiexec' } | Select-Object -First 1\n}\nif (-not $entry) {\n $entry = $candidates | Where-Object { $_.UninstallString } | Select-Object -First 1\n}\nif (-not $entry) {\n Write-Host \"Uninstall entry found but has no uninstall string\"\n Exit 0\n}\n\nStop-Process -Name \"PowerToys\" -Force -ErrorAction SilentlyContinue\n\n$uninstallCommand = if ($entry.QuietUninstallString) { $entry.QuietUninstallString } else { $entry.UninstallString }\n\n$exePath = \"\"\n$existingArgs = \"\"\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\nif ($exePath -match '(?i)msiexec') {\n # Force an uninstall (/x), never a repair (/i), and run with MSI quiet flags.\n $existingArgs = $existingArgs -replace '(?i)/i(\\{)', '/x$1'\n $existingArgs = ($existingArgs -replace '(?i)/uninstall', '') -replace '(?i)/quiet', ''\n if ($existingArgs -notmatch '(?i)/x') { $existingArgs = (\"/x $existingArgs\").Trim() }\n if ($existingArgs -notmatch '(?i)/qn') { $existingArgs = (\"$existingArgs /qn\").Trim() }\n if ($existingArgs -notmatch '(?i)/norestart') { $existingArgs = (\"$existingArgs /norestart\").Trim() }\n} else {\n # WiX Burn bootstrapper.\n if ($existingArgs -notmatch '(?i)/uninstall') { $existingArgs = (\"$existingArgs /uninstall\").Trim() }\n if ($existingArgs -notmatch '(?i)/quiet') { $existingArgs = (\"$existingArgs /quiet\").Trim() }\n if ($existingArgs -notmatch '(?i)/norestart') { $existingArgs = (\"$existingArgs /norestart\").Trim() }\n}\n\nWrite-Host \"Uninstall command: $exePath\"\nWrite-Host \"Uninstall args: $existingArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $exePath\n ArgumentList = $existingArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n if ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n" } } From c63133d420f8f2f8e6be494d222d36e66ecd3c77 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 17:11:46 -0500 Subject: [PATCH 2/8] Apply Pattern A remediation to all dual-variant Windows FMAs (#48248) Extends the per-user vs per-machine fix from PowerToys/GIMP to every dual-variant Windows Fleet-maintained app. Each install script now removes the stale opposite-scope copy before installing the managed copy, so the scope-blind patch policy can truthfully report the host patched. - Unified all apps onto one canonical, best-effort "Remove-FmaOtherScopeCopies" block: it scans ONLY the opposite scope's uninstall hives (HKEY_USERS for machine-target apps, since Fleet runs as SYSTEM where HKCU is SYSTEM's own hive; HKLM for user-target apps), prefers each vendor's QuietUninstallString verbatim, and never aborts the install or goes false-green. - Machine-target (remove per-user): cursor, firefox, firefox@esr, google-drive, onedrive, visual-studio-code, vivaldi, vscodium, windsurf, powertoys, gimp, and the MSI apps 1password, box-drive, dropbox, github-desktop, google-chrome, microsoft-edge, zoom (MSI apps get a custom install script that runs cleanup then msiexec; uninstall stays upgrade-code based). - User-target (remove per-machine): antigravity-ide, brave-browser, kiro, mullvad-browser. - Per-app DisplayName match + silent-uninstall fallback flags derived from each app's own existing uninstall script. Detection queries remain scope-blind. Not yet validated on a live Windows host; per-app QA still required before merge. --- changes/48248-windows-fma-install-scope | 2 +- .../inputs/winget/1password.json | 7 +- .../inputs/winget/box-drive.json | 5 +- ee/maintained-apps/inputs/winget/dropbox.json | 5 +- .../inputs/winget/github-desktop.json | 5 +- .../inputs/winget/google-chrome.json | 5 +- .../inputs/winget/microsoft-edge.json | 5 +- .../winget/scripts/1password_install.ps1 | 126 ++++++++++++++++ .../scripts/antigravity_ide_install.ps1 | 110 ++++++++++++++ .../winget/scripts/box-drive_install.ps1 | 126 ++++++++++++++++ .../inputs/winget/scripts/brave_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/cursor_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/dropbox_install.ps1 | 126 ++++++++++++++++ .../winget/scripts/firefox_esr_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/firefox_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/gimp_install.ps1 | 136 +++++++++-------- .../winget/scripts/github-desktop_install.ps1 | 126 ++++++++++++++++ .../winget/scripts/google-chrome_install.ps1 | 126 ++++++++++++++++ .../winget/scripts/google_drive_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/kiro_install.ps1 | 110 ++++++++++++++ .../winget/scripts/microsoft-edge_install.ps1 | 126 ++++++++++++++++ .../scripts/mullvad-browser_install.ps1 | 110 ++++++++++++++ .../winget/scripts/onedrive_install.ps1 | 110 ++++++++++++++ .../winget/scripts/powertoys_install.ps1 | 137 +++++++++--------- .../inputs/winget/scripts/vivaldi_install.ps1 | 110 ++++++++++++++ .../inputs/winget/scripts/vscode_install.ps1 | 110 ++++++++++++++ .../winget/scripts/vscodium_install.ps1 | 110 ++++++++++++++ .../winget/scripts/windsurf_install.ps1 | 109 ++++++++++++++ .../inputs/winget/scripts/zoom_install.ps1 | 126 ++++++++++++++++ ee/maintained-apps/inputs/winget/zoom.json | 5 +- .../outputs/1password/windows.json | 4 +- .../outputs/antigravity-ide/windows.json | 4 +- .../outputs/box-drive/windows.json | 4 +- .../outputs/brave-browser/windows.json | 4 +- .../outputs/cursor/windows.json | 6 +- .../outputs/dropbox/windows.json | 4 +- .../outputs/firefox/windows.json | 6 +- .../outputs/firefox@esr/windows.json | 4 +- ee/maintained-apps/outputs/gimp/windows.json | 4 +- .../outputs/github-desktop/windows.json | 4 +- .../outputs/google-chrome/windows.json | 4 +- .../outputs/google-drive/windows.json | 4 +- ee/maintained-apps/outputs/kiro/windows.json | 4 +- .../outputs/microsoft-edge/windows.json | 6 +- .../outputs/mullvad-browser/windows.json | 6 +- .../outputs/onedrive/windows.json | 4 +- .../outputs/powertoys/windows.json | 4 +- .../outputs/visual-studio-code/windows.json | 4 +- .../outputs/vivaldi/windows.json | 4 +- .../outputs/vscodium/windows.json | 4 +- .../outputs/windsurf/windows.json | 4 +- ee/maintained-apps/outputs/zoom/windows.json | 4 +- 52 files changed, 2531 insertions(+), 188 deletions(-) create mode 100644 ee/maintained-apps/inputs/winget/scripts/1password_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/microsoft-edge_install.ps1 create mode 100644 ee/maintained-apps/inputs/winget/scripts/zoom_install.ps1 diff --git a/changes/48248-windows-fma-install-scope b/changes/48248-windows-fma-install-scope index 2c69465f9bd..eada070e2a6 100644 --- a/changes/48248-windows-fma-install-scope +++ b/changes/48248-windows-fma-install-scope @@ -1 +1 @@ -- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. The PowerToys and GIMP install scripts now remove any per-user copy before installing the managed per-machine copy, and all Windows Fleet-maintained apps now declare an explicit install scope. +- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. Dual-variant Windows apps (including PowerToys, GIMP, VS Code, Chrome, Zoom, Dropbox, 1Password, Firefox, and others) now remove any opposite-scope copy before installing the managed copy, and all Windows Fleet-maintained apps now declare an explicit install scope. diff --git a/ee/maintained-apps/inputs/winget/1password.json b/ee/maintained-apps/inputs/winget/1password.json index aaa111ce199..676c141d608 100644 --- a/ee/maintained-apps/inputs/winget/1password.json +++ b/ee/maintained-apps/inputs/winget/1password.json @@ -6,7 +6,10 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Productivity"], + "default_categories": [ + "Productivity" + ], "uninstall_script_path": "ee/maintained-apps/inputs/winget/scripts/1password_uninstall.ps1", - "program_publisher": "Agilebits Inc." + "program_publisher": "Agilebits Inc.", + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/1password_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/box-drive.json b/ee/maintained-apps/inputs/winget/box-drive.json index 9eb1c8a849b..6a3fb91aef8 100644 --- a/ee/maintained-apps/inputs/winget/box-drive.json +++ b/ee/maintained-apps/inputs/winget/box-drive.json @@ -6,5 +6,8 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Productivity"] + "default_categories": [ + "Productivity" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/dropbox.json b/ee/maintained-apps/inputs/winget/dropbox.json index 718a7d412b6..d80614f1f1f 100644 --- a/ee/maintained-apps/inputs/winget/dropbox.json +++ b/ee/maintained-apps/inputs/winget/dropbox.json @@ -6,5 +6,8 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Productivity"] + "default_categories": [ + "Productivity" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/github-desktop.json b/ee/maintained-apps/inputs/winget/github-desktop.json index 0e199347300..6bd68bb0dac 100644 --- a/ee/maintained-apps/inputs/winget/github-desktop.json +++ b/ee/maintained-apps/inputs/winget/github-desktop.json @@ -6,5 +6,8 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Developer tools"] + "default_categories": [ + "Developer tools" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/google-chrome.json b/ee/maintained-apps/inputs/winget/google-chrome.json index 111b363d7be..a6c87a37615 100644 --- a/ee/maintained-apps/inputs/winget/google-chrome.json +++ b/ee/maintained-apps/inputs/winget/google-chrome.json @@ -7,5 +7,8 @@ "installer_type": "msi", "installer_scope": "machine", "ignore_hash": true, - "default_categories": ["Browsers"] + "default_categories": [ + "Browsers" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/microsoft-edge.json b/ee/maintained-apps/inputs/winget/microsoft-edge.json index a273b193295..3aff879929c 100644 --- a/ee/maintained-apps/inputs/winget/microsoft-edge.json +++ b/ee/maintained-apps/inputs/winget/microsoft-edge.json @@ -6,5 +6,8 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Browsers"] + "default_categories": [ + "Browsers" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/microsoft-edge_install.ps1" } diff --git a/ee/maintained-apps/inputs/winget/scripts/1password_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/1password_install.ps1 new file mode 100644 index 00000000000..6394984234c --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/1password_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "1Password*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--silent" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/antigravity_ide_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/antigravity_ide_install.ps1 index 2e618b851f2..c0debfb807d 100644 --- a/ee/maintained-apps/inputs/winget/scripts/antigravity_ide_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/antigravity_ide_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "user" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "user" +$fmaDisplayNameLike = "Antigravity*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + $exeFilePath = "${env:INSTALLER_PATH}" $ExpectedExitCodes = @(0, 3010) diff --git a/ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1 new file mode 100644 index 00000000000..f6164c1f5e9 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/box-drive_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Box" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/quiet" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/brave_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/brave_install.ps1 index 3fd7f4dc71b..64a8a2ed957 100644 --- a/ee/maintained-apps/inputs/winget/scripts/brave_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/brave_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "user" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "user" +$fmaDisplayNameLike = "Brave*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--uninstall --force-uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + $exeFilePath = "${env:INSTALLER_PATH}" $exitCode = 0 diff --git a/ee/maintained-apps/inputs/winget/scripts/cursor_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/cursor_install.ps1 index ee7c8a0fcdc..a99cb5d76bf 100644 --- a/ee/maintained-apps/inputs/winget/scripts/cursor_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/cursor_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Cursor*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts diff --git a/ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1 new file mode 100644 index 00000000000..8bd8c5172a8 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/dropbox_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Dropbox*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/S" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 index 06a0f1fabcf..df153ecb969 100644 --- a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Mozilla Firefox ESR*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/S" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_install.ps1 index c30a4691fca..56e3a4526e9 100644 --- a/ee/maintained-apps/inputs/winget/scripts/firefox_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Mozilla Firefox (x64*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/S" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts diff --git a/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 index bc272f2bc1f..7757461ab84 100644 --- a/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/gimp_install.ps1 @@ -1,48 +1,55 @@ -# GIMP is managed by Fleet as a PER-MACHINE install (Inno Setup /ALLUSERS). -# GIMP also offers a per-user install, so a host may already have a stale per-user -# copy. Fleet's patch policy is scope-blind (osquery's "programs" table reads HKLM -# + every loaded user hive), so a lingering per-user copy keeps the policy red and -# leaves two copies on disk. +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). # -# Pattern A (remove-and-replace): before installing the machine copy, remove any -# per-user copy so the device converges on a single canonical copy. The machine -# installer upgrades an existing machine copy in place, so same-scope data is -# preserved; only the cross-scope (per-user) copy is removed. -# See https://github.com/fleetdm/fleet/issues/48248. +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). # -# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own -# hive — NOT the logged-on user's. Per-user copies must be found under -# HKEY_USERS\, which is what Remove-OtherScopeCopies does below. -# Removal is best-effort: it never aborts the machine install, and a copy that -# survives keeps the (truthful) scope-blind policy red rather than false-green. +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. -# Match GIMP 3.x only (the FMA targets GIMP.GIMP.3); avoids touching GIMP 2. -$displayNameLike = "GIMP 3*" +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "GIMP 3*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" -function Get-UninstallExeAndArgs { +function Get-FmaUninstallExeAndArgs { param([string]$Command) # Registry uninstall strings come in three shapes; parse defensively. - if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } elseif ($Command -match '^\s*(\S+)\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } return $null } -function Remove-OtherScopeCopies { - param([Parameter(Mandatory = $true)][string]$DisplayNameLike) +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) - # Per-user uninstall registrations live in the logged-on users' hives. + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. $roots = @() - foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { - if ($hive.Name -match '_Classes$') { continue } - # Real interactive users only (skip .DEFAULT and service SIDs). - if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } - $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" - $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' } foreach ($root in $roots) { @@ -50,55 +57,60 @@ function Remove-OtherScopeCopies { $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue if (-not $key.DisplayName) { continue } if ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } - $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString } - if (-not $command) { - Write-Host "Per-user copy '$($key.DisplayName)' has no uninstall string; skipping." - continue - } - - $parsed = Get-UninstallExeAndArgs $command - if (-not $parsed) { - Write-Host "Could not parse uninstall string for '$($key.DisplayName)': $command" - continue - } + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } $exe = $parsed.Exe - $uninstallArgs = $parsed.Args - - # GIMP uses an Inno Setup uninstaller; ensure a silent uninstall. - if ($uninstallArgs -notmatch '(?i)/VERYSILENT') { $uninstallArgs = ("$uninstallArgs /VERYSILENT").Trim() } - if ($uninstallArgs -notmatch '(?i)/SUPPRESSMSGBOXES') { $uninstallArgs = ("$uninstallArgs /SUPPRESSMSGBOXES").Trim() } - if ($uninstallArgs -notmatch '(?i)/NORESTART') { $uninstallArgs = ("$uninstallArgs /NORESTART").Trim() } + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } - if (-not (Test-Path -LiteralPath $exe)) { - Write-Host "Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe" + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" continue } - Write-Host "Removing per-user copy: '$($key.DisplayName)'" + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" Write-Host " Command: $exe" - Write-Host " Args: $uninstallArgs" + Write-Host " Args: $uargs" try { $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } - if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } $p = Start-Process @opts - Write-Host " Per-user uninstall exit code: $($p.ExitCode)" + Write-Host " Exit code: $($p.ExitCode)" } catch { - # Best effort: never fail the machine install because of other-scope cleanup. - Write-Host " WARNING: failed to remove per-user copy: $_" + Write-Host " WARNING: failed to remove opposite-scope copy: $_" } } } } try { - Remove-OtherScopeCopies -DisplayNameLike $displayNameLike + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs } catch { - # Cleanup is best-effort; proceed to install regardless. - Write-Host "Warning during per-user cleanup: $_" + Write-Host "Fleet: warning during opposite-scope cleanup: $_" } +# ---- App install ---- + +# Learn more about .exe install scripts: +# http://fleetdm.com/learn-more-about/exe-install-scripts + $exeFilePath = "${env:INSTALLER_PATH}" try { diff --git a/ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1 new file mode 100644 index 00000000000..ff47eec6ee4 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/github-desktop_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "GitHub Desktop*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "-s" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1 new file mode 100644 index 00000000000..ce155a3ecc6 --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/google-chrome_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Google Chrome*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--uninstall --force-uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/google_drive_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/google_drive_install.ps1 index c7b959a1a37..a2433bf2c45 100644 --- a/ee/maintained-apps/inputs/winget/scripts/google_drive_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/google_drive_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Google Drive" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--silent --force_stop" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + $exeFilePath = "${env:INSTALLER_PATH}" try { diff --git a/ee/maintained-apps/inputs/winget/scripts/kiro_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/kiro_install.ps1 index a01a61a525b..330538d803b 100644 --- a/ee/maintained-apps/inputs/winget/scripts/kiro_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/kiro_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "user" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "user" +$fmaDisplayNameLike = "Kiro*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts # diff --git a/ee/maintained-apps/inputs/winget/scripts/microsoft-edge_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/microsoft-edge_install.ps1 new file mode 100644 index 00000000000..64a8c480d4f --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/microsoft-edge_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Microsoft Edge*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--uninstall --force-uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/scripts/mullvad-browser_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/mullvad-browser_install.ps1 index b995102a180..9ee5dc9d7c9 100644 --- a/ee/maintained-apps/inputs/winget/scripts/mullvad-browser_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/mullvad-browser_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "user" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "user" +$fmaDisplayNameLike = "Mullvad Browser*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/S" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts # diff --git a/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 index 06a366d3c1b..4f52a54fe99 100644 --- a/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Microsoft OneDrive*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts diff --git a/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 index 8acf884a358..5da2a3e9cc8 100644 --- a/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/powertoys_install.ps1 @@ -1,50 +1,55 @@ -# PowerToys is managed by Fleet as a PER-MACHINE (HKLM) install. Windows also -# ships a per-user installer (PowerToysUserSetup), so a host may already have a -# stale per-user copy. Fleet's patch policy is scope-blind (osquery's "programs" -# table reads HKLM + every loaded user hive), so a lingering per-user copy keeps -# the policy red and leaves two copies on disk. +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). # -# Pattern A (remove-and-replace): before installing the machine copy, remove any -# per-user copy so the device converges on a single canonical copy. The machine -# installer upgrades an existing machine copy in place, so same-scope data is -# preserved; only the cross-scope (per-user) copy is removed. -# See https://github.com/fleetdm/fleet/issues/48248. +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). # -# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own -# hive — NOT the logged-on user's. Per-user copies must be found under -# HKEY_USERS\, which is what Remove-OtherScopeCopies does below. -# Removal is best-effort: it never aborts the machine install, and a copy that -# survives keeps the (truthful) scope-blind policy red rather than false-green. +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. -$ExpectedExitCodes = @(0, 1641, 3010, 1223) +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "PowerToys*" +$fmaPublisherLike = "Microsoft Corporation*" +$fmaFallbackUninstallArgs = "/uninstall /quiet /norestart" -function Get-UninstallExeAndArgs { +function Get-FmaUninstallExeAndArgs { param([string]$Command) # Registry uninstall strings come in three shapes; parse defensively. - if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } elseif ($Command -match '^\s*(\S+)\s*(.*)$') { - return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } - } + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } return $null } -function Remove-OtherScopeCopies { +function Remove-FmaOtherScopeCopies { param( - [Parameter(Mandatory = $true)][string]$DisplayNameLike, - [string]$PublisherLike = '' + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs ) - # Per-user uninstall registrations live in the logged-on users' hives. + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. $roots = @() - foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { - if ($hive.Name -match '_Classes$') { continue } - # Real interactive users only (skip .DEFAULT and service SIDs). - if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } - $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" - $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' } foreach ($root in $roots) { @@ -54,63 +59,57 @@ function Remove-OtherScopeCopies { if ($key.DisplayName -notlike $DisplayNameLike) { continue } if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } - $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString } - if (-not $command) { - Write-Host "Per-user copy '$($key.DisplayName)' has no uninstall string; skipping." - continue - } - - $parsed = Get-UninstallExeAndArgs $command - if (-not $parsed) { - Write-Host "Could not parse uninstall string for '$($key.DisplayName)': $command" - continue - } + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } $exe = $parsed.Exe - $uninstallArgs = $parsed.Args - - # PowerToys installers are WiX Burn bundles. Ensure a quiet uninstall. - if ($exe -match '(?i)msiexec') { - $uninstallArgs = $uninstallArgs -replace '(?i)/i(\{)', '/x$1' - if ($uninstallArgs -notmatch '(?i)/x') { $uninstallArgs = ("/x $uninstallArgs").Trim() } - if ($uninstallArgs -notmatch '(?i)/qn') { $uninstallArgs = ("$uninstallArgs /qn").Trim() } - if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = ("$uninstallArgs /norestart").Trim() } - } else { - if ($uninstallArgs -notmatch '(?i)/uninstall') { $uninstallArgs = ("$uninstallArgs /uninstall").Trim() } - if ($uninstallArgs -notmatch '(?i)/quiet') { $uninstallArgs = ("$uninstallArgs /quiet").Trim() } - if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = ("$uninstallArgs /norestart").Trim() } + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() } - if (-not ($exe -match '(?i)msiexec') -and -not (Test-Path -LiteralPath $exe)) { - Write-Host "Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe" + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" continue } - Write-Host "Removing per-user copy: '$($key.DisplayName)'" + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" Write-Host " Command: $exe" - Write-Host " Args: $uninstallArgs" + Write-Host " Args: $uargs" try { $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } - if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } $p = Start-Process @opts - Write-Host " Per-user uninstall exit code: $($p.ExitCode)" + Write-Host " Exit code: $($p.ExitCode)" } catch { - # Best effort: never fail the machine install because of other-scope cleanup. - Write-Host " WARNING: failed to remove per-user copy: $_" + Write-Host " WARNING: failed to remove opposite-scope copy: $_" } } } } try { - Stop-Process -Name "PowerToys" -Force -ErrorAction SilentlyContinue - Remove-OtherScopeCopies -DisplayNameLike "PowerToys*" -PublisherLike "Microsoft Corporation*" + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs } catch { - # Cleanup is best-effort; proceed to install regardless. - Write-Host "Warning during per-user cleanup: $_" + Write-Host "Fleet: warning during opposite-scope cleanup: $_" } +# ---- App install ---- + $exeFilePath = "${env:INSTALLER_PATH}" +$ExpectedExitCodes = @(0, 1641, 3010, 1223) try { diff --git a/ee/maintained-apps/inputs/winget/scripts/vivaldi_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/vivaldi_install.ps1 index 6205f13747e..57c8ad313c4 100644 --- a/ee/maintained-apps/inputs/winget/scripts/vivaldi_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/vivaldi_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Vivaldi*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "--uninstall --force-uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Install Vivaldi silently, machine-wide (Chromium-based browser). # Fleet runs installs as SYSTEM, so --system-level is required to install for # all users under %ProgramFiles% (and register under HKLM). Without it the diff --git a/ee/maintained-apps/inputs/winget/scripts/vscode_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/vscode_install.ps1 index 308c900a33f..da62fa5c25a 100644 --- a/ee/maintained-apps/inputs/winget/scripts/vscode_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/vscode_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Microsoft Visual Studio Code*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + # Learn more about .exe install scripts: # http://fleetdm.com/learn-more-about/exe-install-scripts diff --git a/ee/maintained-apps/inputs/winget/scripts/vscodium_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/vscodium_install.ps1 index f0c55387803..5cb10940a12 100644 --- a/ee/maintained-apps/inputs/winget/scripts/vscodium_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/vscodium_install.ps1 @@ -1,3 +1,113 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "VSCodium*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + $exeFilePath = "${env:INSTALLER_PATH}" try { diff --git a/ee/maintained-apps/inputs/winget/scripts/windsurf_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/windsurf_install.ps1 index 92d26fbb178..ab42c9dc898 100644 --- a/ee/maintained-apps/inputs/winget/scripts/windsurf_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/windsurf_install.ps1 @@ -1,3 +1,112 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Windsurf*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- # install switches: # /VERYSILENT = no UI at all diff --git a/ee/maintained-apps/inputs/winget/scripts/zoom_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/zoom_install.ps1 new file mode 100644 index 00000000000..18aac8ea57f --- /dev/null +++ b/ee/maintained-apps/inputs/winget/scripts/zoom_install.ps1 @@ -0,0 +1,126 @@ +# Fleet Pattern A: converge this app on a single install scope +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app at "machine" scope. Windows also offers the opposite +# scope, so a host may already have a stale copy there. The patch policy is +# scope-blind (osquery's "programs" table reads HKLM + every loaded user hive), +# so a lingering opposite-scope copy keeps the policy red and leaves two copies +# on disk. Remove the opposite-scope copy before installing the managed copy so +# the device converges on one canonical copy; a same-scope upgrade is left to the +# installer below (preserves data). +# +# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive -- +# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS. +# Removal is best-effort: it never aborts the install, and a copy that survives +# keeps the (truthful) scope-blind policy red rather than false-green. + +$fmaTargetScope = "machine" +$fmaDisplayNameLike = "Zoom*" +$fmaPublisherLike = "" +$fmaFallbackUninstallArgs = "/uninstall" + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaOtherScopeCopies { + param( + [string]$TargetScope, + [string]$DisplayNameLike, + [string]$PublisherLike, + [string]$FallbackArgs + ) + + # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match + # can't touch the copy Fleet manages. + $roots = @() + if ($TargetScope -eq 'machine') { + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + # Real interactive users only (skip .DEFAULT and service SIDs). + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + } else { + $roots += 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' + $roots += 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + } + + 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 ($key.DisplayName -notlike $DisplayNameLike) { continue } + if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim -and $FallbackArgs -ne '') { + $uargs = ("$uargs $FallbackArgs").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: opposite-scope uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing opposite-scope copy '$($key.DisplayName)'" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove opposite-scope copy: $_" + } + } + } +} + +try { + Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs +} catch { + Write-Host "Fleet: warning during opposite-scope cleanup: $_" +} + +# ---- App install ---- + +$logFile = "${env:TEMP}/fleet-install-software.log" + +try { + +$installProcess = Start-Process msiexec.exe ` + -ArgumentList "/quiet /norestart /lv ${logFile} /i `"${env:INSTALLER_PATH}`"" ` + -PassThru -Verb RunAs -Wait + +Get-Content $logFile -Tail 500 + +Exit $installProcess.ExitCode + +} catch { + Write-Host "Error: $_" + Exit 1 +} diff --git a/ee/maintained-apps/inputs/winget/zoom.json b/ee/maintained-apps/inputs/winget/zoom.json index 1dd7db7878e..21775f31ae7 100644 --- a/ee/maintained-apps/inputs/winget/zoom.json +++ b/ee/maintained-apps/inputs/winget/zoom.json @@ -6,5 +6,8 @@ "installer_arch": "x64", "installer_type": "msi", "installer_scope": "machine", - "default_categories": ["Communication"] + "default_categories": [ + "Communication" + ], + "install_script_path": "ee/maintained-apps/inputs/winget/scripts/zoom_install.ps1" } diff --git a/ee/maintained-apps/outputs/1password/windows.json b/ee/maintained-apps/outputs/1password/windows.json index 665e9002818..e14794adb04 100644 --- a/ee/maintained-apps/outputs/1password/windows.json +++ b/ee/maintained-apps/outputs/1password/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = '1Password' AND publisher = 'Agilebits Inc.' AND version_compare(version, '8.12.24') < 0);" }, "installer_url": "https://c.1password.com/dist/1P/win8/1PasswordSetup-8.12.24.msi", - "install_script_ref": "8959087b", + "install_script_ref": "b5d4dd9b", "uninstall_script_ref": "eeb3b3d3", "sha256": "d685551fc9769b68ae5ad68365abf609124f232fe4b313d87288fe89f3bb6813", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "b5d4dd9b": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"1Password*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--silent\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "eeb3b3d3": "# 1Password Uninstall Script\n# Closes running processes before uninstalling to prevent hangs\n\n$product_code = '{D56E499A-302F-403E-A362-450BCE7AD94F}'\n$timeoutSeconds = 300 # 5 minute timeout\n\n# Close any running 1Password processes\nGet-Process -Name \"1Password*\" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue\nStart-Sleep -Seconds 2\n\n# Fleet uninstalls app using product code that's extracted on upload\n$process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n# Wait for process with timeout\n$completed = $process.WaitForExit($timeoutSeconds * 1000)\n\nif (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_INSTALL_FAILURE\n}\n\n# Check exit code and output result\nif ($process.ExitCode -eq 0) {\n Write-Output \"Exit 0\"\n Exit 0\n} else {\n Write-Output \"Exit $($process.ExitCode)\"\n Exit $process.ExitCode\n}\n\n" } } diff --git a/ee/maintained-apps/outputs/antigravity-ide/windows.json b/ee/maintained-apps/outputs/antigravity-ide/windows.json index 697b5ef63a1..c95a56493cf 100644 --- a/ee/maintained-apps/outputs/antigravity-ide/windows.json +++ b/ee/maintained-apps/outputs/antigravity-ide/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'Antigravity IDE %' AND publisher = 'Google' AND version_compare(version, '2.1.1') < 0);" }, "installer_url": "https://edgedl.me.gvt1.com/edgedl/release2/j0qc3/antigravity/stable/2.1.1-6123990880747520/windows-x64/Antigravity%20IDE.exe", - "install_script_ref": "8b15c4d0", + "install_script_ref": "c7f687a6", "uninstall_script_ref": "65cab11a", "sha256": "d6d17a8f91c70f349505086847a79f60271a6ecdd851252e95ff0469dd5ad985", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "65cab11a": "$softwareNameLike = \"Antigravity*\"\n$publisherLike = \"*Google*\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$ExpectedExitCodes = @(0, 3010, 1641)\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }\n\n$selected = $null\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike -and $key.Publisher -like $publisherLike) {\n $selected = $key\n break\n }\n}\n\nif (-not $selected -or (-not $selected.UninstallString -and -not $selected.QuietUninstallString)) {\n Write-Host \"Uninstall entry not found for $softwareNameLike\"\n Exit 0\n}\n\n# Stop Antigravity and any helpers running out of the install dir so the\n# uninstaller doesn't fail on locked files.\nStop-Process -Name \"Antigravity\" -Force -ErrorAction SilentlyContinue\nif ($selected.InstallLocation -and (Test-Path -LiteralPath $selected.InstallLocation)) {\n $loc = $selected.InstallLocation.TrimEnd('\\')\n Get-Process | Where-Object { $_.Path -and $_.Path -like \"$loc\\*\" } |\n ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }\n}\nStart-Sleep -Seconds 2\n\n# Prefer QuietUninstallString -- Inno populates it with /VERYSILENT\n# /SUPPRESSMSGBOXES already, so we just add /NORESTART if missing.\n$uninstallCommand = if ($selected.QuietUninstallString) {\n $selected.QuietUninstallString\n} else {\n $selected.UninstallString\n}\n\n# Parse the uninstaller exe path. Inno usually quotes it.\n$exePath = \"\"\n$existingArgs = \"\"\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\n$argumentList = @()\nif ($existingArgs) { $argumentList += ($existingArgs -split '\\s+') }\nforeach ($s in @(\"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\")) {\n if ($argumentList -notcontains $s) { $argumentList += $s }\n}\n\nWrite-Host \"Selected entry DisplayName: $($selected.DisplayName)\"\nWrite-Host \"Uninstall command: $exePath\"\nWrite-Host \"Uninstall args: $($argumentList -join ' ')\"\n\n$processOptions = @{\n FilePath = $exePath\n ArgumentList = $argumentList\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\nWrite-Host \"Uninstall exit code: $exitCode\"\n\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", - "8b15c4d0": "$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n$ExpectedExitCodes = @(0, 3010)\n\ntry {\n\n# Antigravity uses an Inno Setup-based installer (user scope).\n# /MERGETASKS=!runcode deselects the \"launch after install\" task.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\n\n# Stop the app if the installer auto-launched it despite !runcode\nStop-Process -Name \"Antigravity\" -Force -ErrorAction SilentlyContinue\n\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "c7f687a6": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"user\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"user\"\n$fmaDisplayNameLike = \"Antigravity*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n$ExpectedExitCodes = @(0, 3010)\n\ntry {\n\n# Antigravity uses an Inno Setup-based installer (user scope).\n# /MERGETASKS=!runcode deselects the \"launch after install\" task.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\n\n# Stop the app if the installer auto-launched it despite !runcode\nStop-Process -Name \"Antigravity\" -Force -ErrorAction SilentlyContinue\n\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/box-drive/windows.json b/ee/maintained-apps/outputs/box-drive/windows.json index 86afe6a5ed1..6fb68647f72 100644 --- a/ee/maintained-apps/outputs/box-drive/windows.json +++ b/ee/maintained-apps/outputs/box-drive/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Box' AND publisher = 'Box, Inc.' AND version_compare(version, '2.51.234') < 0);" }, "installer_url": "https://e3.boxcdn.net/desktop/releases/win/BoxDrive-2.51.234.msi", - "install_script_ref": "8959087b", + "install_script_ref": "653463f0", "uninstall_script_ref": "13b5870c", "sha256": "b45ed2dc6cd1ecfa901a93fbc9cb8b14d0ae77e486a3d40ef13d744bb2f2b840", "default_categories": [ @@ -18,6 +18,6 @@ ], "refs": { "13b5870c": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{46AF5B38-D258-487A-92BD-792911248CCD}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n", - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "653463f0": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Box\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/quiet\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/brave-browser/windows.json b/ee/maintained-apps/outputs/brave-browser/windows.json index 470474ab61c..c55a909e568 100644 --- a/ee/maintained-apps/outputs/brave-browser/windows.json +++ b/ee/maintained-apps/outputs/brave-browser/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Brave' AND publisher = 'Brave Software Inc' AND version_compare(version, '149.1.91.180') < 0);" }, "installer_url": "https://github.com/brave/brave-browser/releases/download/v1.91.180/BraveBrowserStandaloneSilentSetup.exe", - "install_script_ref": "9a0c2b15", + "install_script_ref": "b2a17e99", "uninstall_script_ref": "30c77f69", "sha256": "aca365fb42fcc09f3c13358743790e9b23e237f626eb0f91b686bbd4c00aea40", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "30c77f69": "$softwareName = \"Brave\"\n\n# Script to uninstall software as the current logged-in user.\n$userScript = @'\n\n# Define acceptable/expected exit codes\n$ExpectedExitCodes = @(0, 19)\n\n$softwareName = \"Brave\"\n\n# Using the exact software name here is recommended to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"*$softwareName*\"\n\n# Some uninstallers require additional flags to run silently.\n# Each uninstaller might use a different argument (usually it's \"/S\" or \"/s\")\n$uninstallArgs = \"--force-uninstall\"\n\n$uninstallCommand = \"\"\n$exitCode = 0\n\ntry {\n\n$userKey = `\n 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($userKey) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start the process and track the exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\n# Treat acceptable exit codes as success\nif ($ExpectedExitCodes -contains $exitCode) {\n Exit 0\n} else {\n Exit $exitCode\n}\n'@\n\n$exitCode = 0\n\n# Create a script in a public folder so that it can be accessed by all users.\n$uninstallScriptPath = \"${env:PUBLIC}/uninstall-$softwareName.ps1\"\n$taskName = \"fleet-uninstall-$softwareName\"\ntry {\n Set-Content -Path $uninstallScriptPath -Value $userScript -Force\n\n # Task properties. The task will be started by the logged in user\n $action = New-ScheduledTaskAction -Execute \"PowerShell.exe\" `\n -Argument \"$uninstallScriptPath\"\n $trigger = New-ScheduledTaskTrigger -AtLogOn\n $userName = (Get-CimInstance Win32_Process -Filter 'name = \"explorer.exe\"' | Invoke-CimMethod -MethodName getowner).User\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n\n # Create a task object with the properties defined above\n $task = New-ScheduledTask -Action $action -Trigger $trigger `\n -Settings $settings\n\n # Register the task\n Register-ScheduledTask \"$taskName\" -InputObject $task -User \"$userName\"\n\n # keep track of the start time to cancel if taking too long to start\n $startDate = Get-Date\n\n # Start the task now that it is ready\n Start-ScheduledTask -TaskName \"$taskName\" -TaskPath \"\\\"\n\n # Wait for the task to be running\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n Write-Host \"ScheduledTask is '$state'\"\n\n while ($state -ne \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting to uninstall...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 1\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n }\n\n # Wait for the task to be done\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n while ($state -eq \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting for .exe to complete...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 10\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n }\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n} finally {\n # Remove task\n Write-Host \"Removing ScheduledTask: $taskName.\"\n Unregister-ScheduledTask -TaskName \"$taskName\" -Confirm:$false\n\n # Remove user script\n Remove-Item -Path $uninstallScriptPath -Force\n}\n\nExit $exitCode", - "9a0c2b15": "$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n$exitCode = 0\n\ntry {\n\n# Copy the installer to a public folder so that all can access it\n# users\n$exeFilename = Split-Path $exeFilePath -leaf\nCopy-Item -Path $exeFilePath -Destination \"${env:PUBLIC}\" -Force\n$exeFilePath = \"${env:PUBLIC}\\$exeFilename\"\n\n# Task properties. The task will be started by the logged in user\n$action = New-ScheduledTaskAction -Execute \"$exeFilePath\"\n$trigger = New-ScheduledTaskTrigger -AtLogOn\n$userName = (Get-CimInstance Win32_Process -Filter 'name = \"explorer.exe\"' | Invoke-CimMethod -MethodName getowner).User\n$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n\n# Create a task object with the properties defined above\n$task = New-ScheduledTask -Action $action -Trigger $trigger `\n -Settings $settings\n\n# Register the task\n$taskName = \"fleet-install-$exeFilename\"\nRegister-ScheduledTask \"$taskName\" -InputObject $task -User \"$userName\"\n\n# keep track of the start time to cancel if taking too long to start\n$startDate = Get-Date\n\n# Start the task now that it is ready\nStart-ScheduledTask -TaskName \"$taskName\" -TaskPath \"\\\"\n\n# Wait for the task to be running\n$state = (Get-ScheduledTask -TaskName \"$taskName\").State\nWrite-Host \"ScheduledTask is '$state'\"\n\nwhile ($state -ne \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting to run .exe...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 1\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n}\n\n# Wait for the task to be done\n$state = (Get-ScheduledTask -TaskName \"$taskName\").State\nwhile ($state -eq \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting for .exe to complete...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 10\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n}\n\n# Remove task\nWrite-Host \"Removing ScheduledTask: $taskName.\"\nUnregister-ScheduledTask -TaskName \"$taskName\" -Confirm:$false\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n} finally {\n # Remove installer\n Remove-Item -Path $exeFilePath -Force\n}\n\nExit $exitCode" + "b2a17e99": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"user\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"user\"\n$fmaDisplayNameLike = \"Brave*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--uninstall --force-uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n$exitCode = 0\n\ntry {\n\n# Copy the installer to a public folder so that all can access it\n# users\n$exeFilename = Split-Path $exeFilePath -leaf\nCopy-Item -Path $exeFilePath -Destination \"${env:PUBLIC}\" -Force\n$exeFilePath = \"${env:PUBLIC}\\$exeFilename\"\n\n# Task properties. The task will be started by the logged in user\n$action = New-ScheduledTaskAction -Execute \"$exeFilePath\"\n$trigger = New-ScheduledTaskTrigger -AtLogOn\n$userName = (Get-CimInstance Win32_Process -Filter 'name = \"explorer.exe\"' | Invoke-CimMethod -MethodName getowner).User\n$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n\n# Create a task object with the properties defined above\n$task = New-ScheduledTask -Action $action -Trigger $trigger `\n -Settings $settings\n\n# Register the task\n$taskName = \"fleet-install-$exeFilename\"\nRegister-ScheduledTask \"$taskName\" -InputObject $task -User \"$userName\"\n\n# keep track of the start time to cancel if taking too long to start\n$startDate = Get-Date\n\n# Start the task now that it is ready\nStart-ScheduledTask -TaskName \"$taskName\" -TaskPath \"\\\"\n\n# Wait for the task to be running\n$state = (Get-ScheduledTask -TaskName \"$taskName\").State\nWrite-Host \"ScheduledTask is '$state'\"\n\nwhile ($state -ne \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting to run .exe...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 1\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n}\n\n# Wait for the task to be done\n$state = (Get-ScheduledTask -TaskName \"$taskName\").State\nwhile ($state -eq \"Running\") {\n Write-Host \"ScheduledTask is '$state'. Waiting for .exe to complete...\"\n\n $endDate = Get-Date\n $elapsedTime = New-Timespan -Start $startDate -End $endDate\n if ($elapsedTime.TotalSeconds -gt 120) {\n Throw \"Timed-out waiting for scheduled task state.\"\n }\n\n Start-Sleep -Seconds 10\n $state = (Get-ScheduledTask -TaskName \"$taskName\").State\n}\n\n# Remove task\nWrite-Host \"Removing ScheduledTask: $taskName.\"\nUnregister-ScheduledTask -TaskName \"$taskName\" -Confirm:$false\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n} finally {\n # Remove installer\n Remove-Item -Path $exeFilePath -Force\n}\n\nExit $exitCode" } } diff --git a/ee/maintained-apps/outputs/cursor/windows.json b/ee/maintained-apps/outputs/cursor/windows.json index fcaec9bc0fb..03d549709dd 100644 --- a/ee/maintained-apps/outputs/cursor/windows.json +++ b/ee/maintained-apps/outputs/cursor/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Cursor' AND publisher = 'Anysphere' AND version_compare(version, '3.9.16') < 0);" }, "installer_url": "https://downloads.cursor.com/production/042b3c1a4c53f2c3808067f519fbfc67b72cad8b/win32/x64/system-setup/CursorSetup-x64-3.9.16.exe", - "install_script_ref": "03589b5e", + "install_script_ref": "79e083fb", "uninstall_script_ref": "6c8096c5", "sha256": "64c1c180c08954fa169ba4675a9e7d0b40a7a2b1ca4ac257d2a019781d99697c", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "03589b5e": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add arguments to install silently (Cursor uses an Inno Setup-based installer)\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", - "6c8096c5": "# Attempts to locate Cursor's uninstaller from registry and execute it silently\n\n$displayName = \"Cursor\"\n$publisher = \"Anysphere\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -and ($_.DisplayName -eq $displayName -or $_.DisplayName -like \"$displayName*\") -and ($publisher -eq \"\" -or $_.Publisher -eq $publisher)\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or -not $uninstall.UninstallString) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\n# Kill any running Cursor processes before uninstalling\nStop-Process -Name \"Cursor\" -Force -ErrorAction SilentlyContinue\n\n$uninstallCommand = $uninstall.UninstallString\n$uninstallArgs = \"/VERYSILENT /NORESTART\"\n\n# Parse the uninstall command to separate executable from existing arguments\n$splitArgs = $uninstallCommand.Split('\"')\nif ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $existingArgs = $splitArgs[2].Trim()\n if ($existingArgs -ne '') {\n $uninstallArgs = \"$existingArgs $uninstallArgs\"\n }\n } elseif ($splitArgs.Length -gt 3) {\n Write-Host \"Error: Uninstall command contains multiple quoted strings\"\n Exit 1\n }\n $uninstallCommand = $splitArgs[1]\n}\n\nWrite-Host \"Uninstall command: $uninstallCommand\"\nWrite-Host \"Uninstall args: $uninstallArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $uninstallCommand\n ArgumentList = $uninstallArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n \n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n \n Write-Host \"Uninstall exit code: $exitCode\"\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n" + "6c8096c5": "# Attempts to locate Cursor's uninstaller from registry and execute it silently\n\n$displayName = \"Cursor\"\n$publisher = \"Anysphere\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -and ($_.DisplayName -eq $displayName -or $_.DisplayName -like \"$displayName*\") -and ($publisher -eq \"\" -or $_.Publisher -eq $publisher)\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or -not $uninstall.UninstallString) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\n# Kill any running Cursor processes before uninstalling\nStop-Process -Name \"Cursor\" -Force -ErrorAction SilentlyContinue\n\n$uninstallCommand = $uninstall.UninstallString\n$uninstallArgs = \"/VERYSILENT /NORESTART\"\n\n# Parse the uninstall command to separate executable from existing arguments\n$splitArgs = $uninstallCommand.Split('\"')\nif ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $existingArgs = $splitArgs[2].Trim()\n if ($existingArgs -ne '') {\n $uninstallArgs = \"$existingArgs $uninstallArgs\"\n }\n } elseif ($splitArgs.Length -gt 3) {\n Write-Host \"Error: Uninstall command contains multiple quoted strings\"\n Exit 1\n }\n $uninstallCommand = $splitArgs[1]\n}\n\nWrite-Host \"Uninstall command: $uninstallCommand\"\nWrite-Host \"Uninstall args: $uninstallArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $uninstallCommand\n ArgumentList = $uninstallArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n \n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n \n Write-Host \"Uninstall exit code: $exitCode\"\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n", + "79e083fb": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Cursor*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add arguments to install silently (Cursor uses an Inno Setup-based installer)\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/dropbox/windows.json b/ee/maintained-apps/outputs/dropbox/windows.json index 0efc1d5087a..739d58973bd 100644 --- a/ee/maintained-apps/outputs/dropbox/windows.json +++ b/ee/maintained-apps/outputs/dropbox/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Dropbox' AND publisher = 'Dropbox, Inc.' AND version_compare(version, '260.3.2813') < 0);" }, "installer_url": "https://edge.dropboxstatic.com/dbx-releng/client/Dropbox%20260.3.2813%20Enterprise%20Installer.x64.msi", - "install_script_ref": "8959087b", + "install_script_ref": "65bc4efa", "uninstall_script_ref": "db5b3d96", "sha256": "a11e81320fc427c5a79c6699398c510ceafc97d500f0b3f95596e061b85ee631", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "65bc4efa": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Dropbox*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "db5b3d96": "$product_code = '{C3CA8C95-7298-5ACF-ACD0-1F954F0A551F}'\n$timeoutSeconds = 300 # 5 minute timeout\n\n# Fleet uninstalls app using product code that's extracted on upload\n$process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n# Wait for process with timeout\n$completed = $process.WaitForExit($timeoutSeconds * 1000)\n\nif (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n}\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\n# Check exit code and output result\nif ($successCodes -contains $process.ExitCode) {\n Write-Output \"Exit 0\"\n Exit 0\n} else {\n Write-Output \"Exit $($process.ExitCode)\"\n Exit $process.ExitCode\n}\n" } } diff --git a/ee/maintained-apps/outputs/firefox/windows.json b/ee/maintained-apps/outputs/firefox/windows.json index 77e12610c49..4bd73a852a1 100644 --- a/ee/maintained-apps/outputs/firefox/windows.json +++ b/ee/maintained-apps/outputs/firefox/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Mozilla Firefox (x64 en-US)' AND publisher = 'Mozilla' AND version_compare(version, '152.0.4') < 0);" }, "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/152.0.4/win64/en-US/Firefox%20Setup%20152.0.4.exe", - "install_script_ref": "80fb9175", + "install_script_ref": "d6bc46f1", "uninstall_script_ref": "8b5e20e4", "sha256": "a1cd9b563a1c084db7e77f6e2a7e1f07876d8e58e4e33463052d53cc0a5d7069", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "80fb9175": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add argument to install silently\n# Argument to make install silent depends on installer,\n# each installer might use different argument (usually it's \"/S\" or \"/s\")\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add arguments to install silently (Firefox uses an Inno Setup-based installer)\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", - "8b5e20e4": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"Firefox\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"*$softwareName*\"\n\n# Some uninstallers require a flag to run silently.\n# Each uninstaller might use different argument (usually it's \"/S\" or \"/s\")\n$uninstallArgs = \"/S\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n # Change exit code to 0 if you don't want to fail if uninstaller is not\n # found. This could happen if program was already uninstalled.\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" + "8b5e20e4": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"Firefox\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"*$softwareName*\"\n\n# Some uninstallers require a flag to run silently.\n# Each uninstaller might use different argument (usually it's \"/S\" or \"/s\")\n$uninstallArgs = \"/S\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n # If needed, add -notlike to the comparison to exclude certain similar\n # software\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n # Change exit code to 0 if you don't want to fail if uninstaller is not\n # found. This could happen if program was already uninstalled.\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n", + "d6bc46f1": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Mozilla Firefox (x64*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add argument to install silently\n# Argument to make install silent depends on installer,\n# each installer might use different argument (usually it's \"/S\" or \"/s\")\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add arguments to install silently (Firefox uses an Inno Setup-based installer)\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/firefox@esr/windows.json b/ee/maintained-apps/outputs/firefox@esr/windows.json index 00883d60da1..dac05e5e532 100644 --- a/ee/maintained-apps/outputs/firefox@esr/windows.json +++ b/ee/maintained-apps/outputs/firefox@esr/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'Mozilla Firefox % ESR %' AND publisher = 'Mozilla' AND version_compare(version, '140.12.0') < 0);" }, "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/140.12.0esr/win64/en-US/Firefox%20Setup%20140.12.0esr.exe", - "install_script_ref": "36995f4f", + "install_script_ref": "389738de", "uninstall_script_ref": "5dc712f7", "sha256": "8faf3b35e8272d320eab45bc75bb6d644b29a0ab0cced4ad243f9bbaf6148dea", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "36995f4f": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$installDir = \"C:\\Program Files\\Mozilla Firefox\"\n$maxWaitSeconds = 120\n\ntry {\n\n# Start silent install without -Wait; the Firefox ESR installer launches the\n# browser after installing and blocks until it is closed.\nStart-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\"\n\n# Poll for installation to complete\n$elapsed = 0\nwhile ($elapsed -lt $maxWaitSeconds) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n if (Test-Path \"$installDir\\firefox.exe\") {\n Write-Host \"Firefox ESR installed successfully after $elapsed seconds\"\n Exit 0\n }\n}\n\nWrite-Host \"Timed out waiting for Firefox ESR to install\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "389738de": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Mozilla Firefox ESR*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$installDir = \"C:\\Program Files\\Mozilla Firefox\"\n$maxWaitSeconds = 120\n\ntry {\n\n# Start silent install without -Wait; the Firefox ESR installer launches the\n# browser after installing and blocks until it is closed.\nStart-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\"\n\n# Poll for installation to complete\n$elapsed = 0\nwhile ($elapsed -lt $maxWaitSeconds) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n if (Test-Path \"$installDir\\firefox.exe\") {\n Write-Host \"Firefox ESR installed successfully after $elapsed seconds\"\n Exit 0\n }\n}\n\nWrite-Host \"Timed out waiting for Firefox ESR to install\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "5dc712f7": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n# Match Firefox ESR only (e.g. \"Mozilla Firefox 140.7.1 ESR (x64 en-US)\"), not regular Firefox\n$softwareNameLike = \"*Firefox*ESR*\"\n\n# NSIS installers require /S flag for silent uninstall\n$uninstallArgs = \"/S\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Mozilla Firefox ESR\\uninstall\\helper.exe\" /S\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $existingArgs = $splitArgs[2].Trim()\n if ($existingArgs -notmatch '\\b/S\\b') {\n $uninstallArgs = \"$existingArgs /S\".Trim()\n } else {\n $uninstallArgs = $existingArgs\n }\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n } else {\n if ($uninstallCommand -notmatch '\\b/S\\b') {\n $uninstallArgs = \"/S\"\n } else {\n $uninstallArgs = \"\"\n }\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = $uninstallArgs\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for Firefox ESR not found.\"\n $exitCode = 1\n}\n\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/gimp/windows.json b/ee/maintained-apps/outputs/gimp/windows.json index 505cef27e5b..b88b4293716 100644 --- a/ee/maintained-apps/outputs/gimp/windows.json +++ b/ee/maintained-apps/outputs/gimp/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'GIMP %' AND publisher = 'The GIMP Team' AND version_compare(version, '3.2.4.0') < 0);" }, "installer_url": "https://download.gimp.org/gimp/v3.2/windows/gimp-3.2.4-setup.exe", - "install_script_ref": "72113c10", + "install_script_ref": "3816f627", "uninstall_script_ref": "79fb181d", "sha256": "ec31d757dd82831d201ffcf47ffeac4175df739e0c02d5122e89aeeadfb988cc", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "72113c10": "# GIMP is managed by Fleet as a PER-MACHINE install (Inno Setup /ALLUSERS).\n# GIMP also offers a per-user install, so a host may already have a stale per-user\n# copy. Fleet's patch policy is scope-blind (osquery's \"programs\" table reads HKLM\n# + every loaded user hive), so a lingering per-user copy keeps the policy red and\n# leaves two copies on disk.\n#\n# Pattern A (remove-and-replace): before installing the machine copy, remove any\n# per-user copy so the device converges on a single canonical copy. The machine\n# installer upgrades an existing machine copy in place, so same-scope data is\n# preserved; only the cross-scope (per-user) copy is removed.\n# See https://github.com/fleetdm/fleet/issues/48248.\n#\n# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own\n# hive — NOT the logged-on user's. Per-user copies must be found under\n# HKEY_USERS\\, which is what Remove-OtherScopeCopies does below.\n# Removal is best-effort: it never aborts the machine install, and a copy that\n# survives keeps the (truthful) scope-blind policy red rather than false-green.\n\n# Match GIMP 3.x only (the FMA targets GIMP.GIMP.3); avoids touching GIMP 2.\n$displayNameLike = \"GIMP 3*\"\n\nfunction Get-UninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n }\n return $null\n}\n\nfunction Remove-OtherScopeCopies {\n param([Parameter(Mandatory = $true)][string]$DisplayNameLike)\n\n # Per-user uninstall registrations live in the logged-on users' hives.\n $roots = @()\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n\n $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) {\n Write-Host \"Per-user copy '$($key.DisplayName)' has no uninstall string; skipping.\"\n continue\n }\n\n $parsed = Get-UninstallExeAndArgs $command\n if (-not $parsed) {\n Write-Host \"Could not parse uninstall string for '$($key.DisplayName)': $command\"\n continue\n }\n\n $exe = $parsed.Exe\n $uninstallArgs = $parsed.Args\n\n # GIMP uses an Inno Setup uninstaller; ensure a silent uninstall.\n if ($uninstallArgs -notmatch '(?i)/VERYSILENT') { $uninstallArgs = (\"$uninstallArgs /VERYSILENT\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/SUPPRESSMSGBOXES') { $uninstallArgs = (\"$uninstallArgs /SUPPRESSMSGBOXES\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/NORESTART') { $uninstallArgs = (\"$uninstallArgs /NORESTART\").Trim() }\n\n if (-not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe\"\n continue\n }\n\n Write-Host \"Removing per-user copy: '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uninstallArgs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs }\n $p = Start-Process @opts\n Write-Host \" Per-user uninstall exit code: $($p.ExitCode)\"\n } catch {\n # Best effort: never fail the machine install because of other-scope cleanup.\n Write-Host \" WARNING: failed to remove per-user copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-OtherScopeCopies -DisplayNameLike $displayNameLike\n} catch {\n # Cleanup is best-effort; proceed to install regardless.\n Write-Host \"Warning during per-user cleanup: $_\"\n}\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Inno Setup installer with /ALLUSERS for machine-scope installation\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /ALLUSERS\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "3816f627": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"GIMP 3*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Inno Setup installer with /ALLUSERS for machine-scope installation\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /ALLUSERS\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "79fb181d": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n$softwareName = \"GIMP\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"GIMP 3*\"\n\n# Inno Setup installers require /VERYSILENT flag for silent uninstall\n$uninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" /SILENT\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n # Exit the loop once the software is found and uninstalled.\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" } } diff --git a/ee/maintained-apps/outputs/github-desktop/windows.json b/ee/maintained-apps/outputs/github-desktop/windows.json index 0c5924bac99..93b33a19634 100644 --- a/ee/maintained-apps/outputs/github-desktop/windows.json +++ b/ee/maintained-apps/outputs/github-desktop/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'GitHub Desktop' AND publisher = 'GitHub, Inc.' AND version_compare(version, '3.6.1') < 0);" }, "installer_url": "https://desktop.githubusercontent.com/releases/3.6.1-8fa814ac/GitHubDesktopSetup-x64.msi", - "install_script_ref": "8959087b", + "install_script_ref": "9fe80ace", "uninstall_script_ref": "cfc2b24c", "sha256": "767c25addc1e75d1fbaa235055a4c45172dcb82056327c710e5314537f210b61", "default_categories": [ @@ -17,7 +17,7 @@ } ], "refs": { - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "9fe80ace": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"GitHub Desktop*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"-s\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "cfc2b24c": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{00D8E2EE-13EA-5BEB-87F0-70EFC46A7D4A}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n" } } diff --git a/ee/maintained-apps/outputs/google-chrome/windows.json b/ee/maintained-apps/outputs/google-chrome/windows.json index b04888a0a46..005bb62b95f 100644 --- a/ee/maintained-apps/outputs/google-chrome/windows.json +++ b/ee/maintained-apps/outputs/google-chrome/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Google Chrome' AND publisher = 'Google LLC' AND version_compare(version, '150.0.7871.47') < 0);" }, "installer_url": "https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi", - "install_script_ref": "8959087b", + "install_script_ref": "29e7984f", "uninstall_script_ref": "06362a80", "sha256": "no_check", "default_categories": [ @@ -18,6 +18,6 @@ ], "refs": { "06362a80": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{C1DFDF69-5945-32F2-A35E-EE94C99C7CF4}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n", - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "29e7984f": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Google Chrome*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--uninstall --force-uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/google-drive/windows.json b/ee/maintained-apps/outputs/google-drive/windows.json index 69d2738a34f..ce13f9f3f6c 100644 --- a/ee/maintained-apps/outputs/google-drive/windows.json +++ b/ee/maintained-apps/outputs/google-drive/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Google Drive' AND publisher = 'Google LLC' AND version_compare(version, '127.0.1.0') < 0);" }, "installer_url": "https://dl.google.com/release2/drive-file-stream/ad2knx26du4ounqnih7mksvpk62q_127.0.1.0/setup.exe", - "install_script_ref": "fa36b892", + "install_script_ref": "8e24f67d", "uninstall_script_ref": "785a96b9", "sha256": "215cdf6f01d1ddc1da64210e4cbac71515d33e88c7c45742fef5b230e180b94d", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "785a96b9": "$softwareName = \"Google Drive\"\n\n$uninstallArgs = \"--silent --force_stop\"\n\n$expectedExitCodes = @()\n\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n$machineKey32on64 = `\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n\n$exitCode = 0\n\ntry {\n\n [array]$uninstallKeys = Get-ChildItem `\n -Path @($machineKey, $machineKey32on64) `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n $foundUninstaller = $false\n foreach ($key in $uninstallKeys) {\n if ($key.DisplayName -eq $softwareName) {\n $foundUninstaller = $true\n # Get the uninstall command.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n ArgumentList = \"$uninstallArgs\".Split(' ')\n NoNewWindow = $true\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n }\n\n if (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n Exit 1\n }\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n\nif ($expectedExitCodes -contains $exitCode) {\n $exitCode = 0\n}\n\nExit $exitCode\n", - "fa36b892": "$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"--silent --skip_launch_new --gsuite_shortcuts=false\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "8e24f67d": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Google Drive\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--silent --force_stop\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"--silent --skip_launch_new --gsuite_shortcuts=false\"\n PassThru = $true\n Wait = $true\n}\n\n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/kiro/windows.json b/ee/maintained-apps/outputs/kiro/windows.json index f8ebdbf8dc1..0d912fa7290 100644 --- a/ee/maintained-apps/outputs/kiro/windows.json +++ b/ee/maintained-apps/outputs/kiro/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Kiro (User)' AND publisher = 'Amazon Web Services' AND version_compare(version, '1.0.0') < 0);" }, "installer_url": "https://prod.download.desktop.kiro.dev/releases/stable/win32-x64/signed/1.0.0/kiro-ide-1.0.0-stable-win32-x64.exe", - "install_script_ref": "c5bc3c21", + "install_script_ref": "d7705b90", "uninstall_script_ref": "afe8f2fa", "sha256": "246e49284a8fad583280d86f5afa030807fd03aaa920fbe633a4129af31ffb29", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "afe8f2fa": "# Attempts to locate Kiro's Inno Setup uninstaller from registry and execute it silently.\n# Kiro is user-scope; the ARP DisplayName is \"Kiro (User)\".\n\n$displayName = \"Kiro\"\n$publisher = \"Amazon\"\n\n$paths = @(\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -and ($_.DisplayName -eq $displayName -or $_.DisplayName -like \"$displayName *\") -and\n ($publisher -eq \"\" -or $_.Publisher -like \"*$publisher*\")\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or -not $uninstall.UninstallString) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\n# Kill any running Kiro processes before uninstalling\nStop-Process -Name \"Kiro\" -Force -ErrorAction SilentlyContinue\nStart-Sleep -Seconds 2\n\n$uninstallCommand = $uninstall.UninstallString\n$uninstallArgs = \"/VERYSILENT /NORESTART\"\n\n# Parse the uninstall command to separate executable from existing arguments\nif ($uninstallCommand -match '^\"([^\"]+)\"\\s*(.*)$') {\n $uninstallCommand = $matches[1]; $extra = $matches[2].Trim()\n if ($extra) { $uninstallArgs = \"$extra $uninstallArgs\".Trim() }\n} elseif ($uninstallCommand -match '^(.+?\\.exe)\\s*(.*)$') {\n $uninstallCommand = $matches[1]; $extra = $matches[2].Trim()\n if ($extra) { $uninstallArgs = \"$extra $uninstallArgs\".Trim() }\n} else {\n Write-Host \"Error: Could not parse uninstall command: $uninstallCommand\"; Exit 1\n}\n\nWrite-Host \"Uninstall command: $uninstallCommand\"\nWrite-Host \"Uninstall args: $uninstallArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $uninstallCommand\n ArgumentList = $uninstallArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n Write-Host \"Uninstall exit code: $exitCode\"\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n", - "c5bc3c21": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n#\n# Kiro ships as an Inno Setup-based installer (user scope, VS Code fork).\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n if (-not (Test-Path $exeFilePath)) {\n Write-Host \"Error: Installer file not found at: $exeFilePath\"\n Exit 1\n }\n\n# Inno Setup silent install. /mergetasks=!runcode prevents launching after install.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "d7705b90": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"user\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"user\"\n$fmaDisplayNameLike = \"Kiro*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n#\n# Kiro ships as an Inno Setup-based installer (user scope, VS Code fork).\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n if (-not (Test-Path $exeFilePath)) {\n Write-Host \"Error: Installer file not found at: $exeFilePath\"\n Exit 1\n }\n\n# Inno Setup silent install. /mergetasks=!runcode prevents launching after install.\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/SP- /VERYSILENT /SUPPRESSMSGBOXES /NORESTART /CLOSEAPPLICATIONS /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/microsoft-edge/windows.json b/ee/maintained-apps/outputs/microsoft-edge/windows.json index 954accf96bf..bb270bb44a6 100644 --- a/ee/maintained-apps/outputs/microsoft-edge/windows.json +++ b/ee/maintained-apps/outputs/microsoft-edge/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Microsoft Edge' AND publisher = 'Microsoft Corporation' AND version_compare(version, '149.0.4022.98') < 0);" }, "installer_url": "https://msedge.sf.dl.delivery.mp.microsoft.com/filestreamingservice/files/82374039-b772-4d0a-a9a9-ee311dabec12/MicrosoftEdgeEnterpriseX64.msi", - "install_script_ref": "8959087b", + "install_script_ref": "e460919b", "uninstall_script_ref": "e12e9902", "sha256": "d026379848222d1e32800a1ff4268b9f5252a9d50f98819768ba54662f27979e", "default_categories": [ @@ -17,7 +17,7 @@ } ], "refs": { - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", - "e12e9902": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{883C2625-37F7-357F-A0F4-DFAF391B2B9C}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n" + "e12e9902": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{883C2625-37F7-357F-A0F4-DFAF391B2B9C}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n", + "e460919b": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Microsoft Edge*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--uninstall --force-uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/mullvad-browser/windows.json b/ee/maintained-apps/outputs/mullvad-browser/windows.json index 22185308ecc..796605a2a91 100644 --- a/ee/maintained-apps/outputs/mullvad-browser/windows.json +++ b/ee/maintained-apps/outputs/mullvad-browser/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Mullvad Browser' AND publisher = 'Mullvad VPN' AND version_compare(version, '15.0.16') < 0);" }, "installer_url": "https://github.com/mullvad/mullvad-browser/releases/download/15.0.16/mullvad-browser-windows-x86_64-15.0.16.exe", - "install_script_ref": "de703749", + "install_script_ref": "00b6fdaa", "uninstall_script_ref": "77ded4aa", "sha256": "72f10b35a388c5f14ebff0eadc32b2a84b70f6e459f801f8fc9d8a21c398329a", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "77ded4aa": "# Attempts to locate this application's NSIS uninstaller from the registry and run it silently.\n\n$displayName = \"Mullvad Browser\"\n$publisher = \"Mullvad VPN\"\n\n$paths = @(\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n ($_.DisplayName -eq $displayName -or $_.DisplayName -like \"$displayName *\") -and\n ($publisher -eq \"\" -or $_.Publisher -like \"*$publisher*\")\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or -not $uninstall.UninstallString) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\ntry {\n $uninstallString = $uninstall.UninstallString\n $exePath = \"\"\n if ($uninstallString -match '^\"([^\"]+)\"') {\n $exePath = $matches[1]\n } elseif ($uninstallString -match '^(.+?\\.exe)') {\n $exePath = $matches[1]\n } else {\n $exePath = $uninstallString\n }\n\n $installDir = $uninstall.InstallLocation\n if (-not $installDir -or -not (Test-Path $installDir)) {\n $installDir = Split-Path -Parent $exePath\n }\n\n $argumentList = @(\"/S\", \"_?=$installDir\")\n\n Write-Host \"Uninstall command: $exePath\"\n Write-Host \"Uninstall args: $argumentList\"\n\n $processOptions = @{\n FilePath = $exePath\n ArgumentList = $argumentList\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n\n # Only sweep leftovers on a successful uninstall, and never a root/short path\n if ($exitCode -eq 0 -and $installDir) {\n $resolvedDir = $null\n try { $resolvedDir = (Resolve-Path -LiteralPath $installDir -ErrorAction Stop).Path } catch { $resolvedDir = $null }\n if ($resolvedDir -and ($resolvedDir -match '^[A-Za-z]:\\\\') -and ((($resolvedDir.TrimEnd('\\')) -split '\\\\').Count -ge 3) -and (Test-Path -LiteralPath $resolvedDir)) {\n Remove-Item -LiteralPath $resolvedDir -Recurse -Force -ErrorAction SilentlyContinue\n }\n }\n\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n", - "de703749": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n#\n# This application ships as an NSIS installer.\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n if (-not (Test-Path $exeFilePath)) {\n Write-Host \"Error: Installer file not found at: $exeFilePath\"\n Exit 1\n }\n\n $processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n NoNewWindow = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n Exit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "00b6fdaa": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"user\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"user\"\n$fmaDisplayNameLike = \"Mullvad Browser*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n#\n# This application ships as an NSIS installer.\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n if (-not (Test-Path $exeFilePath)) {\n Write-Host \"Error: Installer file not found at: $exeFilePath\"\n Exit 1\n }\n\n $processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/S\"\n PassThru = $true\n Wait = $true\n NoNewWindow = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n Exit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "77ded4aa": "# Attempts to locate this application's NSIS uninstaller from the registry and run it silently.\n\n$displayName = \"Mullvad Browser\"\n$publisher = \"Mullvad VPN\"\n\n$paths = @(\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n ($_.DisplayName -eq $displayName -or $_.DisplayName -like \"$displayName *\") -and\n ($publisher -eq \"\" -or $_.Publisher -like \"*$publisher*\")\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or -not $uninstall.UninstallString) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\ntry {\n $uninstallString = $uninstall.UninstallString\n $exePath = \"\"\n if ($uninstallString -match '^\"([^\"]+)\"') {\n $exePath = $matches[1]\n } elseif ($uninstallString -match '^(.+?\\.exe)') {\n $exePath = $matches[1]\n } else {\n $exePath = $uninstallString\n }\n\n $installDir = $uninstall.InstallLocation\n if (-not $installDir -or -not (Test-Path $installDir)) {\n $installDir = Split-Path -Parent $exePath\n }\n\n $argumentList = @(\"/S\", \"_?=$installDir\")\n\n Write-Host \"Uninstall command: $exePath\"\n Write-Host \"Uninstall args: $argumentList\"\n\n $processOptions = @{\n FilePath = $exePath\n ArgumentList = $argumentList\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n\n # Only sweep leftovers on a successful uninstall, and never a root/short path\n if ($exitCode -eq 0 -and $installDir) {\n $resolvedDir = $null\n try { $resolvedDir = (Resolve-Path -LiteralPath $installDir -ErrorAction Stop).Path } catch { $resolvedDir = $null }\n if ($resolvedDir -and ($resolvedDir -match '^[A-Za-z]:\\\\') -and ((($resolvedDir.TrimEnd('\\')) -split '\\\\').Count -ge 3) -and (Test-Path -LiteralPath $resolvedDir)) {\n Remove-Item -LiteralPath $resolvedDir -Recurse -Force -ErrorAction SilentlyContinue\n }\n }\n\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/onedrive/windows.json b/ee/maintained-apps/outputs/onedrive/windows.json index 9080d212d8f..9b810dd8518 100644 --- a/ee/maintained-apps/outputs/onedrive/windows.json +++ b/ee/maintained-apps/outputs/onedrive/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Microsoft OneDrive' AND publisher = 'Microsoft Corporation' AND version_compare(version, '26.098.0524.0004') < 0);" }, "installer_url": "https://oneclient.sfx.ms/Win/Installers/26.098.0524.0004/amd64/OneDriveSetup.exe", - "install_script_ref": "ab0b56ab", + "install_script_ref": "991bcdbd", "uninstall_script_ref": "374be511", "sha256": "3d04143831ba248f597791e732d37a634418d7d495d2284ecf7338f256bb141c", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "374be511": "# Fleet runs this uninstall script as SYSTEM (machine scope).\n# Match against the registry DisplayName for OneDrive.\n$softwareName = \"Microsoft OneDrive\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"$softwareName\"\n\n# OneDriveSetup.exe uninstalls with /uninstall (and /allusers for machine-wide\n# installs). Used only if the registered UninstallString carries no flags.\n$defaultArgs = \"/uninstall /allusers\"\n\n# A machine-wide OneDrive registers under HKLM; also check WOW6432Node since the\n# installer is 32-bit.\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n)\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n\n # Prefer QuietUninstallString when present; it already includes silent flags.\n $useQuiet = [bool]$key.QuietUninstallString\n $uninstallCommand = if ($useQuiet) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n if ([string]::IsNullOrWhiteSpace($uninstallCommand)) {\n Throw \"No UninstallString found for '$($key.DisplayName)'.\"\n }\n\n # Parse the UninstallString defensively. It comes in three shapes:\n # 1. \"C:\\Program Files\\Microsoft OneDrive\\...\\OneDriveSetup.exe\" /uninstall (quoted)\n # 2. C:\\Program Files\\Microsoft OneDrive\\...\\OneDriveSetup.exe /uninstall (unquoted, may contain spaces)\n # 3. MsiExec.exe /X{GUID} (bare token)\n if ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exe = $matches[1]\n $args = $matches[2].Trim()\n } elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exe = $matches[1]\n $args = $matches[2].Trim()\n } else {\n $uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$' | Out-Null\n $exe = $matches[1]\n $args = $matches[2].Trim()\n }\n\n # If we fell back to the raw UninstallString (no quiet variant), ensure the\n # uninstall runs unattended and all-users.\n if (-not $useQuiet -and ($args -notmatch '(?i)/uninstall')) {\n $args = \"$args $defaultArgs\".Trim()\n }\n\n Write-Host \"Uninstall command: $exe\"\n Write-Host \"Uninstall args: $args\"\n\n $processOptions = @{\n FilePath = $exe\n PassThru = $true\n Wait = $true\n }\n if ($args -ne '') {\n $processOptions.ArgumentList = $args\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n", - "ab0b56ab": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# OneDriveSetup.exe performs a per-machine install with \"/allusers /silent\"\n# (switches verified against the winget InstallerSwitches Custom: /allusers and\n# silentinstallhq.com). The catch: OneDriveSetup.exe spawns several child\n# processes and starts the resident OneDrive.exe, so a plain Start-Process -Wait\n# can wait indefinitely and hit the CI step timeout. Instead, start the\n# installer, then poll for the per-machine install to land (registry uninstall\n# key + the all-users binary) and return success as soon as it appears.\n\n$process = Start-Process -FilePath \"$exeFilePath\" -ArgumentList \"/allusers /silent\" -PassThru\n\n# Per-machine OneDrive registers an uninstall key and drops OneDrive.exe under\n# Program Files (x86) (or Program Files on x86 OS).\n$uninstallKey = \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OneDriveSetup.exe\"\n$exePaths = @(\n \"$env:ProgramFiles\\Microsoft OneDrive\\OneDrive.exe\",\n \"${env:ProgramFiles(x86)}\\Microsoft OneDrive\\OneDrive.exe\"\n)\n\n$timeoutSeconds = 240\n$deadline = (Get-Date).AddSeconds($timeoutSeconds)\n$installed = $false\n\nwhile ((Get-Date) -lt $deadline) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) {\n $installed = $true\n break\n }\n # If the top-level setup process exited, capture its code and stop polling.\n if ($process.HasExited) { break }\n Start-Sleep -Seconds 5\n}\n\n# Final check in case the setup process exited right before the loop bailed.\nif (-not $installed) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) { $installed = $true }\n}\n\nif ($installed) {\n Write-Host \"OneDrive per-machine install detected.\"\n Exit 0\n}\n\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"OneDriveSetup exited with code: $exitCode\"\n if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n Exit $exitCode\n}\n\nWrite-Host \"Timed out waiting for OneDrive install to complete.\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "991bcdbd": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Microsoft OneDrive*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# OneDriveSetup.exe performs a per-machine install with \"/allusers /silent\"\n# (switches verified against the winget InstallerSwitches Custom: /allusers and\n# silentinstallhq.com). The catch: OneDriveSetup.exe spawns several child\n# processes and starts the resident OneDrive.exe, so a plain Start-Process -Wait\n# can wait indefinitely and hit the CI step timeout. Instead, start the\n# installer, then poll for the per-machine install to land (registry uninstall\n# key + the all-users binary) and return success as soon as it appears.\n\n$process = Start-Process -FilePath \"$exeFilePath\" -ArgumentList \"/allusers /silent\" -PassThru\n\n# Per-machine OneDrive registers an uninstall key and drops OneDrive.exe under\n# Program Files (x86) (or Program Files on x86 OS).\n$uninstallKey = \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OneDriveSetup.exe\"\n$exePaths = @(\n \"$env:ProgramFiles\\Microsoft OneDrive\\OneDrive.exe\",\n \"${env:ProgramFiles(x86)}\\Microsoft OneDrive\\OneDrive.exe\"\n)\n\n$timeoutSeconds = 240\n$deadline = (Get-Date).AddSeconds($timeoutSeconds)\n$installed = $false\n\nwhile ((Get-Date) -lt $deadline) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) {\n $installed = $true\n break\n }\n # If the top-level setup process exited, capture its code and stop polling.\n if ($process.HasExited) { break }\n Start-Sleep -Seconds 5\n}\n\n# Final check in case the setup process exited right before the loop bailed.\nif (-not $installed) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) { $installed = $true }\n}\n\nif ($installed) {\n Write-Host \"OneDrive per-machine install detected.\"\n Exit 0\n}\n\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"OneDriveSetup exited with code: $exitCode\"\n if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n Exit $exitCode\n}\n\nWrite-Host \"Timed out waiting for OneDrive install to complete.\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/powertoys/windows.json b/ee/maintained-apps/outputs/powertoys/windows.json index 3a9cc3629f5..ae765d61fe2 100644 --- a/ee/maintained-apps/outputs/powertoys/windows.json +++ b/ee/maintained-apps/outputs/powertoys/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'PowerToys %' AND publisher = 'Microsoft Corporation' AND version_compare(version, '0.100.2') < 0);" }, "installer_url": "https://github.com/microsoft/PowerToys/releases/download/v0.100.2/PowerToysSetup-0.100.2-x64.exe", - "install_script_ref": "733f8df6", + "install_script_ref": "47adf288", "uninstall_script_ref": "a97a3cf8", "sha256": "73c04aac8052420111fe5cdc0098ec8415d87cbdbd42de253e9af959781cbf9e", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "733f8df6": "# PowerToys is managed by Fleet as a PER-MACHINE (HKLM) install. Windows also\n# ships a per-user installer (PowerToysUserSetup), so a host may already have a\n# stale per-user copy. Fleet's patch policy is scope-blind (osquery's \"programs\"\n# table reads HKLM + every loaded user hive), so a lingering per-user copy keeps\n# the policy red and leaves two copies on disk.\n#\n# Pattern A (remove-and-replace): before installing the machine copy, remove any\n# per-user copy so the device converges on a single canonical copy. The machine\n# installer upgrades an existing machine copy in place, so same-scope data is\n# preserved; only the cross-scope (per-user) copy is removed.\n# See https://github.com/fleetdm/fleet/issues/48248.\n#\n# NOTE: Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own\n# hive — NOT the logged-on user's. Per-user copies must be found under\n# HKEY_USERS\\, which is what Remove-OtherScopeCopies does below.\n# Removal is best-effort: it never aborts the machine install, and a copy that\n# survives keeps the (truthful) scope-blind policy red rather than false-green.\n\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\nfunction Get-UninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n } elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') {\n return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() }\n }\n return $null\n}\n\nfunction Remove-OtherScopeCopies {\n param(\n [Parameter(Mandatory = $true)][string]$DisplayNameLike,\n [string]$PublisherLike = ''\n )\n\n # Per-user uninstall registrations live in the logged-on users' hives.\n $roots = @()\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n $command = if ($key.QuietUninstallString) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) {\n Write-Host \"Per-user copy '$($key.DisplayName)' has no uninstall string; skipping.\"\n continue\n }\n\n $parsed = Get-UninstallExeAndArgs $command\n if (-not $parsed) {\n Write-Host \"Could not parse uninstall string for '$($key.DisplayName)': $command\"\n continue\n }\n\n $exe = $parsed.Exe\n $uninstallArgs = $parsed.Args\n\n # PowerToys installers are WiX Burn bundles. Ensure a quiet uninstall.\n if ($exe -match '(?i)msiexec') {\n $uninstallArgs = $uninstallArgs -replace '(?i)/i(\\{)', '/x$1'\n if ($uninstallArgs -notmatch '(?i)/x') { $uninstallArgs = (\"/x $uninstallArgs\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/qn') { $uninstallArgs = (\"$uninstallArgs /qn\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = (\"$uninstallArgs /norestart\").Trim() }\n } else {\n if ($uninstallArgs -notmatch '(?i)/uninstall') { $uninstallArgs = (\"$uninstallArgs /uninstall\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/quiet') { $uninstallArgs = (\"$uninstallArgs /quiet\").Trim() }\n if ($uninstallArgs -notmatch '(?i)/norestart') { $uninstallArgs = (\"$uninstallArgs /norestart\").Trim() }\n }\n\n if (-not ($exe -match '(?i)msiexec') -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Per-user uninstaller missing on disk for '$($key.DisplayName)': $exe\"\n continue\n }\n\n Write-Host \"Removing per-user copy: '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uninstallArgs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uninstallArgs -ne '') { $opts.ArgumentList = $uninstallArgs }\n $p = Start-Process @opts\n Write-Host \" Per-user uninstall exit code: $($p.ExitCode)\"\n } catch {\n # Best effort: never fail the machine install because of other-scope cleanup.\n Write-Host \" WARNING: failed to remove per-user copy: $_\"\n }\n }\n }\n}\n\ntry {\n Stop-Process -Name \"PowerToys\" -Force -ErrorAction SilentlyContinue\n Remove-OtherScopeCopies -DisplayNameLike \"PowerToys*\" -PublisherLike \"Microsoft Corporation*\"\n} catch {\n # Cleanup is best-effort; proceed to install regardless.\n Write-Host \"Warning during per-user cleanup: $_\"\n}\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/install /quiet /norestart\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "47adf288": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"PowerToys*\"\n$fmaPublisherLike = \"Microsoft Corporation*\"\n$fmaFallbackUninstallArgs = \"/uninstall /quiet /norestart\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/install /quiet /norestart\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "a97a3cf8": "$displayNameLike = \"PowerToys*\"\n$publisherLike = \"Microsoft Corporation*\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$ExpectedExitCodes = @(0, 1641, 3010, 1223)\n\n# A WiX Burn bundle and its child MSI packages can all share the same DisplayName.\n# The bundle is the entry that exposes a QuietUninstallString / an .exe bootstrapper\n# UninstallString; child MSIs only expose \"MsiExec.exe /I{GUID}\". Prefer the bundle\n# so the whole product is removed (uninstalling a single child MSI would not).\n$candidates = @()\nforeach ($p in $paths) {\n $candidates += Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -like $displayNameLike -and $_.Publisher -like $publisherLike\n }\n}\n\nif (-not $candidates -or $candidates.Count -eq 0) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\n$entry = $candidates | Where-Object { $_.QuietUninstallString } | Select-Object -First 1\nif (-not $entry) {\n $entry = $candidates | Where-Object { $_.UninstallString -and $_.UninstallString -notmatch '(?i)msiexec' } | Select-Object -First 1\n}\nif (-not $entry) {\n $entry = $candidates | Where-Object { $_.UninstallString } | Select-Object -First 1\n}\nif (-not $entry) {\n Write-Host \"Uninstall entry found but has no uninstall string\"\n Exit 0\n}\n\nStop-Process -Name \"PowerToys\" -Force -ErrorAction SilentlyContinue\n\n$uninstallCommand = if ($entry.QuietUninstallString) { $entry.QuietUninstallString } else { $entry.UninstallString }\n\n$exePath = \"\"\n$existingArgs = \"\"\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $exePath = $matches[1]; $existingArgs = $matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\nif ($exePath -match '(?i)msiexec') {\n # Force an uninstall (/x), never a repair (/i), and run with MSI quiet flags.\n $existingArgs = $existingArgs -replace '(?i)/i(\\{)', '/x$1'\n $existingArgs = ($existingArgs -replace '(?i)/uninstall', '') -replace '(?i)/quiet', ''\n if ($existingArgs -notmatch '(?i)/x') { $existingArgs = (\"/x $existingArgs\").Trim() }\n if ($existingArgs -notmatch '(?i)/qn') { $existingArgs = (\"$existingArgs /qn\").Trim() }\n if ($existingArgs -notmatch '(?i)/norestart') { $existingArgs = (\"$existingArgs /norestart\").Trim() }\n} else {\n # WiX Burn bootstrapper.\n if ($existingArgs -notmatch '(?i)/uninstall') { $existingArgs = (\"$existingArgs /uninstall\").Trim() }\n if ($existingArgs -notmatch '(?i)/quiet') { $existingArgs = (\"$existingArgs /quiet\").Trim() }\n if ($existingArgs -notmatch '(?i)/norestart') { $existingArgs = (\"$existingArgs /norestart\").Trim() }\n}\n\nWrite-Host \"Uninstall command: $exePath\"\nWrite-Host \"Uninstall args: $existingArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $exePath\n ArgumentList = $existingArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n if ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/visual-studio-code/windows.json b/ee/maintained-apps/outputs/visual-studio-code/windows.json index 329f8eacb38..76c007b7178 100644 --- a/ee/maintained-apps/outputs/visual-studio-code/windows.json +++ b/ee/maintained-apps/outputs/visual-studio-code/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Microsoft Visual Studio Code' AND publisher = 'Microsoft Corporation' AND version_compare(version, '1.126.0') < 0);" }, "installer_url": "https://vscode.download.prss.microsoft.com/dbazure/download/stable/7e7950df89d055b5a378379db9ee14290772148a/VSCodeSetup-x64-1.126.0.exe", - "install_script_ref": "49122823", + "install_script_ref": "2c16ed6d", "uninstall_script_ref": "e09509e2", "sha256": "28eab4540effebb773266f231fd13e0d1b8a26bb296f44ae97054a98602b17d8", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "49122823": "# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add argument to install silently\n# Argument to make install silent depends on installer,\n# each installer might use different argument (usually it's \"/S\" or \"/s\")\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /NORESTART /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "2c16ed6d": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Microsoft Visual Studio Code*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# Add argument to install silently\n# Argument to make install silent depends on installer,\n# each installer might use different argument (usually it's \"/S\" or \"/s\")\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT /NORESTART /MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n}\n \n# Start process and track exit code\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\n# Prints the exit code\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "e09509e2": "\n$machineKey = `\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{EA457B21-F73E-494C-ACAB-524FDE069978}_is1'\n$uninstallArgs = \"/VERYSILENT\"\n$exitCode = 0\n\ntry {\n\n $key = Get-ItemProperty -Path $machineKey -ErrorAction Stop\n\n # Get the uninstall command. Some uninstallers do not include\n # 'QuietUninstallString' and require a flag to run silently.\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Software\\uninstall.exe\" --uninstall --silent\n # Split the command and args\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $uninstallArgs = \"$( $splitArgs[2] ) $uninstallArgs\".Trim()\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = \"$uninstallArgs\"\n }\n\n # Start process and track exit code\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n\n # Kill Brave process\n Stop-Process -Name \"brave\" -Force -ErrorAction SilentlyContinue\n\n # Prints the exit code\n Write-Host \"Uninstall exit code: $exitCode\"\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n" } } diff --git a/ee/maintained-apps/outputs/vivaldi/windows.json b/ee/maintained-apps/outputs/vivaldi/windows.json index 2c90f76b24e..f63bca95274 100644 --- a/ee/maintained-apps/outputs/vivaldi/windows.json +++ b/ee/maintained-apps/outputs/vivaldi/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Vivaldi' AND publisher = 'Vivaldi Technologies AS.' AND version_compare(version, '8.0.4033.54') < 0);" }, "installer_url": "https://downloads.vivaldi.com/stable/Vivaldi.8.0.4033.54.x64.exe", - "install_script_ref": "d5d49757", + "install_script_ref": "d9389dbd", "uninstall_script_ref": "4e1acf1b", "sha256": "4c3ec0a3828bdfc860fc8e953b76b9e24b581d77532c6c045b2f88f3760ff0e3", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "4e1acf1b": "# Uninstall Vivaldi (machine-wide Chromium-based browser).\n# Looks up the uninstall entry under HKLM (machine install) with an HKCU\n# fallback, then runs the Chromium uninstaller with --force-uninstall.\n\n$displayName = \"Vivaldi\"\n$publisher = \"Vivaldi Technologies AS.\"\n\n# Install is machine-wide (--system-level), which registers under HKLM, so look\n# there first. HKCU is only a fallback for a stale user-level install.\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -eq $displayName -and $_.Publisher -eq $publisher\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or (-not $uninstall.UninstallString -and -not $uninstall.QuietUninstallString)) {\n Write-Host \"Uninstall entry not found for '$displayName'\"\n Exit 0\n}\n\n# Kill any running Vivaldi processes before uninstalling\nStop-Process -Name \"vivaldi\" -Force -ErrorAction SilentlyContinue\nStart-Sleep -Seconds 2\n\n$uninstallCommand = if ($uninstall.QuietUninstallString) {\n $uninstall.QuietUninstallString\n} else {\n $uninstall.UninstallString\n}\n\n# Parse the executable + trailing args, handling the three registry shapes:\n# quoted, unquoted-with-spaces (capture through .exe), and a bare token.\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exe = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exe = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $exe = $Matches[1]\n $existingArgs = $Matches[2].Trim()\n} else {\n Write-Host \"Unable to parse uninstall command: $uninstallCommand\"\n Exit 1\n}\n\n# Chromium-based uninstaller flags\n$uninstallArgs = \"$existingArgs --uninstall --force-uninstall\".Trim()\n\nWrite-Host \"Uninstall command: $exe\"\nWrite-Host \"Uninstall args: $uninstallArgs\"\n\ntry {\n $process = Start-Process -FilePath $exe -ArgumentList $uninstallArgs -NoNewWindow -PassThru -Wait\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n\n # Chromium uninstallers return 19 on success\n if ($exitCode -eq 0 -or $exitCode -eq 19) {\n Exit 0\n }\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n", - "d5d49757": "# Install Vivaldi silently, machine-wide (Chromium-based browser).\n# Fleet runs installs as SYSTEM, so --system-level is required to install for\n# all users under %ProgramFiles% (and register under HKLM). Without it the\n# installer lands in the SYSTEM profile and is invisible to the real user.\n$process = Start-Process -FilePath $env:INSTALLER_PATH `\n -ArgumentList \"--vivaldi-silent --do-not-launch-chrome --system-level\" `\n -NoNewWindow -PassThru -Wait\nExit $process.ExitCode\n" + "d9389dbd": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Vivaldi*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"--uninstall --force-uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Install Vivaldi silently, machine-wide (Chromium-based browser).\n# Fleet runs installs as SYSTEM, so --system-level is required to install for\n# all users under %ProgramFiles% (and register under HKLM). Without it the\n# installer lands in the SYSTEM profile and is invisible to the real user.\n$process = Start-Process -FilePath $env:INSTALLER_PATH `\n -ArgumentList \"--vivaldi-silent --do-not-launch-chrome --system-level\" `\n -NoNewWindow -PassThru -Wait\nExit $process.ExitCode\n" } } diff --git a/ee/maintained-apps/outputs/vscodium/windows.json b/ee/maintained-apps/outputs/vscodium/windows.json index e1cf593638d..fc01d42816b 100644 --- a/ee/maintained-apps/outputs/vscodium/windows.json +++ b/ee/maintained-apps/outputs/vscodium/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'VSCodium' AND publisher = 'VSCodium' AND version_compare(version, '1.121.03429') < 0);" }, "installer_url": "https://github.com/VSCodium/vscodium/releases/download/1.121.03429/VSCodiumSetup-x64-1.121.03429.exe", - "install_script_ref": "38852240", + "install_script_ref": "808c2033", "uninstall_script_ref": "b7b4a3ad", "sha256": "4e67a8147e9fc4b9e7ef31d95d8b0d3cd3bab66caa59ada1b4de99f4243f9cdf", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "38852240": "$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\", \"/mergetasks=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "808c2033": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"VSCodium*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n$processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\", \"/mergetasks=!runcode\"\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\n\nWrite-Host \"Install exit code: $exitCode\"\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "b7b4a3ad": "$displayName = \"VSCodium\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$uninstall = $null\nforeach ($p in $paths) {\n $items = Get-ItemProperty \"$p\\*\" -ErrorAction SilentlyContinue | Where-Object {\n $_.DisplayName -eq $displayName\n }\n if ($items) { $uninstall = $items | Select-Object -First 1; break }\n}\n\nif (-not $uninstall -or (-not $uninstall.UninstallString -and -not $uninstall.QuietUninstallString)) {\n Write-Host \"Uninstall entry not found\"\n Exit 0\n}\n\nStop-Process -Name \"VSCodium\" -Force -ErrorAction SilentlyContinue\n\n$uninstallCommand = if ($uninstall.QuietUninstallString) {\n $uninstall.QuietUninstallString\n} else {\n $uninstall.UninstallString\n}\n\n$exePath = \"\"\n$existingArgs = \"\"\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\nforeach ($flag in @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART')) {\n if ($existingArgs -notmatch [regex]::Escape($flag)) {\n $existingArgs = (\"$existingArgs $flag\").Trim()\n }\n}\n\nWrite-Host \"Uninstall command: $exePath\"\nWrite-Host \"Uninstall args: $existingArgs\"\n\ntry {\n $processOptions = @{\n FilePath = $exePath\n ArgumentList = $existingArgs\n NoNewWindow = $true\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n Exit $exitCode\n} catch {\n Write-Host \"Error running uninstaller: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/windsurf/windows.json b/ee/maintained-apps/outputs/windsurf/windows.json index 280b38913eb..082b3865e84 100644 --- a/ee/maintained-apps/outputs/windsurf/windows.json +++ b/ee/maintained-apps/outputs/windsurf/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Windsurf' AND publisher = 'Codeium' AND version_compare(version, '2.3.15') < 0);" }, "installer_url": "https://windsurf-stable.codeiumdata.com/win32-x64/stable/c46c49e94b4d3f41181204d59809d8f1b2c48d68/WindsurfSetup-x64-2.3.15.exe", - "install_script_ref": "232abd06", + "install_script_ref": "044c2bcd", "uninstall_script_ref": "bb230cc3", "sha256": "d30ff9c6a490597aac354746b689335587f6bf8da671b935e8d1be51b3825d15", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "232abd06": "\n# install switches:\n# /VERYSILENT = no UI at all\n# /SUPPRESSMSGBOXES = suppress every message box\n# /NORESTART = don't reboot (returns 3010 instead, treat as success)\n# /ALLUSERS = machine-scope install. Requires elevation; the\n# installer self-elevates (manifest:\n# ElevationRequirement: elevatesSelf), and Fleet/\n# SYSTEM already runs elevated anyway.\n# /MERGETASKS=!runcode = keep default selectable tasks but deselect\n# \"runcode\" (which auto-launches Windsurf after\n# install). Inno's \"!\" prefix means \"deselect\".\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n# 0 = success; 3010 = success but reboot required.\n$ExpectedExitCodes = @(0, 3010)\n\ntry {\n $processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\",\n \"/ALLUSERS\", \"/MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n\n # Defensive: even with !runcode, Inno installers occasionally launch the\n # app via post-install scripts. Stop Windsurf if it slipped through so\n # files aren't locked for later detection/uninstall.\n Start-Sleep -Seconds 5\n Stop-Process -Name \"Windsurf\" -Force -ErrorAction SilentlyContinue\n\n if ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\n Exit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "044c2bcd": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Windsurf*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/VERYSILENT /SUPPRESSMSGBOXES /NORESTART\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# install switches:\n# /VERYSILENT = no UI at all\n# /SUPPRESSMSGBOXES = suppress every message box\n# /NORESTART = don't reboot (returns 3010 instead, treat as success)\n# /ALLUSERS = machine-scope install. Requires elevation; the\n# installer self-elevates (manifest:\n# ElevationRequirement: elevatesSelf), and Fleet/\n# SYSTEM already runs elevated anyway.\n# /MERGETASKS=!runcode = keep default selectable tasks but deselect\n# \"runcode\" (which auto-launches Windsurf after\n# install). Inno's \"!\" prefix means \"deselect\".\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\n# 0 = success; 3010 = success but reboot required.\n$ExpectedExitCodes = @(0, 3010)\n\ntry {\n $processOptions = @{\n FilePath = \"$exeFilePath\"\n ArgumentList = \"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\",\n \"/ALLUSERS\", \"/MERGETASKS=!runcode\"\n PassThru = $true\n Wait = $true\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Install exit code: $exitCode\"\n\n # Defensive: even with !runcode, Inno installers occasionally launch the\n # app via post-install scripts. Stop Windsurf if it slipped through so\n # files aren't locked for later detection/uninstall.\n Start-Sleep -Seconds 5\n Stop-Process -Name \"Windsurf\" -Force -ErrorAction SilentlyContinue\n\n if ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\n Exit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "bb230cc3": "$softwareNameLike = \"Windsurf*\"\n$publisherLike = \"*Codeium*\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$ExpectedExitCodes = @(0, 3010, 1641)\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue }\n\n$selected = $null\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -and $key.DisplayName -like $softwareNameLike -and $key.Publisher -like $publisherLike) {\n $selected = $key\n break\n }\n}\n\nif (-not $selected -or (-not $selected.UninstallString -and -not $selected.QuietUninstallString)) {\n Write-Host \"Uninstall entry not found for $softwareNameLike\"\n Exit 0\n}\n\n# Stop Windsurf and any helpers running out of the install dir so the\n# uninstaller doesn't fail on locked files.\nStop-Process -Name \"Windsurf\" -Force -ErrorAction SilentlyContinue\nif ($selected.InstallLocation -and (Test-Path -LiteralPath $selected.InstallLocation)) {\n $loc = $selected.InstallLocation.TrimEnd('\\')\n Get-Process | Where-Object { $_.Path -and $_.Path -like \"$loc\\*\" } |\n ForEach-Object { Stop-Process -Id $_.Id -Force -ErrorAction SilentlyContinue }\n}\nStart-Sleep -Seconds 2\n\n# Prefer QuietUninstallString -- Inno populates it with /VERYSILENT\n# /SUPPRESSMSGBOXES already, so we just add /NORESTART if missing.\n$uninstallCommand = if ($selected.QuietUninstallString) {\n $selected.QuietUninstallString\n} else {\n $selected.UninstallString\n}\n\n# Parse the uninstaller exe path. Inno usually quotes it because the install\n# path often contains \"Program Files\".\n$exePath = \"\"\n$existingArgs = \"\"\nif ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exePath = $matches[1]\n $existingArgs = $matches[2].Trim()\n} else {\n Throw \"Could not parse uninstall string: $uninstallCommand\"\n}\n\n\n$argumentList = @()\nif ($existingArgs) { $argumentList += ($existingArgs -split '\\s+') }\nforeach ($s in @(\"/VERYSILENT\", \"/SUPPRESSMSGBOXES\", \"/NORESTART\")) {\n if ($argumentList -notcontains $s) { $argumentList += $s }\n}\n\nWrite-Host \"Selected entry DisplayName: $($selected.DisplayName)\"\nWrite-Host \"Uninstall command: $exePath\"\nWrite-Host \"Uninstall args: $($argumentList -join ' ')\"\n\n$processOptions = @{\n FilePath = $exePath\n ArgumentList = $argumentList\n PassThru = $true\n Wait = $true\n}\n\n$process = Start-Process @processOptions\n$exitCode = $process.ExitCode\nWrite-Host \"Uninstall exit code: $exitCode\"\n\nif ($ExpectedExitCodes -contains $exitCode) { Exit 0 }\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } diff --git a/ee/maintained-apps/outputs/zoom/windows.json b/ee/maintained-apps/outputs/zoom/windows.json index 1822b660c82..69c3ae324f0 100644 --- a/ee/maintained-apps/outputs/zoom/windows.json +++ b/ee/maintained-apps/outputs/zoom/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Zoom Workplace (X64)' AND publisher = 'Zoom' AND version_compare(version, '7.1.41345') < 0);" }, "installer_url": "https://zoom.us/client/7.1.0.41345/ZoomInstallerFull.msi?archType=x64", - "install_script_ref": "8959087b", + "install_script_ref": "a6f3a656", "uninstall_script_ref": "2e978ed3", "sha256": "e0b18dc33c828a0606d6678f6d511ef66e133d900a512bed31798b4e7c24b093", "default_categories": [ @@ -18,6 +18,6 @@ ], "refs": { "2e978ed3": "# Fleet uninstalls app by finding all related product codes for the specified upgrade code\n$inst = New-Object -ComObject \"WindowsInstaller.Installer\"\n$timeoutSeconds = 300 # 5 minute timeout per product\n\n# MSI exit codes that indicate success. 3010 = ERROR_SUCCESS_REBOOT_REQUIRED,\n# 1641 = ERROR_SUCCESS_REBOOT_INITIATED. Treat these as success rather than failure.\n$successCodes = @(0, 3010, 1641)\n\nforeach ($product_code in $inst.RelatedProducts('{C819B794-A45C-4F27-9860-0C86492A52CC}')) {\n $process = Start-Process msiexec -ArgumentList @(\"/quiet\", \"/x\", $product_code, \"/norestart\") -PassThru\n\n # Wait for process with timeout\n $completed = $process.WaitForExit($timeoutSeconds * 1000)\n\n if (-not $completed) {\n Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue\n Exit 1603 # ERROR_UNINSTALL_FAILURE\n }\n\n # If the uninstall failed, bail\n if ($successCodes -notcontains $process.ExitCode) {\n Write-Output \"Uninstall for $($product_code) exited $($process.ExitCode)\"\n Exit $process.ExitCode\n }\n}\n\n# All uninstalls succeeded; exit success\nExit 0\n", - "8959087b": "$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "a6f3a656": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Zoom*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n$logFile = \"${env:TEMP}/fleet-install-software.log\"\n\ntry {\n\n$installProcess = Start-Process msiexec.exe `\n -ArgumentList \"/quiet /norestart /lv ${logFile} /i `\"${env:INSTALLER_PATH}`\"\" `\n -PassThru -Verb RunAs -Wait\n\nGet-Content $logFile -Tail 500\n\nExit $installProcess.ExitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } From 29baf18ae42fcaca9d137444d53ca16e69c7ae47 Mon Sep 17 00:00:00 2001 From: Allen Houchins <32207388+allenhouchins@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:13:27 -0500 Subject: [PATCH 3/8] Fix duplicate installation scope for Windows apps Ensure Windows Fleet-maintained apps declare an explicit install scope and remove any opposite-scope copy before installation. --- changes/48248-windows-fma-install-scope | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/48248-windows-fma-install-scope b/changes/48248-windows-fma-install-scope index eada070e2a6..7408f28b6cd 100644 --- a/changes/48248-windows-fma-install-scope +++ b/changes/48248-windows-fma-install-scope @@ -1 +1 @@ -- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. Dual-variant Windows apps (including PowerToys, GIMP, VS Code, Chrome, Zoom, Dropbox, 1Password, Firefox, and others) now remove any opposite-scope copy before installing the managed copy, and all Windows Fleet-maintained apps now declare an explicit install scope. +- Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. From c133820edc5e09f5e3f1014bdafb852e5a977932 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 21:29:54 -0500 Subject: [PATCH 4/8] Remove legacy Win32 copies for MSIX Windows FMAs (#48248) --- changes/48248-windows-fma-install-scope | 1 + ee/maintained-apps/README.md | 1 + .../winget/scripts/affinity_install.ps1 | 145 +++++++++++++++++ .../inputs/winget/scripts/arc_install.ps1 | 145 +++++++++++++++++ .../inputs/winget/scripts/claude_install.ps1 | 145 +++++++++++++++++ .../scripts/microsoft-teams_install.ps1 | 147 ++++++++++++++++++ .../inputs/winget/scripts/slack_install.ps1 | 145 +++++++++++++++++ .../winget/scripts/windows-app_install.ps1 | 145 +++++++++++++++++ .../outputs/affinity/windows.json | 4 +- ee/maintained-apps/outputs/arc/windows.json | 4 +- .../outputs/claude/windows.json | 4 +- .../outputs/microsoft-teams/windows.json | 6 +- ee/maintained-apps/outputs/slack/windows.json | 4 +- .../outputs/windows-app/windows.json | 4 +- 14 files changed, 887 insertions(+), 13 deletions(-) diff --git a/changes/48248-windows-fma-install-scope b/changes/48248-windows-fma-install-scope index 7408f28b6cd..e51a18a06d5 100644 --- a/changes/48248-windows-fma-install-scope +++ b/changes/48248-windows-fma-install-scope @@ -1 +1,2 @@ - Fixed Windows Fleet-maintained apps installing a duplicate copy at a different scope (per-user vs. per-machine), which left a stale copy behind and kept the patch policy reporting the host as not patched. +- Fixed Windows Fleet-maintained apps packaged as MSIX (Slack, Microsoft Teams, Arc, Claude, Affinity, Windows App) leaving a legacy Win32 copy behind: the install script now removes leftover exe/MSI copies at both scopes before provisioning the MSIX package. diff --git a/ee/maintained-apps/README.md b/ee/maintained-apps/README.md index 4e48d9c5886..87126f25ce8 100644 --- a/ee/maintained-apps/README.md +++ b/ee/maintained-apps/README.md @@ -137,6 +137,7 @@ Follow these guardrails: - **Fix scope in remediation, not the query.** Set `installer_scope` to the scope the installer actually uses (verify it — see the schema note), and make the install/uninstall scripts converge the device on a single canonical copy: - **Pattern A — remove-and-replace (default):** the install script first removes any *other-scope* copy, then installs one copy at the target scope. The scope-blind policy then truthfully goes green. (PowerToys and GIMP install scripts enumerate both `HKLM` and `HKCU` to do this.) - **Pattern B — update-in-place per scope:** detect which scope the existing copy lives in and upgrade *that* one. More data-safe; per-user installs from the SYSTEM context are the hard case (run in the logged-on user's session via a scheduled task — see the Slack MSIX script). +- **MSIX-managed apps: every Win32 copy is legacy.** For apps Fleet manages as MSIX (e.g. Slack, Microsoft Teams), there is no "same-scope" Win32 copy to leave for the installer — a leftover exe/MSI copy at *either* scope keeps the scope-blind policy red while the MSIX is current. Their install scripts sweep **both** Win32 uninstall hives before provisioning, with guards: never touch PackageFullName-style keys or entries under `\WindowsApps\` (so a re-run can't remove the MSIX itself), skip entries with no quiet uninstall path (a raw `UninstallString` run as SYSTEM can hang on UI), and delete a per-user `HKEY_USERS` uninstall key only after verifying the uninstaller removed itself from disk (per-user uninstallers run as SYSTEM can't clean their own key — but deleting the key while files remain would be false-green). - **Keep installer ↔ detection ↔ uninstaller consistent.** `installer_scope`, the install/uninstall scripts, and the detection query must agree about the app. Avoid over-narrow `name = '…'` matches that could miss a scope variant; use `fuzzy_match_name` or a custom `exists_query` where a package's `DisplayName` differs by scope. - **Data preservation is a separate guarantee from "no false-green."** A same-scope upgrade preserves data normally. Cross-scope removal (Pattern A) can lose data: config in `%APPDATA%`/`HKCU` for the same packaging is usually **preserved**, Squirrel/Electron local session/cache may be **partially** lost, and cross-packaging to MSIX generally **resets** data. Call this out in the PR when it applies. diff --git a/ee/maintained-apps/inputs/winget/scripts/affinity_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/affinity_install.ps1 index be1155c1d82..67fcfa5d1b5 100644 --- a/ee/maintained-apps/inputs/winget/scripts/affinity_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/affinity_install.ps1 @@ -1,3 +1,148 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Affinity'; Publisher = 'Canva*'; FallbackArgs = '' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/inputs/winget/scripts/arc_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/arc_install.ps1 index eb16d78f3ed..08978238831 100644 --- a/ee/maintained-apps/inputs/winget/scripts/arc_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/arc_install.ps1 @@ -1,3 +1,148 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Arc'; Publisher = 'The Browser Company*'; FallbackArgs = '' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/inputs/winget/scripts/claude_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/claude_install.ps1 index 0a7f3a1e7ad..38b6f41412e 100644 --- a/ee/maintained-apps/inputs/winget/scripts/claude_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/claude_install.ps1 @@ -1,3 +1,148 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Claude'; Publisher = 'Anthropic*'; FallbackArgs = '--uninstall -s' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/inputs/winget/scripts/microsoft-teams_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/microsoft-teams_install.ps1 index d24cd24325d..d38728554fe 100644 --- a/ee/maintained-apps/inputs/winget/scripts/microsoft-teams_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/microsoft-teams_install.ps1 @@ -1,3 +1,150 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Microsoft Teams'; Publisher = 'Microsoft*'; FallbackArgs = '--uninstall -s' } + @{ Name = 'Microsoft Teams classic'; Publisher = 'Microsoft*'; FallbackArgs = '--uninstall -s' } + @{ Name = 'Teams Machine-Wide Installer'; Publisher = 'Microsoft*'; FallbackArgs = '' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/inputs/winget/scripts/slack_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/slack_install.ps1 index c72918d6fe6..9c97eae3e5c 100644 --- a/ee/maintained-apps/inputs/winget/scripts/slack_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/slack_install.ps1 @@ -1,3 +1,148 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Slack'; Publisher = 'Slack Technologies*'; FallbackArgs = '--uninstall -s' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/inputs/winget/scripts/windows-app_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/windows-app_install.ps1 index b4eca5cdae6..40c578aab95 100644 --- a/ee/maintained-apps/inputs/winget/scripts/windows-app_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/windows-app_install.ps1 @@ -1,3 +1,148 @@ +# Fleet Pattern A (MSIX): converge this app on the MSIX package +# (https://github.com/fleetdm/fleet/issues/48248). +# +# Fleet manages this app as an MSIX package, but the app also shipped (or still +# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps +# the scope-blind patch policy (osquery's "programs" table) red while the MSIX is +# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant +# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an +# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and +# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to +# SYSTEM's own hive, so per-user copies are found under HKEY_USERS). +# +# Guards: +# - DisplayName matching is exact, mirrors the detection query's names, and +# requires a publisher match, so unrelated software can't match. +# - MSIX registrations are never touched: PackageFullName-style keys and entries +# under \WindowsApps\ are skipped, so a re-run can't remove the package this +# script just installed. +# - Entries with no quiet uninstall path are skipped: a raw UninstallString run +# as SYSTEM can hang on UI until Fleet's script timeout kills the install. +# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean +# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's +# hive). That phantom entry would keep the policy red forever, so the matched +# key is deleted -- but ONLY after verifying the uninstaller removed itself +# from disk. If files remain, the key stays and the policy stays truthfully red. +# - Best-effort: cleanup never aborts the MSIX install below. +# +# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not +# carry into the MSIX container (account-based/server-synced data survives). + +$fmaWin32Matchers = @( + @{ Name = 'Windows App'; Publisher = 'Microsoft*'; FallbackArgs = '' } +) + +function Get-FmaUninstallExeAndArgs { + param([string]$Command) + # Registry uninstall strings come in three shapes; parse defensively. + if ($Command -match '^\s*"([^"]+)"\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '(?i)^\s*(.+?\.exe)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + elseif ($Command -match '^\s*(\S+)\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } } + return $null +} + +function Remove-FmaWin32Copies { + param([array]$Matchers) + + # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all + # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes). + $roots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' + ) + foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) { + if ($hive.Name -match '_Classes$') { continue } + if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue } + $roots += "Registry::$($hive.Name)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" + $roots += "Registry::$($hive.Name)\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall" + } + + foreach ($root in $roots) { + foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) { + # MSIX self-guard: a PackageFullName-style key name means an MSIX + # registration, never a legacy Win32 copy. + if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue } + $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue + if (-not $key.DisplayName) { continue } + if ("$($key.UninstallString)$($key.InstallLocation)" -match '(?i)\\WindowsApps\\') { continue } + + $matcher = $null + foreach ($m in $Matchers) { + if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) { + $matcher = $m + break + } + } + if (-not $matcher) { continue } + + # Prefer the vendor's QuietUninstallString verbatim; it already carries + # the correct silent switches for that installer technology. + $useVerbatim = [bool]$key.QuietUninstallString + $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString } + if (-not $command) { continue } + + $parsed = Get-FmaUninstallExeAndArgs $command + if (-not $parsed) { Write-Host "Fleet: could not parse uninstall string: $command"; continue } + $exe = $parsed.Exe + $uargs = $parsed.Args + $isMsi = $exe -match '(?i)msiexec' + + if ($isMsi) { + $uargs = $uargs -replace '(?i)/i(\{)', '/x$1' + if ($uargs -notmatch '(?i)/x') { $uargs = ("/x $uargs").Trim() } + if ($uargs -notmatch '(?i)/qn') { $uargs = ("$uargs /qn").Trim() } + if ($uargs -notmatch '(?i)/norestart') { $uargs = ("$uargs /norestart").Trim() } + } elseif (-not $useVerbatim) { + if ($matcher.FallbackArgs -eq '') { + Write-Host "Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)" + continue + } + $uargs = ("$uargs $($matcher.FallbackArgs)").Trim() + } + + if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) { + Write-Host "Fleet: legacy Win32 uninstaller missing on disk: $exe" + continue + } + + Write-Host "Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root" + Write-Host " Command: $exe" + Write-Host " Args: $uargs" + try { + $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true } + if ($uargs -ne '') { $opts.ArgumentList = $uargs } + $p = Start-Process @opts + Write-Host " Exit code: $($p.ExitCode)" + } catch { + Write-Host " WARNING: failed to remove legacy Win32 copy: $_" + continue + } + + # Per-user uninstallers can't clean their HKEY_USERS key when run as + # SYSTEM. Remove the matched key only after the uninstaller is + # verifiably gone from disk (Squirrel-style uninstallers self-delete + # with a short delay, hence the settle time). + if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) { + Start-Sleep -Seconds 5 + if (-not (Test-Path -LiteralPath $exe)) { + Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue + Write-Host " Removed leftover per-user uninstall registry entry" + } else { + Write-Host " Uninstaller still on disk; leaving registry entry (policy stays red)" + } + } + } + } +} + +try { + Remove-FmaWin32Copies -Matchers $fmaWin32Matchers +} catch { + Write-Host "Fleet: warning during legacy Win32 cleanup: $_" +} + +# ---- MSIX install ---- + # MSIX: provision machine-wide so the app is available to all users at sign-in, then # opportunistically register for the currently logged-on console user (via a scheduled # task in their session) so the app is immediately visible without requiring sign-out. diff --git a/ee/maintained-apps/outputs/affinity/windows.json b/ee/maintained-apps/outputs/affinity/windows.json index d54ba07d9c0..e91e251f83b 100644 --- a/ee/maintained-apps/outputs/affinity/windows.json +++ b/ee/maintained-apps/outputs/affinity/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Affinity' AND publisher = 'Canva' AND version_compare(version, '3.2.2.4557') < 0);" }, "installer_url": "https://affinity-update.serif.com/windows/3/studio/retail/Affinity-Affinity-Store-x64-4557-edef263cf88fb866afb3d227aef39230812deabc.msix", - "install_script_ref": "9e9c1496", + "install_script_ref": "5d093085", "uninstall_script_ref": "bde90d3c", "sha256": "b15df22e96582de73bef487f202d063e884ff6c38d3699fabae96cc3988a049d", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "9e9c1496": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Affinity\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", + "5d093085": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Affinity'; Publisher = 'Canva*'; FallbackArgs = '' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Affinity\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", "bde90d3c": "$timeoutSeconds = 300 # 5 minute timeout\n\n# Match only Affinity (published by Canva). We deliberately do NOT rely on\n# $PACKAGE_ID or on a PackageFamilyName property: Get-AppxProvisionedPackage\n# objects don't expose PackageFamilyName, so an \"-eq\" match against it is $null\n# on every package and would select unrelated packages (e.g. DesktopAppInstaller).\nfunction ShouldRemoveAffinityPackage {\n param([Parameter(Mandatory=$true)]$pkg)\n try {\n $name = [string]$pkg.Name\n $family = [string]$pkg.PackageFamilyName\n $publisher = [string]$pkg.Publisher\n\n if ($name -and ($name -like \"*Affinity*\")) { return $true }\n if ($family -and ($family -like \"*Affinity*\")) { return $true }\n if ($publisher -and ($publisher -like \"*Canva*\") -and $name -and ($name -like \"*Affinity*\")) { return $true }\n } catch {}\n return $false\n}\n\ntry {\n\n $start = Get-Date\n\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {\n ($_.DisplayName -and ($_.DisplayName -like \"*Affinity*\")) -or\n ($_.PackageName -and ($_.PackageName -like \"*Affinity*\"))\n }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object {\n ShouldRemoveAffinityPackage $_\n }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1603\n}\n" } } diff --git a/ee/maintained-apps/outputs/arc/windows.json b/ee/maintained-apps/outputs/arc/windows.json index 19657d9f082..a46a8734112 100644 --- a/ee/maintained-apps/outputs/arc/windows.json +++ b/ee/maintained-apps/outputs/arc/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Arc' AND publisher = 'The Browser Company of New York' AND version_compare(version, '1.112.1.2') < 0);" }, "installer_url": "https://releases.arc.net/windows/prod/1.112.1.2/Arc.x64.msix", - "install_script_ref": "d2d5c7dc", + "install_script_ref": "9b1928a1", "uninstall_script_ref": "ebcac670", "sha256": "6a90a95a4e7c973a948663aefbdaf9550b3595c00bd119948511f7ca5f07d354", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "d2d5c7dc": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Arc\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions \"all\" -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", + "9b1928a1": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Arc'; Publisher = 'The Browser Company*'; FallbackArgs = '' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Arc\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions \"all\" -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", "ebcac670": "$timeoutSeconds = 300 # 5 minute timeout\n\nfunction ShouldRemoveArcPackage {\n param([Parameter(Mandatory=$true)]$pkg)\n try {\n $name = [string]$pkg.Name\n $family = [string]$pkg.PackageFamilyName\n $publisher = [string]$pkg.Publisher\n\n if ($family -and ($family -like \"TheBrowserCompany.Arc_*\")) { return $true }\n if ($name -and ($name -like \"TheBrowserCompany.Arc\")) { return $true }\n if ($publisher -and ($publisher -like \"*Browser Company*\")) { return $true }\n } catch {}\n return $false\n}\n\ntry {\n\n $start = Get-Date\n\n # Best-effort: close app if running (name may vary)\n Stop-Process -Name \"Arc\" -Force -ErrorAction SilentlyContinue\n\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {\n ($_.PackageFamilyName -and ($_.PackageFamilyName -like \"TheBrowserCompany.Arc_*\")) -or\n ($_.DisplayName -and ($_.DisplayName -like \"TheBrowserCompany.Arc\")) -or\n ($_.PackageName -and ($_.PackageName -like \"TheBrowserCompany.Arc*\"))\n }\n\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) { Exit 1603 }\n }\n\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object {\n ShouldRemoveArcPackage $_\n }\n\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) { Exit 1603 }\n }\n\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1603\n}\n" } } diff --git a/ee/maintained-apps/outputs/claude/windows.json b/ee/maintained-apps/outputs/claude/windows.json index e5bbd08826b..cdb370b5543 100644 --- a/ee/maintained-apps/outputs/claude/windows.json +++ b/ee/maintained-apps/outputs/claude/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Claude' AND publisher = 'Anthropic, PBC' AND version_compare(version, '1.17377.1') < 0);" }, "installer_url": "https://downloads.claude.ai/releases/win32/x64/1.17377.1/Claude-2b3ab429b13f2c904d7552b7ca82a0d2a22af52f.msix", - "install_script_ref": "16c020cc", + "install_script_ref": "5b1e734d", "uninstall_script_ref": "03f72055", "sha256": "553b9161d1913ed38dd2990d3d0ed9ce9cfbda2a2167dc17b9966e7ea809fb16", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "03f72055": "$timeoutSeconds = 300 # 5 minute timeout\n\nfunction ShouldRemoveClaudePackage {\n param([Parameter(Mandatory=$true)]$pkg)\n try {\n $name = [string]$pkg.Name\n $family = [string]$pkg.PackageFamilyName\n $publisher = [string]$pkg.Publisher\n\n if ($name -and ($name -like \"*Claude*\" -or $name -like \"*Anthropic*\")) { return $true }\n if ($family -and ($family -like \"*Claude*\" -or $family -like \"*Anthropic*\")) { return $true }\n if ($publisher -and ($publisher -like \"*Anthropic*\")) { return $true }\n } catch {}\n return $false\n}\n\ntry {\n\n $start = Get-Date\n\n # Best-effort: close app if running (name may vary)\n Stop-Process -Name \"Claude\" -Force -ErrorAction SilentlyContinue\n\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {\n ($_.PackageFamilyName -and (($_.PackageFamilyName -like \"*Claude*\") -or ($_.PackageFamilyName -like \"*Anthropic*\"))) -or\n ($_.DisplayName -and (($_.DisplayName -like \"*Claude*\") -or ($_.DisplayName -like \"*Anthropic*\"))) -or\n ($_.PackageName -and (($_.PackageName -like \"*Claude*\") -or ($_.PackageName -like \"*Anthropic*\")))\n }\n\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) { Exit 1603 }\n }\n\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object {\n ShouldRemoveClaudePackage $_\n }\n\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) { Exit 1603 }\n }\n\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1603\n}\n", - "16c020cc": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Claude\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions \"all\" -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Claude's Cowork feature requires the Virtual Machine Platform optional feature. Enabling\n # an optional feature requires administrator privileges, which standard users don't have,\n # so it must be done here in the machine context (the Fleet agent runs as Local System) and\n # not in the per-user scheduled task below. -NoRestart defers the reboot that may be needed\n # for the feature to become fully active.\n # See https://support.claude.com/en/articles/12622703-deploy-claude-desktop-for-windows\n try {\n $vmp = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -ErrorAction Stop\n if ($vmp.State -eq \"Enabled\") {\n Write-Host \"Virtual Machine Platform already enabled (required for Cowork).\"\n } else {\n Write-Host \"Enabling Virtual Machine Platform (required for Cowork)...\"\n Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart -ErrorAction Stop | Out-Null\n Write-Host \"Virtual Machine Platform enabled; a restart may be required for Cowork to become available.\"\n }\n } catch {\n Write-Host \"Could not enable Virtual Machine Platform: $($_.Exception.Message). Cowork may be unavailable until it is enabled.\"\n }\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" + "5b1e734d": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Claude'; Publisher = 'Anthropic*'; FallbackArgs = '--uninstall -s' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Claude\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -Regions \"all\" -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Claude's Cowork feature requires the Virtual Machine Platform optional feature. Enabling\n # an optional feature requires administrator privileges, which standard users don't have,\n # so it must be done here in the machine context (the Fleet agent runs as Local System) and\n # not in the per-user scheduled task below. -NoRestart defers the reboot that may be needed\n # for the feature to become fully active.\n # See https://support.claude.com/en/articles/12622703-deploy-claude-desktop-for-windows\n try {\n $vmp = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -ErrorAction Stop\n if ($vmp.State -eq \"Enabled\") {\n Write-Host \"Virtual Machine Platform already enabled (required for Cowork).\"\n } else {\n Write-Host \"Enabling Virtual Machine Platform (required for Cowork)...\"\n Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart -ErrorAction Stop | Out-Null\n Write-Host \"Virtual Machine Platform enabled; a restart may be required for Cowork to become available.\"\n }\n } catch {\n Write-Host \"Could not enable Virtual Machine Platform: $($_.Exception.Message). Cowork may be unavailable until it is enabled.\"\n }\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" } } diff --git a/ee/maintained-apps/outputs/microsoft-teams/windows.json b/ee/maintained-apps/outputs/microsoft-teams/windows.json index 38ca66e37b3..d3440adb3b5 100644 --- a/ee/maintained-apps/outputs/microsoft-teams/windows.json +++ b/ee/maintained-apps/outputs/microsoft-teams/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Microsoft Teams' AND publisher = 'Microsoft Corporation' AND version_compare(version, '26149.1205.4798.6437') < 0);" }, "installer_url": "https://teamsinstaller.public.onecdn.static.microsoft/production-windows-x64/26149.1205.4798.6437/MSTeams-x64.msix", - "install_script_ref": "d1b37440", + "install_script_ref": "83db6f82", "uninstall_script_ref": "c2a1ea76", "sha256": "0935181e9c4c4d53d1a0b002c58c4a20cd96c4af7103d7f3640ae3722d809f7e", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "c2a1ea76": "# Uninstall the new Microsoft Teams (MSIX, PackageFamilyName MSTeams_8wekyb3d8bbwe).\n#\n# The Fleet agent runs as Local System. Removing the system-provisioned new Teams the\n# naive way (Remove-AppxProvisionedPackage / Remove-AppxPackage with -ErrorAction Stop)\n# fails with exit 1603 / \"Removal failed. Please contact your software vendor.\" because a\n# single non-fatal cmdlet error aborts the whole script even when the package does end up\n# removed. Instead we remove best-effort (no -ErrorAction Stop abort), then re-check with\n# Get-AppxPackage and exit 0 when the package is actually gone. Treat \"already absent\" as\n# success so the script is idempotent.\n\n$packageFamilyName = 'MSTeams_8wekyb3d8bbwe'\n$timeoutSeconds = 300 # 5 minute timeout\n$start = Get-Date\n\nfunction Test-PackagePresent {\n param([string]$pfn)\n $prov = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n $inst = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n return (($prov | Measure-Object).Count -gt 0) -or (($inst | Measure-Object).Count -gt 0)\n}\n\ntry {\n\n # Best-effort: stop the app if it is running so files aren't locked.\n Stop-Process -Name \"ms-teams\" -Force -ErrorAction SilentlyContinue\n Stop-Process -Name \"Teams\" -Force -ErrorAction SilentlyContinue\n\n # Remove the machine-wide provisioning so the package is not re-registered at next sign-in.\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n try {\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxProvisionedPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Remove the registered package for every user profile.\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n try {\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Verify the outcome rather than trusting cmdlet exit status: a non-fatal error above is\n # fine as long as the package is actually gone.\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Microsoft Teams ($packageFamilyName) is no longer present.\"\n Exit 0\n }\n\n Write-Host \"Microsoft Teams ($packageFamilyName) is still present after removal attempts.\"\n Exit 1603\n\n} catch {\n Write-Host \"Error: $_\"\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Package is absent despite the error; treating as success.\"\n Exit 0\n }\n Exit 1603\n}\n", - "d1b37440": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Microsoft Teams\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" + "83db6f82": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Microsoft Teams'; Publisher = 'Microsoft*'; FallbackArgs = '--uninstall -s' }\n @{ Name = 'Microsoft Teams classic'; Publisher = 'Microsoft*'; FallbackArgs = '--uninstall -s' }\n @{ Name = 'Teams Machine-Wide Installer'; Publisher = 'Microsoft*'; FallbackArgs = '' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Microsoft Teams\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n", + "c2a1ea76": "# Uninstall the new Microsoft Teams (MSIX, PackageFamilyName MSTeams_8wekyb3d8bbwe).\n#\n# The Fleet agent runs as Local System. Removing the system-provisioned new Teams the\n# naive way (Remove-AppxProvisionedPackage / Remove-AppxPackage with -ErrorAction Stop)\n# fails with exit 1603 / \"Removal failed. Please contact your software vendor.\" because a\n# single non-fatal cmdlet error aborts the whole script even when the package does end up\n# removed. Instead we remove best-effort (no -ErrorAction Stop abort), then re-check with\n# Get-AppxPackage and exit 0 when the package is actually gone. Treat \"already absent\" as\n# success so the script is idempotent.\n\n$packageFamilyName = 'MSTeams_8wekyb3d8bbwe'\n$timeoutSeconds = 300 # 5 minute timeout\n$start = Get-Date\n\nfunction Test-PackagePresent {\n param([string]$pfn)\n $prov = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n $inst = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n return (($prov | Measure-Object).Count -gt 0) -or (($inst | Measure-Object).Count -gt 0)\n}\n\ntry {\n\n # Best-effort: stop the app if it is running so files aren't locked.\n Stop-Process -Name \"ms-teams\" -Force -ErrorAction SilentlyContinue\n Stop-Process -Name \"Teams\" -Force -ErrorAction SilentlyContinue\n\n # Remove the machine-wide provisioning so the package is not re-registered at next sign-in.\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n try {\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxProvisionedPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Remove the registered package for every user profile.\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n try {\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Verify the outcome rather than trusting cmdlet exit status: a non-fatal error above is\n # fine as long as the package is actually gone.\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Microsoft Teams ($packageFamilyName) is no longer present.\"\n Exit 0\n }\n\n Write-Host \"Microsoft Teams ($packageFamilyName) is still present after removal attempts.\"\n Exit 1603\n\n} catch {\n Write-Host \"Error: $_\"\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Package is absent despite the error; treating as success.\"\n Exit 0\n }\n Exit 1603\n}\n" } } diff --git a/ee/maintained-apps/outputs/slack/windows.json b/ee/maintained-apps/outputs/slack/windows.json index 680eecc7448..86aeb85cd56 100644 --- a/ee/maintained-apps/outputs/slack/windows.json +++ b/ee/maintained-apps/outputs/slack/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Slack' AND publisher = 'Slack Technologies Inc.' AND version_compare(version, '4.50.143') < 0);" }, "installer_url": "https://downloads.slack-edge.com/desktop-releases/windows/x64/4.50.143/Slack.msix", - "install_script_ref": "8b41f934", + "install_script_ref": "b6e159c4", "uninstall_script_ref": "034eae14", "sha256": "87360ab9a99392619b63133dbf50111e104ba599f04b21740de16d380d723da6", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "034eae14": "$timeoutSeconds = 300 # 5 minute timeout\n\n# Match only Slack (published by Slack Technologies). We deliberately do NOT rely\n# on 'com.tinyspeck.slackdesktop_8yrtsj140pw4g' or on a PackageFamilyName property: Get-AppxProvisionedPackage\n# objects don't expose PackageFamilyName, so an \"-eq\" match against it is $null on\n# every package and would select unrelated packages (e.g. DesktopAppInstaller).\nfunction ShouldRemoveSlackPackage {\n param([Parameter(Mandatory=$true)]$pkg)\n try {\n $name = [string]$pkg.Name\n $family = [string]$pkg.PackageFamilyName\n $publisher = [string]$pkg.Publisher\n\n if ($name -and ($name -like \"*Slack*\")) { return $true }\n if ($family -and ($family -like \"*Slack*\")) { return $true }\n if ($publisher -and ($publisher -like \"*Slack Technologies*\") -and $name -and ($name -like \"*Slack*\")) { return $true }\n } catch {}\n return $false\n}\n\ntry {\n\n $start = Get-Date\n\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction Stop | Where-Object {\n ($_.DisplayName -and ($_.DisplayName -like \"*Slack*\")) -or\n ($_.PackageName -and ($_.PackageName -like \"*Slack*\"))\n }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue | Where-Object {\n ShouldRemoveSlackPackage $_\n }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n $elapsed = (New-TimeSpan -Start $start).TotalSeconds\n if ($elapsed -gt $timeoutSeconds) {\n Exit 1603\n }\n }\n\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1603\n}\n", - "8b41f934": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Slack\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" + "b6e159c4": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Slack'; Publisher = 'Slack Technologies*'; FallbackArgs = '--uninstall -s' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"Slack\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" } } diff --git a/ee/maintained-apps/outputs/windows-app/windows.json b/ee/maintained-apps/outputs/windows-app/windows.json index e9e864a5147..7b8cdfaf5d6 100644 --- a/ee/maintained-apps/outputs/windows-app/windows.json +++ b/ee/maintained-apps/outputs/windows-app/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Windows App' AND publisher = 'Microsoft Corp.' AND version_compare(version, '2.0.1186.0') < 0);" }, "installer_url": "https://res.cdn.office.net/remote-desktop-windows-client/f10b58f7-bb1d-4781-81de-1df8e4d08acf/WindowsApp_x64_Release_2.0.1186.0.msix", - "install_script_ref": "f3fab53b", + "install_script_ref": "86e2a0dd", "uninstall_script_ref": "3518ea18", "sha256": "6c27b5d8e01b59bf2cca68467e9852a1b451358e7177066d35d1458bab5e8fc3", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "3518ea18": "# Uninstall Windows App (MSIX, PackageFamilyName MicrosoftCorporationII.Windows365_8wekyb3d8bbwe).\n#\n# The Fleet agent runs as Local System. Removing this provisioned MSIX the naive way\n# (Remove-AppxProvisionedPackage / Remove-AppxPackage with -ErrorAction Stop) fails with\n# exit 1603 / \"Removal failed. Please contact your software vendor.\" because a single\n# non-fatal cmdlet error aborts the whole script even when the package does end up removed.\n# Instead we remove best-effort (no -ErrorAction Stop abort), then re-check with\n# Get-AppxPackage and exit 0 when the package is actually gone. Treat \"already absent\" as\n# success so the script is idempotent.\n\n$packageFamilyName = $PACKAGE_ID\n$timeoutSeconds = 300 # 5 minute timeout\n$start = Get-Date\n\nfunction Test-PackagePresent {\n param([string]$pfn)\n $prov = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n $inst = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $pfn }\n return (($prov | Measure-Object).Count -gt 0) -or (($inst | Measure-Object).Count -gt 0)\n}\n\ntry {\n\n # Best-effort: stop the app if it is running so files aren't locked.\n Stop-Process -Name \"WindowsApp\" -Force -ErrorAction SilentlyContinue\n\n # Remove the machine-wide provisioning so the package is not re-registered at next sign-in.\n $provisioned = Get-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($pkg in $provisioned) {\n Write-Host \"Removing provisioned package: $($pkg.PackageName)\"\n try {\n Remove-AppxProvisionedPackage -Online -PackageName $pkg.PackageName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxProvisionedPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Remove the registered package for every user profile.\n $installed = Get-AppxPackage -AllUsers -PackageTypeFilter Main -ErrorAction SilentlyContinue |\n Where-Object { $_.PackageFamilyName -eq $packageFamilyName }\n foreach ($app in $installed) {\n Write-Host \"Removing installed package: $($app.PackageFullName)\"\n try {\n Remove-AppxPackage -Package $app.PackageFullName -AllUsers -ErrorAction Stop | Out-String | Write-Host\n } catch {\n Write-Host \"Remove-AppxPackage reported: $($_.Exception.Message)\"\n }\n if ((New-TimeSpan -Start $start).TotalSeconds -gt $timeoutSeconds) { break }\n }\n\n # Verify the outcome rather than trusting cmdlet exit status: a non-fatal error above is\n # fine as long as the package is actually gone.\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Windows App ($packageFamilyName) is no longer present.\"\n Exit 0\n }\n\n Write-Host \"Windows App ($packageFamilyName) is still present after removal attempts.\"\n Exit 1603\n\n} catch {\n Write-Host \"Error: $_\"\n if (-not (Test-PackagePresent -pfn $packageFamilyName)) {\n Write-Host \"Package is absent despite the error; treating as success.\"\n Exit 0\n }\n Exit 1603\n}\n", - "f3fab53b": "# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"WindowsApp\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" + "86e2a0dd": "# Fleet Pattern A (MSIX): converge this app on the MSIX package\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app as an MSIX package, but the app also shipped (or still\n# ships) as a Win32 installer (exe/MSI). A leftover Win32 copy at ANY scope keeps\n# the scope-blind patch policy (osquery's \"programs\" table) red while the MSIX is\n# current, and leaves a stale, unmanaged copy on disk. Unlike the dual-variant\n# Win32 apps -- where only the opposite scope is swept -- every Win32 copy of an\n# MSIX-managed app is legacy, so BOTH Win32 uninstall hives are swept: HKLM, and\n# HKEY_USERS for per-user installs (Fleet runs as SYSTEM, where HKCU maps to\n# SYSTEM's own hive, so per-user copies are found under HKEY_USERS).\n#\n# Guards:\n# - DisplayName matching is exact, mirrors the detection query's names, and\n# requires a publisher match, so unrelated software can't match.\n# - MSIX registrations are never touched: PackageFullName-style keys and entries\n# under \\WindowsApps\\ are skipped, so a re-run can't remove the package this\n# script just installed.\n# - Entries with no quiet uninstall path are skipped: a raw UninstallString run\n# as SYSTEM can hang on UI until Fleet's script timeout kills the install.\n# - A per-user uninstaller launched by SYSTEM removes its files but cannot clean\n# the user's HKEY_USERS uninstall key (it writes to HKCU, which is SYSTEM's\n# hive). That phantom entry would keep the policy red forever, so the matched\n# key is deleted -- but ONLY after verifying the uninstaller removed itself\n# from disk. If files remain, the key stays and the policy stays truthfully red.\n# - Best-effort: cleanup never aborts the MSIX install below.\n#\n# Data note: Win32 -> MSIX is a cross-packaging move; local app data does not\n# carry into the MSIX container (account-based/server-synced data survives).\n\n$fmaWin32Matchers = @(\n @{ Name = 'Windows App'; Publisher = 'Microsoft*'; FallbackArgs = '' }\n)\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaWin32Copies {\n param([array]$Matchers)\n\n # Every Win32 scope is legacy for an MSIX-managed app: sweep HKLM and all\n # real interactive users' hives (skip .DEFAULT, service SIDs, _Classes).\n $roots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n )\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n # MSIX self-guard: a PackageFullName-style key name means an MSIX\n # registration, never a legacy Win32 copy.\n if ($sub.PSChildName -match '_[a-z0-9]{13}$') { continue }\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if (\"$($key.UninstallString)$($key.InstallLocation)\" -match '(?i)\\\\WindowsApps\\\\') { continue }\n\n $matcher = $null\n foreach ($m in $Matchers) {\n if ($key.DisplayName -like $m.Name -and ($m.Publisher -eq '' -or $key.Publisher -like $m.Publisher)) {\n $matcher = $m\n break\n }\n }\n if (-not $matcher) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim) {\n if ($matcher.FallbackArgs -eq '') {\n Write-Host \"Fleet: no quiet uninstall path for legacy copy '$($key.DisplayName)', leaving it (policy stays red)\"\n continue\n }\n $uargs = (\"$uargs $($matcher.FallbackArgs)\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: legacy Win32 uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing legacy Win32 copy '$($key.DisplayName)' from $root\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove legacy Win32 copy: $_\"\n continue\n }\n\n # Per-user uninstallers can't clean their HKEY_USERS key when run as\n # SYSTEM. Remove the matched key only after the uninstaller is\n # verifiably gone from disk (Squirrel-style uninstallers self-delete\n # with a short delay, hence the settle time).\n if ($root -like 'Registry::HKEY_USERS*' -and -not $isMsi) {\n Start-Sleep -Seconds 5\n if (-not (Test-Path -LiteralPath $exe)) {\n Remove-Item -Path $sub.PSPath -Recurse -Force -ErrorAction SilentlyContinue\n Write-Host \" Removed leftover per-user uninstall registry entry\"\n } else {\n Write-Host \" Uninstaller still on disk; leaving registry entry (policy stays red)\"\n }\n }\n }\n }\n}\n\ntry {\n Remove-FmaWin32Copies -Matchers $fmaWin32Matchers\n} catch {\n Write-Host \"Fleet: warning during legacy Win32 cleanup: $_\"\n}\n\n# ---- MSIX install ----\n\n# MSIX: provision machine-wide so the app is available to all users at sign-in, then\n# opportunistically register for the currently logged-on console user (via a scheduled\n# task in their session) so the app is immediately visible without requiring sign-out.\n#\n# The Fleet agent runs as Local System on Windows, and Add-AppxPackage cannot run in that\n# context (HRESULT 0x80073CF9). The scheduled task is the supported way to register a\n# package in a user session from a system-context script.\n\n$softwareName = \"WindowsApp\"\n$taskName = \"fleet-install-$softwareName.msix\"\n$scriptPath = \"$env:PUBLIC\\install-$softwareName.ps1\"\n$exitCodeFile = \"$env:PUBLIC\\install-exitcode-$softwareName.txt\"\n\ntry {\n\n $msixPath = $env:INSTALLER_PATH\n if (-not $msixPath) {\n throw \"INSTALLER_PATH is not set\"\n }\n\n Write-Host \"Provisioning MSIX for all users...\"\n $result = Add-AppxProvisionedPackage -Online -PackagePath $msixPath -SkipLicense -ErrorAction Stop\n $result | Out-String | Write-Host\n\n # Win32_ComputerSystem.UserName returns the console user (DOMAIN\\User) or null when no\n # interactive session is active. Other RDP/fast-user-switch sessions won't get the\n # immediate registration; those users will pick it up from the provisioned install at\n # their next sign-in.\n $userName = (Get-CimInstance Win32_ComputerSystem).UserName\n if (-not $userName -or $userName -notlike \"*\\*\") {\n Write-Host \"No interactive user logged on; provisioned install will register for each user at sign-in.\"\n Start-Sleep -Seconds 5\n Exit 0\n }\n\n Write-Host \"Registering MSIX for logged-on user '$userName' via scheduled task...\"\n\n $userScript = @\"\n`$msixPath = \"$msixPath\"\n`$exitCodeFile = \"$exitCodeFile\"\ntry {\n Add-AppxPackage -Path `$msixPath -ErrorAction Stop | Out-String | Write-Host\n Set-Content -Path `$exitCodeFile -Value 0\n} catch {\n Write-Host \"Add-AppxPackage failed: `$(`$_.Exception.Message)\"\n Set-Content -Path `$exitCodeFile -Value 1\n}\n\"@\n\n Set-Content -Path $scriptPath -Value $userScript -Force\n\n $action = New-ScheduledTaskAction -Execute \"powershell.exe\" `\n -Argument \"-WindowStyle Hidden -ExecutionPolicy Bypass -File `\"$scriptPath`\"\"\n $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries\n $principal = New-ScheduledTaskPrincipal -UserId $userName -RunLevel Highest\n $task = New-ScheduledTask -Action $action -Settings $settings -Principal $principal\n Register-ScheduledTask -TaskName $taskName -InputObject $task -User $userName -Force | Out-Null\n Start-ScheduledTask -TaskName $taskName\n\n $startDate = Get-Date\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n while ($state -ne \"Running\") {\n Start-Sleep -Seconds 1\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 30) {\n Write-Host \"Per-user registration task did not start within 30s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n while ($state -eq \"Running\") {\n Start-Sleep -Seconds 2\n if ((New-Timespan -Start $startDate).TotalSeconds -gt 90) {\n Write-Host \"Per-user registration task did not complete within 90s; provisioned install is still valid.\"\n break\n }\n $state = (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue).State\n }\n\n if (Test-Path $exitCodeFile) {\n $code = (Get-Content $exitCodeFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()\n if ($code -eq \"0\") {\n Write-Host \"Per-user registration completed for '$userName'.\"\n } else {\n Write-Host \"Per-user registration did not complete cleanly (exit code: $code). Provisioned install is still valid.\"\n }\n }\n\n Start-Sleep -Seconds 5\n Exit 0\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n} finally {\n Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue | Out-Null\n Remove-Item -Path $scriptPath -Force -ErrorAction SilentlyContinue\n Remove-Item -Path $exitCodeFile -Force -ErrorAction SilentlyContinue\n}\n" } } From c67e53a5e6ae68dd45c90672d65f51191133a450 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 22:01:03 -0500 Subject: [PATCH 5/8] Ignore WebView2/Edge Update in FMA validator Edge checks (#48248) The validator's broad LIKE '%Microsoft Edge%' search also matches the preinstalled 'Microsoft Edge WebView2 Runtime', which Microsoft version-locks to Edge releases. When the runner image's WebView2 version coincides with the manifest version, the post-uninstall check falsely reported Edge as still installed (seen on run 28561163533: WebView2 149.0.4022.98 == Edge target after the image updated from .80). --- cmd/maintained-apps/validate/windows.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/maintained-apps/validate/windows.go b/cmd/maintained-apps/validate/windows.go index 0bc45cde68f..b7109104fa8 100644 --- a/cmd/maintained-apps/validate/windows.go +++ b/cmd/maintained-apps/validate/windows.go @@ -106,6 +106,18 @@ func appExists(ctx context.Context, logger *slog.Logger, appName, uniqueIdentifi logger.InfoContext(ctx, fmt.Sprintf("Found app: '%s' at %s, Version: %s", result.Name, result.InstallLocation, result.Version)) + // "Microsoft Edge WebView2 Runtime" and "Microsoft Edge Update" are + // separate products picked up by the broad LIKE '%Microsoft Edge%' + // search, and Microsoft version-locks WebView2 releases to Edge + // releases. When the runner image's preinstalled WebView2 version + // coincides with the manifest version, the post-uninstall check would + // falsely report Edge as still installed. + if appName == "Microsoft Edge" && + (strings.Contains(result.Name, "WebView2") || strings.Contains(result.Name, "Edge Update")) { + logger.InfoContext(ctx, fmt.Sprintf("Ignoring '%s': separate product matched by the broad name search", result.Name)) + continue + } + // Sublime Text's Inno Setup installer may not write version to registry properly // If app is found but version is empty, check if it's Sublime Text and skip version check if appName == "Sublime Text" && result.Version == "" { From eefbb6da0eb0d51c8500abbfffd5f0124234cd4a Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 22:33:01 -0500 Subject: [PATCH 6/8] Fix Firefox ESR install-completion race in FMA install script (#48248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The poll exited as soon as firefox.exe appeared on disk, but the NSIS installer writes the registry uninstall entry (what detection reads) last — so osquery could query programs before the entry existed (seen on run 28562265782: version 140.12.0 not found 6s after script start while the installer child was still running). Now waits for both the exe and the Mozilla Firefox*ESR* uninstall entry. --- .../winget/scripts/firefox_esr_install.ps1 | 17 +++++++++++++++-- .../outputs/firefox@esr/windows.json | 4 ++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 index df153ecb969..832aabd68b7 100644 --- a/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/firefox_esr_install.ps1 @@ -121,12 +121,25 @@ try { # browser after installing and blocks until it is closed. Start-Process -FilePath "$exeFilePath" -ArgumentList "/S" -# Poll for installation to complete +# Poll for installation to complete. firefox.exe lands on disk before the +# installer finishes, and the registry uninstall entry -- what Fleet's +# detection (osquery "programs") reads -- is written last, so wait for both. +# Exiting on the file alone races detection: the policy/validator can query +# "programs" before the entry exists. +$uninstallRoots = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall', + 'HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall' +) $elapsed = 0 while ($elapsed -lt $maxWaitSeconds) { Start-Sleep -Seconds 5 $elapsed += 5 - if (Test-Path "$installDir\firefox.exe") { + if (-not (Test-Path "$installDir\firefox.exe")) { continue } + $entry = Get-ChildItem -Path $uninstallRoots -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | + Where-Object { $_.DisplayName -like 'Mozilla Firefox*ESR*' } | + Select-Object -First 1 + if ($entry) { Write-Host "Firefox ESR installed successfully after $elapsed seconds" Exit 0 } diff --git a/ee/maintained-apps/outputs/firefox@esr/windows.json b/ee/maintained-apps/outputs/firefox@esr/windows.json index dac05e5e532..e092fb67ef5 100644 --- a/ee/maintained-apps/outputs/firefox@esr/windows.json +++ b/ee/maintained-apps/outputs/firefox@esr/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name LIKE 'Mozilla Firefox % ESR %' AND publisher = 'Mozilla' AND version_compare(version, '140.12.0') < 0);" }, "installer_url": "https://download-installer.cdn.mozilla.net/pub/firefox/releases/140.12.0esr/win64/en-US/Firefox%20Setup%20140.12.0esr.exe", - "install_script_ref": "389738de", + "install_script_ref": "04e4b74e", "uninstall_script_ref": "5dc712f7", "sha256": "8faf3b35e8272d320eab45bc75bb6d644b29a0ab0cced4ad243f9bbaf6148dea", "default_categories": [ @@ -16,7 +16,7 @@ } ], "refs": { - "389738de": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Mozilla Firefox ESR*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$installDir = \"C:\\Program Files\\Mozilla Firefox\"\n$maxWaitSeconds = 120\n\ntry {\n\n# Start silent install without -Wait; the Firefox ESR installer launches the\n# browser after installing and blocks until it is closed.\nStart-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\"\n\n# Poll for installation to complete\n$elapsed = 0\nwhile ($elapsed -lt $maxWaitSeconds) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n if (Test-Path \"$installDir\\firefox.exe\") {\n Write-Host \"Firefox ESR installed successfully after $elapsed seconds\"\n Exit 0\n }\n}\n\nWrite-Host \"Timed out waiting for Firefox ESR to install\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", + "04e4b74e": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Mozilla Firefox ESR*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/S\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n$installDir = \"C:\\Program Files\\Mozilla Firefox\"\n$maxWaitSeconds = 120\n\ntry {\n\n# Start silent install without -Wait; the Firefox ESR installer launches the\n# browser after installing and blocks until it is closed.\nStart-Process -FilePath \"$exeFilePath\" -ArgumentList \"/S\"\n\n# Poll for installation to complete. firefox.exe lands on disk before the\n# installer finishes, and the registry uninstall entry -- what Fleet's\n# detection (osquery \"programs\") reads -- is written last, so wait for both.\n# Exiting on the file alone races detection: the policy/validator can query\n# \"programs\" before the entry exists.\n$uninstallRoots = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n$elapsed = 0\nwhile ($elapsed -lt $maxWaitSeconds) {\n Start-Sleep -Seconds 5\n $elapsed += 5\n if (-not (Test-Path \"$installDir\\firefox.exe\")) { continue }\n $entry = Get-ChildItem -Path $uninstallRoots -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -like 'Mozilla Firefox*ESR*' } |\n Select-Object -First 1\n if ($entry) {\n Write-Host \"Firefox ESR installed successfully after $elapsed seconds\"\n Exit 0\n }\n}\n\nWrite-Host \"Timed out waiting for Firefox ESR to install\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n", "5dc712f7": "# Fleet extracts name from installer (EXE) and saves it to PACKAGE_ID\n# variable\n# Match Firefox ESR only (e.g. \"Mozilla Firefox 140.7.1 ESR (x64 en-US)\"), not regular Firefox\n$softwareNameLike = \"*Firefox*ESR*\"\n\n# NSIS installers require /S flag for silent uninstall\n$uninstallArgs = \"/S\"\n\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n)\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n $uninstallCommand = if ($key.QuietUninstallString) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n # The uninstall command may contain command and args, like:\n # \"C:\\Program Files\\Mozilla Firefox ESR\\uninstall\\helper.exe\" /S\n $splitArgs = $uninstallCommand.Split('\"')\n if ($splitArgs.Length -gt 1) {\n if ($splitArgs.Length -eq 3) {\n $existingArgs = $splitArgs[2].Trim()\n if ($existingArgs -notmatch '\\b/S\\b') {\n $uninstallArgs = \"$existingArgs /S\".Trim()\n } else {\n $uninstallArgs = $existingArgs\n }\n } elseif ($splitArgs.Length -gt 3) {\n Throw `\n \"Uninstall command contains multiple quoted strings. \" +\n \"Please update the uninstall script.`n\" +\n \"Uninstall command: $uninstallCommand\"\n }\n $uninstallCommand = $splitArgs[1]\n } else {\n if ($uninstallCommand -notmatch '\\b/S\\b') {\n $uninstallArgs = \"/S\"\n } else {\n $uninstallArgs = \"\"\n }\n }\n Write-Host \"Uninstall command: $uninstallCommand\"\n Write-Host \"Uninstall args: $uninstallArgs\"\n\n $processOptions = @{\n FilePath = $uninstallCommand\n PassThru = $true\n Wait = $true\n }\n\n if ($uninstallArgs -ne '') {\n $processOptions.ArgumentList = $uninstallArgs\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for Firefox ESR not found.\"\n $exitCode = 1\n}\n\nExit $exitCode\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } From a76bf7b0600017ce599555d897fe43be5dbc1d58 Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Wed, 1 Jul 2026 23:05:18 -0500 Subject: [PATCH 7/8] Fix OneDrive install-completion race in FMA install script (#48248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion poll accepted the OneDrive.exe binary OR the registry uninstall entry, but the binary lands before OneDriveSetup's child registers the ARP entry that detection reads — so the script could exit while registration was still pending (seen on run 28563420399). Success now requires the registry entry (native or WOW6432Node hive, with a DisplayName fallback), polling to the deadline even after the setup process exits. --- .../winget/scripts/onedrive_install.ps1 | 65 ++++++++++++------- .../outputs/onedrive/windows.json | 4 +- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 b/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 index 4f52a54fe99..4fe8b8cf5f7 100644 --- a/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 +++ b/ee/maintained-apps/inputs/winget/scripts/onedrive_install.ps1 @@ -120,48 +120,63 @@ try { # silentinstallhq.com). The catch: OneDriveSetup.exe spawns several child # processes and starts the resident OneDrive.exe, so a plain Start-Process -Wait # can wait indefinitely and hit the CI step timeout. Instead, start the -# installer, then poll for the per-machine install to land (registry uninstall -# key + the all-users binary) and return success as soon as it appears. +# installer, then poll for the per-machine install's registry uninstall entry +# and return success once it is registered. $process = Start-Process -FilePath "$exeFilePath" -ArgumentList "/allusers /silent" -PassThru -# Per-machine OneDrive registers an uninstall key and drops OneDrive.exe under -# Program Files (x86) (or Program Files on x86 OS). -$uninstallKey = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\OneDriveSetup.exe" +# Per-machine OneDrive drops OneDrive.exe under Program Files and registers an +# uninstall entry -- which is what Fleet's detection (osquery "programs") reads. +# The binary lands BEFORE the entry is registered, so success requires the +# registry entry; exiting on the binary alone races detection. Modern x64 +# builds register under the native hive, older ones under WOW6432Node. +# OneDriveSetup.exe may also exit while a child process finishes the +# registration, so keep polling until the deadline even after it exits. +$uninstallKeys = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\OneDriveSetup.exe", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\OneDriveSetup.exe" +) +$uninstallRoots = @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall" +) $exePaths = @( "$env:ProgramFiles\Microsoft OneDrive\OneDrive.exe", "${env:ProgramFiles(x86)}\Microsoft OneDrive\OneDrive.exe" ) +function Test-OneDriveRegistered { + foreach ($k in $uninstallKeys) { + if (Test-Path $k) { return $true } + } + # Fallback in case the key name drifts across OneDrive builds. + $entry = Get-ChildItem -Path $uninstallRoots -ErrorAction SilentlyContinue | + ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } | + Where-Object { $_.DisplayName -eq 'Microsoft OneDrive' } | + Select-Object -First 1 + return [bool]$entry +} + $timeoutSeconds = 240 $deadline = (Get-Date).AddSeconds($timeoutSeconds) -$installed = $false +$registered = $false while ((Get-Date) -lt $deadline) { - $exeExists = $false - foreach ($p in $exePaths) { - if ($p -and (Test-Path $p)) { $exeExists = $true; break } - } - if ((Test-Path $uninstallKey) -or $exeExists) { - $installed = $true - break - } - # If the top-level setup process exited, capture its code and stop polling. - if ($process.HasExited) { break } + if (Test-OneDriveRegistered) { $registered = $true; break } Start-Sleep -Seconds 5 } -# Final check in case the setup process exited right before the loop bailed. -if (-not $installed) { - $exeExists = $false - foreach ($p in $exePaths) { - if ($p -and (Test-Path $p)) { $exeExists = $true; break } - } - if ((Test-Path $uninstallKey) -or $exeExists) { $installed = $true } +if ($registered) { + Write-Host "OneDrive per-machine install registered." + Exit 0 } -if ($installed) { - Write-Host "OneDrive per-machine install detected." +$exeExists = $false +foreach ($p in $exePaths) { + if ($p -and (Test-Path $p)) { $exeExists = $true; break } +} +if ($exeExists) { + Write-Host "Warning: OneDrive.exe present but uninstall entry not registered within $timeoutSeconds seconds." Exit 0 } diff --git a/ee/maintained-apps/outputs/onedrive/windows.json b/ee/maintained-apps/outputs/onedrive/windows.json index 9b810dd8518..805cd35cd96 100644 --- a/ee/maintained-apps/outputs/onedrive/windows.json +++ b/ee/maintained-apps/outputs/onedrive/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Microsoft OneDrive' AND publisher = 'Microsoft Corporation' AND version_compare(version, '26.098.0524.0004') < 0);" }, "installer_url": "https://oneclient.sfx.ms/Win/Installers/26.098.0524.0004/amd64/OneDriveSetup.exe", - "install_script_ref": "991bcdbd", + "install_script_ref": "fb13c961", "uninstall_script_ref": "374be511", "sha256": "3d04143831ba248f597791e732d37a634418d7d495d2284ecf7338f256bb141c", "default_categories": [ @@ -17,6 +17,6 @@ ], "refs": { "374be511": "# Fleet runs this uninstall script as SYSTEM (machine scope).\n# Match against the registry DisplayName for OneDrive.\n$softwareName = \"Microsoft OneDrive\"\n\n# It is recommended to use exact software name here if possible to avoid\n# uninstalling unintended software.\n$softwareNameLike = \"$softwareName\"\n\n# OneDriveSetup.exe uninstalls with /uninstall (and /allusers for machine-wide\n# installs). Used only if the registered UninstallString carries no flags.\n$defaultArgs = \"/uninstall /allusers\"\n\n# A machine-wide OneDrive registers under HKLM; also check WOW6432Node since the\n# installer is 32-bit.\n$paths = @(\n 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*',\n 'HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*'\n)\n\n$exitCode = 0\n\ntry {\n\n[array]$uninstallKeys = Get-ChildItem `\n -Path $paths `\n -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath }\n\n$foundUninstaller = $false\nforeach ($key in $uninstallKeys) {\n if ($key.DisplayName -like $softwareNameLike) {\n $foundUninstaller = $true\n\n # Prefer QuietUninstallString when present; it already includes silent flags.\n $useQuiet = [bool]$key.QuietUninstallString\n $uninstallCommand = if ($useQuiet) {\n $key.QuietUninstallString\n } else {\n $key.UninstallString\n }\n\n if ([string]::IsNullOrWhiteSpace($uninstallCommand)) {\n Throw \"No UninstallString found for '$($key.DisplayName)'.\"\n }\n\n # Parse the UninstallString defensively. It comes in three shapes:\n # 1. \"C:\\Program Files\\Microsoft OneDrive\\...\\OneDriveSetup.exe\" /uninstall (quoted)\n # 2. C:\\Program Files\\Microsoft OneDrive\\...\\OneDriveSetup.exe /uninstall (unquoted, may contain spaces)\n # 3. MsiExec.exe /X{GUID} (bare token)\n if ($uninstallCommand -match '^\\s*\"([^\"]+)\"\\s*(.*)$') {\n $exe = $matches[1]\n $args = $matches[2].Trim()\n } elseif ($uninstallCommand -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') {\n $exe = $matches[1]\n $args = $matches[2].Trim()\n } else {\n $uninstallCommand -match '^\\s*(\\S+)\\s*(.*)$' | Out-Null\n $exe = $matches[1]\n $args = $matches[2].Trim()\n }\n\n # If we fell back to the raw UninstallString (no quiet variant), ensure the\n # uninstall runs unattended and all-users.\n if (-not $useQuiet -and ($args -notmatch '(?i)/uninstall')) {\n $args = \"$args $defaultArgs\".Trim()\n }\n\n Write-Host \"Uninstall command: $exe\"\n Write-Host \"Uninstall args: $args\"\n\n $processOptions = @{\n FilePath = $exe\n PassThru = $true\n Wait = $true\n }\n if ($args -ne '') {\n $processOptions.ArgumentList = $args\n }\n\n $process = Start-Process @processOptions\n $exitCode = $process.ExitCode\n Write-Host \"Uninstall exit code: $exitCode\"\n break\n }\n}\n\nif (-not $foundUninstaller) {\n Write-Host \"Uninstaller for '$softwareName' not found.\"\n $exitCode = 1\n}\n\n} catch {\n Write-Host \"Error: $_\"\n $exitCode = 1\n}\n\nExit $exitCode\n", - "991bcdbd": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Microsoft OneDrive*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# OneDriveSetup.exe performs a per-machine install with \"/allusers /silent\"\n# (switches verified against the winget InstallerSwitches Custom: /allusers and\n# silentinstallhq.com). The catch: OneDriveSetup.exe spawns several child\n# processes and starts the resident OneDrive.exe, so a plain Start-Process -Wait\n# can wait indefinitely and hit the CI step timeout. Instead, start the\n# installer, then poll for the per-machine install to land (registry uninstall\n# key + the all-users binary) and return success as soon as it appears.\n\n$process = Start-Process -FilePath \"$exeFilePath\" -ArgumentList \"/allusers /silent\" -PassThru\n\n# Per-machine OneDrive registers an uninstall key and drops OneDrive.exe under\n# Program Files (x86) (or Program Files on x86 OS).\n$uninstallKey = \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OneDriveSetup.exe\"\n$exePaths = @(\n \"$env:ProgramFiles\\Microsoft OneDrive\\OneDrive.exe\",\n \"${env:ProgramFiles(x86)}\\Microsoft OneDrive\\OneDrive.exe\"\n)\n\n$timeoutSeconds = 240\n$deadline = (Get-Date).AddSeconds($timeoutSeconds)\n$installed = $false\n\nwhile ((Get-Date) -lt $deadline) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) {\n $installed = $true\n break\n }\n # If the top-level setup process exited, capture its code and stop polling.\n if ($process.HasExited) { break }\n Start-Sleep -Seconds 5\n}\n\n# Final check in case the setup process exited right before the loop bailed.\nif (-not $installed) {\n $exeExists = $false\n foreach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n }\n if ((Test-Path $uninstallKey) -or $exeExists) { $installed = $true }\n}\n\nif ($installed) {\n Write-Host \"OneDrive per-machine install detected.\"\n Exit 0\n}\n\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"OneDriveSetup exited with code: $exitCode\"\n if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n Exit $exitCode\n}\n\nWrite-Host \"Timed out waiting for OneDrive install to complete.\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" + "fb13c961": "# Fleet Pattern A: converge this app on a single install scope\n# (https://github.com/fleetdm/fleet/issues/48248).\n#\n# Fleet manages this app at \"machine\" scope. Windows also offers the opposite\n# scope, so a host may already have a stale copy there. The patch policy is\n# scope-blind (osquery's \"programs\" table reads HKLM + every loaded user hive),\n# so a lingering opposite-scope copy keeps the policy red and leaves two copies\n# on disk. Remove the opposite-scope copy before installing the managed copy so\n# the device converges on one canonical copy; a same-scope upgrade is left to the\n# installer below (preserves data).\n#\n# Fleet runs install scripts as SYSTEM, where HKCU maps to SYSTEM's own hive --\n# NOT the logged-on user's -- so per-user copies are found under HKEY_USERS.\n# Removal is best-effort: it never aborts the install, and a copy that survives\n# keeps the (truthful) scope-blind policy red rather than false-green.\n\n$fmaTargetScope = \"machine\"\n$fmaDisplayNameLike = \"Microsoft OneDrive*\"\n$fmaPublisherLike = \"\"\n$fmaFallbackUninstallArgs = \"/uninstall\"\n\nfunction Get-FmaUninstallExeAndArgs {\n param([string]$Command)\n # Registry uninstall strings come in three shapes; parse defensively.\n if ($Command -match '^\\s*\"([^\"]+)\"\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '(?i)^\\s*(.+?\\.exe)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n elseif ($Command -match '^\\s*(\\S+)\\s*(.*)$') { return @{ Exe = $Matches[1]; Args = $Matches[2].Trim() } }\n return $null\n}\n\nfunction Remove-FmaOtherScopeCopies {\n param(\n [string]$TargetScope,\n [string]$DisplayNameLike,\n [string]$PublisherLike,\n [string]$FallbackArgs\n )\n\n # Scan ONLY the opposite scope's uninstall hives, so a broad DisplayName match\n # can't touch the copy Fleet manages.\n $roots = @()\n if ($TargetScope -eq 'machine') {\n foreach ($hive in (Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue)) {\n if ($hive.Name -match '_Classes$') { continue }\n # Real interactive users only (skip .DEFAULT and service SIDs).\n if ($hive.PSChildName -notmatch '^S-1-5-21-') { continue }\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n $roots += \"Registry::$($hive.Name)\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n }\n } else {\n $roots += 'HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n $roots += 'HKLM:\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall'\n }\n\n foreach ($root in $roots) {\n foreach ($sub in (Get-ChildItem -Path $root -ErrorAction SilentlyContinue)) {\n $key = Get-ItemProperty $sub.PSPath -ErrorAction SilentlyContinue\n if (-not $key.DisplayName) { continue }\n if ($key.DisplayName -notlike $DisplayNameLike) { continue }\n if ($PublisherLike -ne '' -and $key.Publisher -notlike $PublisherLike) { continue }\n\n # Prefer the vendor's QuietUninstallString verbatim; it already carries\n # the correct silent switches for that installer technology.\n $useVerbatim = [bool]$key.QuietUninstallString\n $command = if ($useVerbatim) { $key.QuietUninstallString } else { $key.UninstallString }\n if (-not $command) { continue }\n\n $parsed = Get-FmaUninstallExeAndArgs $command\n if (-not $parsed) { Write-Host \"Fleet: could not parse uninstall string: $command\"; continue }\n $exe = $parsed.Exe\n $uargs = $parsed.Args\n $isMsi = $exe -match '(?i)msiexec'\n\n if ($isMsi) {\n $uargs = $uargs -replace '(?i)/i(\\{)', '/x$1'\n if ($uargs -notmatch '(?i)/x') { $uargs = (\"/x $uargs\").Trim() }\n if ($uargs -notmatch '(?i)/qn') { $uargs = (\"$uargs /qn\").Trim() }\n if ($uargs -notmatch '(?i)/norestart') { $uargs = (\"$uargs /norestart\").Trim() }\n } elseif (-not $useVerbatim -and $FallbackArgs -ne '') {\n $uargs = (\"$uargs $FallbackArgs\").Trim()\n }\n\n if (-not $isMsi -and -not (Test-Path -LiteralPath $exe)) {\n Write-Host \"Fleet: opposite-scope uninstaller missing on disk: $exe\"\n continue\n }\n\n Write-Host \"Fleet: removing opposite-scope copy '$($key.DisplayName)'\"\n Write-Host \" Command: $exe\"\n Write-Host \" Args: $uargs\"\n try {\n $opts = @{ FilePath = $exe; PassThru = $true; Wait = $true; NoNewWindow = $true }\n if ($uargs -ne '') { $opts.ArgumentList = $uargs }\n $p = Start-Process @opts\n Write-Host \" Exit code: $($p.ExitCode)\"\n } catch {\n Write-Host \" WARNING: failed to remove opposite-scope copy: $_\"\n }\n }\n }\n}\n\ntry {\n Remove-FmaOtherScopeCopies -TargetScope $fmaTargetScope -DisplayNameLike $fmaDisplayNameLike -PublisherLike $fmaPublisherLike -FallbackArgs $fmaFallbackUninstallArgs\n} catch {\n Write-Host \"Fleet: warning during opposite-scope cleanup: $_\"\n}\n\n# ---- App install ----\n\n# Learn more about .exe install scripts:\n# http://fleetdm.com/learn-more-about/exe-install-scripts\n\n$exeFilePath = \"${env:INSTALLER_PATH}\"\n\ntry {\n\n# OneDriveSetup.exe performs a per-machine install with \"/allusers /silent\"\n# (switches verified against the winget InstallerSwitches Custom: /allusers and\n# silentinstallhq.com). The catch: OneDriveSetup.exe spawns several child\n# processes and starts the resident OneDrive.exe, so a plain Start-Process -Wait\n# can wait indefinitely and hit the CI step timeout. Instead, start the\n# installer, then poll for the per-machine install's registry uninstall entry\n# and return success once it is registered.\n\n$process = Start-Process -FilePath \"$exeFilePath\" -ArgumentList \"/allusers /silent\" -PassThru\n\n# Per-machine OneDrive drops OneDrive.exe under Program Files and registers an\n# uninstall entry -- which is what Fleet's detection (osquery \"programs\") reads.\n# The binary lands BEFORE the entry is registered, so success requires the\n# registry entry; exiting on the binary alone races detection. Modern x64\n# builds register under the native hive, older ones under WOW6432Node.\n# OneDriveSetup.exe may also exit while a child process finishes the\n# registration, so keep polling until the deadline even after it exits.\n$uninstallKeys = @(\n \"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OneDriveSetup.exe\",\n \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\OneDriveSetup.exe\"\n)\n$uninstallRoots = @(\n \"HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\",\n \"HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\"\n)\n$exePaths = @(\n \"$env:ProgramFiles\\Microsoft OneDrive\\OneDrive.exe\",\n \"${env:ProgramFiles(x86)}\\Microsoft OneDrive\\OneDrive.exe\"\n)\n\nfunction Test-OneDriveRegistered {\n foreach ($k in $uninstallKeys) {\n if (Test-Path $k) { return $true }\n }\n # Fallback in case the key name drifts across OneDrive builds.\n $entry = Get-ChildItem -Path $uninstallRoots -ErrorAction SilentlyContinue |\n ForEach-Object { Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue } |\n Where-Object { $_.DisplayName -eq 'Microsoft OneDrive' } |\n Select-Object -First 1\n return [bool]$entry\n}\n\n$timeoutSeconds = 240\n$deadline = (Get-Date).AddSeconds($timeoutSeconds)\n$registered = $false\n\nwhile ((Get-Date) -lt $deadline) {\n if (Test-OneDriveRegistered) { $registered = $true; break }\n Start-Sleep -Seconds 5\n}\n\nif ($registered) {\n Write-Host \"OneDrive per-machine install registered.\"\n Exit 0\n}\n\n$exeExists = $false\nforeach ($p in $exePaths) {\n if ($p -and (Test-Path $p)) { $exeExists = $true; break }\n}\nif ($exeExists) {\n Write-Host \"Warning: OneDrive.exe present but uninstall entry not registered within $timeoutSeconds seconds.\"\n Exit 0\n}\n\nif ($process.HasExited) {\n $exitCode = $process.ExitCode\n Write-Host \"OneDriveSetup exited with code: $exitCode\"\n if ($exitCode -eq 0 -or $exitCode -eq 3010 -or $exitCode -eq 1641) { Exit 0 }\n Exit $exitCode\n}\n\nWrite-Host \"Timed out waiting for OneDrive install to complete.\"\nExit 1\n\n} catch {\n Write-Host \"Error: $_\"\n Exit 1\n}\n" } } From 815c8f69c81062fcd9c4ae3499fdf320e9acae1f Mon Sep 17 00:00:00 2001 From: Allen Houchins Date: Thu, 2 Jul 2026 11:05:27 -0500 Subject: [PATCH 8/8] Reingest Claude and GitHub Desktop to fix dangling install_script_ref --- ee/maintained-apps/outputs/claude/windows.json | 2 +- ee/maintained-apps/outputs/github-desktop/windows.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ee/maintained-apps/outputs/claude/windows.json b/ee/maintained-apps/outputs/claude/windows.json index 2c1659780c5..402e34c92cd 100644 --- a/ee/maintained-apps/outputs/claude/windows.json +++ b/ee/maintained-apps/outputs/claude/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'Claude' AND publisher = 'Anthropic, PBC' AND version_compare(version, '1.17377.2') < 0);" }, "installer_url": "https://downloads.claude.ai/releases/win32/x64/1.17377.2/Claude-e0ea9e9df1d5a7e3848ea088cc6b1863adc020b9.msix", - "install_script_ref": "16c020cc", + "install_script_ref": "5b1e734d", "uninstall_script_ref": "03f72055", "sha256": "b071b480bd8547ae6a4f8dd0b8598bd1c675fd72886a3576e4a266060c9ff4e4", "default_categories": [ diff --git a/ee/maintained-apps/outputs/github-desktop/windows.json b/ee/maintained-apps/outputs/github-desktop/windows.json index c1f68047c33..1c88cd5acbc 100644 --- a/ee/maintained-apps/outputs/github-desktop/windows.json +++ b/ee/maintained-apps/outputs/github-desktop/windows.json @@ -7,7 +7,7 @@ "patched": "SELECT 1 WHERE NOT EXISTS (SELECT 1 FROM programs WHERE name = 'GitHub Desktop' AND publisher = 'GitHub, Inc.' AND version_compare(version, '3.6.2') < 0);" }, "installer_url": "https://desktop.githubusercontent.com/releases/3.6.2-57f0b637/GitHubDesktopSetup-x64.msi", - "install_script_ref": "8959087b", + "install_script_ref": "9fe80ace", "uninstall_script_ref": "cfc2b24c", "sha256": "36a7a7faf601f0726214d14ddaaf2ea9ae1c39d3526b1f4b7c8c432fd8e770b8", "default_categories": [