| 1 | //go:build windows |
| 2 | |
| 3 | package appidentity |
| 4 | |
| 5 | import ( |
| 6 | "os" |
| 7 | "os/exec" |
| 8 | "path/filepath" |
| 9 | "runtime" |
| 10 | "syscall" |
| 11 | "testing" |
| 12 | "unsafe" |
| 13 | |
| 14 | "golang.org/x/sys/windows" |
| 15 | ) |
| 16 | |
| 17 | func TestOwnedShortcutTargetCoversStableAndVersionedEntries(t *testing.T) { |
| 18 | root := t.TempDir() |
| 19 | tests := []struct { |
| 20 | target string |
| 21 | want bool |
| 22 | }{ |
| 23 | {filepath.Join(root, "reasonix-launcher.exe"), true}, |
| 24 | {filepath.Join(root, "Reasonix.exe"), true}, |
| 25 | {filepath.Join(root, "reasonix-desktop.exe"), true}, |
| 26 | {filepath.Join(root, "versions", "v1.20.0", "reasonix-desktop.exe"), true}, |
| 27 | {filepath.Join(root, "versions", "v1.20.0", "reasonix-cli.exe"), false}, |
| 28 | {filepath.Join(`D:\Apps`, "Reasonix", "reasonix-launcher.exe"), false}, |
| 29 | } |
| 30 | for _, test := range tests { |
| 31 | if got := ownedShortcutTarget(test.target, root); got != test.want { |
| 32 | t.Errorf("ownedShortcutTarget(%q, %q) = %v, want %v", test.target, root, got, test.want) |
| 33 | } |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | func TestReasonixShortcutName(t *testing.T) { |
| 38 | tests := []struct { |
| 39 | name string |
| 40 | want bool |
| 41 | }{ |
| 42 | {"Reasonix.lnk", true}, |
| 43 | {"reasonix launcher.LNK", true}, |
| 44 | {"Reasonix (2).lnk", true}, |
| 45 | {"Other.lnk", false}, |
| 46 | {"Reasonix.exe", false}, |
| 47 | } |
| 48 | for _, test := range tests { |
| 49 | if got := reasonixShortcutName(test.name); got != test.want { |
| 50 | t.Errorf("reasonixShortcutName(%q) = %v, want %v", test.name, got, test.want) |
| 51 | } |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | func TestOwnedShortcutTargetAcceptsVersionedDesktopThroughJunction(t *testing.T) { |
| 56 | root := t.TempDir() |
| 57 | versionDir := filepath.Join(root, "versions", "v1.20.0") |
| 58 | if err := os.MkdirAll(versionDir, 0o755); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | if err := os.WriteFile(filepath.Join(versionDir, "reasonix-desktop.exe"), []byte("desktop"), 0o600); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | junction := filepath.Join(t.TempDir(), "current") |
| 65 | if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, root).CombinedOutput(); err != nil { |
| 66 | t.Fatalf("create directory junction: %v: %s", err, output) |
| 67 | } |
| 68 | target := filepath.Join(junction, "versions", "v1.20.0", "reasonix-desktop.exe") |
| 69 | if !ownedShortcutTarget(target, root) { |
| 70 | resolvedRoot, rootErr := existingShortcutPath(root) |
| 71 | resolvedTarget, targetErr := resolveShortcutTarget(target) |
| 72 | linkTarget, linkErr := os.Readlink(junction) |
| 73 | _, statErr := os.Stat(target) |
| 74 | t.Logf("junction value=%q (%v), target stat=%v", linkTarget, linkErr, statErr) |
| 75 | t.Fatalf("junction target %q was not recognised under %q; resolved root=%q (%v), target=%q (%v)", target, root, resolvedRoot, rootErr, resolvedTarget, targetErr) |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestRepairOwnedShortcutPersistsAppUserModelID(t *testing.T) { |
| 80 | root := t.TempDir() |
| 81 | target := filepath.Join(root, "Reasonix.exe") |
| 82 | if err := os.WriteFile(target, []byte("launcher"), 0o600); err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | shortcutPath := filepath.Join(root, "Reasonix.lnk") |
| 86 | |
| 87 | runtime.LockOSThread() |
| 88 | defer runtime.UnlockOSThread() |
| 89 | uninitialize, err := initializeCOM() |
| 90 | if err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | defer uninitialize() |
| 94 | createTestShortcut(t, shortcutPath, target) |
| 95 | |
| 96 | changed, err := repairOwnedShortcut(shortcutPath, root) |
| 97 | if err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | if !changed { |
| 101 | t.Fatal("first repair did not report a change") |
| 102 | } |
| 103 | shortcut, err := loadShortcut(shortcutPath, stgmReadWrite) |
| 104 | if err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | got, err := shortcut.appUserModelID() |
| 108 | shortcut.release() |
| 109 | if err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if got != AppUserModelID { |
| 113 | t.Fatalf("shortcut AppUserModelID = %q, want %q", got, AppUserModelID) |
| 114 | } |
| 115 | changed, err = repairOwnedShortcut(shortcutPath, root) |
| 116 | if err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | if changed { |
| 120 | t.Fatal("second repair rewrote an already healthy shortcut") |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | func TestRepairOwnedShortcutLeavesSeparateReasonix053InstallUntouched(t *testing.T) { |
| 125 | currentRoot := t.TempDir() |
| 126 | legacyRoot := t.TempDir() |
| 127 | legacyTarget := filepath.Join(legacyRoot, "reasonix-desktop.exe") |
| 128 | if err := os.WriteFile(legacyTarget, []byte("legacy tauri desktop"), 0o600); err != nil { |
| 129 | t.Fatal(err) |
| 130 | } |
| 131 | shortcutPath := filepath.Join(t.TempDir(), "Reasonix.lnk") |
| 132 | |
| 133 | runtime.LockOSThread() |
| 134 | defer runtime.UnlockOSThread() |
| 135 | uninitialize, err := initializeCOM() |
| 136 | if err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | defer uninitialize() |
| 140 | createTestShortcut(t, shortcutPath, legacyTarget) |
| 141 | |
| 142 | shortcut, err := loadShortcut(shortcutPath, stgmReadWrite) |
| 143 | if err != nil { |
| 144 | t.Fatal(err) |
| 145 | } |
| 146 | if err := shortcut.setAppUserModelID(legacyTauriAppUserModelID); err != nil { |
| 147 | shortcut.release() |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | shortcut.release() |
| 151 | |
| 152 | changed, err := repairOwnedShortcut(shortcutPath, currentRoot) |
| 153 | if err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | if changed { |
| 157 | t.Fatal("current install rewrote a shortcut owned by a separate Reasonix 0.53 installation") |
| 158 | } |
| 159 | |
| 160 | shortcut, err = loadShortcut(shortcutPath, stgmReadWrite) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | gotTarget, targetErr := shortcut.targetPath() |
| 165 | gotID, idErr := shortcut.appUserModelID() |
| 166 | shortcut.release() |
| 167 | if targetErr != nil { |
| 168 | t.Fatal(targetErr) |
| 169 | } |
| 170 | if idErr != nil { |
| 171 | t.Fatal(idErr) |
| 172 | } |
| 173 | if !migrationSamePath(gotTarget, legacyTarget) { |
| 174 | t.Fatalf("legacy shortcut target = %q, want %q", gotTarget, legacyTarget) |
| 175 | } |
| 176 | if gotID != legacyTauriAppUserModelID { |
| 177 | t.Fatalf("legacy shortcut AppUserModelID = %q, want %q", gotID, legacyTauriAppUserModelID) |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | func createTestShortcut(t *testing.T, shortcutPath, target string) { |
| 182 | t.Helper() |
| 183 | var link *shellLinkW |
| 184 | hr, _, _ := procCoCreateInstance.Call( |
| 185 | uintptr(unsafe.Pointer(&clsidShellLink)), |
| 186 | 0, |
| 187 | clsctxInprocServer, |
| 188 | uintptr(unsafe.Pointer(&iidIShellLinkW)), |
| 189 | uintptr(unsafe.Pointer(&link)), |
| 190 | ) |
| 191 | if err := checkHRESULT("CoCreateInstance(CLSID_ShellLink)", hr); err != nil { |
| 192 | t.Fatal(err) |
| 193 | } |
| 194 | defer releaseInterface(unsafe.Pointer(link)) |
| 195 | targetPtr, err := windows.UTF16PtrFromString(target) |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | hr, _, _ = syscall.SyscallN( |
| 200 | link.VTable.SetPath, |
| 201 | uintptr(unsafe.Pointer(link)), |
| 202 | uintptr(unsafe.Pointer(targetPtr)), |
| 203 | ) |
| 204 | if err := checkHRESULT("IShellLinkW.SetPath", hr); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | var persist *persistFile |
| 208 | if err := queryInterface(unsafe.Pointer(link), &iidIPersistFile, unsafe.Pointer(&persist)); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | defer releaseInterface(unsafe.Pointer(persist)) |
| 212 | shortcutPtr, err := windows.UTF16PtrFromString(shortcutPath) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | hr, _, _ = syscall.SyscallN( |
| 217 | persist.VTable.Save, |
| 218 | uintptr(unsafe.Pointer(persist)), |
| 219 | uintptr(unsafe.Pointer(shortcutPtr)), |
| 220 | 1, |
| 221 | ) |
| 222 | if err := checkHRESULT("IPersistFile.Save", hr); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | } |
| 226 |