fix(windows): unify desktop taskbar identity

Problem:
Pinned Reasonix shortcuts opened the desktop under a second taskbar icon, and shortcut repair needed to coexist with separately installed 0.53 builds.

Root cause:
The permanent launcher, versioned Wails desktop, shortcuts, and toast notifications did not share an explicit AppUserModelID, so Windows derived different identities from their executable paths.

Fix:
Introduce a shared Windows AppUserModelID, apply it before either process creates windows, and repair only shortcuts owned by the current install. Preserve the distinct Tauri 0.53 identity and cover the native COM path in Windows CI.

Verification:
- go test ./...
- go vet ./...
- cd desktop && go test ./...
- cd desktop && go vet ./...
- Windows amd64/arm64 cross-compilation for appidentity and desktop tests
- go run ./tools/repolint
This commit is contained in:
SivanCola committed 2026-08-08 10:53:33 +08:00
1 parent 646f158439
commit de57f7920d
11 files changed
+808 -5

No files matched your search

+1 -1
View File
@@ -145,7 +145,7 @@ jobs:
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
run: go test -p 4 -timeout=3m ./internal/agent/... ./internal/appidentity/... ./internal/boot/... ./internal/checkpoint/... ./internal/cli/... ./internal/control/... ./internal/desktoplauncher/... ./internal/filelock/... ./internal/fileutil/... ./internal/hook/... ./internal/instruction/... ./internal/mcplaunch/... ./internal/notify/... ./internal/proc/... ./internal/remote/... ./internal/repair/... ./internal/sandbox/... ./internal/sysproxy/... ./internal/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/tool.
# Keep the session-temp portability contract covered without widening the
+27
View File
@@ -0,0 +1,27 @@
//go:build windows
package main
import (
"os"
"reasonix/internal/appidentity"
"reasonix/internal/installlayout"
)
func init() {
if err := appidentity.ApplyToCurrentProcess(); err != nil {
println("Warning: apply Windows app identity:", err.Error())
}
executable, err := os.Executable()
if err != nil {
return
}
installRoot, err := installlayout.ResolveInstallRoot(executable)
if err != nil {
return
}
if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
println("Warning: repair Windows shortcut identity:", err.Error())
}
}
+5 -1
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-ole/go-ole/oleutil"
"golang.org/x/sys/windows"
"reasonix/internal/appidentity"
"reasonix/internal/installlayout"
)
@@ -42,7 +43,10 @@ func repairDesktopIconIntegration() error {
if err != nil {
return err
}
return repairExistingWindowsShortcuts(paths, launcher, windowsRepairShortcut)
return errors.Join(
repairExistingWindowsShortcuts(paths, launcher, windowsRepairShortcut),
appidentity.RepairOwnedShortcuts(installRoot),
)
}
func reasonixWindowsShortcutPaths() ([]string, error) {
+1
View File
@@ -60,6 +60,7 @@ func TestReasonixWindowsShortcutTargetRequiresCurrentInstall(t *testing.T) {
{name: "launcher alias", target: filepath.Join(root, "Reasonix.exe"), want: true},
{name: "versioned desktop", target: filepath.Join(root, "versions", "v1.19.3", "reasonix-desktop.exe"), want: true},
{name: "other install", target: filepath.Join(`D:\Apps`, "Reasonix", "reasonix-launcher.exe"), want: false},
{name: "separate 0.53 install", target: filepath.Join(`D:\Legacy`, "Reasonix", "reasonix-desktop.exe"), want: false},
{name: "unrelated app", target: filepath.Join(root, "other.exe"), want: false},
{name: "empty", target: "", want: false},
}
+13
View File
@@ -0,0 +1,13 @@
package appidentity
const (
// AppUserModelID is shared by every current-generation Windows process,
// shortcut, and toast that users perceive as Reasonix. Keep it
// version-independent across upgrades.
AppUserModelID = "Reasonix"
// legacyTauriAppUserModelID was written to Windows shortcuts by Reasonix
// Desktop 0.53. Keep the current identity distinct so separately installed
// Tauri and Wails generations do not merge into one taskbar group.
legacyTauriAppUserModelID = "dev.reasonix.desktop"
)
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package appidentity
func ApplyToCurrentProcess() error { return nil }
func RepairOwnedShortcuts(string) error { return nil }
+21
View File
@@ -0,0 +1,21 @@
package appidentity
import (
"strings"
"testing"
)
func TestAppUserModelIDIsStableAndVersionIndependent(t *testing.T) {
if AppUserModelID != "Reasonix" {
t.Fatalf("AppUserModelID = %q, want stable current-generation identity %q", AppUserModelID, "Reasonix")
}
if strings.ContainsAny(AppUserModelID, " \t\r\n") || len(AppUserModelID) > 128 {
t.Fatalf("invalid AppUserModelID %q", AppUserModelID)
}
}
func TestAppUserModelIDDoesNotMergeLegacyTauriDesktop(t *testing.T) {
if AppUserModelID == legacyTauriAppUserModelID {
t.Fatalf("current AppUserModelID %q must remain distinct from Reasonix Desktop 0.53", AppUserModelID)
}
}
+499
View File
@@ -0,0 +1,499 @@
//go:build windows
package appidentity
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/windows"
)
const (
clsctxInprocServer = 0x1
coinitApartmentThread = 0x2
rpcEChangedMode = 0x80010106
slgpRawPath = 0x4
stgmReadWrite = 0x2
vtLPWSTR = 31
windowsPathBuffer = 32768
)
var (
clsidShellLink = windows.GUID{
Data1: 0x00021401,
Data4: [8]byte{0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46},
}
iidIShellLinkW = windows.GUID{
Data1: 0x000214f9,
Data4: [8]byte{0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46},
}
iidIPersistFile = windows.GUID{
Data1: 0x0000010b,
Data4: [8]byte{0xc0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46},
}
iidIPropertyStore = windows.GUID{
Data1: 0x886d8eeb,
Data2: 0x8cf2,
Data3: 0x4446,
Data4: [8]byte{0x8d, 0x02, 0xcd, 0xba, 0x1d, 0xbd, 0xcf, 0x99},
}
pkeyAppUserModelID = propertyKey{
FormatID: windows.GUID{
Data1: 0x9f4c2855,
Data2: 0x9f79,
Data3: 0x4b39,
Data4: [8]byte{0xa8, 0xd0, 0xe1, 0xd4, 0x2d, 0xe1, 0xd5, 0xf3},
},
PropertyID: 5,
}
ole32 = windows.NewLazySystemDLL("ole32.dll")
propsys = windows.NewLazySystemDLL("propsys.dll")
shell32 = windows.NewLazySystemDLL("shell32.dll")
procCoCreateInstance = ole32.NewProc("CoCreateInstance")
procCoInitializeEx = ole32.NewProc("CoInitializeEx")
procCoUninitialize = ole32.NewProc("CoUninitialize")
procPropVariantClear = ole32.NewProc("PropVariantClear")
procInitPropVariantFromString = propsys.NewProc("InitPropVariantFromString")
procSetCurrentProcessExplicitAppUserModelID = shell32.NewProc("SetCurrentProcessExplicitAppUserModelID")
procSHChangeNotify = shell32.NewProc("SHChangeNotify")
knownFolderPath = windows.KnownFolderPath
)
type propertyKey struct {
FormatID windows.GUID
PropertyID uint32
}
type propVariant struct {
VariantType uint16
Reserved1 uint16
Reserved2 uint16
Reserved3 uint16
Value *uint16
Value2 uintptr
}
type unknownVTable struct {
QueryInterface uintptr
AddRef uintptr
Release uintptr
}
type shellLinkW struct {
VTable *shellLinkWVTable
}
type shellLinkWVTable struct {
unknownVTable
GetPath uintptr
GetIDList uintptr
SetIDList uintptr
GetDescription uintptr
SetDescription uintptr
GetWorkingDirectory uintptr
SetWorkingDirectory uintptr
GetArguments uintptr
SetArguments uintptr
GetHotkey uintptr
SetHotkey uintptr
GetShowCmd uintptr
SetShowCmd uintptr
GetIconLocation uintptr
SetIconLocation uintptr
SetRelativePath uintptr
Resolve uintptr
SetPath uintptr
}
type persistFile struct {
VTable *persistFileVTable
}
type persistFileVTable struct {
unknownVTable
GetClassID uintptr
IsDirty uintptr
Load uintptr
Save uintptr
SaveCompleted uintptr
GetCurFile uintptr
}
type propertyStore struct {
VTable *propertyStoreVTable
}
type propertyStoreVTable struct {
unknownVTable
GetCount uintptr
GetAt uintptr
GetValue uintptr
SetValue uintptr
Commit uintptr
}
type loadedShortcut struct {
link *shellLinkW
persist *persistFile
store *propertyStore
}
func ApplyToCurrentProcess() error {
id, err := windows.UTF16PtrFromString(AppUserModelID)
if err != nil {
return err
}
hr, _, _ := procSetCurrentProcessExplicitAppUserModelID.Call(uintptr(unsafe.Pointer(id)))
return checkHRESULT("SetCurrentProcessExplicitAppUserModelID", hr)
}
func RepairOwnedShortcuts(installRoot string) error {
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
if installRoot == "." || installRoot == "" {
return nil
}
paths, discoveryErr := shortcutCandidates(installRoot)
if len(paths) == 0 {
return discoveryErr
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
uninitialize, err := initializeCOM()
if err != nil {
return errors.Join(discoveryErr, err)
}
defer uninitialize()
var repairErr error
for _, path := range paths {
info, err := os.Lstat(path)
if err != nil {
if !os.IsNotExist(err) && reasonixShortcutName(path) {
repairErr = errors.Join(repairErr, err)
}
continue
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
continue
}
changed, err := repairOwnedShortcut(path, installRoot)
if err != nil {
if reasonixShortcutName(path) {
repairErr = errors.Join(repairErr, fmt.Errorf("%s: %w", path, err))
}
continue
}
if changed {
notifyShortcutChanged(path)
}
}
return errors.Join(discoveryErr, repairErr)
}
func shortcutCandidates(installRoot string) ([]string, error) {
seen := make(map[string]struct{})
paths := make([]string, 0, 8)
add := func(path string) {
path = filepath.Clean(strings.TrimSpace(path))
if path == "." || path == "" {
return
}
key := strings.ToLower(path)
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
paths = append(paths, path)
}
addReasonixLinks := func(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if !entry.IsDir() && reasonixShortcutName(entry.Name()) {
add(filepath.Join(dir, entry.Name()))
}
}
return nil
}
add(filepath.Join(installRoot, "Reasonix.lnk"))
var resultErr error
resultErr = errors.Join(resultErr, addReasonixLinks(installRoot))
for _, folderID := range []*windows.KNOWNFOLDERID{windows.FOLDERID_Desktop, windows.FOLDERID_Programs} {
folder, err := knownFolderPath(folderID, windows.KF_FLAG_DEFAULT)
if err != nil {
resultErr = errors.Join(resultErr, err)
continue
}
add(filepath.Join(folder, "Reasonix.lnk"))
}
roaming, err := knownFolderPath(windows.FOLDERID_RoamingAppData, windows.KF_FLAG_DEFAULT)
if err != nil {
resultErr = errors.Join(resultErr, err)
} else {
pinned := filepath.Join(roaming, "Microsoft", "Internet Explorer", "Quick Launch", "User Pinned", "TaskBar")
resultErr = errors.Join(resultErr, addReasonixLinks(pinned))
}
return paths, resultErr
}
func reasonixShortcutName(path string) bool {
name := filepath.Base(strings.TrimSpace(path))
return strings.EqualFold(filepath.Ext(name), ".lnk") &&
strings.HasPrefix(strings.ToLower(strings.TrimSuffix(name, filepath.Ext(name))), "reasonix")
}
func repairOwnedShortcut(path, installRoot string) (bool, error) {
shortcut, err := loadShortcut(path, stgmReadWrite)
if err != nil {
return false, err
}
defer shortcut.release()
target, err := shortcut.targetPath()
if err != nil {
return false, err
}
if !ownedShortcutTarget(target, installRoot) {
return false, nil
}
currentID, err := shortcut.appUserModelID()
if err != nil {
return false, err
}
if currentID == AppUserModelID {
return false, nil
}
if err := shortcut.setAppUserModelID(AppUserModelID); err != nil {
return false, err
}
return true, nil
}
func ownedShortcutTarget(target, installRoot string) bool {
target = filepath.Clean(strings.TrimSpace(target))
installRoot = filepath.Clean(strings.TrimSpace(installRoot))
if target == "." || target == "" || installRoot == "." || installRoot == "" {
return false
}
for _, candidate := range []string{
filepath.Join(installRoot, "reasonix-launcher.exe"),
filepath.Join(installRoot, "Reasonix.exe"),
filepath.Join(installRoot, "reasonix-desktop.exe"),
} {
if sameWindowsPathOrFile(target, candidate) {
return true
}
}
rel, err := filepath.Rel(installRoot, target)
if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) == 3 && strings.EqualFold(parts[0], "versions") &&
strings.EqualFold(parts[2], "reasonix-desktop.exe") {
return true
}
}
versionDir := filepath.Dir(target)
versionsDir := filepath.Dir(versionDir)
return strings.EqualFold(filepath.Base(target), "reasonix-desktop.exe") &&
strings.EqualFold(filepath.Base(versionsDir), "versions") &&
sameWindowsFile(filepath.Dir(versionsDir), installRoot)
}
func sameWindowsPathOrFile(left, right string) bool {
if strings.EqualFold(filepath.Clean(left), filepath.Clean(right)) {
return true
}
if !strings.EqualFold(filepath.Base(left), filepath.Base(right)) {
return false
}
return sameWindowsFile(left, right)
}
func sameWindowsFile(left, right string) bool {
leftInfo, leftErr := os.Stat(left)
rightInfo, rightErr := os.Stat(right)
return leftErr == nil && rightErr == nil && os.SameFile(leftInfo, rightInfo)
}
func loadShortcut(path string, mode uint32) (*loadedShortcut, error) {
var link *shellLinkW
hr, _, _ := procCoCreateInstance.Call(
uintptr(unsafe.Pointer(&clsidShellLink)),
0,
clsctxInprocServer,
uintptr(unsafe.Pointer(&iidIShellLinkW)),
uintptr(unsafe.Pointer(&link)),
)
if err := checkHRESULT("CoCreateInstance(CLSID_ShellLink)", hr); err != nil {
return nil, err
}
shortcut := &loadedShortcut{link: link}
if err := queryInterface(unsafe.Pointer(link), &iidIPersistFile, unsafe.Pointer(&shortcut.persist)); err != nil {
shortcut.release()
return nil, err
}
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
shortcut.release()
return nil, err
}
hr, _, _ = syscall.SyscallN(
shortcut.persist.VTable.Load,
uintptr(unsafe.Pointer(shortcut.persist)),
uintptr(unsafe.Pointer(pathPtr)),
uintptr(mode),
)
if err := checkHRESULT("IPersistFile.Load", hr); err != nil {
shortcut.release()
return nil, err
}
if err := queryInterface(unsafe.Pointer(link), &iidIPropertyStore, unsafe.Pointer(&shortcut.store)); err != nil {
shortcut.release()
return nil, err
}
return shortcut, nil
}
func (s *loadedShortcut) targetPath() (string, error) {
buffer := make([]uint16, windowsPathBuffer)
hr, _, _ := syscall.SyscallN(
s.link.VTable.GetPath,
uintptr(unsafe.Pointer(s.link)),
uintptr(unsafe.Pointer(&buffer[0])),
uintptr(len(buffer)),
0,
slgpRawPath,
)
if err := checkHRESULT("IShellLinkW.GetPath", hr); err != nil {
return "", err
}
return windows.UTF16ToString(buffer), nil
}
func (s *loadedShortcut) appUserModelID() (string, error) {
var value propVariant
hr, _, _ := syscall.SyscallN(
s.store.VTable.GetValue,
uintptr(unsafe.Pointer(s.store)),
uintptr(unsafe.Pointer(&pkeyAppUserModelID)),
uintptr(unsafe.Pointer(&value)),
)
if err := checkHRESULT("IPropertyStore.GetValue", hr); err != nil {
return "", err
}
defer clearPropVariant(&value)
if value.VariantType != vtLPWSTR || value.Value == nil {
return "", nil
}
return windows.UTF16PtrToString(value.Value), nil
}
func (s *loadedShortcut) setAppUserModelID(id string) error {
idPtr, err := windows.UTF16PtrFromString(id)
if err != nil {
return err
}
var value propVariant
hr, _, _ := procInitPropVariantFromString.Call(
uintptr(unsafe.Pointer(idPtr)),
uintptr(unsafe.Pointer(&value)),
)
if err := checkHRESULT("InitPropVariantFromString", hr); err != nil {
return err
}
defer clearPropVariant(&value)
hr, _, _ = syscall.SyscallN(
s.store.VTable.SetValue,
uintptr(unsafe.Pointer(s.store)),
uintptr(unsafe.Pointer(&pkeyAppUserModelID)),
uintptr(unsafe.Pointer(&value)),
)
if err := checkHRESULT("IPropertyStore.SetValue", hr); err != nil {
return err
}
hr, _, _ = syscall.SyscallN(s.store.VTable.Commit, uintptr(unsafe.Pointer(s.store)))
return checkHRESULT("IPropertyStore.Commit", hr)
}
func (s *loadedShortcut) release() {
if s.store != nil {
releaseInterface(unsafe.Pointer(s.store))
s.store = nil
}
if s.persist != nil {
releaseInterface(unsafe.Pointer(s.persist))
s.persist = nil
}
if s.link != nil {
releaseInterface(unsafe.Pointer(s.link))
s.link = nil
}
}
func initializeCOM() (func(), error) {
hr, _, _ := procCoInitializeEx.Call(0, coinitApartmentThread)
if uint32(hr) == rpcEChangedMode {
return func() {}, nil
}
if err := checkHRESULT("CoInitializeEx", hr); err != nil {
return nil, err
}
return func() { procCoUninitialize.Call() }, nil
}
func queryInterface(object unsafe.Pointer, iid *windows.GUID, result unsafe.Pointer) error {
vtable := (*unknownVTable)(*(*unsafe.Pointer)(object))
hr, _, _ := syscall.SyscallN(
vtable.QueryInterface,
uintptr(object),
uintptr(unsafe.Pointer(iid)),
uintptr(result),
)
return checkHRESULT("IUnknown.QueryInterface", hr)
}
func releaseInterface(object unsafe.Pointer) {
vtable := (*unknownVTable)(*(*unsafe.Pointer)(object))
_, _, _ = syscall.SyscallN(vtable.Release, uintptr(object))
}
func clearPropVariant(value *propVariant) {
_, _, _ = procPropVariantClear.Call(uintptr(unsafe.Pointer(value)))
}
func notifyShortcutChanged(path string) {
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
return
}
const (
shcneUpdateItem = 0x00002000
shcnfFlush = 0x1000
shcnfPathW = 0x0005
)
_, _, _ = procSHChangeNotify.Call(shcneUpdateItem, shcnfPathW|shcnfFlush, uintptr(unsafe.Pointer(pathPtr)), 0)
}
func checkHRESULT(operation string, result uintptr) error {
if int32(uint32(result)) >= 0 {
return nil
}
return fmt.Errorf("%s failed with HRESULT 0x%08X", operation, uint32(result))
}
@@ -0,0 +1,220 @@
//go:build windows
package appidentity
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"syscall"
"testing"
"unsafe"
"golang.org/x/sys/windows"
)
func TestOwnedShortcutTargetCoversStableAndVersionedEntries(t *testing.T) {
root := filepath.Join(`C:\Program Files`, "Reasonix")
tests := []struct {
target string
want bool
}{
{filepath.Join(root, "reasonix-launcher.exe"), true},
{filepath.Join(root, "Reasonix.exe"), true},
{filepath.Join(root, "reasonix-desktop.exe"), true},
{filepath.Join(root, "versions", "v1.20.0", "reasonix-desktop.exe"), true},
{filepath.Join(root, "versions", "v1.20.0", "reasonix-cli.exe"), false},
{filepath.Join(`D:\Apps`, "Reasonix", "reasonix-launcher.exe"), false},
}
for _, test := range tests {
if got := ownedShortcutTarget(test.target, root); got != test.want {
t.Errorf("ownedShortcutTarget(%q, %q) = %v, want %v", test.target, root, got, test.want)
}
}
}
func TestReasonixShortcutName(t *testing.T) {
tests := []struct {
name string
want bool
}{
{"Reasonix.lnk", true},
{"reasonix launcher.LNK", true},
{"Reasonix (2).lnk", true},
{"Other.lnk", false},
{"Reasonix.exe", false},
}
for _, test := range tests {
if got := reasonixShortcutName(test.name); got != test.want {
t.Errorf("reasonixShortcutName(%q) = %v, want %v", test.name, got, test.want)
}
}
}
func TestOwnedShortcutTargetAcceptsVersionedDesktopThroughJunction(t *testing.T) {
root := t.TempDir()
versionDir := filepath.Join(root, "versions", "v1.20.0")
if err := os.MkdirAll(versionDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(versionDir, "reasonix-desktop.exe"), []byte("desktop"), 0o600); err != nil {
t.Fatal(err)
}
junction := filepath.Join(t.TempDir(), "current")
if output, err := exec.Command("cmd", "/c", "mklink", "/J", junction, root).CombinedOutput(); err != nil {
t.Fatalf("create directory junction: %v: %s", err, output)
}
target := filepath.Join(junction, "versions", "v1.20.0", "reasonix-desktop.exe")
if !ownedShortcutTarget(target, root) {
t.Fatalf("junction target %q was not recognised under %q", target, root)
}
}
func TestRepairOwnedShortcutPersistsAppUserModelID(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "Reasonix.exe")
if err := os.WriteFile(target, []byte("launcher"), 0o600); err != nil {
t.Fatal(err)
}
shortcutPath := filepath.Join(root, "Reasonix.lnk")
runtime.LockOSThread()
defer runtime.UnlockOSThread()
uninitialize, err := initializeCOM()
if err != nil {
t.Fatal(err)
}
defer uninitialize()
createTestShortcut(t, shortcutPath, target)
changed, err := repairOwnedShortcut(shortcutPath, root)
if err != nil {
t.Fatal(err)
}
if !changed {
t.Fatal("first repair did not report a change")
}
shortcut, err := loadShortcut(shortcutPath, stgmReadWrite)
if err != nil {
t.Fatal(err)
}
got, err := shortcut.appUserModelID()
shortcut.release()
if err != nil {
t.Fatal(err)
}
if got != AppUserModelID {
t.Fatalf("shortcut AppUserModelID = %q, want %q", got, AppUserModelID)
}
changed, err = repairOwnedShortcut(shortcutPath, root)
if err != nil {
t.Fatal(err)
}
if changed {
t.Fatal("second repair rewrote an already healthy shortcut")
}
}
func TestRepairOwnedShortcutLeavesSeparateReasonix053InstallUntouched(t *testing.T) {
currentRoot := t.TempDir()
legacyRoot := t.TempDir()
legacyTarget := filepath.Join(legacyRoot, "reasonix-desktop.exe")
if err := os.WriteFile(legacyTarget, []byte("legacy tauri desktop"), 0o600); err != nil {
t.Fatal(err)
}
shortcutPath := filepath.Join(t.TempDir(), "Reasonix.lnk")
runtime.LockOSThread()
defer runtime.UnlockOSThread()
uninitialize, err := initializeCOM()
if err != nil {
t.Fatal(err)
}
defer uninitialize()
createTestShortcut(t, shortcutPath, legacyTarget)
shortcut, err := loadShortcut(shortcutPath, stgmReadWrite)
if err != nil {
t.Fatal(err)
}
if err := shortcut.setAppUserModelID(legacyTauriAppUserModelID); err != nil {
shortcut.release()
t.Fatal(err)
}
shortcut.release()
changed, err := repairOwnedShortcut(shortcutPath, currentRoot)
if err != nil {
t.Fatal(err)
}
if changed {
t.Fatal("current install rewrote a shortcut owned by a separate Reasonix 0.53 installation")
}
shortcut, err = loadShortcut(shortcutPath, stgmReadWrite)
if err != nil {
t.Fatal(err)
}
gotTarget, targetErr := shortcut.targetPath()
gotID, idErr := shortcut.appUserModelID()
shortcut.release()
if targetErr != nil {
t.Fatal(targetErr)
}
if idErr != nil {
t.Fatal(idErr)
}
if !sameWindowsPathOrFile(gotTarget, legacyTarget) {
t.Fatalf("legacy shortcut target = %q, want %q", gotTarget, legacyTarget)
}
if gotID != legacyTauriAppUserModelID {
t.Fatalf("legacy shortcut AppUserModelID = %q, want %q", gotID, legacyTauriAppUserModelID)
}
}
func createTestShortcut(t *testing.T, shortcutPath, target string) {
t.Helper()
var link *shellLinkW
hr, _, _ := procCoCreateInstance.Call(
uintptr(unsafe.Pointer(&clsidShellLink)),
0,
clsctxInprocServer,
uintptr(unsafe.Pointer(&iidIShellLinkW)),
uintptr(unsafe.Pointer(&link)),
)
if err := checkHRESULT("CoCreateInstance(CLSID_ShellLink)", hr); err != nil {
t.Fatal(err)
}
defer releaseInterface(unsafe.Pointer(link))
targetPtr, err := windows.UTF16PtrFromString(target)
if err != nil {
t.Fatal(err)
}
hr, _, _ = syscall.SyscallN(
link.VTable.SetPath,
uintptr(unsafe.Pointer(link)),
uintptr(unsafe.Pointer(targetPtr)),
)
if err := checkHRESULT("IShellLinkW.SetPath", hr); err != nil {
t.Fatal(err)
}
var persist *persistFile
if err := queryInterface(unsafe.Pointer(link), &iidIPersistFile, unsafe.Pointer(&persist)); err != nil {
t.Fatal(err)
}
defer releaseInterface(unsafe.Pointer(persist))
shortcutPtr, err := windows.UTF16PtrFromString(shortcutPath)
if err != nil {
t.Fatal(err)
}
hr, _, _ = syscall.SyscallN(
persist.VTable.Save,
uintptr(unsafe.Pointer(persist)),
uintptr(unsafe.Pointer(shortcutPtr)),
1,
)
if err := checkHRESULT("IPersistFile.Save", hr); err != nil {
t.Fatal(err)
}
}
+7
View File
@@ -11,6 +11,7 @@ import (
"runtime"
"strings"
"reasonix/internal/appidentity"
"reasonix/internal/installlayout"
)
@@ -27,12 +28,18 @@ func Run(args []string, buildVersion string) int {
return 0
}
}
if err := appidentity.ApplyToCurrentProcess(); err != nil {
fmt.Fprintln(os.Stderr, "warning: apply Windows app identity:", err)
}
installRoot, err := ResolveInstallRoot()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "warning: repair Windows shortcut identity:", err)
}
if err := runLegacyMigratorIfNeeded(installRoot); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
+7 -3
View File
@@ -2,20 +2,24 @@
package notify
import "git.sr.ht/~jackmordaunt/go-toast/v2"
import (
"git.sr.ht/~jackmordaunt/go-toast/v2"
"reasonix/internal/appidentity"
)
// PlatformSender delivers notifications through the Windows Toast API.
type PlatformSender struct{}
// NewPlatformSender returns the best-effort sender for the current platform.
func NewPlatformSender() PlatformSender {
_ = toast.SetAppData(toast.AppData{AppID: "Reasonix"})
_ = toast.SetAppData(toast.AppData{AppID: appidentity.AppUserModelID})
return PlatformSender{}
}
func (PlatformSender) Send(m Message) error {
notification := toast.Notification{
AppID: "Reasonix",
AppID: appidentity.AppUserModelID,
Title: m.Title,
Body: m.Body,
}