Merge pull request #8073 from erphenheimer/fix/8072-windows-shortcut-root-icon

fix(windows): repair legacy root-level shortcut icon and target / 修复旧版根目录快捷方式
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 03:25:45 +08:00
commit a1e23402e9
2 files changed
+110 -13

No files matched your search

+43 -8
View File
@@ -87,6 +87,9 @@ func repairExistingWindowsShortcuts(paths []string, launcher string, repair func
}
func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
root := filepath.Dir(filepath.Clean(strings.TrimSpace(launcher)))
versionedLayout := installlayout.HasCurrent(root)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
@@ -130,7 +133,7 @@ func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) {
}
iconLocation := iconValue.ToString()
_ = iconValue.Clear()
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher)
repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher, versionedLayout)
if !repointTarget && !fixIcon {
return false, nil
}
@@ -205,13 +208,32 @@ func reasonixWindowsVersionedTarget(target, launcher string) bool {
// repairWindowsShortcutPlan decides which owned-shortcut properties need
// rewriting. repointTarget is true when TargetPath points into a versioned
// directory the updater can delete; fixIcon is true when IconLocation points
// at the versioned desktop binary instead of the stable launcher.
func repairWindowsShortcutPlan(target, iconLocation, launcher string) (repointTarget, fixIcon bool) {
return reasonixWindowsVersionedTarget(target, launcher), reasonixWindowsStaleIcon(iconLocation, launcher)
// directory the updater can delete or at a legacy root-level desktop binary
// after the versioned layout is active; fixIcon applies the same policy to
// IconLocation. Flat installs keep their live root-level binary untouched.
func repairWindowsShortcutPlan(target, iconLocation, launcher string, versionedLayout bool) (repointTarget, fixIcon bool) {
repointTarget = reasonixWindowsVersionedTarget(target, launcher) ||
reasonixWindowsFlatDesktopTarget(target, launcher, versionedLayout)
return repointTarget, reasonixWindowsStaleIcon(iconLocation, launcher, versionedLayout)
}
func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
// reasonixWindowsFlatDesktopTarget reports whether target points at the legacy
// root-level desktop binary after versioned layout activation or after the file
// disappeared. A valid current.json is the commit point, so a leftover flat
// binary is not a live flat install when best-effort cleanup could not remove it.
func reasonixWindowsFlatDesktopTarget(target, launcher string, versionedLayout bool) bool {
flat := filepath.Join(filepath.Dir(filepath.Clean(strings.TrimSpace(launcher))), "reasonix-desktop.exe")
if !strings.EqualFold(filepath.Clean(strings.TrimSpace(target)), flat) {
return false
}
if versionedLayout {
return true
}
_, err := os.Lstat(flat)
return os.IsNotExist(err)
}
func reasonixWindowsStaleIcon(iconLocation, launcher string, versionedLayout bool) bool {
iconPath := strings.TrimSpace(iconLocation)
if comma := strings.LastIndex(iconPath, ","); comma >= 0 {
if _, err := strconv.Atoi(strings.TrimSpace(iconPath[comma+1:])); err == nil {
@@ -228,8 +250,21 @@ func reasonixWindowsStaleIcon(iconLocation, launcher string) bool {
return false
}
parts := strings.Split(rel, string(filepath.Separator))
return len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
strings.EqualFold(parts[2], "reasonix-desktop.exe")
if len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
strings.EqualFold(parts[2], "reasonix-desktop.exe") {
return true
}
// Legacy root-level icon: stale once the versioned layout is committed, even
// if best-effort cleanup left the old binary behind. Without current.json,
// require the file to be gone so a live flat install keeps its working icon.
if len(parts) == 1 && strings.EqualFold(parts[0], "reasonix-desktop.exe") {
if versionedLayout {
return true
}
_, err := os.Lstat(filepath.Clean(iconPath))
return os.IsNotExist(err)
}
return false
}
func notifyWindowsShortcutChanged(path string) {
+67 -5
View File
@@ -73,9 +73,13 @@ func TestReasonixWindowsShortcutTargetRequiresCurrentInstall(t *testing.T) {
}
}
func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
root := filepath.Join(`C:\Program Files, Inc`, "Reasonix")
func TestReasonixWindowsStaleIcon(t *testing.T) {
root := filepath.Join(t.TempDir(), "Program Files, Inc", "Reasonix")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
launcher := filepath.Join(root, "reasonix-launcher.exe")
flat := filepath.Join(root, "reasonix-desktop.exe")
tests := []struct {
name string
icon string
@@ -83,6 +87,7 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
}{
{name: "versioned", icon: filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: true},
{name: "quoted versioned", icon: `"` + filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe") + `", 0`, want: true},
{name: "legacy root-level (file gone)", icon: flat + ",0", want: true},
{name: "stable launcher", icon: launcher + ",0", want: false},
{name: "custom icon", icon: filepath.Join(root, "custom.ico") + ",0", want: false},
{name: "other install", icon: filepath.Join(`D:\Apps`, "Reasonix", "versions", "v1.19.3", "reasonix-desktop.exe") + ",0", want: false},
@@ -90,11 +95,55 @@ func TestReasonixWindowsStaleIconOnlyMatchesVersionedDesktop(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := reasonixWindowsStaleIcon(tt.icon, launcher); got != tt.want {
if got := reasonixWindowsStaleIcon(tt.icon, launcher, false); got != tt.want {
t.Fatalf("reasonixWindowsStaleIcon(%q, %q) = %v, want %v", tt.icon, launcher, got, tt.want)
}
})
}
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if reasonixWindowsStaleIcon(flat+",0", launcher, false) {
t.Fatal("live flat icon = stale, want healthy")
}
if !reasonixWindowsStaleIcon(flat+",0", launcher, true) {
t.Fatal("versioned layout with leftover flat icon = healthy, want stale")
}
}
func TestReasonixWindowsFlatDesktopTargetNeedsMissingFile(t *testing.T) {
root := t.TempDir()
launcher := filepath.Join(root, "reasonix-launcher.exe")
flat := filepath.Join(root, "reasonix-desktop.exe")
// No file on disk: the legacy root-level target dangles after the
// versioned-layout migration and must be repointed.
if !reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
t.Fatalf("missing flat desktop target = false, want true")
}
// A live flat install still has the binary; its shortcut must be left alone.
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if reasonixWindowsFlatDesktopTarget(flat, launcher, false) {
t.Fatalf("existing flat desktop target = true, want false")
}
if !reasonixWindowsFlatDesktopTarget(flat, launcher, true) {
t.Fatalf("versioned layout with leftover flat desktop target = false, want true")
}
// Non-flat targets never match.
for _, target := range []string{
launcher,
filepath.Join(root, "Reasonix.exe"),
filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe"),
} {
if reasonixWindowsFlatDesktopTarget(target, launcher, true) {
t.Fatalf("reasonixWindowsFlatDesktopTarget(%q) = true, want false", target)
}
}
}
func TestReasonixWindowsVersionedTarget(t *testing.T) {
@@ -125,7 +174,7 @@ func TestReasonixWindowsVersionedTarget(t *testing.T) {
}
func TestRepairWindowsShortcutPlan(t *testing.T) {
root := filepath.Join(`C:\Program Files`, "Reasonix")
root := t.TempDir()
launcher := filepath.Join(root, "reasonix-launcher.exe")
versioned := filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe")
tests := []struct {
@@ -139,14 +188,27 @@ func TestRepairWindowsShortcutPlan(t *testing.T) {
{name: "versioned target + clean icon", target: versioned, icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
{name: "stable target + versioned icon", target: launcher, icon: versioned + ",0", wantRepoint: false, wantFixIcon: true},
{name: "stable target + clean icon", target: launcher, icon: launcher + ",0", wantRepoint: false, wantFixIcon: false},
{name: "flat target + flat icon (both gone)", target: filepath.Join(root, "reasonix-desktop.exe"), icon: filepath.Join(root, "reasonix-desktop.exe") + ",0", wantRepoint: true, wantFixIcon: true},
{name: "flat target + clean icon", target: filepath.Join(root, "reasonix-desktop.exe"), icon: launcher + ",0", wantRepoint: true, wantFixIcon: false},
{name: "custom target + custom icon", target: filepath.Join(root, "custom.ico"), icon: filepath.Join(root, "custom.ico") + ",0", wantRepoint: false, wantFixIcon: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher)
repoint, fixIcon := repairWindowsShortcutPlan(tt.target, tt.icon, launcher, false)
if repoint != tt.wantRepoint || fixIcon != tt.wantFixIcon {
t.Fatalf("repairWindowsShortcutPlan(%q, %q) = (%v, %v), want (%v, %v)", tt.target, tt.icon, repoint, fixIcon, tt.wantRepoint, tt.wantFixIcon)
}
})
}
flat := filepath.Join(root, "reasonix-desktop.exe")
if err := os.WriteFile(flat, []byte("binary"), 0o600); err != nil {
t.Fatal(err)
}
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, false); repoint || fixIcon {
t.Fatalf("live flat plan = (%v, %v), want (false, false)", repoint, fixIcon)
}
if repoint, fixIcon := repairWindowsShortcutPlan(flat, flat+",0", launcher, true); !repoint || !fixIcon {
t.Fatalf("versioned layout leftover plan = (%v, %v), want (true, true)", repoint, fixIcon)
}
}