test(ui): make headless gtk tests deterministic
Add testHasX11Auth() preflight check that verifies X11 authorization file exists and has a matching display entry before initializing GTK/Adwaita in requireGTKDisplayApp(). This prevents GTK from emitting 'Failed to open display' warnings to stderr in headless shells where DISPLAY is set but no X authority is configured. - Add testHasX11Auth(): pure function inspecting XAUTHORITY env, ~/.Xauthority fallback, and parsing the binary .Xauthority format for display-number matching. - Update requireGTKDisplayApp() to call testHasX11Auth() after testHasUsableGTKDisplay() and before EnsureAdwaitaInitialized(), so invalid/no-auth displays skip before any GTK initialization. - Preserve existing gdk.DisplayGetDefault() == nil safety check for real-display envs. - Add 20 pure-probe unit tests covering testHasUsableGTKDisplay (env/socket scenarios) and testHasX11Auth (auth file presence and content matching) using t.Setenv and temp dirs. Verification: rtk go test ./internal/ui -count=1 → 198 passed, 0 failed. rtk make test → internal/ui PASS (pre-existing failure only in internal/infrastructure/webkit, unrelated).
- commit
- 42f0f48626f76bdf6d72bccac64a233770c048d1
- author
- brice · · 2026-06-20 09:52 UTC
- committer
- brice · · 2026-06-20 13:00 UTC
- parent
- 2be8535
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +555 -26
M internal/ui/app_browser_launch_test.go +555 -26
| @@ -1,7 +1,9 @@ | |||
| 1 | 1 | package ui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 6 | + | "encoding/binary" | |
| 5 | 7 | "errors" | |
| 6 | 8 | "io" | |
| 7 | 9 | "math" | |
| @@ -79,51 +81,160 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi | |||
| 79 | 81 | ||
| 80 | 82 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | |
| 81 | 83 | ||
| 82 | - | func testHasUsableGTKDisplay() bool { | |
| 83 | - | if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" { | |
| 84 | - | candidates := []string{waylandDisplay} | |
| 85 | - | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | |
| 86 | - | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | |
| 84 | + | func testHasUsableWaylandDisplay() bool { | |
| 85 | + | waylandDisplay := os.Getenv("WAYLAND_DISPLAY") | |
| 86 | + | if waylandDisplay == "" { | |
| 87 | + | return false | |
| 88 | + | } | |
| 89 | + | candidates := []string{waylandDisplay} | |
| 90 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | |
| 91 | + | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | |
| 92 | + | } | |
| 93 | + | for _, candidate := range candidates { | |
| 94 | + | if _, err := os.Stat(candidate); err == nil { | |
| 95 | + | return true | |
| 87 | 96 | } | |
| 88 | - | for _, candidate := range candidates { | |
| 97 | + | } | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func testHasUsableX11Display() bool { | |
| 102 | + | display := os.Getenv("DISPLAY") | |
| 103 | + | if display == "" { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | if strings.HasPrefix(display, ":") { | |
| 107 | + | displayNum := strings.TrimPrefix(display, ":") | |
| 108 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |
| 109 | + | if displayNum == "" { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | |
| 113 | + | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | |
| 114 | + | x11SocketCandidates = append(x11SocketCandidates, fallback) | |
| 115 | + | } | |
| 116 | + | for _, candidate := range x11SocketCandidates { | |
| 89 | 117 | if _, err := os.Stat(candidate); err == nil { | |
| 90 | 118 | return true | |
| 91 | 119 | } | |
| 92 | 120 | } | |
| 121 | + | return false | |
| 93 | 122 | } | |
| 123 | + | return true // TCP display | |
| 124 | + | } | |
| 94 | 125 | ||
| 95 | - | if display := os.Getenv("DISPLAY"); display != "" { | |
| 96 | - | if strings.HasPrefix(display, ":") { | |
| 97 | - | displayNum := strings.TrimPrefix(display, ":") | |
| 98 | - | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |
| 99 | - | if displayNum == "" { | |
| 100 | - | return false | |
| 101 | - | } | |
| 102 | - | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | |
| 103 | - | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | |
| 104 | - | x11SocketCandidates = append(x11SocketCandidates, fallback) | |
| 105 | - | } | |
| 106 | - | for _, candidate := range x11SocketCandidates { | |
| 107 | - | if _, err := os.Stat(candidate); err == nil { | |
| 108 | - | return true | |
| 109 | - | } | |
| 110 | - | } | |
| 126 | + | func testHasUsableGTKDisplay() bool { | |
| 127 | + | return testHasUsableWaylandDisplay() || testHasUsableX11Display() | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // testHasX11Auth checks whether X11 authorization is available for the current | |
| 131 | + | // DISPLAY before any GTK/Adwaita initialization. It is a pure function that | |
| 132 | + | // inspects env vars and the .Xauthority file only; no GTK calls. | |
| 133 | + | func testHasX11Auth() bool { | |
| 134 | + | display := os.Getenv("DISPLAY") | |
| 135 | + | if display == "" || !strings.HasPrefix(display, ":") { | |
| 136 | + | // No DISPLAY or TCP-style display; can't check locally. | |
| 137 | + | // Let GTK try and fall through to gdk.DisplayGetDefault(). | |
| 138 | + | return true | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | displayNum := strings.TrimPrefix(display, ":") | |
| 142 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |
| 143 | + | if displayNum == "" { | |
| 144 | + | return false | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Find the X authority file. | |
| 148 | + | authFile := os.Getenv("XAUTHORITY") | |
| 149 | + | if authFile == "" { | |
| 150 | + | home, err := os.UserHomeDir() | |
| 151 | + | if err != nil { | |
| 111 | 152 | return false | |
| 112 | 153 | } | |
| 113 | - | return true | |
| 154 | + | authFile = filepath.Join(home, ".Xauthority") | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | if _, err := os.Stat(authFile); err != nil { | |
| 158 | + | return false // No authority file; X11 auth will fail. | |
| 114 | 159 | } | |
| 115 | 160 | ||
| 161 | + | // Parse the .Xauthority file to look for a matching display entry. | |
| 162 | + | // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data} | |
| 163 | + | // All multi-byte values are big-endian (network byte order). | |
| 164 | + | data, err := os.ReadFile(authFile) | |
| 165 | + | if err != nil { | |
| 166 | + | return false | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | return testXauthorityHasDisplay(data, displayNum) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func testXauthorityHasDisplay(data []byte, displayNum string) bool { | |
| 173 | + | for len(data) > 0 { | |
| 174 | + | if len(data) < 2 { | |
| 175 | + | return false | |
| 176 | + | } | |
| 177 | + | data = data[2:] // family, unused | |
| 178 | + | ||
| 179 | + | _, rest, ok := testReadXauthorityField(data) // address | |
| 180 | + | if !ok { | |
| 181 | + | return false | |
| 182 | + | } | |
| 183 | + | num, rest, ok := testReadXauthorityField(rest) | |
| 184 | + | if !ok { | |
| 185 | + | return false | |
| 186 | + | } | |
| 187 | + | name, rest, ok := testReadXauthorityField(rest) | |
| 188 | + | if !ok { | |
| 189 | + | return false | |
| 190 | + | } | |
| 191 | + | cookie, rest, ok := testReadXauthorityField(rest) | |
| 192 | + | if !ok { | |
| 193 | + | return false | |
| 194 | + | } | |
| 195 | + | if string(num) == displayNum { | |
| 196 | + | return len(name) > 0 && len(cookie) > 0 | |
| 197 | + | } | |
| 198 | + | data = rest | |
| 199 | + | } | |
| 116 | 200 | return false | |
| 117 | 201 | } | |
| 118 | 202 | ||
| 203 | + | func testReadXauthorityField(data []byte) ([]byte, []byte, bool) { | |
| 204 | + | if len(data) < 2 { | |
| 205 | + | return nil, nil, false | |
| 206 | + | } | |
| 207 | + | fieldLen := int(binary.BigEndian.Uint16(data[:2])) | |
| 208 | + | data = data[2:] | |
| 209 | + | if len(data) < fieldLen { | |
| 210 | + | return nil, nil, false | |
| 211 | + | } | |
| 212 | + | return data[:fieldLen], data[fieldLen:], true | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // testNeedsX11Auth reports whether GTK is expected to use the X11 backend, | |
| 216 | + | // meaning X11 authorization must be available. When a usable Wayland display | |
| 217 | + | // is present and GDK_BACKEND is not forced to x11, X11 auth is not required | |
| 218 | + | // because GTK will use the Wayland backend. | |
| 219 | + | func testNeedsX11Auth() bool { | |
| 220 | + | return !testHasUsableWaylandDisplay() || os.Getenv("GDK_BACKEND") == "x11" | |
| 221 | + | } | |
| 222 | + | ||
| 119 | 223 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |
| 120 | 224 | t.Helper() | |
| 121 | - | if !testHasUsableGTKDisplay() { | |
| 122 | - | t.Skip("GTK display not available") | |
| 225 | + | hasWayland := testHasUsableWaylandDisplay() | |
| 226 | + | hasX11 := testHasUsableX11Display() | |
| 227 | + | if !hasWayland && !hasX11 { | |
| 228 | + | t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)") | |
| 229 | + | } | |
| 230 | + | // Only require X11 auth when the X11 backend will be used: | |
| 231 | + | // no Wayland (fallback to X11), or GDK_BACKEND explicitly set to x11. | |
| 232 | + | if testNeedsX11Auth() && !testHasX11Auth() { | |
| 233 | + | t.Skip("GTK display not available (X11 authorization unavailable)") | |
| 123 | 234 | } | |
| 124 | 235 | EnsureAdwaitaInitialized() | |
| 125 | 236 | if gdk.DisplayGetDefault() == nil { | |
| 126 | - | t.Skip("GTK display not available") | |
| 237 | + | t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)") | |
| 127 | 238 | } | |
| 128 | 239 | appID := AppID | |
| 129 | 240 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| @@ -133,6 +244,424 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |||
| 133 | 244 | return gtkApp | |
| 134 | 245 | } | |
| 135 | 246 | ||
| 247 | + | func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) { | |
| 248 | + | t.Run("no display env vars", func(t *testing.T) { | |
| 249 | + | t.Setenv("DISPLAY", "") | |
| 250 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 251 | + | if got := testHasUsableGTKDisplay(); got { | |
| 252 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 253 | + | } | |
| 254 | + | }) | |
| 255 | + | ||
| 256 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |
| 257 | + | t.Setenv("DISPLAY", ":99") | |
| 258 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 259 | + | // No /tmp/.X11-unix/X99 socket exists | |
| 260 | + | if got := testHasUsableGTKDisplay(); got { | |
| 261 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 262 | + | } | |
| 263 | + | }) | |
| 264 | + | ||
| 265 | + | t.Run("DISPLAY with screen without socket", func(t *testing.T) { | |
| 266 | + | t.Setenv("DISPLAY", ":99.0") | |
| 267 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 268 | + | if got := testHasUsableGTKDisplay(); got { | |
| 269 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 270 | + | } | |
| 271 | + | }) | |
| 272 | + | ||
| 273 | + | t.Run("DISPLAY colon only no number", func(t *testing.T) { | |
| 274 | + | t.Setenv("DISPLAY", ":") | |
| 275 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 276 | + | if got := testHasUsableGTKDisplay(); got { | |
| 277 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got) | |
| 278 | + | } | |
| 279 | + | }) | |
| 280 | + | ||
| 281 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |
| 282 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 283 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 284 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 285 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got) | |
| 286 | + | } | |
| 287 | + | }) | |
| 288 | + | ||
| 289 | + | t.Run("DISPLAY with existing socket", func(t *testing.T) { | |
| 290 | + | t.Setenv("DISPLAY", ":0") | |
| 291 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 292 | + | // /tmp/.X11-unix/X0 exists on this machine | |
| 293 | + | got := testHasUsableGTKDisplay() | |
| 294 | + | // We can't guarantee the socket exists in all environments, | |
| 295 | + | // but this verifies it doesn't crash. | |
| 296 | + | t.Logf("testHasUsableGTKDisplay() = %v (depends on /tmp/.X11-unix/X0)", got) | |
| 297 | + | }) | |
| 298 | + | ||
| 299 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |
| 300 | + | t.Setenv("DISPLAY", "") | |
| 301 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 302 | + | tmpDir := t.TempDir() | |
| 303 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 304 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 305 | + | t.Fatal(err) | |
| 306 | + | } | |
| 307 | + | // XDG_RUNTIME_DIR controls where Wayland sockets are looked up | |
| 308 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 309 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 310 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got) | |
| 311 | + | } | |
| 312 | + | }) | |
| 313 | + | ||
| 314 | + | t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) { | |
| 315 | + | t.Setenv("DISPLAY", "") | |
| 316 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent") | |
| 317 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 318 | + | if got := testHasUsableGTKDisplay(); got { | |
| 319 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got) | |
| 320 | + | } | |
| 321 | + | }) | |
| 322 | + | ||
| 323 | + | t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) { | |
| 324 | + | t.Setenv("DISPLAY", "") | |
| 325 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |
| 326 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 327 | + | t.Fatal(err) | |
| 328 | + | } | |
| 329 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |
| 330 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths | |
| 331 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 332 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got) | |
| 333 | + | } | |
| 334 | + | }) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | func Test_testHasX11Auth_PureProbe(t *testing.T) { | |
| 338 | + | t.Run("DISPLAY unset", func(t *testing.T) { | |
| 339 | + | t.Setenv("DISPLAY", "") | |
| 340 | + | t.Setenv("XAUTHORITY", "") | |
| 341 | + | if got := testHasX11Auth(); !got { | |
| 342 | + | t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got) | |
| 343 | + | } | |
| 344 | + | }) | |
| 345 | + | ||
| 346 | + | t.Run("DISPLAY TCP remote", func(t *testing.T) { | |
| 347 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 348 | + | t.Setenv("XAUTHORITY", "") | |
| 349 | + | if got := testHasX11Auth(); !got { | |
| 350 | + | t.Errorf("testHasX11Auth() = %v, want true for TCP display", got) | |
| 351 | + | } | |
| 352 | + | }) | |
| 353 | + | ||
| 354 | + | t.Run("DISPLAY local no auth file", func(t *testing.T) { | |
| 355 | + | t.Setenv("DISPLAY", ":0") | |
| 356 | + | t.Setenv("XAUTHORITY", "") | |
| 357 | + | // Use a temp home dir with no .Xauthority | |
| 358 | + | t.Setenv("HOME", t.TempDir()) | |
| 359 | + | if got := testHasX11Auth(); got { | |
| 360 | + | t.Errorf("testHasX11Auth() = %v, want false without auth file", got) | |
| 361 | + | } | |
| 362 | + | }) | |
| 363 | + | ||
| 364 | + | t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) { | |
| 365 | + | t.Setenv("DISPLAY", ":0") | |
| 366 | + | t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority") | |
| 367 | + | if got := testHasX11Auth(); got { | |
| 368 | + | t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got) | |
| 369 | + | } | |
| 370 | + | }) | |
| 371 | + | ||
| 372 | + | t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) { | |
| 373 | + | t.Setenv("DISPLAY", ":1") | |
| 374 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 375 | + | createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |
| 376 | + | t.Setenv("XAUTHORITY", authFile) | |
| 377 | + | if got := testHasX11Auth(); !got { | |
| 378 | + | t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got) | |
| 379 | + | } | |
| 380 | + | }) | |
| 381 | + | ||
| 382 | + | t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) { | |
| 383 | + | t.Setenv("DISPLAY", ":2") | |
| 384 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 385 | + | createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |
| 386 | + | t.Setenv("XAUTHORITY", authFile) | |
| 387 | + | if got := testHasX11Auth(); got { | |
| 388 | + | t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got) | |
| 389 | + | } | |
| 390 | + | }) | |
| 391 | + | ||
| 392 | + | t.Run("DISPLAY local with screen number", func(t *testing.T) { | |
| 393 | + | t.Setenv("DISPLAY", ":0.0") | |
| 394 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 395 | + | createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |
| 396 | + | t.Setenv("XAUTHORITY", authFile) | |
| 397 | + | if got := testHasX11Auth(); !got { | |
| 398 | + | t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got) | |
| 399 | + | } | |
| 400 | + | }) | |
| 401 | + | ||
| 402 | + | t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) { | |
| 403 | + | t.Setenv("DISPLAY", ":") | |
| 404 | + | t.Setenv("XAUTHORITY", "") | |
| 405 | + | if got := testHasX11Auth(); got { | |
| 406 | + | t.Errorf("testHasX11Auth() = %v, want false for empty display number", got) | |
| 407 | + | } | |
| 408 | + | }) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // writeXauthRecord appends one local-family .Xauthority record to buf with the given fields. | |
| 412 | + | // If name or data is empty, the length byte is written as zero but the field | |
| 413 | + | // data is omitted, producing a truncated record suitable for malformed tests. | |
| 414 | + | func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) { | |
| 415 | + | // family (2 bytes, big-endian) | |
| 416 | + | familyBytes := make([]byte, 2) | |
| 417 | + | binary.BigEndian.PutUint16(familyBytes, 256) | |
| 418 | + | buf.Write(familyBytes) | |
| 419 | + | ||
| 420 | + | // address | |
| 421 | + | addrLen := make([]byte, 2) | |
| 422 | + | addr := "host" | |
| 423 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |
| 424 | + | buf.Write(addrLen) | |
| 425 | + | buf.WriteString(addr) | |
| 426 | + | ||
| 427 | + | // number | |
| 428 | + | numLen := make([]byte, 2) | |
| 429 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |
| 430 | + | buf.Write(numLen) | |
| 431 | + | buf.WriteString(num) | |
| 432 | + | ||
| 433 | + | // name | |
| 434 | + | nameLen := make([]byte, 2) | |
| 435 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(name))) | |
| 436 | + | buf.Write(nameLen) | |
| 437 | + | if name != "" { | |
| 438 | + | buf.WriteString(name) | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | // data | |
| 442 | + | dLen := make([]byte, 2) | |
| 443 | + | binary.BigEndian.PutUint16(dLen, uint16(len(data))) | |
| 444 | + | buf.Write(dLen) | |
| 445 | + | if len(data) > 0 { | |
| 446 | + | buf.Write(data) | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | func Test_testHasX11Auth_MalformedRecords(t *testing.T) { | |
| 451 | + | t.Run("matching display with empty name field", func(t *testing.T) { | |
| 452 | + | t.Setenv("DISPLAY", ":1") | |
| 453 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 454 | + | var buf bytes.Buffer | |
| 455 | + | writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04}) | |
| 456 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 457 | + | t.Fatal(err) | |
| 458 | + | } | |
| 459 | + | t.Setenv("XAUTHORITY", authFile) | |
| 460 | + | if got := testHasX11Auth(); got { | |
| 461 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got) | |
| 462 | + | } | |
| 463 | + | }) | |
| 464 | + | ||
| 465 | + | t.Run("matching display with empty data field", func(t *testing.T) { | |
| 466 | + | t.Setenv("DISPLAY", ":1") | |
| 467 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 468 | + | var buf bytes.Buffer | |
| 469 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil) | |
| 470 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 471 | + | t.Fatal(err) | |
| 472 | + | } | |
| 473 | + | t.Setenv("XAUTHORITY", authFile) | |
| 474 | + | if got := testHasX11Auth(); got { | |
| 475 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got) | |
| 476 | + | } | |
| 477 | + | }) | |
| 478 | + | ||
| 479 | + | t.Run("truncated record after display number", func(t *testing.T) { | |
| 480 | + | t.Setenv("DISPLAY", ":1") | |
| 481 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 482 | + | var buf bytes.Buffer | |
| 483 | + | family := make([]byte, 2) | |
| 484 | + | binary.BigEndian.PutUint16(family, 256) | |
| 485 | + | buf.Write(family) | |
| 486 | + | addr := "host" | |
| 487 | + | addrLen := make([]byte, 2) | |
| 488 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |
| 489 | + | buf.Write(addrLen) | |
| 490 | + | buf.WriteString(addr) | |
| 491 | + | num := "1" | |
| 492 | + | numLen := make([]byte, 2) | |
| 493 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |
| 494 | + | buf.Write(numLen) | |
| 495 | + | buf.WriteString(num) | |
| 496 | + | // Record ends here — no name or data fields follow. | |
| 497 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 498 | + | t.Fatal(err) | |
| 499 | + | } | |
| 500 | + | t.Setenv("XAUTHORITY", authFile) | |
| 501 | + | if got := testHasX11Auth(); got { | |
| 502 | + | t.Errorf("testHasX11Auth() = %v, want false for truncated record", got) | |
| 503 | + | } | |
| 504 | + | }) | |
| 505 | + | ||
| 506 | + | t.Run("multiple entries with one matching and valid", func(t *testing.T) { | |
| 507 | + | t.Setenv("DISPLAY", ":3") | |
| 508 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 509 | + | var buf bytes.Buffer | |
| 510 | + | writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04}) | |
| 511 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08}) | |
| 512 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |
| 513 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 514 | + | t.Fatal(err) | |
| 515 | + | } | |
| 516 | + | t.Setenv("XAUTHORITY", authFile) | |
| 517 | + | if got := testHasX11Auth(); !got { | |
| 518 | + | t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got) | |
| 519 | + | } | |
| 520 | + | }) | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) { | |
| 524 | + | t.Run("no WAYLAND_DISPLAY", func(t *testing.T) { | |
| 525 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 526 | + | if got := testHasUsableWaylandDisplay(); got { | |
| 527 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got) | |
| 528 | + | } | |
| 529 | + | }) | |
| 530 | + | ||
| 531 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |
| 532 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 533 | + | tmpDir := t.TempDir() | |
| 534 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 535 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 536 | + | t.Fatal(err) | |
| 537 | + | } | |
| 538 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 539 | + | if got := testHasUsableWaylandDisplay(); !got { | |
| 540 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got) | |
| 541 | + | } | |
| 542 | + | }) | |
| 543 | + | ||
| 544 | + | t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) { | |
| 545 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |
| 546 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 547 | + | t.Fatal(err) | |
| 548 | + | } | |
| 549 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |
| 550 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") | |
| 551 | + | if got := testHasUsableWaylandDisplay(); !got { | |
| 552 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got) | |
| 553 | + | } | |
| 554 | + | }) | |
| 555 | + | } | |
| 556 | + | ||
| 557 | + | func Test_testHasUsableX11Display_PureProbe(t *testing.T) { | |
| 558 | + | t.Run("no DISPLAY", func(t *testing.T) { | |
| 559 | + | t.Setenv("DISPLAY", "") | |
| 560 | + | if got := testHasUsableX11Display(); got { | |
| 561 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |
| 562 | + | } | |
| 563 | + | }) | |
| 564 | + | ||
| 565 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |
| 566 | + | t.Setenv("DISPLAY", ":99") | |
| 567 | + | if got := testHasUsableX11Display(); got { | |
| 568 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |
| 569 | + | } | |
| 570 | + | }) | |
| 571 | + | ||
| 572 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |
| 573 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 574 | + | if got := testHasUsableX11Display(); !got { | |
| 575 | + | t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got) | |
| 576 | + | } | |
| 577 | + | }) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | func Test_testNeedsX11Auth_PureProbe(t *testing.T) { | |
| 581 | + | t.Run("Wayland available forces no X11 auth", func(t *testing.T) { | |
| 582 | + | t.Setenv("GDK_BACKEND", "") | |
| 583 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 584 | + | tmpDir := t.TempDir() | |
| 585 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 586 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 587 | + | t.Fatal(err) | |
| 588 | + | } | |
| 589 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 590 | + | t.Setenv("DISPLAY", ":0") | |
| 591 | + | if got := testNeedsX11Auth(); got { | |
| 592 | + | t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got) | |
| 593 | + | } | |
| 594 | + | }) | |
| 595 | + | ||
| 596 | + | t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) { | |
| 597 | + | t.Setenv("GDK_BACKEND", "x11") | |
| 598 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 599 | + | tmpDir := t.TempDir() | |
| 600 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 601 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |
| 602 | + | t.Fatal(err) | |
| 603 | + | } | |
| 604 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 605 | + | if got := testNeedsX11Auth(); !got { | |
| 606 | + | t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got) | |
| 607 | + | } | |
| 608 | + | }) | |
| 609 | + | ||
| 610 | + | t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) { | |
| 611 | + | t.Setenv("GDK_BACKEND", "") | |
| 612 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 613 | + | t.Setenv("DISPLAY", ":0") | |
| 614 | + | if got := testNeedsX11Auth(); !got { | |
| 615 | + | t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got) | |
| 616 | + | } | |
| 617 | + | }) | |
| 618 | + | } | |
| 619 | + | ||
| 620 | + | // createXauthFile writes a minimal .Xauthority file with one entry. | |
| 621 | + | // family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie. | |
| 622 | + | func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) { | |
| 623 | + | t.Helper() | |
| 624 | + | ||
| 625 | + | hostname, err := os.Hostname() | |
| 626 | + | if err != nil { | |
| 627 | + | hostname = "localhost" | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | var buf bytes.Buffer | |
| 631 | + | // family (2 bytes, big-endian) - 256 = FamilyLocal | |
| 632 | + | family := make([]byte, 2) | |
| 633 | + | binary.BigEndian.PutUint16(family, 256) | |
| 634 | + | buf.Write(family) | |
| 635 | + | ||
| 636 | + | // address length + address (hostname) | |
| 637 | + | addrLen := make([]byte, 2) | |
| 638 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(hostname))) | |
| 639 | + | buf.Write(addrLen) | |
| 640 | + | buf.WriteString(hostname) | |
| 641 | + | ||
| 642 | + | // number length + number (display number) | |
| 643 | + | numLen := make([]byte, 2) | |
| 644 | + | binary.BigEndian.PutUint16(numLen, uint16(len(displayNum))) | |
| 645 | + | buf.Write(numLen) | |
| 646 | + | buf.WriteString(displayNum) | |
| 647 | + | ||
| 648 | + | // name length + name (auth protocol) | |
| 649 | + | nameLen := make([]byte, 2) | |
| 650 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(proto))) | |
| 651 | + | buf.Write(nameLen) | |
| 652 | + | buf.WriteString(proto) | |
| 653 | + | ||
| 654 | + | // data length + data (auth cookie) | |
| 655 | + | dataLen := make([]byte, 2) | |
| 656 | + | binary.BigEndian.PutUint16(dataLen, uint16(len(cookie))) | |
| 657 | + | buf.Write(dataLen) | |
| 658 | + | buf.Write(cookie) | |
| 659 | + | ||
| 660 | + | if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { | |
| 661 | + | t.Fatal(err) | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | ||
| 136 | 665 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | |
| 137 | 666 | // Keep this test-only so production visibility stays narrow. | |
| 138 | 667 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | |
| @@ -1,7 +1,9 @@ | |||||
| 1 | package ui | 1 | package ui | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 6 | + | "encoding/binary" | |||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "io" | 8 | "io" | ||
| 7 | "math" | 9 | "math" | ||
| @@ -79,51 +81,160 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi | |||||
| 79 | 81 | ||||
| 80 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | 82 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | ||
| 81 | 83 | ||||
| 82 | - | func testHasUsableGTKDisplay() bool { | 84 | + | func testHasUsableWaylandDisplay() bool { |
| 83 | - | if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" { | 85 | + | waylandDisplay := os.Getenv("WAYLAND_DISPLAY") |
| 84 | - | candidates := []string{waylandDisplay} | 86 | + | if waylandDisplay == "" { |
| 85 | - | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | 87 | + | return false |
| 86 | - | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | 88 | + | } |
| 89 | + | candidates := []string{waylandDisplay} | |||
| 90 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | |||
| 91 | + | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | |||
| 92 | + | } | |||
| 93 | + | for _, candidate := range candidates { | |||
| 94 | + | if _, err := os.Stat(candidate); err == nil { | |||
| 95 | + | return true | |||
| 87 | } | 96 | } | ||
| 88 | - | for _, candidate := range candidates { | 97 | + | } |
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func testHasUsableX11Display() bool { | |||
| 102 | + | display := os.Getenv("DISPLAY") | |||
| 103 | + | if display == "" { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | if strings.HasPrefix(display, ":") { | |||
| 107 | + | displayNum := strings.TrimPrefix(display, ":") | |||
| 108 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |||
| 109 | + | if displayNum == "" { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | |||
| 113 | + | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | |||
| 114 | + | x11SocketCandidates = append(x11SocketCandidates, fallback) | |||
| 115 | + | } | |||
| 116 | + | for _, candidate := range x11SocketCandidates { | |||
| 89 | if _, err := os.Stat(candidate); err == nil { | 117 | if _, err := os.Stat(candidate); err == nil { | ||
| 90 | return true | 118 | return true | ||
| 91 | } | 119 | } | ||
| 92 | } | 120 | } | ||
| 121 | + | return false | |||
| 93 | } | 122 | } | ||
| 123 | + | return true // TCP display | |||
| 124 | + | } | |||
| 94 | 125 | ||||
| 95 | - | if display := os.Getenv("DISPLAY"); display != "" { | 126 | + | func testHasUsableGTKDisplay() bool { |
| 96 | - | if strings.HasPrefix(display, ":") { | 127 | + | return testHasUsableWaylandDisplay() || testHasUsableX11Display() |
| 97 | - | displayNum := strings.TrimPrefix(display, ":") | 128 | + | } |
| 98 | - | displayNum = strings.SplitN(displayNum, ".", 2)[0] | 129 | + | |
| 99 | - | if displayNum == "" { | 130 | + | // testHasX11Auth checks whether X11 authorization is available for the current |
| 100 | - | return false | 131 | + | // DISPLAY before any GTK/Adwaita initialization. It is a pure function that |
| 101 | - | } | 132 | + | // inspects env vars and the .Xauthority file only; no GTK calls. |
| 102 | - | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | 133 | + | func testHasX11Auth() bool { |
| 103 | - | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | 134 | + | display := os.Getenv("DISPLAY") |
| 104 | - | x11SocketCandidates = append(x11SocketCandidates, fallback) | 135 | + | if display == "" || !strings.HasPrefix(display, ":") { |
| 105 | - | } | 136 | + | // No DISPLAY or TCP-style display; can't check locally. |
| 106 | - | for _, candidate := range x11SocketCandidates { | 137 | + | // Let GTK try and fall through to gdk.DisplayGetDefault(). |
| 107 | - | if _, err := os.Stat(candidate); err == nil { | 138 | + | return true |
| 108 | - | return true | 139 | + | } |
| 109 | - | } | 140 | + | |
| 110 | - | } | 141 | + | displayNum := strings.TrimPrefix(display, ":") |
| 142 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |||
| 143 | + | if displayNum == "" { | |||
| 144 | + | return false | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Find the X authority file. | |||
| 148 | + | authFile := os.Getenv("XAUTHORITY") | |||
| 149 | + | if authFile == "" { | |||
| 150 | + | home, err := os.UserHomeDir() | |||
| 151 | + | if err != nil { | |||
| 111 | return false | 152 | return false | ||
| 112 | } | 153 | } | ||
| 113 | - | return true | 154 | + | authFile = filepath.Join(home, ".Xauthority") |
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | if _, err := os.Stat(authFile); err != nil { | |||
| 158 | + | return false // No authority file; X11 auth will fail. | |||
| 114 | } | 159 | } | ||
| 115 | 160 | ||||
| 161 | + | // Parse the .Xauthority file to look for a matching display entry. | |||
| 162 | + | // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data} | |||
| 163 | + | // All multi-byte values are big-endian (network byte order). | |||
| 164 | + | data, err := os.ReadFile(authFile) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return false | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | return testXauthorityHasDisplay(data, displayNum) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func testXauthorityHasDisplay(data []byte, displayNum string) bool { | |||
| 173 | + | for len(data) > 0 { | |||
| 174 | + | if len(data) < 2 { | |||
| 175 | + | return false | |||
| 176 | + | } | |||
| 177 | + | data = data[2:] // family, unused | |||
| 178 | + | ||||
| 179 | + | _, rest, ok := testReadXauthorityField(data) // address | |||
| 180 | + | if !ok { | |||
| 181 | + | return false | |||
| 182 | + | } | |||
| 183 | + | num, rest, ok := testReadXauthorityField(rest) | |||
| 184 | + | if !ok { | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | name, rest, ok := testReadXauthorityField(rest) | |||
| 188 | + | if !ok { | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 191 | + | cookie, rest, ok := testReadXauthorityField(rest) | |||
| 192 | + | if !ok { | |||
| 193 | + | return false | |||
| 194 | + | } | |||
| 195 | + | if string(num) == displayNum { | |||
| 196 | + | return len(name) > 0 && len(cookie) > 0 | |||
| 197 | + | } | |||
| 198 | + | data = rest | |||
| 199 | + | } | |||
| 116 | return false | 200 | return false | ||
| 117 | } | 201 | } | ||
| 118 | 202 | ||||
| 203 | + | func testReadXauthorityField(data []byte) ([]byte, []byte, bool) { | |||
| 204 | + | if len(data) < 2 { | |||
| 205 | + | return nil, nil, false | |||
| 206 | + | } | |||
| 207 | + | fieldLen := int(binary.BigEndian.Uint16(data[:2])) | |||
| 208 | + | data = data[2:] | |||
| 209 | + | if len(data) < fieldLen { | |||
| 210 | + | return nil, nil, false | |||
| 211 | + | } | |||
| 212 | + | return data[:fieldLen], data[fieldLen:], true | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // testNeedsX11Auth reports whether GTK is expected to use the X11 backend, | |||
| 216 | + | // meaning X11 authorization must be available. When a usable Wayland display | |||
| 217 | + | // is present and GDK_BACKEND is not forced to x11, X11 auth is not required | |||
| 218 | + | // because GTK will use the Wayland backend. | |||
| 219 | + | func testNeedsX11Auth() bool { | |||
| 220 | + | return !testHasUsableWaylandDisplay() || os.Getenv("GDK_BACKEND") == "x11" | |||
| 221 | + | } | |||
| 222 | + | ||||
| 119 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | 223 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | ||
| 120 | t.Helper() | 224 | t.Helper() | ||
| 121 | - | if !testHasUsableGTKDisplay() { | 225 | + | hasWayland := testHasUsableWaylandDisplay() |
| 122 | - | t.Skip("GTK display not available") | 226 | + | hasX11 := testHasUsableX11Display() |
| 227 | + | if !hasWayland && !hasX11 { | |||
| 228 | + | t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)") | |||
| 229 | + | } | |||
| 230 | + | // Only require X11 auth when the X11 backend will be used: | |||
| 231 | + | // no Wayland (fallback to X11), or GDK_BACKEND explicitly set to x11. | |||
| 232 | + | if testNeedsX11Auth() && !testHasX11Auth() { | |||
| 233 | + | t.Skip("GTK display not available (X11 authorization unavailable)") | |||
| 123 | } | 234 | } | ||
| 124 | EnsureAdwaitaInitialized() | 235 | EnsureAdwaitaInitialized() | ||
| 125 | if gdk.DisplayGetDefault() == nil { | 236 | if gdk.DisplayGetDefault() == nil { | ||
| 126 | - | t.Skip("GTK display not available") | 237 | + | t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)") |
| 127 | } | 238 | } | ||
| 128 | appID := AppID | 239 | appID := AppID | ||
| 129 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | 240 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | ||
| @@ -133,6 +244,424 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |||||
| 133 | return gtkApp | 244 | return gtkApp | ||
| 134 | } | 245 | } | ||
| 135 | 246 | ||||
| 247 | + | func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) { | |||
| 248 | + | t.Run("no display env vars", func(t *testing.T) { | |||
| 249 | + | t.Setenv("DISPLAY", "") | |||
| 250 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 251 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 252 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 253 | + | } | |||
| 254 | + | }) | |||
| 255 | + | ||||
| 256 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |||
| 257 | + | t.Setenv("DISPLAY", ":99") | |||
| 258 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 259 | + | // No /tmp/.X11-unix/X99 socket exists | |||
| 260 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 261 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 262 | + | } | |||
| 263 | + | }) | |||
| 264 | + | ||||
| 265 | + | t.Run("DISPLAY with screen without socket", func(t *testing.T) { | |||
| 266 | + | t.Setenv("DISPLAY", ":99.0") | |||
| 267 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 268 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 269 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 270 | + | } | |||
| 271 | + | }) | |||
| 272 | + | ||||
| 273 | + | t.Run("DISPLAY colon only no number", func(t *testing.T) { | |||
| 274 | + | t.Setenv("DISPLAY", ":") | |||
| 275 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 276 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 277 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got) | |||
| 278 | + | } | |||
| 279 | + | }) | |||
| 280 | + | ||||
| 281 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |||
| 282 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 283 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 284 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 285 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got) | |||
| 286 | + | } | |||
| 287 | + | }) | |||
| 288 | + | ||||
| 289 | + | t.Run("DISPLAY with existing socket", func(t *testing.T) { | |||
| 290 | + | t.Setenv("DISPLAY", ":0") | |||
| 291 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 292 | + | // /tmp/.X11-unix/X0 exists on this machine | |||
| 293 | + | got := testHasUsableGTKDisplay() | |||
| 294 | + | // We can't guarantee the socket exists in all environments, | |||
| 295 | + | // but this verifies it doesn't crash. | |||
| 296 | + | t.Logf("testHasUsableGTKDisplay() = %v (depends on /tmp/.X11-unix/X0)", got) | |||
| 297 | + | }) | |||
| 298 | + | ||||
| 299 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |||
| 300 | + | t.Setenv("DISPLAY", "") | |||
| 301 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 302 | + | tmpDir := t.TempDir() | |||
| 303 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 304 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 305 | + | t.Fatal(err) | |||
| 306 | + | } | |||
| 307 | + | // XDG_RUNTIME_DIR controls where Wayland sockets are looked up | |||
| 308 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 309 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 310 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got) | |||
| 311 | + | } | |||
| 312 | + | }) | |||
| 313 | + | ||||
| 314 | + | t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) { | |||
| 315 | + | t.Setenv("DISPLAY", "") | |||
| 316 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent") | |||
| 317 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 318 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 319 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got) | |||
| 320 | + | } | |||
| 321 | + | }) | |||
| 322 | + | ||||
| 323 | + | t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) { | |||
| 324 | + | t.Setenv("DISPLAY", "") | |||
| 325 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |||
| 326 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 327 | + | t.Fatal(err) | |||
| 328 | + | } | |||
| 329 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |||
| 330 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths | |||
| 331 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 332 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got) | |||
| 333 | + | } | |||
| 334 | + | }) | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | func Test_testHasX11Auth_PureProbe(t *testing.T) { | |||
| 338 | + | t.Run("DISPLAY unset", func(t *testing.T) { | |||
| 339 | + | t.Setenv("DISPLAY", "") | |||
| 340 | + | t.Setenv("XAUTHORITY", "") | |||
| 341 | + | if got := testHasX11Auth(); !got { | |||
| 342 | + | t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got) | |||
| 343 | + | } | |||
| 344 | + | }) | |||
| 345 | + | ||||
| 346 | + | t.Run("DISPLAY TCP remote", func(t *testing.T) { | |||
| 347 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 348 | + | t.Setenv("XAUTHORITY", "") | |||
| 349 | + | if got := testHasX11Auth(); !got { | |||
| 350 | + | t.Errorf("testHasX11Auth() = %v, want true for TCP display", got) | |||
| 351 | + | } | |||
| 352 | + | }) | |||
| 353 | + | ||||
| 354 | + | t.Run("DISPLAY local no auth file", func(t *testing.T) { | |||
| 355 | + | t.Setenv("DISPLAY", ":0") | |||
| 356 | + | t.Setenv("XAUTHORITY", "") | |||
| 357 | + | // Use a temp home dir with no .Xauthority | |||
| 358 | + | t.Setenv("HOME", t.TempDir()) | |||
| 359 | + | if got := testHasX11Auth(); got { | |||
| 360 | + | t.Errorf("testHasX11Auth() = %v, want false without auth file", got) | |||
| 361 | + | } | |||
| 362 | + | }) | |||
| 363 | + | ||||
| 364 | + | t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) { | |||
| 365 | + | t.Setenv("DISPLAY", ":0") | |||
| 366 | + | t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority") | |||
| 367 | + | if got := testHasX11Auth(); got { | |||
| 368 | + | t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got) | |||
| 369 | + | } | |||
| 370 | + | }) | |||
| 371 | + | ||||
| 372 | + | t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) { | |||
| 373 | + | t.Setenv("DISPLAY", ":1") | |||
| 374 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 375 | + | createXauthFile(t, authFile, "1", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |||
| 376 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 377 | + | if got := testHasX11Auth(); !got { | |||
| 378 | + | t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got) | |||
| 379 | + | } | |||
| 380 | + | }) | |||
| 381 | + | ||||
| 382 | + | t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) { | |||
| 383 | + | t.Setenv("DISPLAY", ":2") | |||
| 384 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 385 | + | createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |||
| 386 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 387 | + | if got := testHasX11Auth(); got { | |||
| 388 | + | t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got) | |||
| 389 | + | } | |||
| 390 | + | }) | |||
| 391 | + | ||||
| 392 | + | t.Run("DISPLAY local with screen number", func(t *testing.T) { | |||
| 393 | + | t.Setenv("DISPLAY", ":0.0") | |||
| 394 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 395 | + | createXauthFile(t, authFile, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}) | |||
| 396 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 397 | + | if got := testHasX11Auth(); !got { | |||
| 398 | + | t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got) | |||
| 399 | + | } | |||
| 400 | + | }) | |||
| 401 | + | ||||
| 402 | + | t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) { | |||
| 403 | + | t.Setenv("DISPLAY", ":") | |||
| 404 | + | t.Setenv("XAUTHORITY", "") | |||
| 405 | + | if got := testHasX11Auth(); got { | |||
| 406 | + | t.Errorf("testHasX11Auth() = %v, want false for empty display number", got) | |||
| 407 | + | } | |||
| 408 | + | }) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | // writeXauthRecord appends one local-family .Xauthority record to buf with the given fields. | |||
| 412 | + | // If name or data is empty, the length byte is written as zero but the field | |||
| 413 | + | // data is omitted, producing a truncated record suitable for malformed tests. | |||
| 414 | + | func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) { | |||
| 415 | + | // family (2 bytes, big-endian) | |||
| 416 | + | familyBytes := make([]byte, 2) | |||
| 417 | + | binary.BigEndian.PutUint16(familyBytes, 256) | |||
| 418 | + | buf.Write(familyBytes) | |||
| 419 | + | ||||
| 420 | + | // address | |||
| 421 | + | addrLen := make([]byte, 2) | |||
| 422 | + | addr := "host" | |||
| 423 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |||
| 424 | + | buf.Write(addrLen) | |||
| 425 | + | buf.WriteString(addr) | |||
| 426 | + | ||||
| 427 | + | // number | |||
| 428 | + | numLen := make([]byte, 2) | |||
| 429 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |||
| 430 | + | buf.Write(numLen) | |||
| 431 | + | buf.WriteString(num) | |||
| 432 | + | ||||
| 433 | + | // name | |||
| 434 | + | nameLen := make([]byte, 2) | |||
| 435 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(name))) | |||
| 436 | + | buf.Write(nameLen) | |||
| 437 | + | if name != "" { | |||
| 438 | + | buf.WriteString(name) | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | // data | |||
| 442 | + | dLen := make([]byte, 2) | |||
| 443 | + | binary.BigEndian.PutUint16(dLen, uint16(len(data))) | |||
| 444 | + | buf.Write(dLen) | |||
| 445 | + | if len(data) > 0 { | |||
| 446 | + | buf.Write(data) | |||
| 447 | + | } | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func Test_testHasX11Auth_MalformedRecords(t *testing.T) { | |||
| 451 | + | t.Run("matching display with empty name field", func(t *testing.T) { | |||
| 452 | + | t.Setenv("DISPLAY", ":1") | |||
| 453 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 454 | + | var buf bytes.Buffer | |||
| 455 | + | writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04}) | |||
| 456 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 457 | + | t.Fatal(err) | |||
| 458 | + | } | |||
| 459 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 460 | + | if got := testHasX11Auth(); got { | |||
| 461 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got) | |||
| 462 | + | } | |||
| 463 | + | }) | |||
| 464 | + | ||||
| 465 | + | t.Run("matching display with empty data field", func(t *testing.T) { | |||
| 466 | + | t.Setenv("DISPLAY", ":1") | |||
| 467 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 468 | + | var buf bytes.Buffer | |||
| 469 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil) | |||
| 470 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 471 | + | t.Fatal(err) | |||
| 472 | + | } | |||
| 473 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 474 | + | if got := testHasX11Auth(); got { | |||
| 475 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got) | |||
| 476 | + | } | |||
| 477 | + | }) | |||
| 478 | + | ||||
| 479 | + | t.Run("truncated record after display number", func(t *testing.T) { | |||
| 480 | + | t.Setenv("DISPLAY", ":1") | |||
| 481 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 482 | + | var buf bytes.Buffer | |||
| 483 | + | family := make([]byte, 2) | |||
| 484 | + | binary.BigEndian.PutUint16(family, 256) | |||
| 485 | + | buf.Write(family) | |||
| 486 | + | addr := "host" | |||
| 487 | + | addrLen := make([]byte, 2) | |||
| 488 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |||
| 489 | + | buf.Write(addrLen) | |||
| 490 | + | buf.WriteString(addr) | |||
| 491 | + | num := "1" | |||
| 492 | + | numLen := make([]byte, 2) | |||
| 493 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |||
| 494 | + | buf.Write(numLen) | |||
| 495 | + | buf.WriteString(num) | |||
| 496 | + | // Record ends here — no name or data fields follow. | |||
| 497 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 498 | + | t.Fatal(err) | |||
| 499 | + | } | |||
| 500 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 501 | + | if got := testHasX11Auth(); got { | |||
| 502 | + | t.Errorf("testHasX11Auth() = %v, want false for truncated record", got) | |||
| 503 | + | } | |||
| 504 | + | }) | |||
| 505 | + | ||||
| 506 | + | t.Run("multiple entries with one matching and valid", func(t *testing.T) { | |||
| 507 | + | t.Setenv("DISPLAY", ":3") | |||
| 508 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 509 | + | var buf bytes.Buffer | |||
| 510 | + | writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04}) | |||
| 511 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08}) | |||
| 512 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |||
| 513 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 514 | + | t.Fatal(err) | |||
| 515 | + | } | |||
| 516 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 517 | + | if got := testHasX11Auth(); !got { | |||
| 518 | + | t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got) | |||
| 519 | + | } | |||
| 520 | + | }) | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) { | |||
| 524 | + | t.Run("no WAYLAND_DISPLAY", func(t *testing.T) { | |||
| 525 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 526 | + | if got := testHasUsableWaylandDisplay(); got { | |||
| 527 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got) | |||
| 528 | + | } | |||
| 529 | + | }) | |||
| 530 | + | ||||
| 531 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |||
| 532 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 533 | + | tmpDir := t.TempDir() | |||
| 534 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 535 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 536 | + | t.Fatal(err) | |||
| 537 | + | } | |||
| 538 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 539 | + | if got := testHasUsableWaylandDisplay(); !got { | |||
| 540 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got) | |||
| 541 | + | } | |||
| 542 | + | }) | |||
| 543 | + | ||||
| 544 | + | t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) { | |||
| 545 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |||
| 546 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 547 | + | t.Fatal(err) | |||
| 548 | + | } | |||
| 549 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |||
| 550 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") | |||
| 551 | + | if got := testHasUsableWaylandDisplay(); !got { | |||
| 552 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got) | |||
| 553 | + | } | |||
| 554 | + | }) | |||
| 555 | + | } | |||
| 556 | + | ||||
| 557 | + | func Test_testHasUsableX11Display_PureProbe(t *testing.T) { | |||
| 558 | + | t.Run("no DISPLAY", func(t *testing.T) { | |||
| 559 | + | t.Setenv("DISPLAY", "") | |||
| 560 | + | if got := testHasUsableX11Display(); got { | |||
| 561 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |||
| 562 | + | } | |||
| 563 | + | }) | |||
| 564 | + | ||||
| 565 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |||
| 566 | + | t.Setenv("DISPLAY", ":99") | |||
| 567 | + | if got := testHasUsableX11Display(); got { | |||
| 568 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |||
| 569 | + | } | |||
| 570 | + | }) | |||
| 571 | + | ||||
| 572 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |||
| 573 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 574 | + | if got := testHasUsableX11Display(); !got { | |||
| 575 | + | t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got) | |||
| 576 | + | } | |||
| 577 | + | }) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | func Test_testNeedsX11Auth_PureProbe(t *testing.T) { | |||
| 581 | + | t.Run("Wayland available forces no X11 auth", func(t *testing.T) { | |||
| 582 | + | t.Setenv("GDK_BACKEND", "") | |||
| 583 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 584 | + | tmpDir := t.TempDir() | |||
| 585 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 586 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 587 | + | t.Fatal(err) | |||
| 588 | + | } | |||
| 589 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 590 | + | t.Setenv("DISPLAY", ":0") | |||
| 591 | + | if got := testNeedsX11Auth(); got { | |||
| 592 | + | t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got) | |||
| 593 | + | } | |||
| 594 | + | }) | |||
| 595 | + | ||||
| 596 | + | t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) { | |||
| 597 | + | t.Setenv("GDK_BACKEND", "x11") | |||
| 598 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 599 | + | tmpDir := t.TempDir() | |||
| 600 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 601 | + | if err := os.WriteFile(socketPath, nil, 0644); err != nil { | |||
| 602 | + | t.Fatal(err) | |||
| 603 | + | } | |||
| 604 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 605 | + | if got := testNeedsX11Auth(); !got { | |||
| 606 | + | t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got) | |||
| 607 | + | } | |||
| 608 | + | }) | |||
| 609 | + | ||||
| 610 | + | t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) { | |||
| 611 | + | t.Setenv("GDK_BACKEND", "") | |||
| 612 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 613 | + | t.Setenv("DISPLAY", ":0") | |||
| 614 | + | if got := testNeedsX11Auth(); !got { | |||
| 615 | + | t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got) | |||
| 616 | + | } | |||
| 617 | + | }) | |||
| 618 | + | } | |||
| 619 | + | ||||
| 620 | + | // createXauthFile writes a minimal .Xauthority file with one entry. | |||
| 621 | + | // family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie. | |||
| 622 | + | func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) { | |||
| 623 | + | t.Helper() | |||
| 624 | + | ||||
| 625 | + | hostname, err := os.Hostname() | |||
| 626 | + | if err != nil { | |||
| 627 | + | hostname = "localhost" | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | var buf bytes.Buffer | |||
| 631 | + | // family (2 bytes, big-endian) - 256 = FamilyLocal | |||
| 632 | + | family := make([]byte, 2) | |||
| 633 | + | binary.BigEndian.PutUint16(family, 256) | |||
| 634 | + | buf.Write(family) | |||
| 635 | + | ||||
| 636 | + | // address length + address (hostname) | |||
| 637 | + | addrLen := make([]byte, 2) | |||
| 638 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(hostname))) | |||
| 639 | + | buf.Write(addrLen) | |||
| 640 | + | buf.WriteString(hostname) | |||
| 641 | + | ||||
| 642 | + | // number length + number (display number) | |||
| 643 | + | numLen := make([]byte, 2) | |||
| 644 | + | binary.BigEndian.PutUint16(numLen, uint16(len(displayNum))) | |||
| 645 | + | buf.Write(numLen) | |||
| 646 | + | buf.WriteString(displayNum) | |||
| 647 | + | ||||
| 648 | + | // name length + name (auth protocol) | |||
| 649 | + | nameLen := make([]byte, 2) | |||
| 650 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(proto))) | |||
| 651 | + | buf.Write(nameLen) | |||
| 652 | + | buf.WriteString(proto) | |||
| 653 | + | ||||
| 654 | + | // data length + data (auth cookie) | |||
| 655 | + | dataLen := make([]byte, 2) | |||
| 656 | + | binary.BigEndian.PutUint16(dataLen, uint16(len(cookie))) | |||
| 657 | + | buf.Write(dataLen) | |||
| 658 | + | buf.Write(cookie) | |||
| 659 | + | ||||
| 660 | + | if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { | |||
| 661 | + | t.Fatal(err) | |||
| 662 | + | } | |||
| 663 | + | } | |||
| 664 | + | ||||
| 136 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | 665 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | ||
| 137 | // Keep this test-only so production visibility stays narrow. | 666 | // Keep this test-only so production visibility stays narrow. | ||
| 138 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | 667 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | ||