mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
646f158439
commit
de57f7920d
11 files changed
+808
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package appidentity
|
||||
|
||||
func ApplyToCurrentProcess() error { return nil }
|
||||
|
||||
func RepairOwnedShortcuts(string) error { return nil }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user