| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "strconv" |
| 12 | "strings" |
| 13 | "unsafe" |
| 14 | |
| 15 | "github.com/go-ole/go-ole" |
| 16 | "github.com/go-ole/go-ole/oleutil" |
| 17 | "golang.org/x/sys/windows" |
| 18 | |
| 19 | "reasonix/internal/installlayout" |
| 20 | ) |
| 21 | |
| 22 | var ( |
| 23 | windowsKnownFolderPath = windows.KnownFolderPath |
| 24 | windowsRepairShortcut = repairWindowsShortcut |
| 25 | windowsNotifyShortcutChange = notifyWindowsShortcutChanged |
| 26 | ) |
| 27 | |
| 28 | func repairDesktopIconIntegration() error { |
| 29 | executable, err := os.Executable() |
| 30 | if err != nil { |
| 31 | return nil |
| 32 | } |
| 33 | installRoot, err := installlayout.ResolveInstallRoot(executable) |
| 34 | if err != nil || installRoot == "" { |
| 35 | return nil |
| 36 | } |
| 37 | launcher := filepath.Join(installRoot, "reasonix-launcher.exe") |
| 38 | if info, err := os.Lstat(launcher); err != nil || !info.Mode().IsRegular() { |
| 39 | return nil |
| 40 | } |
| 41 | paths, err := reasonixWindowsShortcutPaths() |
| 42 | if err != nil { |
| 43 | return err |
| 44 | } |
| 45 | return repairExistingWindowsShortcuts(paths, launcher, windowsRepairShortcut) |
| 46 | } |
| 47 | |
| 48 | func reasonixWindowsShortcutPaths() ([]string, error) { |
| 49 | desktop, desktopErr := windowsKnownFolderPath(windows.FOLDERID_Desktop, windows.KF_FLAG_DEFAULT) |
| 50 | programs, programsErr := windowsKnownFolderPath(windows.FOLDERID_Programs, windows.KF_FLAG_DEFAULT) |
| 51 | if desktopErr != nil || programsErr != nil { |
| 52 | return nil, errors.Join(desktopErr, programsErr) |
| 53 | } |
| 54 | return []string{ |
| 55 | filepath.Join(desktop, "Reasonix.lnk"), |
| 56 | filepath.Join(programs, "Reasonix.lnk"), |
| 57 | }, nil |
| 58 | } |
| 59 | |
| 60 | func repairExistingWindowsShortcuts(paths []string, launcher string, repair func(string, string) (bool, error)) error { |
| 61 | var repairErr error |
| 62 | for _, path := range paths { |
| 63 | info, err := os.Lstat(path) |
| 64 | if err != nil { |
| 65 | if !os.IsNotExist(err) { |
| 66 | repairErr = errors.Join(repairErr, err) |
| 67 | } |
| 68 | continue |
| 69 | } |
| 70 | if !info.Mode().IsRegular() { |
| 71 | continue |
| 72 | } |
| 73 | repaired, err := repair(path, launcher) |
| 74 | if err != nil { |
| 75 | repairErr = errors.Join(repairErr, fmt.Errorf("%s: %w", path, err)) |
| 76 | continue |
| 77 | } |
| 78 | if repaired { |
| 79 | windowsNotifyShortcutChange(path) |
| 80 | } |
| 81 | } |
| 82 | return repairErr |
| 83 | } |
| 84 | |
| 85 | func repairWindowsShortcut(shortcutPath, launcher string) (bool, error) { |
| 86 | runtime.LockOSThread() |
| 87 | defer runtime.UnlockOSThread() |
| 88 | if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil { |
| 89 | return false, err |
| 90 | } |
| 91 | defer ole.CoUninitialize() |
| 92 | |
| 93 | unknown, err := oleutil.CreateObject("WScript.Shell") |
| 94 | if err != nil { |
| 95 | return false, err |
| 96 | } |
| 97 | defer unknown.Release() |
| 98 | shell, err := unknown.QueryInterface(ole.IID_IDispatch) |
| 99 | if err != nil { |
| 100 | return false, err |
| 101 | } |
| 102 | defer shell.Release() |
| 103 | created, err := oleutil.CallMethod(shell, "CreateShortcut", shortcutPath) |
| 104 | if err != nil { |
| 105 | return false, err |
| 106 | } |
| 107 | shortcut := created.ToIDispatch() |
| 108 | if shortcut == nil { |
| 109 | _ = created.Clear() |
| 110 | return false, fmt.Errorf("WScript.Shell returned no shortcut object") |
| 111 | } |
| 112 | defer shortcut.Release() |
| 113 | |
| 114 | targetValue, err := oleutil.GetProperty(shortcut, "TargetPath") |
| 115 | if err != nil { |
| 116 | return false, fmt.Errorf("read TargetPath: %w", err) |
| 117 | } |
| 118 | target := targetValue.ToString() |
| 119 | _ = targetValue.Clear() |
| 120 | if !reasonixWindowsShortcutTarget(target, launcher) { |
| 121 | return false, nil |
| 122 | } |
| 123 | iconValue, err := oleutil.GetProperty(shortcut, "IconLocation") |
| 124 | if err != nil { |
| 125 | return false, fmt.Errorf("read IconLocation: %w", err) |
| 126 | } |
| 127 | iconLocation := iconValue.ToString() |
| 128 | _ = iconValue.Clear() |
| 129 | repointTarget, fixIcon := repairWindowsShortcutPlan(target, iconLocation, launcher) |
| 130 | if !repointTarget && !fixIcon { |
| 131 | return false, nil |
| 132 | } |
| 133 | if repointTarget { |
| 134 | // A version-scoped target points into versions/<v>/reasonix-desktop.exe, |
| 135 | // which the updater deletes when it switches or prunes versions. Repoint |
| 136 | // it at the stable launcher so the shortcut survives updates. |
| 137 | result, err := oleutil.PutProperty(shortcut, "TargetPath", launcher) |
| 138 | if result != nil { |
| 139 | _ = result.Clear() |
| 140 | } |
| 141 | if err != nil { |
| 142 | return false, fmt.Errorf("set TargetPath: %w", err) |
| 143 | } |
| 144 | result, err = oleutil.PutProperty(shortcut, "WorkingDirectory", filepath.Dir(launcher)) |
| 145 | if result != nil { |
| 146 | _ = result.Clear() |
| 147 | } |
| 148 | if err != nil { |
| 149 | return false, fmt.Errorf("set WorkingDirectory: %w", err) |
| 150 | } |
| 151 | } |
| 152 | if fixIcon { |
| 153 | result, err := oleutil.PutProperty(shortcut, "IconLocation", launcher+",0") |
| 154 | if result != nil { |
| 155 | _ = result.Clear() |
| 156 | } |
| 157 | if err != nil { |
| 158 | return false, fmt.Errorf("set IconLocation: %w", err) |
| 159 | } |
| 160 | } |
| 161 | result, err := oleutil.CallMethod(shortcut, "Save") |
| 162 | if result != nil { |
| 163 | _ = result.Clear() |
| 164 | } |
| 165 | return err == nil, err |
| 166 | } |
| 167 | |
| 168 | func reasonixWindowsShortcutTarget(target, launcher string) bool { |
| 169 | target = filepath.Clean(strings.TrimSpace(target)) |
| 170 | launcher = filepath.Clean(strings.TrimSpace(launcher)) |
| 171 | if target == "." || launcher == "." { |
| 172 | return false |
| 173 | } |
| 174 | root := filepath.Dir(launcher) |
| 175 | for _, owned := range []string{ |
| 176 | launcher, |
| 177 | filepath.Join(root, "Reasonix.exe"), |
| 178 | filepath.Join(root, "reasonix-desktop.exe"), |
| 179 | } { |
| 180 | if strings.EqualFold(target, owned) { |
| 181 | return true |
| 182 | } |
| 183 | } |
| 184 | return reasonixWindowsVersionedTarget(target, launcher) |
| 185 | } |
| 186 | |
| 187 | // reasonixWindowsVersionedTarget reports whether target points into this |
| 188 | // install's versions/<version>/reasonix-desktop.exe, a version-scoped path |
| 189 | // that the updater deletes when it switches or prunes versions. Such targets |
| 190 | // dangle after an update, so repair must repoint them at the stable launcher. |
| 191 | func reasonixWindowsVersionedTarget(target, launcher string) bool { |
| 192 | root := filepath.Dir(filepath.Clean(strings.TrimSpace(launcher))) |
| 193 | rel, err := filepath.Rel(root, filepath.Clean(strings.TrimSpace(target))) |
| 194 | if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| 195 | return false |
| 196 | } |
| 197 | parts := strings.Split(rel, string(filepath.Separator)) |
| 198 | return len(parts) == 3 && strings.EqualFold(parts[0], "versions") && |
| 199 | strings.EqualFold(parts[2], "reasonix-desktop.exe") |
| 200 | } |
| 201 | |
| 202 | // repairWindowsShortcutPlan decides which owned-shortcut properties need |
| 203 | // rewriting. repointTarget is true when TargetPath points into a versioned |
| 204 | // directory the updater can delete; fixIcon is true when IconLocation points |
| 205 | // at the versioned desktop binary instead of the stable launcher. |
| 206 | func repairWindowsShortcutPlan(target, iconLocation, launcher string) (repointTarget, fixIcon bool) { |
| 207 | return reasonixWindowsVersionedTarget(target, launcher), reasonixWindowsStaleIcon(iconLocation, launcher) |
| 208 | } |
| 209 | |
| 210 | func reasonixWindowsStaleIcon(iconLocation, launcher string) bool { |
| 211 | iconPath := strings.TrimSpace(iconLocation) |
| 212 | if comma := strings.LastIndex(iconPath, ","); comma >= 0 { |
| 213 | if _, err := strconv.Atoi(strings.TrimSpace(iconPath[comma+1:])); err == nil { |
| 214 | iconPath = iconPath[:comma] |
| 215 | } |
| 216 | } |
| 217 | iconPath = strings.Trim(strings.TrimSpace(iconPath), `"`) |
| 218 | if iconPath == "" { |
| 219 | return false |
| 220 | } |
| 221 | root := filepath.Dir(filepath.Clean(strings.TrimSpace(launcher))) |
| 222 | rel, err := filepath.Rel(root, filepath.Clean(iconPath)) |
| 223 | if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { |
| 224 | return false |
| 225 | } |
| 226 | parts := strings.Split(rel, string(filepath.Separator)) |
| 227 | return len(parts) == 3 && strings.EqualFold(parts[0], "versions") && |
| 228 | strings.EqualFold(parts[2], "reasonix-desktop.exe") |
| 229 | } |
| 230 | |
| 231 | func notifyWindowsShortcutChanged(path string) { |
| 232 | pathPtr, err := windows.UTF16PtrFromString(path) |
| 233 | if err != nil { |
| 234 | return |
| 235 | } |
| 236 | // SHCNE_UPDATEITEM + SHCNF_PATHW asks Explorer to invalidate only this |
| 237 | // shortcut instead of flushing the entire association cache. |
| 238 | const ( |
| 239 | shcneUpdateItem = 0x00002000 |
| 240 | shcnfPathW = 0x0005 |
| 241 | ) |
| 242 | proc := windows.NewLazySystemDLL("shell32.dll").NewProc("SHChangeNotify") |
| 243 | _, _, _ = proc.Call(shcneUpdateItem, shcnfPathW, uintptr(unsafe.Pointer(pathPtr)), 0) |
| 244 | } |
| 245 |