desktop: do not delete the whole install folder when uninstalling on Windows (#7789)

* desktop: do not delete the whole install folder when uninstalling on Windows

* plans: Windows uninstall deleting the chosen install folder
This commit is contained in:
Narasimha-sc authored and GitHub committed 2026-10-21 21:31:32 +01:00
1 parent 1ad402d06b
commit 79d91e716d
3 files changed
+181

No files matched your search

@@ -1,5 +1,6 @@
import org.gradle.internal.extensions.stdlib.toDefaultLowerCase
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.desktop.application.tasks.AbstractJPackageTask
plugins {
kotlin("multiplatform")
@@ -165,6 +166,19 @@ tasks.named("clean") {
tasks.named("compileKotlinJvm") {
dependsOn("cmakeBuildAndCopy")
}
tasks.withType<AbstractJPackageTask>().configureEach {
if (targetFormat == TargetFormat.Msi) {
doLast {
exec {
commandLine(
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-File", project.file("../../../scripts/desktop/disable-msi-install-dir-wipe-windows.ps1"),
destinationDir.asFileTree.matching { include("*.msi") }.singleFile
)
}
}
}
}
afterEvaluate {
tasks.create("cmakeBuildAndCopy") {
dependsOn("cmakeBuild")
@@ -0,0 +1,152 @@
# Fix Windows uninstall and update deleting everything in the chosen install folder
## Problem
The Windows MSI installer has a folder selection page. When the selected folder already contains
the user's files, for example Desktop, uninstalling SimpleX deletes that folder recursively, as
far as it can: for the Desktop, the user's files and subfolders, and the Desktop folder itself.
Updating has the same result, because an update first removes the installed version.
Reproduced with the released MSIs on a GitHub Windows runner, with the installer UI driven
through UI Automation (fork runs 39472341425, 39473484103 and 39492828153):
- Desktop contained `My notes.txt`, `Tax return 2025.txt` and `Holiday photos\beach.txt`.
- `C:\Users\runneradmin\Desktop\` was entered on the folder page. The installer showed "The
folder C:\Users\runneradmin\Desktop\ already exist. Would you like to install to that folder
anyway?", and choosing Yes continued the installation.
- Uninstalling v7.0.3 removed `C:\Users\runneradmin\Desktop`; Explorer reported
"C:\Users\runneradmin\Desktop is unavailable". The uninstall log contains `Removing foreign
folder: C:\Users\runneradmin\Desktop\Holiday photos\`.
- Updating v7.0.2 installed in Desktop to v7.0.3 through the installer UI, as the in-app updater
does (`msiexec /i`), removed `C:\Users\runneradmin\Desktop` in the same way, during
`RemoveExistingProducts`.
- Uninstalling v7.0.3 installed in the user profile folder: the `WixRemoveFoldersEx` recursion
collected 189 of the profile's folders (in `.cargo`, `.docker`, `.dotnet`, `.rustup`,
`.templateengine` and `AppData\Local\.IdentityService`), and in run 39492828153 also the
user's `.my-settings`, before it failed with access denied at the junction
`AppData\Local\Application Data`. Windows Installer ignores errors from that custom action, so
`RemoveFiles` then deleted the files in those folders and the folders that became empty;
`AppData\Roaming` was not reached.
- Uninstalling v7.0.3 installed in `%AppData%` (`C:\Users\runneradmin\AppData\Roaming\`)
removed every folder in `%AppData%` except `Microsoft`, including `%AppData%\SimpleX`, where the
app stores its database and settings (`Platform.desktop.kt`), and the user's file there. In
`Microsoft` it removed 71 files, including the DPAPI master keys in `Microsoft\Protect`, a key
file in `Microsoft\Crypto\RSA` and Start Menu shortcuts; the folder itself remained.
## Cause
The MSI is built by jpackage through the Compose `packageMsi` task, with `dirChooser = true` in
`apps/multiplatform/desktop/build.gradle.kts`. For `INSTALLDIR`, jpackage adds a WiX
`util:RemoveFolderEx On="uninstall"` element (`WixAppImageFragmentBuilder.addDirectoryCleaner` in
JDK 17, which CI uses, and in the JDK `master` branch as of October 2026). WiX compiles this
element into these MSI entries:
- a `WixRemoveFolderEx` row (`InstallMode` 2, uninstall) for property `RM_RF…`;
- a `Registry` row, from which Windows Installer writes `[INSTALLDIR]` to
`HKLM\Software\Unknown\SimpleX\<version>\RM_RF…` during installation, and
`AppSearch`/`RegLocator` rows, from which the `AppSearch` action reads it back at the start of
every installer run, including removal;
- the WiX `WixRemoveFoldersEx` custom action, which schedules every file and folder under that
path for removal.
The folder page sets `INSTALLDIR` to the selected folder without appending a product subfolder.
Windows Installer caches the MSI of every installed version and removes that version with its
cached tables, both on uninstall and when a later version removes it during an update.
## Fix
As the last action (`doLast`) of every Compose MSI packaging task (`packageMsi`,
`packageReleaseMsi`), `scripts/desktop/disable-msi-install-dir-wipe-windows.ps1` deletes the rows
of the `WixRemoveFolderEx` table through the Windows Installer automation interface
(`WindowsInstaller.Installer`). Removing a version installed from such an MSI, by uninstalling or
updating, deletes only the files that version installed, and the folders that become empty when
those files are removed: each file belongs to an MSI component, and Windows Installer removes
component files itself, independently of `RemoveFolderEx`.
- With WiX 3.11.2, the version Compose downloads, the `WixRemoveFoldersEx` custom action
schedules nothing when the `WixRemoveFolderEx` table is empty (`RemoveFoldersEx.cpp`: the
record loop ends with `E_NOMOREITEMS`, which the function maps to `S_OK`).
- The `Registry`, `AppSearch` and `RegLocator` rows and the custom action stay: the registry
value is the key path of a jpackage component, and without `WixRemoveFolderEx` rows nothing
uses the `RM_RF…` property.
- The hook is added only to `TargetFormat.Msi` tasks, which Compose enables only on Windows.
- If a future jpackage no longer emits `util:RemoveFolderEx`, the MSI has no `WixRemoveFolderEx`
table, the query fails, and the build fails. With WiX 4 or later, WiX compiles the element into
the `Wix4RemoveFolderEx` table, so the script then has to target that table.
- The script calls the `WindowsInstaller.Installer` methods through `InvokeMember`, which worked in
Windows PowerShell 5.1 (the hook and the patch step of the tests) and in PowerShell 7 (the test
scenarios). A direct `OpenDatabase` call was tested only in PowerShell 7, where it works.
- The hook runs the script with `Project.exec`, as `apps/multiplatform/android/build.gradle.kts`
does. `Project.exec` is deprecated since Gradle 8.11; the project uses Gradle 8.12.
## Alternatives considered
| Alternative | Why not |
|---|---|
| `dirChooser = false` | Removes the folder choice for everyone and does not change silent installs with `INSTALLDIR=`. |
| Append a `SimpleX\` subfolder to the selected folder (MSI `ControlEvent` rows) | Keeps the recursive delete: an existing unrelated folder named `SimpleX`, or a folder passed as `INSTALLDIR=` on the command line, is still deleted. A larger change, and a change to the installer UI. |
| Override jpackage resources (`--resource-dir`) | Compose 1.8.2 clears its resource directory before running jpackage and passes its own `--resource-dir` after `freeArgs`; jpackage uses the last one. Not possible until JetBrains/compose-multiplatform#5612. |
| Run jpackage a second time with Compose's arguments file and another resource directory | Doubles the MSI build time and depends on Compose's internal arguments file. |
| Replace Compose's resource directory through reflection | Compose clears that directory too; depends on Compose internals. |
| A step in `.github/workflows/build.yml` after `packageMsi` | Shorter, but local builds and `packageReleaseMsi` would still produce MSIs with the recursive delete. |
| Remove `WixRemoveFoldersEx` from `InstallExecuteSequence` instead of deleting the table rows | A `DELETE` that matches no row succeeds, so if the action name changes (for example with WiX 4) the build would not detect that the recursive delete remains. |
| The script as an inline `-Command` in the Gradle file | Every PowerShell `$` would need escaping in the Kotlin string. |
| Patch or vendor the Compose Gradle plugin, or change installer tooling | A far larger change than deleting the table rows. |
### Protecting existing installations during an update
An installed earlier version is removed with its own cached tables, including its
`WixRemoveFolderEx` row. These options were evaluated and not taken:
| Option | Why not |
|---|---|
| Delete the earlier versions' `RM_RF…` registry values in an elevated deferred custom action before `RemoveExistingProducts` | Fails with error 2613 (`RemoveExistingProducts action sequenced incorrectly`, fork run 39474876911): only deferred actions can run without impersonation, deferred actions must follow `InstallInitialize`, and after `InstallInitialize`, `RemoveExistingProducts` must either precede every action that generates execution script or follow `InstallExecute` (the next row). |
| The same, with `RemoveExistingProducts` after `InstallExecute` | Changes every update: new files are installed before the old version is removed, so a bundled file with a lower version than the installed one is not replaced, and updating from a Desktop installation would leave the old files there (not tested; most component GUIDs are the same in every version, because jpackage derives them from the install path). |
| Stop the update when the earlier version's install folder contains other files | Leaves the user to move the files; adds a PowerShell custom action and an `Error` table entry. |
| A check in the app before it starts an update | The update to this version is started by the updater of the installed version, which does not contain the check. |
## Scope
- Linux `.deb`: the maintainer scripts contain no recursive delete, and the package installs to
the fixed `/opt/simplex`.
- Linux AppImage in-app update: replaces only the running AppImage file, through a staging file
next to it. macOS in-app update:
replaces or removes only `/Applications/SimpleX.app` and `/Applications/SimpleX-old.app`.
- `TargetFormat.Exe`: the EXE contains an MSI that jpackage builds in the same task; the hook is
not added to that task. The EXE is not built in CI or released, but
`packageDistributionForCurrentOS`, the documented desktop build command, builds it on Windows,
and the MSI inside that EXE still contains the `WixRemoveFolderEx` row.
- Not fixed: when Windows Installer removes a version installed from an earlier release MSI, on
uninstall or during an update, it uses that version's cached `WixRemoveFolderEx` row and
deletes the install folder. Users who installed into a folder with other files need to move
those files before uninstalling or updating.
## Testing
The workflow `windows-msi-gui-test.yml` with `apps/multiplatform/scripts/windows-msi/` (in
draft PR #7723, not merged; the runs below used the same scripts on a fork) drives the installer
UI on a Windows runner and records screenshots, installer logs and check results. Expected
results with the fix:
| Scenario | Expected |
|---|---|
| Install into Desktop, uninstall | The user's files and Desktop remain; `SimpleX.exe`, `app` and `runtime` are removed. |
| Install the fixed version into Desktop, update to a copy of the same MSI with a higher version (the test script changes the product version, product code, package code, `Upgrade` rows and versioned registry key) | The user's files remain; the old files are removed; the updated app starts. |
| Install into the default folder, start the app, uninstall | The app starts; `C:\Program Files\SimpleX` is removed after uninstall. |
| Install into the user profile folder, start the app, uninstall | The user's files in the profile remain. |
| Install into `%AppData%`, start the app, uninstall | The user's files in `%AppData%` and every file in `%AppData%\SimpleX`, including the database, remain. |
| Install v7.0.2 into Desktop, update to the fixed version | Known issue: the user's files are deleted, because Windows Installer removes v7.0.2 with v7.0.2's cached `WixRemoveFolderEx` row. |
Results (fork runs):
- Before, released MSIs (39492828153): 17 checks failed. Deleted: the user's files in the Desktop
uninstall and update scenarios, `.my-settings` in the profile folder, and the user's file and
folder and the SimpleX database in `%AppData%` (`Adobe`, `Mozilla` and `NuGet` were also
removed, and 71 files in `Microsoft`, including the DPAPI master keys); the default-folder
scenario passed.
- After, MSI built by `packageMsi` from this branch (build 39495576670, test 39570009651):
`WixRemoveFolderEx` has 0 rows, no uninstall or update of the fixed version logged `Removing
foreign folder`, and only the three checks of the known-issue scenario (last table row) failed.
The app started after each update and after installing into the default folder, the user
profile folder and `%AppData%`.
@@ -0,0 +1,15 @@
param([string]$Msi)
$ErrorActionPreference = 'Stop'
$msiOpenDatabaseModeTransact = 1
function Invoke-ComMethod($Object, [string]$Method, [object[]]$Arguments) {
$Object.GetType().InvokeMember($Method, [Reflection.BindingFlags]::InvokeMethod, $null, $Object, $Arguments)
}
$installer = New-Object -ComObject WindowsInstaller.Installer
$database = Invoke-ComMethod $installer 'OpenDatabase' @($Msi, $msiOpenDatabaseModeTransact)
$view = Invoke-ComMethod $database 'OpenView' @('DELETE FROM `WixRemoveFolderEx`')
Invoke-ComMethod $view 'Execute' @()
Invoke-ComMethod $view 'Close' @()
Invoke-ComMethod $database 'Commit' @()