| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "runtime" |
| 11 | "syscall" |
| 12 | "testing" |
| 13 | "time" |
| 14 | "unsafe" |
| 15 | |
| 16 | "golang.org/x/sys/windows" |
| 17 | |
| 18 | "reasonix/desktop/internal/instanceidentity" |
| 19 | ) |
| 20 | |
| 21 | // This worker uses the real message-only window and mutex contract from Wails. |
| 22 | func TestInstanceEndpointWorker(t *testing.T) { |
| 23 | id := os.Getenv("REASONIX_TEST_ENDPOINT") |
| 24 | if id == "" { |
| 25 | t.Skip("subprocess fixture") |
| 26 | } |
| 27 | runtime.LockOSThread() |
| 28 | defer runtime.UnlockOSThread() |
| 29 | name := "wails-app-" + id |
| 30 | mutexName, _ := windows.UTF16PtrFromString(name + "sim") |
| 31 | mutex, err := windows.CreateMutex(nil, false, mutexName) |
| 32 | if err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | defer windows.CloseHandle(mutex) |
| 36 | class, _ := windows.UTF16PtrFromString(name + "-sic") |
| 37 | title, _ := windows.UTF16PtrFromString(name + "-siw") |
| 38 | def := handoffUser32.NewProc("DefWindowProcW") |
| 39 | callback := syscall.NewCallback(func(hwnd uintptr, msg uint32, w, l uintptr) uintptr { |
| 40 | r, _, _ := def.Call(hwnd, uintptr(msg), w, l) |
| 41 | return r |
| 42 | }) |
| 43 | wc := struct { |
| 44 | Size, Style uint32 |
| 45 | Proc uintptr |
| 46 | ClassExtra, WindowExtra int32 |
| 47 | Instance, Icon, Cursor, Background uintptr |
| 48 | Menu, Class *uint16 |
| 49 | SmallIcon uintptr |
| 50 | }{Proc: callback, Class: class} |
| 51 | wc.Size = uint32(unsafe.Sizeof(wc)) |
| 52 | if r, _, err := handoffUser32.NewProc("RegisterClassExW").Call(uintptr(unsafe.Pointer(&wc))); r == 0 { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | hwnd, _, err := handoffUser32.NewProc("CreateWindowExW").Call(0, uintptr(unsafe.Pointer(class)), uintptr(unsafe.Pointer(title)), 0, 0, 0, 0, 0, ^uintptr(2), 0, 0, 0) |
| 56 | if hwnd == 0 { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | defer handoffUser32.NewProc("DestroyWindow").Call(hwnd) |
| 60 | marker := os.Getenv("REASONIX_TEST_MARKER") |
| 61 | if err := os.WriteFile(marker, []byte("ready"), 0600); err != nil { |
| 62 | t.Fatal(err) |
| 63 | } |
| 64 | deadline := time.Now().Add(30 * time.Second) |
| 65 | for time.Now().Before(deadline) { |
| 66 | if _, err := os.Stat(marker + ".stop"); err == nil { |
| 67 | return |
| 68 | } |
| 69 | time.Sleep(20 * time.Millisecond) |
| 70 | } |
| 71 | t.Fatal("fixture timed out") |
| 72 | } |
| 73 | |
| 74 | func TestNativeEndpointIsolationAndHandoff(t *testing.T) { |
| 75 | root, homeA, homeB := t.TempDir(), t.TempDir(), t.TempDir() |
| 76 | source, err := os.Executable() |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | payload, err := os.ReadFile(source) |
| 81 | if err != nil { |
| 82 | t.Fatal(err) |
| 83 | } |
| 84 | target := filepath.Join(root, "reasonix-desktop.exe") |
| 85 | old := filepath.Join(root, "versions", "old", "reasonix-desktop.exe") |
| 86 | if err := os.MkdirAll(filepath.Dir(old), 0700); err != nil { |
| 87 | t.Fatal(err) |
| 88 | } |
| 89 | for _, p := range []string{target, old} { |
| 90 | if err := os.WriteFile(p, payload, 0700); err != nil { |
| 91 | t.Fatal(err) |
| 92 | } |
| 93 | } |
| 94 | t.Setenv("REASONIX_HOME", homeA) |
| 95 | t.Setenv(instanceidentity.UpdateEnvironmentKey, instanceidentity.ForHome(homeA)) |
| 96 | start := func(path, home string) func() { |
| 97 | marker := filepath.Join(t.TempDir(), "ready") |
| 98 | cmd := exec.Command(path, "-test.run=^TestInstanceEndpointWorker$") |
| 99 | cmd.Env = append(os.Environ(), "REASONIX_TEST_ENDPOINT="+instanceidentity.ForHome(home), "REASONIX_TEST_MARKER="+marker) |
| 100 | if err := cmd.Start(); err != nil { |
| 101 | t.Fatal(err) |
| 102 | } |
| 103 | stopped := false |
| 104 | stop := func() { |
| 105 | if stopped { |
| 106 | return |
| 107 | } |
| 108 | stopped = true |
| 109 | _ = os.WriteFile(marker+".stop", nil, 0600) |
| 110 | if err := cmd.Wait(); err != nil { |
| 111 | t.Errorf("worker: %v", err) |
| 112 | } |
| 113 | } |
| 114 | t.Cleanup(stop) |
| 115 | deadline := time.Now().Add(10 * time.Second) |
| 116 | for { |
| 117 | if _, err := os.Stat(marker); err == nil { |
| 118 | break |
| 119 | } |
| 120 | if time.Now().After(deadline) { |
| 121 | t.Fatal("worker did not register endpoint") |
| 122 | } |
| 123 | time.Sleep(20 * time.Millisecond) |
| 124 | } |
| 125 | return stop |
| 126 | } |
| 127 | start(old, homeB) |
| 128 | if err := verifyDesktopHandoff(root, false); err != nil { |
| 129 | t.Fatalf("independent old home blocked upgrade: %v", err) |
| 130 | } |
| 131 | stopA := start(old, homeA) |
| 132 | if err := verifyDesktopHandoff(root, false); err == nil { |
| 133 | t.Fatal("same-home old owner was accepted") |
| 134 | } |
| 135 | if image, err := desktopEndpointImage(instanceidentity.ForHome(homeB)); err != nil || image == "" { |
| 136 | t.Fatalf("independent owner lost: %s %v", image, err) |
| 137 | } |
| 138 | stopA() |
| 139 | start(target, homeA) |
| 140 | if err := verifyDesktopHandoff(root, true); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | if image, err := desktopEndpointImage(instanceidentity.ForHome(homeB)); err != nil || image == "" { |
| 144 | t.Fatal(fmt.Errorf("independent owner did not survive: %w", err)) |
| 145 | } |
| 146 | } |
| 147 |