fix(desktop): repair Windows uninstall registration / 修复 Windows 卸载注册项 (#7729)

* fix(desktop): repair Windows uninstall registration

Problem:
- Users upgrading from legacy 0.53 builds can retain a stale Apps & features entry whose uninstaller removes the current installation.
- Versioned Windows updates do not refresh the registered display version.

Root cause:
- The legacy Tauri and current Wails installers use different HKCU uninstall-key identities.
- Staged NSIS updates intentionally skip registry writes and left metadata stale.

Fix:
- Reuse and migrate a legacy install root only when its uninstaller is present, then delete the legacy alias only after writing the current entry.
- Reconcile owned Wails registrations after version activation while leaving portable, unrelated, and legacy-only trees untouched.
- Add focused migration, ownership, installer-ordering, and update-handoff coverage.

Verification:
- cd desktop && go test ./...
- GOOS=windows GOARCH=amd64 go test -c ./internal/winuninstall
- GOOS=windows GOARCH=amd64 go test -c ./cmd/update-helper
- git diff --check

* Prevent legacy installer from reclaiming current install

Problem: Re-running the Tauri 0.53 installer after a migration could overwrite the current Reasonix uninstaller and recreate the stale uninstall entry.

Root cause: Tauri persisted its install root in a separate manufacturer/product registry value that the migration left behind.

Fix: Clear only a same-root legacy default value before deleting the old uninstall alias, preserve named values and separate legacy roots, and cover the registry and NSIS ordering contracts.

Verification: desktop go test ./...; focused race test; Windows amd64 test cross-compiles; makensis macro smoke compile; git diff --check.
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-06 17:37:42 +08:00
1 parent fbab42452a
commit 6c22ae0a3f
9 files changed
+617 -9

No files matched your search

@@ -103,6 +103,8 @@ OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the inst
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"
!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"
!define REASONIX_UNLOCK_RETRIES 60
Var ReasonixUpdateMode
Var ReasonixStageMode
@@ -124,7 +126,11 @@ ShowInstDetails show # This will always show the installation details.
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
!if /FileExists "${REASONIX_LAUNCHER}"
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${REASONIX_LAUNCHER}"
!else
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
!endif
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
# Persist the resolved install path so a subsequent update picks it up
@@ -140,6 +146,48 @@ ShowInstDetails show # This will always show the installation details.
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
!macroend
; Tauri 0.53 separately persisted $INSTDIR under its manufacturer/product key
; and restores that value before every later install. Clear only a same-root
; value so re-running 0.53 cannot overwrite the current uninstaller; preserve a
; genuinely separate legacy installation. If this cleanup fails, retain the old
; uninstall alias so a later update can retry the migration.
!macro reasonix.deleteLegacyInstallerStateIfOwned
StrCpy $1 "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} $0 == "$INSTDIR"
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${ElseIf} $0 == "$\"$INSTDIR$\""
ClearErrors
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
${If} ${Errors}
StrCpy $1 "0"
${EndIf}
${EndIf}
${If} $1 == "1"
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
${If} $0 == "$INSTDIR"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${Else}
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
${If} $0 == "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${ElseIf} $0 == "$\"$INSTDIR\uninstall.exe$\""
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
${EndIf}
${EndIf}
${EndIf}
!macroend
!macro reasonix.deleteUninstaller
Delete "$INSTDIR\uninstall.exe"
DeleteRegKey HKCU "${UNINST_KEY}"
@@ -176,8 +224,44 @@ reasonix_stage_mode_done:
StrCmp $INSTDIR "" 0 done
ClearErrors
ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
IfErrors legacy_location
StrCmp $0 "" legacy_location
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" legacy_location done
legacy_location:
; Tauri 0.53 used a different uninstall key and may have stored the selected
; directory with surrounding quotes (for example "D:\Reasonix"). Reuse it
; only while its uninstaller still exists so a stale registry value cannot
; redirect the repair installer into an unrelated directory.
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
IfErrors legacy_uninstaller
StrCmp $0 "" legacy_uninstaller
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_location_ready
StrCpy $0 $0 -1 1
legacy_location_ready:
IfFileExists "$0\uninstall.exe" 0 legacy_uninstaller
StrCpy $INSTDIR $0
Goto done
legacy_uninstaller:
ClearErrors
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
IfErrors fallback
StrCmp $0 "" fallback
StrCpy $1 $0 1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $1 $0 1 -1
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
StrCpy $0 $0 -1 1
legacy_uninstaller_ready:
IfFileExists "$0" 0 fallback
${GetParent} "$0" $INSTDIR
StrCmp $INSTDIR "" fallback done
@@ -382,6 +466,7 @@ reasonix_layout_activated:
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
reasonix_section_done:
SectionEnd
@@ -408,6 +493,7 @@ Section "uninstall"
!insertmacro wails.unassociateCustomProtocols
!insertmacro reasonix.deleteUninstaller
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
; Only remove the installation directory if it is empty to prevent data loss
RMDir $INSTDIR
+17 -9
View File
@@ -19,6 +19,7 @@ import (
"golang.org/x/sys/windows"
"reasonix/desktop/internal/winuninstall"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
@@ -26,15 +27,16 @@ import (
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
)
func main() {
@@ -287,6 +289,12 @@ func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, i
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
// The release is already active and must not be rolled back for stale
// Add/Remove Programs metadata. A later update or full installer retries
// this idempotent reconciliation.
logger.Printf("reconcile Windows uninstall registration: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
@@ -115,12 +115,14 @@ func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
@@ -131,6 +133,14 @@ func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
reconciledVersion := ""
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
if root != installDir {
t.Fatalf("reconcile root = %q, want %q", root, installDir)
}
reconciledVersion = targetVersion
return true, nil
}
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
@@ -158,6 +168,9 @@ func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
if reconciledVersion != "v1.20.1" {
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
+125
View File
@@ -0,0 +1,125 @@
package winuninstall
import (
"fmt"
"strings"
"golang.org/x/mod/semver"
)
const (
CurrentKeyName = "ReasonixReasonix"
LegacyKeyName = "Reasonix"
)
type Registration struct {
DisplayName string
DisplayVersion string
Publisher string
InstallLocation string
DisplayIcon string
UninstallString string
QuietUninstallString string
}
type ReconcilePlan struct {
Managed bool
Desired Registration
DeleteLegacy bool
}
// Plan returns the safe per-user uninstall-registration mutation for an
// installed Reasonix tree. Portable trees have no owned registration and no
// root uninstaller, so they deliberately produce a no-op plan.
func Plan(current, legacy *Registration, installRoot, version string, uninstallerPresent bool) (ReconcilePlan, error) {
root := cleanWindowsPath(installRoot)
if root == "" {
return ReconcilePlan{}, fmt.Errorf("Windows install root is empty")
}
version = strings.TrimSpace(version)
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if !semver.IsValid(version) {
return ReconcilePlan{}, fmt.Errorf("Windows install version is invalid")
}
if !uninstallerPresent {
return ReconcilePlan{}, nil
}
currentOwned := registrationOwnsRoot(current, root)
legacyOwned := registrationOwnsRoot(legacy, root)
// A legacy-only key may still point at the Tauri 0.53 uninstaller. The full
// signed installer replaces that binary before migrating the key; the
// update helper must not promote it into the current Wails identity.
if !currentOwned {
return ReconcilePlan{}, nil
}
uninstaller := joinWindowsPath(root, "uninstall.exe")
return ReconcilePlan{
Managed: true,
Desired: Registration{
DisplayName: "Reasonix",
DisplayVersion: strings.TrimPrefix(version, "v"),
Publisher: "Reasonix",
InstallLocation: root,
DisplayIcon: joinWindowsPath(root, "reasonix-launcher.exe"),
UninstallString: quoteWindowsPath(uninstaller),
QuietUninstallString: quoteWindowsPath(uninstaller) + " /S",
},
DeleteLegacy: legacyOwned,
}, nil
}
func registrationOwnsRoot(reg *Registration, root string) bool {
if reg == nil || !strings.EqualFold(strings.TrimSpace(reg.DisplayName), "Reasonix") {
return false
}
if sameWindowsPath(reg.InstallLocation, root) {
return true
}
return sameWindowsPath(uninstallExecutable(reg.UninstallString), joinWindowsPath(root, "uninstall.exe"))
}
func uninstallExecutable(command string) string {
command = strings.TrimSpace(command)
if command == "" {
return ""
}
if command[0] == '"' {
if end := strings.Index(command[1:], `"`); end >= 0 {
return command[1 : end+1]
}
}
if end := strings.IndexAny(command, " \t"); end >= 0 {
return command[:end]
}
return command
}
func sameWindowsPath(left, right string) bool {
left = strings.ToLower(cleanWindowsPath(left))
right = strings.ToLower(cleanWindowsPath(right))
return left != "" && left == right
}
func cleanWindowsPath(value string) string {
value = strings.TrimSpace(value)
if len(value) >= 2 && value[0] == '"' && value[len(value)-1] == '"' {
value = value[1 : len(value)-1]
}
value = strings.ReplaceAll(strings.TrimSpace(value), "/", `\`)
for len(value) > 3 && strings.HasSuffix(value, `\`) {
value = strings.TrimSuffix(value, `\`)
}
return value
}
func joinWindowsPath(root, name string) string {
return cleanWindowsPath(root) + `\` + strings.TrimLeft(strings.ReplaceAll(name, "/", `\`), `\`)
}
func quoteWindowsPath(path string) string {
return `"` + cleanWindowsPath(path) + `"`
}
+109
View File
@@ -0,0 +1,109 @@
package winuninstall
import "testing"
func TestPlanDoesNotPromoteLegacyOnlyRegistrationWithoutManagedWailsInstall(t *testing.T) {
legacy := &Registration{
DisplayName: "Reasonix",
DisplayVersion: "0.53.0",
InstallLocation: `"D:\Reasonix"`,
UninstallString: `"D:\Reasonix\uninstall.exe"`,
}
got, err := Plan(nil, legacy, `D:\Reasonix`, "v1.21.0", true)
if err != nil {
t.Fatal(err)
}
if got.Managed || got.DeleteLegacy {
t.Fatalf("plan = %+v, want the full installer to migrate a legacy-only registration", got)
}
}
func TestPlanRefreshesManagedWailsRegistrationAndDeletesMatchingLegacyAlias(t *testing.T) {
current := &Registration{
DisplayName: "Reasonix",
DisplayVersion: "1.18.0",
InstallLocation: `D:\Reasonix`,
UninstallString: `"D:\Reasonix\uninstall.exe"`,
}
legacy := &Registration{
DisplayName: "Reasonix",
DisplayVersion: "0.53.0",
InstallLocation: `D:\Reasonix`,
UninstallString: `"D:\Reasonix\uninstall.exe"`,
}
got, err := Plan(current, legacy, `d:\reasonix\`, "1.21.0", true)
if err != nil {
t.Fatal(err)
}
if !got.Managed || !got.DeleteLegacy || got.Desired.DisplayVersion != "1.21.0" {
t.Fatalf("plan = %+v, want current registration refresh", got)
}
if got.Desired.InstallLocation != `d:\reasonix` ||
got.Desired.UninstallString != `"d:\reasonix\uninstall.exe"` ||
got.Desired.DisplayIcon != `d:\reasonix\reasonix-launcher.exe` {
t.Fatalf("desired registration = %+v", got.Desired)
}
}
func TestPlanDoesNotRegisterPortableOrUnrelatedInstall(t *testing.T) {
tests := []struct {
name string
current *Registration
legacy *Registration
uninstall bool
}{
{name: "portable", uninstall: false},
{
name: "unrelated legacy install",
legacy: &Registration{
DisplayName: "Reasonix",
InstallLocation: `C:\Other\Reasonix`,
UninstallString: `"C:\Other\Reasonix\uninstall.exe"`,
},
uninstall: true,
},
{
name: "foreign display name",
legacy: &Registration{
DisplayName: "Another App",
InstallLocation: `D:\Reasonix`,
UninstallString: `"D:\Reasonix\uninstall.exe"`,
},
uninstall: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Plan(tt.current, tt.legacy, `D:\Reasonix`, "v1.21.0", tt.uninstall)
if err != nil {
t.Fatal(err)
}
if got.Managed || got.DeleteLegacy {
t.Fatalf("plan = %+v, want no registry mutation", got)
}
})
}
}
func TestPlanRejectsInvalidInputs(t *testing.T) {
managed := &Registration{
DisplayName: "Reasonix",
InstallLocation: `D:\Reasonix`,
UninstallString: `"D:\Reasonix\uninstall.exe"`,
}
for _, tc := range []struct {
root string
version string
}{
{root: "", version: "v1.21.0"},
{root: `D:\Reasonix`, version: ""},
{root: `D:\Reasonix`, version: "dev"},
} {
if _, err := Plan(managed, nil, tc.root, tc.version, true); err == nil {
t.Fatalf("Plan(%q, %q) succeeded, want error", tc.root, tc.version)
}
}
}
@@ -0,0 +1,5 @@
//go:build !windows
package winuninstall
func Reconcile(string, string) (bool, error) { return false, nil }
@@ -0,0 +1,167 @@
//go:build windows
package winuninstall
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/windows/registry"
)
const (
uninstallRegistryBase = `Software\Microsoft\Windows\CurrentVersion\Uninstall\`
legacyProductRegistryPath = `Software\reasonix\Reasonix`
)
// Reconcile refreshes the current Wails per-user registration after a
// successful version activation. It intentionally refuses legacy-only and
// portable trees; those require the full signed installer to establish a
// trustworthy uninstaller first.
func Reconcile(installRoot, version string) (bool, error) {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
if installRoot == "" || installRoot == "." || !filepath.IsAbs(installRoot) {
return false, fmt.Errorf("reconcile Windows uninstall registration: invalid install root")
}
uninstaller := filepath.Join(installRoot, "uninstall.exe")
info, err := os.Lstat(uninstaller)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return false, nil
}
current, err := readRegistration(CurrentKeyName)
if err != nil {
return false, err
}
legacy, err := readRegistration(LegacyKeyName)
if err != nil {
return false, err
}
plan, err := Plan(current, legacy, installRoot, version, true)
if err != nil || !plan.Managed {
return false, err
}
if err := writeRegistration(CurrentKeyName, plan.Desired); err != nil {
return false, err
}
written, err := readRegistration(CurrentKeyName)
if err != nil {
return false, err
}
if !sameRegistration(written, &plan.Desired) {
return false, fmt.Errorf("reconcile Windows uninstall registration: registry verification failed")
}
// Tauri 0.53 stored its install root separately from the Add/Remove
// Programs entry and restores it before every later install. Remove only a
// same-root default value so an old installer cannot overwrite the current
// uninstaller; named values and separate legacy installations remain intact.
if err := deleteInstallLocationIfOwned(legacyProductRegistryPath, installRoot); err != nil {
return false, err
}
if plan.DeleteLegacy {
if err := registry.DeleteKey(registry.CURRENT_USER, uninstallRegistryBase+LegacyKeyName); err != nil && !errors.Is(err, registry.ErrNotExist) {
return false, err
}
}
return true, nil
}
func deleteInstallLocationIfOwned(keyPath, installRoot string) error {
key, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE|registry.SET_VALUE)
if errors.Is(err, registry.ErrNotExist) {
return nil
}
if err != nil {
return err
}
defer key.Close()
location, _, err := key.GetStringValue("")
if errors.Is(err, registry.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !sameWindowsPath(location, installRoot) {
return nil
}
if err := key.DeleteValue(""); err != nil && !errors.Is(err, registry.ErrNotExist) {
return err
}
return nil
}
func readRegistration(name string) (*Registration, error) {
key, err := registry.OpenKey(registry.CURRENT_USER, uninstallRegistryBase+name, registry.QUERY_VALUE)
if errors.Is(err, registry.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer key.Close()
read := func(value string) string {
got, _, valueErr := key.GetStringValue(value)
if valueErr != nil {
return ""
}
return got
}
return &Registration{
DisplayName: read("DisplayName"),
DisplayVersion: read("DisplayVersion"),
Publisher: read("Publisher"),
InstallLocation: read("InstallLocation"),
DisplayIcon: read("DisplayIcon"),
UninstallString: read("UninstallString"),
QuietUninstallString: read("QuietUninstallString"),
}, nil
}
func writeRegistration(name string, value Registration) error {
key, _, err := registry.CreateKey(registry.CURRENT_USER, uninstallRegistryBase+name, registry.QUERY_VALUE|registry.SET_VALUE)
if err != nil {
return err
}
defer key.Close()
for name, value := range map[string]string{
"DisplayName": value.DisplayName,
"DisplayVersion": value.DisplayVersion,
"Publisher": value.Publisher,
"InstallLocation": value.InstallLocation,
"DisplayIcon": value.DisplayIcon,
"UninstallString": value.UninstallString,
"QuietUninstallString": value.QuietUninstallString,
} {
if err := key.SetStringValue(name, value); err != nil {
return err
}
}
if err := key.SetDWordValue("NoModify", 1); err != nil {
return err
}
return key.SetDWordValue("NoRepair", 1)
}
func sameRegistration(left, right *Registration) bool {
if left == nil || right == nil {
return left == right
}
return left.DisplayName == right.DisplayName &&
left.DisplayVersion == right.DisplayVersion &&
left.Publisher == right.Publisher &&
sameWindowsPath(left.InstallLocation, right.InstallLocation) &&
sameWindowsPath(left.DisplayIcon, right.DisplayIcon) &&
sameWindowsPath(uninstallExecutable(left.UninstallString), uninstallExecutable(right.UninstallString)) &&
strings.EqualFold(strings.TrimSpace(left.QuietUninstallString), strings.TrimSpace(right.QuietUninstallString))
}
@@ -0,0 +1,77 @@
//go:build windows
package winuninstall
import (
"errors"
"fmt"
"os"
"testing"
"golang.org/x/sys/windows/registry"
)
func TestDeleteInstallLocationIfOwned(t *testing.T) {
tests := []struct {
name string
location string
wantDelete bool
}{
{name: "same root", location: `D:\Reasonix`, wantDelete: true},
{name: "quoted case variant", location: `"d:\reasonix\"`, wantDelete: true},
{name: "separate legacy install", location: `C:\Legacy\Reasonix`, wantDelete: false},
}
for i, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
keyPath := fmt.Sprintf(`Software\ReasonixTests\winuninstall-%d-%d`, os.Getpid(), i)
key, _, err := registry.CreateKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE|registry.SET_VALUE)
if err != nil {
t.Fatal(err)
}
if err := key.SetStringValue("", tt.location); err != nil {
key.Close()
t.Fatal(err)
}
if err := key.SetStringValue("Installer Language", "1033"); err != nil {
key.Close()
t.Fatal(err)
}
if err := key.Close(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = registry.DeleteKey(registry.CURRENT_USER, keyPath)
})
if err := deleteInstallLocationIfOwned(keyPath, `D:\Reasonix`); err != nil {
t.Fatal(err)
}
key, err = registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err != nil {
t.Fatal(err)
}
defer key.Close()
gotLocation, _, locationErr := key.GetStringValue("")
if tt.wantDelete {
if !errors.Is(locationErr, registry.ErrNotExist) {
t.Fatalf("default install location still exists: value=%q err=%v", gotLocation, locationErr)
}
} else if locationErr != nil || gotLocation != tt.location {
t.Fatalf("separate install location = %q, %v; want %q", gotLocation, locationErr, tt.location)
}
if language, _, err := key.GetStringValue("Installer Language"); err != nil || language != "1033" {
t.Fatalf("named value = %q, %v; want preserved", language, err)
}
})
}
}
func TestDeleteInstallLocationIfOwnedIgnoresMissingKey(t *testing.T) {
keyPath := fmt.Sprintf(`Software\ReasonixTests\missing-%d`, os.Getpid())
_ = registry.DeleteKey(registry.CURRENT_USER, keyPath)
if err := deleteInstallLocationIfOwned(keyPath, `D:\Reasonix`); err != nil {
t.Fatal(err)
}
}
+18
View File
@@ -81,6 +81,8 @@ func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
}
script := string(data)
for _, want := range []string{
`!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"`,
`!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"`,
`!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"`,
`!define REASONIX_GUARD "reasonix-guard.exe"`,
`!define REASONIX_LAUNCHER "reasonix-launcher.exe"`,
@@ -125,6 +127,8 @@ func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
`File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"`,
`Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"`,
`Delete "$INSTDIR\${REASONIX_CLI}"`,
`DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""`,
`!insertmacro reasonix.deleteLegacyInstallerStateIfOwned`,
} {
if !strings.Contains(script, want) {
t.Fatalf("project.nsi missing %q", want)
@@ -150,6 +154,17 @@ func TestWindowsInstallerScriptWaitsBeforeCopyingExecutable(t *testing.T) {
if strings.Contains(script, `FileOpen $0 "$INSTDIR\current.json" w`) {
t.Fatal("normal installer must delegate the current.json commit to the atomic Go activator")
}
writeCurrent := strings.Index(script, `!insertmacro reasonix.writeUninstaller`)
deleteLegacy := strings.Index(script, `!insertmacro reasonix.deleteLegacyInstallerStateIfOwned`)
if writeCurrent < 0 || deleteLegacy < 0 || writeCurrent > deleteLegacy {
t.Fatalf("installer must write the current uninstall entry before reconciling owned legacy state (write=%d delete=%d)", writeCurrent, deleteLegacy)
}
legacyMacro := script[strings.Index(script, `!macro reasonix.deleteLegacyInstallerStateIfOwned`):strings.Index(script, `!macro reasonix.deleteUninstaller`)]
deleteLegacyLocation := strings.Index(legacyMacro, `DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""`)
deleteLegacyAlias := strings.Index(legacyMacro, `DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"`)
if deleteLegacyLocation < 0 || deleteLegacyAlias < 0 || deleteLegacyLocation > deleteLegacyAlias {
t.Fatalf("installer must clear the same-root Tauri install-location breadcrumb before deleting its uninstall alias (location=%d alias=%d)", deleteLegacyLocation, deleteLegacyAlias)
}
metadataBranch := strings.Index(script, `reasonix_stage_payload:`)
metadataFile := strings.Index(script, `File "/oname=${REASONIX_PAYLOAD_MANIFEST}"`)
normalInstall := strings.Index(script, `reasonix_normal_install:`)
@@ -214,6 +229,9 @@ func TestWindowsUpdateRequiresObservedHelperHandoff(t *testing.T) {
t.Fatal(err)
}
helperSource := string(helperData)
if !strings.Contains(helperSource, "reconcileWindowsUninstallRegistrationFn(installDir, toVersion)") {
t.Fatal("versioned Windows activation must refresh its managed uninstall registration")
}
if strings.Contains(helperSource, "installerCommandLine(installer, installDir), HideWindow: true") {
t.Fatal("update helper still hides the NSIS progress window")
}