mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) + `"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user