diff --git a/CHANGELOG.md b/CHANGELOG.md
index 040a8ae..62a3a2e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+## [0.8.1] - 2026-09-27
+
+### Fixed
+
+- Windows setup keeps a single desktop shortcut across upgrades. An icon that is already there stays when the checkbox is off, and a checked box no longer leaves a second `.lnk` or a `(2)` copy.
+
## [0.8.0] - 2026-09-26
### Added
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index d7bae65..a8bfa53 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -3,7 +3,7 @@
latest
enable
enable
- 0.8.0
+ 0.8.1
MaksIT.ClusterConsole
MaksIT.ClusterConsole
diff --git a/utils/plugins/Desktop/DesktopPackSupport.psm1 b/utils/plugins/Desktop/DesktopPackSupport.psm1
index 7277fde..4f7bf9d 100644
--- a/utils/plugins/Desktop/DesktopPackSupport.psm1
+++ b/utils/plugins/Desktop/DesktopPackSupport.psm1
@@ -323,8 +323,33 @@ function New-WixPackageXml {
$desktopProp = $xml.CreateElement('Property', $ns)
$null = $desktopProp.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
$null = $desktopProp.SetAttribute('Value', '0')
+ $null = $desktopProp.SetAttribute('Secure', 'yes')
$null = $package.AppendChild($desktopProp)
+ # Present when a previous install wrote the desktop shortcut marker.
+ # Burn also searches HKCU as the user and passes DESKTOPSHORTCUTFOUND,
+ # because a per-machine execute sequence runs as SYSTEM.
+ $foundProp = $xml.CreateElement('Property', $ns)
+ $null = $foundProp.SetAttribute('Id', 'DESKTOPSHORTCUTFOUND')
+ $null = $foundProp.SetAttribute('Secure', 'yes')
+ $foundSearch = $xml.CreateElement('RegistrySearch', $ns)
+ $null = $foundSearch.SetAttribute('Id', 'DesktopShortcutFoundSearch')
+ $null = $foundSearch.SetAttribute('Root', 'HKCU')
+ $null = $foundSearch.SetAttribute('Key', "Software\$Manufacturer\$AppName")
+ $null = $foundSearch.SetAttribute('Name', 'desktop')
+ $null = $foundSearch.SetAttribute('Type', 'raw')
+ $null = $foundProp.AppendChild($foundSearch)
+ $null = $package.AppendChild($foundProp)
+
+ # Checkbox off on reinstall must not drop an icon that is already there.
+ # Major upgrade removes the previous shortcut; this installs that one again.
+ $keepShortcut = $xml.CreateElement('SetProperty', $ns)
+ $null = $keepShortcut.SetAttribute('Id', 'INSTALLDESKTOPSHORTCUT')
+ $null = $keepShortcut.SetAttribute('Value', '1')
+ $null = $keepShortcut.SetAttribute('Before', 'CostFinalize')
+ $null = $keepShortcut.SetAttribute('Condition', 'DESKTOPSHORTCUTFOUND = "1"')
+ $null = $package.AppendChild($keepShortcut)
+
$productFolder = Get-DesktopInstallFolderName `
-AppName $AppName `
-Manufacturer $Manufacturer `
@@ -449,7 +474,8 @@ function New-WixPackageXml {
$desktop = $xml.CreateElement('Component', $ns)
$null = $desktop.SetAttribute('Id', 'DesktopShortcut')
$null = $desktop.SetAttribute('Directory', 'DesktopFolder')
- $null = $desktop.SetAttribute('Guid', '*')
+ $shortcutGuid = Get-DesktopShortcutComponentGuid -Manufacturer $Manufacturer -AppName $AppName
+ $null = $desktop.SetAttribute('Guid', $shortcutGuid.ToString('D'))
$null = $desktop.SetAttribute('Condition', 'INSTALLDESKTOPSHORTCUT = 1')
$desktopShortcut = $xml.CreateElement('Shortcut', $ns)
$null = $desktopShortcut.SetAttribute('Id', 'AppDesktopShortcut')
@@ -461,6 +487,26 @@ function New-WixPackageXml {
}
$null = $desktop.AppendChild($desktopShortcut)
+ # Same file name is replaced. A second ".lnk" (and the "(2)" copy) is removed
+ # before CreateShortcuts so a checked box does not leave two desktop icons.
+ $cleanupNames = [System.Collections.Generic.List[string]]::new()
+ $cleanupNames.Add($productFolder)
+ if (-not $cleanupNames.Contains($AppName)) {
+ $cleanupNames.Add($AppName)
+ }
+
+ $cleanupIndex = 0
+ foreach ($shortcutName in $cleanupNames) {
+ foreach ($suffix in @('', ' (2)')) {
+ $cleanupIndex++
+ $removeLnk = $xml.CreateElement('RemoveFile', $ns)
+ $null = $removeLnk.SetAttribute('Id', "DesktopShortcutCleanup$cleanupIndex")
+ $null = $removeLnk.SetAttribute('Name', "$shortcutName$suffix.lnk")
+ $null = $removeLnk.SetAttribute('On', 'install')
+ $null = $desktop.AppendChild($removeLnk)
+ }
+ }
+
$desktopReg = $xml.CreateElement('RegistryValue', $ns)
$null = $desktopReg.SetAttribute('Root', 'HKCU')
$null = $desktopReg.SetAttribute('Key', "Software\$Manufacturer\$AppName")
@@ -874,6 +920,27 @@ function Assert-FlatpakAppId {
}
}
+function Get-DesktopShortcutComponentGuid {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$Manufacturer,
+
+ [Parameter(Mandatory = $true)]
+ [string]$AppName
+ )
+
+ $identity = "maksit-desktop-shortcut|$Manufacturer|$AppName"
+ $md5 = [System.Security.Cryptography.MD5]::Create()
+ try {
+ $hash = $md5.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($identity))
+ }
+ finally {
+ $md5.Dispose()
+ }
+
+ return [guid]::new($hash)
+}
+
function Get-DerivedBundleUpgradeCode {
param(
[Parameter(Mandatory = $true)]
@@ -1196,15 +1263,18 @@ function New-WixBundleXml {
$folderRoot + $Manufacturer + '\' + $productFolder
}
$escapedFolder = [System.Security.SecurityElement]::Escape($folderPath)
+ $escapedShortcutKey = [System.Security.SecurityElement]::Escape("Software\$Manufacturer\$AppName")
# WiX v7 Bundle has no Scope attribute (WIX0004). MSI Package/@Scope plus
# InstallFolder tokens decide per-machine vs per-user; Burn infers bundle scope.
return @"
-
+
+
+
@@ -1212,6 +1282,7 @@ function New-WixBundleXml {
+
diff --git a/utils/plugins/Desktop/WindowsInstaller.psm1 b/utils/plugins/Desktop/WindowsInstaller.psm1
index ce27788..8a4a083 100644
--- a/utils/plugins/Desktop/WindowsInstaller.psm1
+++ b/utils/plugins/Desktop/WindowsInstaller.psm1
@@ -13,7 +13,8 @@
per-machine installs go to `C:\Program Files`, not Program Files (x86).
Requires the WiX CLI (`dotnet tool install -g wix`). WiX v7: accept the
OSMF EULA (`wix eula accept wix7` or `-acceptEula wix7`) and
- `wix extension add -g WixToolset.BootstrapperApplications.wixext`.
+ `wix extension add -g WixToolset.BootstrapperApplications.wixext` and
+ `wix extension add -g WixToolset.Util.wixext`.
Reopen the shell so `%USERPROFILE%\.dotnet\tools` is on PATH.
#>
@@ -40,7 +41,7 @@ function Test-WixMissingException {
}
function Get-WixMissingInstallMessage {
- return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
+ return "WiX CLI ('wix') is not on PATH. Install with: dotnet tool install -g wix. For WiX v7 also run: wix eula accept wix7; wix extension add -g WixToolset.BootstrapperApplications.wixext; wix extension add -g WixToolset.Util.wixext. Reopen the terminal so %USERPROFILE%\.dotnet\tools is on PATH."
}
function Get-WixCliVersionText {
@@ -280,7 +281,7 @@ function Invoke-Plugin {
Write-Log -Level "STEP" -Message "Building Windows installer exe ($wixArch)..."
try {
- Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-o', $exePath)) | Out-Null
+ Invoke-ExternalCommand -Name wix -ArgumentList (@('build') + $eulaArgs + $archArgs + @($bundleWxsPath, '-ext', $bundleExt, '-ext', 'WixToolset.Util.wixext', '-o', $exePath)) | Out-Null
}
catch {
if (Test-WixMissingException -ErrorRecord $_) {