Merge pull request #316 from bnema/backlog/testcov-headless-tests
test(ui): make headless gtk tests deterministic
1 file · +671 -27
M internal/ui/app_browser_launch_test.go +671 -27
| @@ -1,10 +1,13 @@ | |||
| 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" | |
| 10 | + | "net" | |
| 8 | 11 | "os" | |
| 9 | 12 | "path/filepath" | |
| 10 | 13 | "reflect" | |
| @@ -79,51 +82,220 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi | |||
| 79 | 82 | ||
| 80 | 83 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | |
| 81 | 84 | ||
| 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...) | |
| 85 | + | func testPathIsSocket(path string) bool { | |
| 86 | + | info, err := os.Stat(path) | |
| 87 | + | return err == nil && info.Mode()&os.ModeSocket != 0 | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func testGDKBackendAllows(backend string) bool { | |
| 91 | + | configured := os.Getenv("GDK_BACKEND") | |
| 92 | + | if configured == "" { | |
| 93 | + | return true | |
| 94 | + | } | |
| 95 | + | for _, candidate := range strings.Split(configured, ",") { | |
| 96 | + | if strings.TrimSpace(candidate) == backend { | |
| 97 | + | return true | |
| 87 | 98 | } | |
| 88 | - | for _, candidate := range candidates { | |
| 89 | - | if _, err := os.Stat(candidate); err == nil { | |
| 99 | + | } | |
| 100 | + | return false | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func testHasUsableWaylandDisplay() bool { | |
| 104 | + | if !testGDKBackendAllows("wayland") { | |
| 105 | + | return false | |
| 106 | + | } | |
| 107 | + | waylandDisplay := os.Getenv("WAYLAND_DISPLAY") | |
| 108 | + | if waylandDisplay == "" { | |
| 109 | + | return false | |
| 110 | + | } | |
| 111 | + | candidates := []string{waylandDisplay} | |
| 112 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | |
| 113 | + | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | |
| 114 | + | } | |
| 115 | + | for _, candidate := range candidates { | |
| 116 | + | if testPathIsSocket(candidate) { | |
| 117 | + | return true | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | return false | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func testHasUsableX11Display() bool { | |
| 124 | + | if !testGDKBackendAllows("x11") { | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | display := os.Getenv("DISPLAY") | |
| 128 | + | if display == "" { | |
| 129 | + | return false | |
| 130 | + | } | |
| 131 | + | if strings.HasPrefix(display, ":") { | |
| 132 | + | displayNum := strings.TrimPrefix(display, ":") | |
| 133 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |
| 134 | + | if displayNum == "" { | |
| 135 | + | return false | |
| 136 | + | } | |
| 137 | + | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | |
| 138 | + | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | |
| 139 | + | x11SocketCandidates = append(x11SocketCandidates, fallback) | |
| 140 | + | } | |
| 141 | + | for _, candidate := range x11SocketCandidates { | |
| 142 | + | if testPathIsSocket(candidate) { | |
| 90 | 143 | return true | |
| 91 | 144 | } | |
| 92 | 145 | } | |
| 146 | + | return false | |
| 93 | 147 | } | |
| 148 | + | return true // TCP display | |
| 149 | + | } | |
| 94 | 150 | ||
| 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 | - | } | |
| 151 | + | func testHasUsableGTKDisplay() bool { | |
| 152 | + | return testHasUsableWaylandDisplay() || testHasUsableX11Display() | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | // testHasX11Auth checks whether X11 authorization is available for the current | |
| 156 | + | // DISPLAY before any GTK/Adwaita initialization. It is a pure function that | |
| 157 | + | // inspects env vars and the .Xauthority file only; no GTK calls. | |
| 158 | + | func testHasX11Auth() bool { | |
| 159 | + | display := os.Getenv("DISPLAY") | |
| 160 | + | if display == "" || !strings.HasPrefix(display, ":") { | |
| 161 | + | // No DISPLAY or TCP-style display; can't check locally. | |
| 162 | + | // Let GTK try and fall through to gdk.DisplayGetDefault(). | |
| 163 | + | return true | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | displayNum := strings.TrimPrefix(display, ":") | |
| 167 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |
| 168 | + | if displayNum == "" { | |
| 169 | + | return false | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // Find the X authority file. | |
| 173 | + | authFile := os.Getenv("XAUTHORITY") | |
| 174 | + | if authFile == "" { | |
| 175 | + | home, err := os.UserHomeDir() | |
| 176 | + | if err != nil { | |
| 111 | 177 | return false | |
| 112 | 178 | } | |
| 113 | - | return true | |
| 179 | + | authFile = filepath.Join(home, ".Xauthority") | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | if _, err := os.Stat(authFile); err != nil { | |
| 183 | + | return false // No authority file; X11 auth will fail. | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Parse the .Xauthority file to look for a matching display entry. | |
| 187 | + | // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data} | |
| 188 | + | // All multi-byte values are big-endian (network byte order). | |
| 189 | + | data, err := os.ReadFile(authFile) | |
| 190 | + | if err != nil { | |
| 191 | + | return false | |
| 114 | 192 | } | |
| 115 | 193 | ||
| 194 | + | return testXauthorityHasDisplay(data, displayNum) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func testXauthorityHasDisplay(data []byte, displayNum string) bool { | |
| 198 | + | for len(data) > 0 { | |
| 199 | + | if len(data) < 2 { | |
| 200 | + | return false | |
| 201 | + | } | |
| 202 | + | data = data[2:] // family, unused | |
| 203 | + | ||
| 204 | + | _, rest, ok := testReadXauthorityField(data) // address | |
| 205 | + | if !ok { | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | num, rest, ok := testReadXauthorityField(rest) | |
| 209 | + | if !ok { | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | name, rest, ok := testReadXauthorityField(rest) | |
| 213 | + | if !ok { | |
| 214 | + | return false | |
| 215 | + | } | |
| 216 | + | cookie, rest, ok := testReadXauthorityField(rest) | |
| 217 | + | if !ok { | |
| 218 | + | return false | |
| 219 | + | } | |
| 220 | + | if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 { | |
| 221 | + | return true | |
| 222 | + | } | |
| 223 | + | data = rest | |
| 224 | + | } | |
| 116 | 225 | return false | |
| 117 | 226 | } | |
| 118 | 227 | ||
| 228 | + | func testReadXauthorityField(data []byte) ([]byte, []byte, bool) { | |
| 229 | + | if len(data) < 2 { | |
| 230 | + | return nil, nil, false | |
| 231 | + | } | |
| 232 | + | fieldLen := int(binary.BigEndian.Uint16(data[:2])) | |
| 233 | + | data = data[2:] | |
| 234 | + | if len(data) < fieldLen { | |
| 235 | + | return nil, nil, false | |
| 236 | + | } | |
| 237 | + | return data[:fieldLen], data[fieldLen:], true | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // testNeedsX11Auth reports whether GTK may need the X11 backend and therefore | |
| 241 | + | // requires X11 authorization before GTK initialization. Wayland-only backend | |
| 242 | + | // configuration never needs X11 auth; X11 is required when it is the only usable | |
| 243 | + | // allowed backend. | |
| 244 | + | func testNeedsX11Auth() bool { | |
| 245 | + | if !testGDKBackendAllows("x11") { | |
| 246 | + | return false | |
| 247 | + | } | |
| 248 | + | return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay() | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func testCreateUnixSocket(t *testing.T, path string) { | |
| 252 | + | t.Helper() | |
| 253 | + | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |
| 254 | + | t.Fatal(err) | |
| 255 | + | } | |
| 256 | + | listener, err := net.Listen("unix", path) | |
| 257 | + | if err != nil { | |
| 258 | + | t.Fatal(err) | |
| 259 | + | } | |
| 260 | + | t.Cleanup(func() { | |
| 261 | + | _ = listener.Close() | |
| 262 | + | _ = os.Remove(path) | |
| 263 | + | }) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func testUnusedX11DisplayNumber(t *testing.T) string { | |
| 267 | + | t.Helper() | |
| 268 | + | for display := 90; display < 300; display++ { | |
| 269 | + | displayNum := strconv.Itoa(display) | |
| 270 | + | if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) { | |
| 271 | + | return displayNum | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | t.Fatal("could not find an unused X11 display number") | |
| 275 | + | return "" | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func testCreateX11Socket(t *testing.T, displayNum string) { | |
| 279 | + | t.Helper() | |
| 280 | + | tmpDir := t.TempDir() | |
| 281 | + | t.Setenv("TMPDIR", tmpDir) | |
| 282 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum)) | |
| 283 | + | } | |
| 284 | + | ||
| 119 | 285 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |
| 120 | 286 | t.Helper() | |
| 121 | - | if !testHasUsableGTKDisplay() { | |
| 122 | - | t.Skip("GTK display not available") | |
| 287 | + | hasWayland := testHasUsableWaylandDisplay() | |
| 288 | + | hasX11 := testHasUsableX11Display() | |
| 289 | + | if !hasWayland && !hasX11 { | |
| 290 | + | t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)") | |
| 291 | + | } | |
| 292 | + | // Only require X11 auth when X11 is the only usable allowed backend. | |
| 293 | + | if testNeedsX11Auth() && !testHasX11Auth() { | |
| 294 | + | t.Skip("GTK display not available (X11 authorization unavailable)") | |
| 123 | 295 | } | |
| 124 | 296 | EnsureAdwaitaInitialized() | |
| 125 | 297 | if gdk.DisplayGetDefault() == nil { | |
| 126 | - | t.Skip("GTK display not available") | |
| 298 | + | t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)") | |
| 127 | 299 | } | |
| 128 | 300 | appID := AppID | |
| 129 | 301 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| @@ -133,6 +305,478 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |||
| 133 | 305 | return gtkApp | |
| 134 | 306 | } | |
| 135 | 307 | ||
| 308 | + | func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) { | |
| 309 | + | t.Setenv("GDK_BACKEND", "") | |
| 310 | + | ||
| 311 | + | t.Run("no display env vars", func(t *testing.T) { | |
| 312 | + | t.Setenv("DISPLAY", "") | |
| 313 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 314 | + | if got := testHasUsableGTKDisplay(); got { | |
| 315 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 316 | + | } | |
| 317 | + | }) | |
| 318 | + | ||
| 319 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |
| 320 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 321 | + | t.Setenv("DISPLAY", ":"+displayNum) | |
| 322 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 323 | + | if got := testHasUsableGTKDisplay(); got { | |
| 324 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 325 | + | } | |
| 326 | + | }) | |
| 327 | + | ||
| 328 | + | t.Run("DISPLAY with screen without socket", func(t *testing.T) { | |
| 329 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 330 | + | t.Setenv("DISPLAY", ":"+displayNum+".0") | |
| 331 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 332 | + | if got := testHasUsableGTKDisplay(); got { | |
| 333 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |
| 334 | + | } | |
| 335 | + | }) | |
| 336 | + | ||
| 337 | + | t.Run("DISPLAY colon only no number", func(t *testing.T) { | |
| 338 | + | t.Setenv("DISPLAY", ":") | |
| 339 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 340 | + | if got := testHasUsableGTKDisplay(); got { | |
| 341 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got) | |
| 342 | + | } | |
| 343 | + | }) | |
| 344 | + | ||
| 345 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |
| 346 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 347 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 348 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 349 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got) | |
| 350 | + | } | |
| 351 | + | }) | |
| 352 | + | ||
| 353 | + | t.Run("DISPLAY with existing socket", func(t *testing.T) { | |
| 354 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 355 | + | tmpDir := t.TempDir() | |
| 356 | + | t.Setenv("TMPDIR", tmpDir) | |
| 357 | + | t.Setenv("DISPLAY", ":"+displayNum) | |
| 358 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 359 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum)) | |
| 360 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 361 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got) | |
| 362 | + | } | |
| 363 | + | }) | |
| 364 | + | ||
| 365 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |
| 366 | + | t.Setenv("DISPLAY", "") | |
| 367 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 368 | + | tmpDir := t.TempDir() | |
| 369 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 370 | + | testCreateUnixSocket(t, socketPath) | |
| 371 | + | // XDG_RUNTIME_DIR controls where Wayland sockets are looked up | |
| 372 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 373 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 374 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got) | |
| 375 | + | } | |
| 376 | + | }) | |
| 377 | + | ||
| 378 | + | t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) { | |
| 379 | + | t.Setenv("DISPLAY", "") | |
| 380 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent") | |
| 381 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 382 | + | if got := testHasUsableGTKDisplay(); got { | |
| 383 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got) | |
| 384 | + | } | |
| 385 | + | }) | |
| 386 | + | ||
| 387 | + | t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) { | |
| 388 | + | t.Setenv("DISPLAY", "") | |
| 389 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |
| 390 | + | testCreateUnixSocket(t, socketPath) | |
| 391 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |
| 392 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths | |
| 393 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 394 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got) | |
| 395 | + | } | |
| 396 | + | }) | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | func Test_testHasX11Auth_PureProbe(t *testing.T) { | |
| 400 | + | t.Run("DISPLAY unset", func(t *testing.T) { | |
| 401 | + | t.Setenv("DISPLAY", "") | |
| 402 | + | t.Setenv("XAUTHORITY", "") | |
| 403 | + | if got := testHasX11Auth(); !got { | |
| 404 | + | t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got) | |
| 405 | + | } | |
| 406 | + | }) | |
| 407 | + | ||
| 408 | + | t.Run("DISPLAY TCP remote", func(t *testing.T) { | |
| 409 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 410 | + | t.Setenv("XAUTHORITY", "") | |
| 411 | + | if got := testHasX11Auth(); !got { | |
| 412 | + | t.Errorf("testHasX11Auth() = %v, want true for TCP display", got) | |
| 413 | + | } | |
| 414 | + | }) | |
| 415 | + | ||
| 416 | + | t.Run("DISPLAY local no auth file", func(t *testing.T) { | |
| 417 | + | t.Setenv("DISPLAY", ":0") | |
| 418 | + | t.Setenv("XAUTHORITY", "") | |
| 419 | + | // Use a temp home dir with no .Xauthority | |
| 420 | + | t.Setenv("HOME", t.TempDir()) | |
| 421 | + | if got := testHasX11Auth(); got { | |
| 422 | + | t.Errorf("testHasX11Auth() = %v, want false without auth file", got) | |
| 423 | + | } | |
| 424 | + | }) | |
| 425 | + | ||
| 426 | + | t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) { | |
| 427 | + | t.Setenv("DISPLAY", ":0") | |
| 428 | + | t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority") | |
| 429 | + | if got := testHasX11Auth(); got { | |
| 430 | + | t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got) | |
| 431 | + | } | |
| 432 | + | }) | |
| 433 | + | ||
| 434 | + | t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) { | |
| 435 | + | t.Setenv("DISPLAY", ":1") | |
| 436 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 437 | + | 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}) | |
| 438 | + | t.Setenv("XAUTHORITY", authFile) | |
| 439 | + | if got := testHasX11Auth(); !got { | |
| 440 | + | t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got) | |
| 441 | + | } | |
| 442 | + | }) | |
| 443 | + | ||
| 444 | + | t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) { | |
| 445 | + | t.Setenv("DISPLAY", ":2") | |
| 446 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 447 | + | 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}) | |
| 448 | + | t.Setenv("XAUTHORITY", authFile) | |
| 449 | + | if got := testHasX11Auth(); got { | |
| 450 | + | t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got) | |
| 451 | + | } | |
| 452 | + | }) | |
| 453 | + | ||
| 454 | + | t.Run("DISPLAY local with screen number", func(t *testing.T) { | |
| 455 | + | t.Setenv("DISPLAY", ":0.0") | |
| 456 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 457 | + | 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}) | |
| 458 | + | t.Setenv("XAUTHORITY", authFile) | |
| 459 | + | if got := testHasX11Auth(); !got { | |
| 460 | + | t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got) | |
| 461 | + | } | |
| 462 | + | }) | |
| 463 | + | ||
| 464 | + | t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) { | |
| 465 | + | t.Setenv("DISPLAY", ":") | |
| 466 | + | t.Setenv("XAUTHORITY", "") | |
| 467 | + | if got := testHasX11Auth(); got { | |
| 468 | + | t.Errorf("testHasX11Auth() = %v, want false for empty display number", got) | |
| 469 | + | } | |
| 470 | + | }) | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | // writeXauthRecord appends one local-family .Xauthority record to buf with the given fields. | |
| 474 | + | // If name or data is empty, the length byte is written as zero but the field | |
| 475 | + | // data is omitted, producing a truncated record suitable for malformed tests. | |
| 476 | + | func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) { | |
| 477 | + | // family (2 bytes, big-endian) | |
| 478 | + | familyBytes := make([]byte, 2) | |
| 479 | + | binary.BigEndian.PutUint16(familyBytes, 256) | |
| 480 | + | buf.Write(familyBytes) | |
| 481 | + | ||
| 482 | + | // address | |
| 483 | + | addrLen := make([]byte, 2) | |
| 484 | + | addr := "host" | |
| 485 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |
| 486 | + | buf.Write(addrLen) | |
| 487 | + | buf.WriteString(addr) | |
| 488 | + | ||
| 489 | + | // number | |
| 490 | + | numLen := make([]byte, 2) | |
| 491 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |
| 492 | + | buf.Write(numLen) | |
| 493 | + | buf.WriteString(num) | |
| 494 | + | ||
| 495 | + | // name | |
| 496 | + | nameLen := make([]byte, 2) | |
| 497 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(name))) | |
| 498 | + | buf.Write(nameLen) | |
| 499 | + | if name != "" { | |
| 500 | + | buf.WriteString(name) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // data | |
| 504 | + | dLen := make([]byte, 2) | |
| 505 | + | binary.BigEndian.PutUint16(dLen, uint16(len(data))) | |
| 506 | + | buf.Write(dLen) | |
| 507 | + | if len(data) > 0 { | |
| 508 | + | buf.Write(data) | |
| 509 | + | } | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | func Test_testHasX11Auth_MalformedRecords(t *testing.T) { | |
| 513 | + | t.Run("matching display with empty name field", func(t *testing.T) { | |
| 514 | + | t.Setenv("DISPLAY", ":1") | |
| 515 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 516 | + | var buf bytes.Buffer | |
| 517 | + | writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04}) | |
| 518 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 519 | + | t.Fatal(err) | |
| 520 | + | } | |
| 521 | + | t.Setenv("XAUTHORITY", authFile) | |
| 522 | + | if got := testHasX11Auth(); got { | |
| 523 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got) | |
| 524 | + | } | |
| 525 | + | }) | |
| 526 | + | ||
| 527 | + | t.Run("matching display with empty data field", func(t *testing.T) { | |
| 528 | + | t.Setenv("DISPLAY", ":1") | |
| 529 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 530 | + | var buf bytes.Buffer | |
| 531 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil) | |
| 532 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 533 | + | t.Fatal(err) | |
| 534 | + | } | |
| 535 | + | t.Setenv("XAUTHORITY", authFile) | |
| 536 | + | if got := testHasX11Auth(); got { | |
| 537 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got) | |
| 538 | + | } | |
| 539 | + | }) | |
| 540 | + | ||
| 541 | + | t.Run("truncated record after display number", func(t *testing.T) { | |
| 542 | + | t.Setenv("DISPLAY", ":1") | |
| 543 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 544 | + | var buf bytes.Buffer | |
| 545 | + | family := make([]byte, 2) | |
| 546 | + | binary.BigEndian.PutUint16(family, 256) | |
| 547 | + | buf.Write(family) | |
| 548 | + | addr := "host" | |
| 549 | + | addrLen := make([]byte, 2) | |
| 550 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |
| 551 | + | buf.Write(addrLen) | |
| 552 | + | buf.WriteString(addr) | |
| 553 | + | num := "1" | |
| 554 | + | numLen := make([]byte, 2) | |
| 555 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |
| 556 | + | buf.Write(numLen) | |
| 557 | + | buf.WriteString(num) | |
| 558 | + | // Record ends here — no name or data fields follow. | |
| 559 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 560 | + | t.Fatal(err) | |
| 561 | + | } | |
| 562 | + | t.Setenv("XAUTHORITY", authFile) | |
| 563 | + | if got := testHasX11Auth(); got { | |
| 564 | + | t.Errorf("testHasX11Auth() = %v, want false for truncated record", got) | |
| 565 | + | } | |
| 566 | + | }) | |
| 567 | + | ||
| 568 | + | t.Run("multiple entries with one matching and valid", func(t *testing.T) { | |
| 569 | + | t.Setenv("DISPLAY", ":3") | |
| 570 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 571 | + | var buf bytes.Buffer | |
| 572 | + | writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04}) | |
| 573 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08}) | |
| 574 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |
| 575 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 576 | + | t.Fatal(err) | |
| 577 | + | } | |
| 578 | + | t.Setenv("XAUTHORITY", authFile) | |
| 579 | + | if got := testHasX11Auth(); !got { | |
| 580 | + | t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got) | |
| 581 | + | } | |
| 582 | + | }) | |
| 583 | + | ||
| 584 | + | t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) { | |
| 585 | + | t.Setenv("DISPLAY", ":3") | |
| 586 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |
| 587 | + | var buf bytes.Buffer | |
| 588 | + | writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04}) | |
| 589 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |
| 590 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |
| 591 | + | t.Fatal(err) | |
| 592 | + | } | |
| 593 | + | t.Setenv("XAUTHORITY", authFile) | |
| 594 | + | if got := testHasX11Auth(); !got { | |
| 595 | + | t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got) | |
| 596 | + | } | |
| 597 | + | }) | |
| 598 | + | } | |
| 599 | + | ||
| 600 | + | func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) { | |
| 601 | + | t.Setenv("GDK_BACKEND", "") | |
| 602 | + | ||
| 603 | + | t.Run("no WAYLAND_DISPLAY", func(t *testing.T) { | |
| 604 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 605 | + | if got := testHasUsableWaylandDisplay(); got { | |
| 606 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got) | |
| 607 | + | } | |
| 608 | + | }) | |
| 609 | + | ||
| 610 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |
| 611 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 612 | + | tmpDir := t.TempDir() | |
| 613 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 614 | + | testCreateUnixSocket(t, socketPath) | |
| 615 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 616 | + | if got := testHasUsableWaylandDisplay(); !got { | |
| 617 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got) | |
| 618 | + | } | |
| 619 | + | }) | |
| 620 | + | ||
| 621 | + | t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) { | |
| 622 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |
| 623 | + | testCreateUnixSocket(t, socketPath) | |
| 624 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |
| 625 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") | |
| 626 | + | if got := testHasUsableWaylandDisplay(); !got { | |
| 627 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got) | |
| 628 | + | } | |
| 629 | + | }) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | func Test_testHasUsableX11Display_PureProbe(t *testing.T) { | |
| 633 | + | t.Setenv("GDK_BACKEND", "") | |
| 634 | + | ||
| 635 | + | t.Run("no DISPLAY", func(t *testing.T) { | |
| 636 | + | t.Setenv("DISPLAY", "") | |
| 637 | + | if got := testHasUsableX11Display(); got { | |
| 638 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |
| 639 | + | } | |
| 640 | + | }) | |
| 641 | + | ||
| 642 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |
| 643 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 644 | + | t.Setenv("DISPLAY", ":"+displayNum) | |
| 645 | + | if got := testHasUsableX11Display(); got { | |
| 646 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |
| 647 | + | } | |
| 648 | + | }) | |
| 649 | + | ||
| 650 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |
| 651 | + | t.Setenv("DISPLAY", "remote-host:0") | |
| 652 | + | if got := testHasUsableX11Display(); !got { | |
| 653 | + | t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got) | |
| 654 | + | } | |
| 655 | + | }) | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) { | |
| 659 | + | t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) { | |
| 660 | + | t.Setenv("GDK_BACKEND", "x11") | |
| 661 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 662 | + | tmpDir := t.TempDir() | |
| 663 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1")) | |
| 664 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 665 | + | t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t)) | |
| 666 | + | if got := testHasUsableGTKDisplay(); got { | |
| 667 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got) | |
| 668 | + | } | |
| 669 | + | }) | |
| 670 | + | ||
| 671 | + | t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) { | |
| 672 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 673 | + | t.Setenv("GDK_BACKEND", "wayland") | |
| 674 | + | t.Setenv("DISPLAY", ":"+displayNum) | |
| 675 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-missing") | |
| 676 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 677 | + | testCreateX11Socket(t, displayNum) | |
| 678 | + | if got := testHasUsableGTKDisplay(); got { | |
| 679 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got) | |
| 680 | + | } | |
| 681 | + | }) | |
| 682 | + | ||
| 683 | + | t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) { | |
| 684 | + | displayNum := testUnusedX11DisplayNumber(t) | |
| 685 | + | t.Setenv("GDK_BACKEND", "wayland,x11") | |
| 686 | + | t.Setenv("DISPLAY", ":"+displayNum) | |
| 687 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-missing") | |
| 688 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |
| 689 | + | testCreateX11Socket(t, displayNum) | |
| 690 | + | if got := testHasUsableGTKDisplay(); !got { | |
| 691 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got) | |
| 692 | + | } | |
| 693 | + | if got := testNeedsX11Auth(); !got { | |
| 694 | + | t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got) | |
| 695 | + | } | |
| 696 | + | }) | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | func Test_testNeedsX11Auth_PureProbe(t *testing.T) { | |
| 700 | + | t.Run("Wayland available forces no X11 auth", func(t *testing.T) { | |
| 701 | + | t.Setenv("GDK_BACKEND", "") | |
| 702 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 703 | + | tmpDir := t.TempDir() | |
| 704 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 705 | + | testCreateUnixSocket(t, socketPath) | |
| 706 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 707 | + | t.Setenv("DISPLAY", ":0") | |
| 708 | + | if got := testNeedsX11Auth(); got { | |
| 709 | + | t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got) | |
| 710 | + | } | |
| 711 | + | }) | |
| 712 | + | ||
| 713 | + | t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) { | |
| 714 | + | t.Setenv("GDK_BACKEND", "x11") | |
| 715 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |
| 716 | + | tmpDir := t.TempDir() | |
| 717 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |
| 718 | + | testCreateUnixSocket(t, socketPath) | |
| 719 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |
| 720 | + | if got := testNeedsX11Auth(); !got { | |
| 721 | + | t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got) | |
| 722 | + | } | |
| 723 | + | }) | |
| 724 | + | ||
| 725 | + | t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) { | |
| 726 | + | t.Setenv("GDK_BACKEND", "") | |
| 727 | + | t.Setenv("WAYLAND_DISPLAY", "") | |
| 728 | + | t.Setenv("DISPLAY", ":0") | |
| 729 | + | if got := testNeedsX11Auth(); !got { | |
| 730 | + | t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got) | |
| 731 | + | } | |
| 732 | + | }) | |
| 733 | + | } | |
| 734 | + | ||
| 735 | + | // createXauthFile writes a minimal .Xauthority file with one entry. | |
| 736 | + | // family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie. | |
| 737 | + | func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) { | |
| 738 | + | t.Helper() | |
| 739 | + | ||
| 740 | + | hostname, err := os.Hostname() | |
| 741 | + | if err != nil { | |
| 742 | + | hostname = "localhost" | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | var buf bytes.Buffer | |
| 746 | + | // family (2 bytes, big-endian) - 256 = FamilyLocal | |
| 747 | + | family := make([]byte, 2) | |
| 748 | + | binary.BigEndian.PutUint16(family, 256) | |
| 749 | + | buf.Write(family) | |
| 750 | + | ||
| 751 | + | // address length + address (hostname) | |
| 752 | + | addrLen := make([]byte, 2) | |
| 753 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(hostname))) | |
| 754 | + | buf.Write(addrLen) | |
| 755 | + | buf.WriteString(hostname) | |
| 756 | + | ||
| 757 | + | // number length + number (display number) | |
| 758 | + | numLen := make([]byte, 2) | |
| 759 | + | binary.BigEndian.PutUint16(numLen, uint16(len(displayNum))) | |
| 760 | + | buf.Write(numLen) | |
| 761 | + | buf.WriteString(displayNum) | |
| 762 | + | ||
| 763 | + | // name length + name (auth protocol) | |
| 764 | + | nameLen := make([]byte, 2) | |
| 765 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(proto))) | |
| 766 | + | buf.Write(nameLen) | |
| 767 | + | buf.WriteString(proto) | |
| 768 | + | ||
| 769 | + | // data length + data (auth cookie) | |
| 770 | + | dataLen := make([]byte, 2) | |
| 771 | + | binary.BigEndian.PutUint16(dataLen, uint16(len(cookie))) | |
| 772 | + | buf.Write(dataLen) | |
| 773 | + | buf.Write(cookie) | |
| 774 | + | ||
| 775 | + | if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { | |
| 776 | + | t.Fatal(err) | |
| 777 | + | } | |
| 778 | + | } | |
| 779 | + | ||
| 136 | 780 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | |
| 137 | 781 | // Keep this test-only so production visibility stays narrow. | |
| 138 | 782 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | |
| @@ -1,10 +1,13 @@ | |||||
| 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" | ||
| 10 | + | "net" | |||
| 8 | "os" | 11 | "os" | ||
| 9 | "path/filepath" | 12 | "path/filepath" | ||
| 10 | "reflect" | 13 | "reflect" | ||
| @@ -79,51 +82,220 @@ func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.Sessi | |||||
| 79 | 82 | ||||
| 80 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | 83 | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | ||
| 81 | 84 | ||||
| 82 | - | func testHasUsableGTKDisplay() bool { | 85 | + | func testPathIsSocket(path string) bool { |
| 83 | - | if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" { | 86 | + | info, err := os.Stat(path) |
| 84 | - | candidates := []string{waylandDisplay} | 87 | + | return err == nil && info.Mode()&os.ModeSocket != 0 |
| 85 | - | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | 88 | + | } |
| 86 | - | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | 89 | + | |
| 90 | + | func testGDKBackendAllows(backend string) bool { | |||
| 91 | + | configured := os.Getenv("GDK_BACKEND") | |||
| 92 | + | if configured == "" { | |||
| 93 | + | return true | |||
| 94 | + | } | |||
| 95 | + | for _, candidate := range strings.Split(configured, ",") { | |||
| 96 | + | if strings.TrimSpace(candidate) == backend { | |||
| 97 | + | return true | |||
| 87 | } | 98 | } | ||
| 88 | - | for _, candidate := range candidates { | 99 | + | } |
| 89 | - | if _, err := os.Stat(candidate); err == nil { | 100 | + | return false |
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func testHasUsableWaylandDisplay() bool { | |||
| 104 | + | if !testGDKBackendAllows("wayland") { | |||
| 105 | + | return false | |||
| 106 | + | } | |||
| 107 | + | waylandDisplay := os.Getenv("WAYLAND_DISPLAY") | |||
| 108 | + | if waylandDisplay == "" { | |||
| 109 | + | return false | |||
| 110 | + | } | |||
| 111 | + | candidates := []string{waylandDisplay} | |||
| 112 | + | if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) { | |||
| 113 | + | candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...) | |||
| 114 | + | } | |||
| 115 | + | for _, candidate := range candidates { | |||
| 116 | + | if testPathIsSocket(candidate) { | |||
| 117 | + | return true | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | return false | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func testHasUsableX11Display() bool { | |||
| 124 | + | if !testGDKBackendAllows("x11") { | |||
| 125 | + | return false | |||
| 126 | + | } | |||
| 127 | + | display := os.Getenv("DISPLAY") | |||
| 128 | + | if display == "" { | |||
| 129 | + | return false | |||
| 130 | + | } | |||
| 131 | + | if strings.HasPrefix(display, ":") { | |||
| 132 | + | displayNum := strings.TrimPrefix(display, ":") | |||
| 133 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |||
| 134 | + | if displayNum == "" { | |||
| 135 | + | return false | |||
| 136 | + | } | |||
| 137 | + | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | |||
| 138 | + | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | |||
| 139 | + | x11SocketCandidates = append(x11SocketCandidates, fallback) | |||
| 140 | + | } | |||
| 141 | + | for _, candidate := range x11SocketCandidates { | |||
| 142 | + | if testPathIsSocket(candidate) { | |||
| 90 | return true | 143 | return true | ||
| 91 | } | 144 | } | ||
| 92 | } | 145 | } | ||
| 146 | + | return false | |||
| 93 | } | 147 | } | ||
| 148 | + | return true // TCP display | |||
| 149 | + | } | |||
| 94 | 150 | ||||
| 95 | - | if display := os.Getenv("DISPLAY"); display != "" { | 151 | + | func testHasUsableGTKDisplay() bool { |
| 96 | - | if strings.HasPrefix(display, ":") { | 152 | + | return testHasUsableWaylandDisplay() || testHasUsableX11Display() |
| 97 | - | displayNum := strings.TrimPrefix(display, ":") | 153 | + | } |
| 98 | - | displayNum = strings.SplitN(displayNum, ".", 2)[0] | 154 | + | |
| 99 | - | if displayNum == "" { | 155 | + | // testHasX11Auth checks whether X11 authorization is available for the current |
| 100 | - | return false | 156 | + | // DISPLAY before any GTK/Adwaita initialization. It is a pure function that |
| 101 | - | } | 157 | + | // inspects env vars and the .Xauthority file only; no GTK calls. |
| 102 | - | x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)} | 158 | + | func testHasX11Auth() bool { |
| 103 | - | if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] { | 159 | + | display := os.Getenv("DISPLAY") |
| 104 | - | x11SocketCandidates = append(x11SocketCandidates, fallback) | 160 | + | if display == "" || !strings.HasPrefix(display, ":") { |
| 105 | - | } | 161 | + | // No DISPLAY or TCP-style display; can't check locally. |
| 106 | - | for _, candidate := range x11SocketCandidates { | 162 | + | // Let GTK try and fall through to gdk.DisplayGetDefault(). |
| 107 | - | if _, err := os.Stat(candidate); err == nil { | 163 | + | return true |
| 108 | - | return true | 164 | + | } |
| 109 | - | } | 165 | + | |
| 110 | - | } | 166 | + | displayNum := strings.TrimPrefix(display, ":") |
| 167 | + | displayNum = strings.SplitN(displayNum, ".", 2)[0] | |||
| 168 | + | if displayNum == "" { | |||
| 169 | + | return false | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // Find the X authority file. | |||
| 173 | + | authFile := os.Getenv("XAUTHORITY") | |||
| 174 | + | if authFile == "" { | |||
| 175 | + | home, err := os.UserHomeDir() | |||
| 176 | + | if err != nil { | |||
| 111 | return false | 177 | return false | ||
| 112 | } | 178 | } | ||
| 113 | - | return true | 179 | + | authFile = filepath.Join(home, ".Xauthority") |
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | if _, err := os.Stat(authFile); err != nil { | |||
| 183 | + | return false // No authority file; X11 auth will fail. | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Parse the .Xauthority file to look for a matching display entry. | |||
| 187 | + | // Format: sequence of {family(2B), addrLen(2B), addr, numLen(2B), num, nameLen(2B), name, dataLen(2B), data} | |||
| 188 | + | // All multi-byte values are big-endian (network byte order). | |||
| 189 | + | data, err := os.ReadFile(authFile) | |||
| 190 | + | if err != nil { | |||
| 191 | + | return false | |||
| 114 | } | 192 | } | ||
| 115 | 193 | ||||
| 194 | + | return testXauthorityHasDisplay(data, displayNum) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func testXauthorityHasDisplay(data []byte, displayNum string) bool { | |||
| 198 | + | for len(data) > 0 { | |||
| 199 | + | if len(data) < 2 { | |||
| 200 | + | return false | |||
| 201 | + | } | |||
| 202 | + | data = data[2:] // family, unused | |||
| 203 | + | ||||
| 204 | + | _, rest, ok := testReadXauthorityField(data) // address | |||
| 205 | + | if !ok { | |||
| 206 | + | return false | |||
| 207 | + | } | |||
| 208 | + | num, rest, ok := testReadXauthorityField(rest) | |||
| 209 | + | if !ok { | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 212 | + | name, rest, ok := testReadXauthorityField(rest) | |||
| 213 | + | if !ok { | |||
| 214 | + | return false | |||
| 215 | + | } | |||
| 216 | + | cookie, rest, ok := testReadXauthorityField(rest) | |||
| 217 | + | if !ok { | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | if string(num) == displayNum && len(name) > 0 && len(cookie) > 0 { | |||
| 221 | + | return true | |||
| 222 | + | } | |||
| 223 | + | data = rest | |||
| 224 | + | } | |||
| 116 | return false | 225 | return false | ||
| 117 | } | 226 | } | ||
| 118 | 227 | ||||
| 228 | + | func testReadXauthorityField(data []byte) ([]byte, []byte, bool) { | |||
| 229 | + | if len(data) < 2 { | |||
| 230 | + | return nil, nil, false | |||
| 231 | + | } | |||
| 232 | + | fieldLen := int(binary.BigEndian.Uint16(data[:2])) | |||
| 233 | + | data = data[2:] | |||
| 234 | + | if len(data) < fieldLen { | |||
| 235 | + | return nil, nil, false | |||
| 236 | + | } | |||
| 237 | + | return data[:fieldLen], data[fieldLen:], true | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | // testNeedsX11Auth reports whether GTK may need the X11 backend and therefore | |||
| 241 | + | // requires X11 authorization before GTK initialization. Wayland-only backend | |||
| 242 | + | // configuration never needs X11 auth; X11 is required when it is the only usable | |||
| 243 | + | // allowed backend. | |||
| 244 | + | func testNeedsX11Auth() bool { | |||
| 245 | + | if !testGDKBackendAllows("x11") { | |||
| 246 | + | return false | |||
| 247 | + | } | |||
| 248 | + | return !testGDKBackendAllows("wayland") || !testHasUsableWaylandDisplay() | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | func testCreateUnixSocket(t *testing.T, path string) { | |||
| 252 | + | t.Helper() | |||
| 253 | + | if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { | |||
| 254 | + | t.Fatal(err) | |||
| 255 | + | } | |||
| 256 | + | listener, err := net.Listen("unix", path) | |||
| 257 | + | if err != nil { | |||
| 258 | + | t.Fatal(err) | |||
| 259 | + | } | |||
| 260 | + | t.Cleanup(func() { | |||
| 261 | + | _ = listener.Close() | |||
| 262 | + | _ = os.Remove(path) | |||
| 263 | + | }) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func testUnusedX11DisplayNumber(t *testing.T) string { | |||
| 267 | + | t.Helper() | |||
| 268 | + | for display := 90; display < 300; display++ { | |||
| 269 | + | displayNum := strconv.Itoa(display) | |||
| 270 | + | if !testPathIsSocket("/tmp/.X11-unix/X" + displayNum) { | |||
| 271 | + | return displayNum | |||
| 272 | + | } | |||
| 273 | + | } | |||
| 274 | + | t.Fatal("could not find an unused X11 display number") | |||
| 275 | + | return "" | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func testCreateX11Socket(t *testing.T, displayNum string) { | |||
| 279 | + | t.Helper() | |||
| 280 | + | tmpDir := t.TempDir() | |||
| 281 | + | t.Setenv("TMPDIR", tmpDir) | |||
| 282 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum)) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 119 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | 285 | func requireGTKDisplayApp(t *testing.T) *gtk.Application { | ||
| 120 | t.Helper() | 286 | t.Helper() | ||
| 121 | - | if !testHasUsableGTKDisplay() { | 287 | + | hasWayland := testHasUsableWaylandDisplay() |
| 122 | - | t.Skip("GTK display not available") | 288 | + | hasX11 := testHasUsableX11Display() |
| 289 | + | if !hasWayland && !hasX11 { | |||
| 290 | + | t.Skip("GTK display not available (no DISPLAY/WAYLAND_DISPLAY socket)") | |||
| 291 | + | } | |||
| 292 | + | // Only require X11 auth when X11 is the only usable allowed backend. | |||
| 293 | + | if testNeedsX11Auth() && !testHasX11Auth() { | |||
| 294 | + | t.Skip("GTK display not available (X11 authorization unavailable)") | |||
| 123 | } | 295 | } | ||
| 124 | EnsureAdwaitaInitialized() | 296 | EnsureAdwaitaInitialized() | ||
| 125 | if gdk.DisplayGetDefault() == nil { | 297 | if gdk.DisplayGetDefault() == nil { | ||
| 126 | - | t.Skip("GTK display not available") | 298 | + | t.Skip("GTK display not available (gdk.DisplayGetDefault returned nil)") |
| 127 | } | 299 | } | ||
| 128 | appID := AppID | 300 | appID := AppID | ||
| 129 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | 301 | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | ||
| @@ -133,6 +305,478 @@ func requireGTKDisplayApp(t *testing.T) *gtk.Application { | |||||
| 133 | return gtkApp | 305 | return gtkApp | ||
| 134 | } | 306 | } | ||
| 135 | 307 | ||||
| 308 | + | func Test_testHasUsableGTKDisplay_PureProbe(t *testing.T) { | |||
| 309 | + | t.Setenv("GDK_BACKEND", "") | |||
| 310 | + | ||||
| 311 | + | t.Run("no display env vars", func(t *testing.T) { | |||
| 312 | + | t.Setenv("DISPLAY", "") | |||
| 313 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 314 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 315 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 316 | + | } | |||
| 317 | + | }) | |||
| 318 | + | ||||
| 319 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |||
| 320 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 321 | + | t.Setenv("DISPLAY", ":"+displayNum) | |||
| 322 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 323 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 324 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 325 | + | } | |||
| 326 | + | }) | |||
| 327 | + | ||||
| 328 | + | t.Run("DISPLAY with screen without socket", func(t *testing.T) { | |||
| 329 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 330 | + | t.Setenv("DISPLAY", ":"+displayNum+".0") | |||
| 331 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 332 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 333 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false", got) | |||
| 334 | + | } | |||
| 335 | + | }) | |||
| 336 | + | ||||
| 337 | + | t.Run("DISPLAY colon only no number", func(t *testing.T) { | |||
| 338 | + | t.Setenv("DISPLAY", ":") | |||
| 339 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 340 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 341 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false for empty display number", got) | |||
| 342 | + | } | |||
| 343 | + | }) | |||
| 344 | + | ||||
| 345 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |||
| 346 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 347 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 348 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 349 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true for TCP display", got) | |||
| 350 | + | } | |||
| 351 | + | }) | |||
| 352 | + | ||||
| 353 | + | t.Run("DISPLAY with existing socket", func(t *testing.T) { | |||
| 354 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 355 | + | tmpDir := t.TempDir() | |||
| 356 | + | t.Setenv("TMPDIR", tmpDir) | |||
| 357 | + | t.Setenv("DISPLAY", ":"+displayNum) | |||
| 358 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 359 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, ".X11-unix", "X"+displayNum)) | |||
| 360 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 361 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing X11 socket", got) | |||
| 362 | + | } | |||
| 363 | + | }) | |||
| 364 | + | ||||
| 365 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |||
| 366 | + | t.Setenv("DISPLAY", "") | |||
| 367 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 368 | + | tmpDir := t.TempDir() | |||
| 369 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 370 | + | testCreateUnixSocket(t, socketPath) | |||
| 371 | + | // XDG_RUNTIME_DIR controls where Wayland sockets are looked up | |||
| 372 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 373 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 374 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with existing wayland socket", got) | |||
| 375 | + | } | |||
| 376 | + | }) | |||
| 377 | + | ||||
| 378 | + | t.Run("WAYLAND_DISPLAY without socket", func(t *testing.T) { | |||
| 379 | + | t.Setenv("DISPLAY", "") | |||
| 380 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-nonexistent") | |||
| 381 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 382 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 383 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false without wayland socket", got) | |||
| 384 | + | } | |||
| 385 | + | }) | |||
| 386 | + | ||||
| 387 | + | t.Run("WAYLAND_DISPLAY absolute path with existing socket", func(t *testing.T) { | |||
| 388 | + | t.Setenv("DISPLAY", "") | |||
| 389 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |||
| 390 | + | testCreateUnixSocket(t, socketPath) | |||
| 391 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |||
| 392 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") // Should be ignored for absolute paths | |||
| 393 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 394 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with absolute wayland socket path", got) | |||
| 395 | + | } | |||
| 396 | + | }) | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | func Test_testHasX11Auth_PureProbe(t *testing.T) { | |||
| 400 | + | t.Run("DISPLAY unset", func(t *testing.T) { | |||
| 401 | + | t.Setenv("DISPLAY", "") | |||
| 402 | + | t.Setenv("XAUTHORITY", "") | |||
| 403 | + | if got := testHasX11Auth(); !got { | |||
| 404 | + | t.Errorf("testHasX11Auth() = %v, want true when DISPLAY unset", got) | |||
| 405 | + | } | |||
| 406 | + | }) | |||
| 407 | + | ||||
| 408 | + | t.Run("DISPLAY TCP remote", func(t *testing.T) { | |||
| 409 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 410 | + | t.Setenv("XAUTHORITY", "") | |||
| 411 | + | if got := testHasX11Auth(); !got { | |||
| 412 | + | t.Errorf("testHasX11Auth() = %v, want true for TCP display", got) | |||
| 413 | + | } | |||
| 414 | + | }) | |||
| 415 | + | ||||
| 416 | + | t.Run("DISPLAY local no auth file", func(t *testing.T) { | |||
| 417 | + | t.Setenv("DISPLAY", ":0") | |||
| 418 | + | t.Setenv("XAUTHORITY", "") | |||
| 419 | + | // Use a temp home dir with no .Xauthority | |||
| 420 | + | t.Setenv("HOME", t.TempDir()) | |||
| 421 | + | if got := testHasX11Auth(); got { | |||
| 422 | + | t.Errorf("testHasX11Auth() = %v, want false without auth file", got) | |||
| 423 | + | } | |||
| 424 | + | }) | |||
| 425 | + | ||||
| 426 | + | t.Run("DISPLAY local XAUTHORITY env missing file", func(t *testing.T) { | |||
| 427 | + | t.Setenv("DISPLAY", ":0") | |||
| 428 | + | t.Setenv("XAUTHORITY", "/nonexistent/.Xauthority") | |||
| 429 | + | if got := testHasX11Auth(); got { | |||
| 430 | + | t.Errorf("testHasX11Auth() = %v, want false when XAUTHORITY file missing", got) | |||
| 431 | + | } | |||
| 432 | + | }) | |||
| 433 | + | ||||
| 434 | + | t.Run("DISPLAY local XAUTHORITY with matching entry", func(t *testing.T) { | |||
| 435 | + | t.Setenv("DISPLAY", ":1") | |||
| 436 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 437 | + | 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}) | |||
| 438 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 439 | + | if got := testHasX11Auth(); !got { | |||
| 440 | + | t.Errorf("testHasX11Auth() = %v, want true with matching auth entry", got) | |||
| 441 | + | } | |||
| 442 | + | }) | |||
| 443 | + | ||||
| 444 | + | t.Run("DISPLAY local XAUTHORITY with non-matching entry", func(t *testing.T) { | |||
| 445 | + | t.Setenv("DISPLAY", ":2") | |||
| 446 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 447 | + | 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}) | |||
| 448 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 449 | + | if got := testHasX11Auth(); got { | |||
| 450 | + | t.Errorf("testHasX11Auth() = %v, want false with non-matching auth entry", got) | |||
| 451 | + | } | |||
| 452 | + | }) | |||
| 453 | + | ||||
| 454 | + | t.Run("DISPLAY local with screen number", func(t *testing.T) { | |||
| 455 | + | t.Setenv("DISPLAY", ":0.0") | |||
| 456 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 457 | + | 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}) | |||
| 458 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 459 | + | if got := testHasX11Auth(); !got { | |||
| 460 | + | t.Errorf("testHasX11Auth() = %v, want true with screen suffix stripped", got) | |||
| 461 | + | } | |||
| 462 | + | }) | |||
| 463 | + | ||||
| 464 | + | t.Run("DISPLAY colon only no number defaults to false", func(t *testing.T) { | |||
| 465 | + | t.Setenv("DISPLAY", ":") | |||
| 466 | + | t.Setenv("XAUTHORITY", "") | |||
| 467 | + | if got := testHasX11Auth(); got { | |||
| 468 | + | t.Errorf("testHasX11Auth() = %v, want false for empty display number", got) | |||
| 469 | + | } | |||
| 470 | + | }) | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | // writeXauthRecord appends one local-family .Xauthority record to buf with the given fields. | |||
| 474 | + | // If name or data is empty, the length byte is written as zero but the field | |||
| 475 | + | // data is omitted, producing a truncated record suitable for malformed tests. | |||
| 476 | + | func writeXauthRecord(buf *bytes.Buffer, num, name string, data []byte) { | |||
| 477 | + | // family (2 bytes, big-endian) | |||
| 478 | + | familyBytes := make([]byte, 2) | |||
| 479 | + | binary.BigEndian.PutUint16(familyBytes, 256) | |||
| 480 | + | buf.Write(familyBytes) | |||
| 481 | + | ||||
| 482 | + | // address | |||
| 483 | + | addrLen := make([]byte, 2) | |||
| 484 | + | addr := "host" | |||
| 485 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |||
| 486 | + | buf.Write(addrLen) | |||
| 487 | + | buf.WriteString(addr) | |||
| 488 | + | ||||
| 489 | + | // number | |||
| 490 | + | numLen := make([]byte, 2) | |||
| 491 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |||
| 492 | + | buf.Write(numLen) | |||
| 493 | + | buf.WriteString(num) | |||
| 494 | + | ||||
| 495 | + | // name | |||
| 496 | + | nameLen := make([]byte, 2) | |||
| 497 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(name))) | |||
| 498 | + | buf.Write(nameLen) | |||
| 499 | + | if name != "" { | |||
| 500 | + | buf.WriteString(name) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // data | |||
| 504 | + | dLen := make([]byte, 2) | |||
| 505 | + | binary.BigEndian.PutUint16(dLen, uint16(len(data))) | |||
| 506 | + | buf.Write(dLen) | |||
| 507 | + | if len(data) > 0 { | |||
| 508 | + | buf.Write(data) | |||
| 509 | + | } | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | func Test_testHasX11Auth_MalformedRecords(t *testing.T) { | |||
| 513 | + | t.Run("matching display with empty name field", func(t *testing.T) { | |||
| 514 | + | t.Setenv("DISPLAY", ":1") | |||
| 515 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 516 | + | var buf bytes.Buffer | |||
| 517 | + | writeXauthRecord(&buf, "1", "", []byte{0x01, 0x02, 0x03, 0x04}) | |||
| 518 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 519 | + | t.Fatal(err) | |||
| 520 | + | } | |||
| 521 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 522 | + | if got := testHasX11Auth(); got { | |||
| 523 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty name", got) | |||
| 524 | + | } | |||
| 525 | + | }) | |||
| 526 | + | ||||
| 527 | + | t.Run("matching display with empty data field", func(t *testing.T) { | |||
| 528 | + | t.Setenv("DISPLAY", ":1") | |||
| 529 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 530 | + | var buf bytes.Buffer | |||
| 531 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", nil) | |||
| 532 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 533 | + | t.Fatal(err) | |||
| 534 | + | } | |||
| 535 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 536 | + | if got := testHasX11Auth(); got { | |||
| 537 | + | t.Errorf("testHasX11Auth() = %v, want false for matching display with empty data", got) | |||
| 538 | + | } | |||
| 539 | + | }) | |||
| 540 | + | ||||
| 541 | + | t.Run("truncated record after display number", func(t *testing.T) { | |||
| 542 | + | t.Setenv("DISPLAY", ":1") | |||
| 543 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 544 | + | var buf bytes.Buffer | |||
| 545 | + | family := make([]byte, 2) | |||
| 546 | + | binary.BigEndian.PutUint16(family, 256) | |||
| 547 | + | buf.Write(family) | |||
| 548 | + | addr := "host" | |||
| 549 | + | addrLen := make([]byte, 2) | |||
| 550 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(addr))) | |||
| 551 | + | buf.Write(addrLen) | |||
| 552 | + | buf.WriteString(addr) | |||
| 553 | + | num := "1" | |||
| 554 | + | numLen := make([]byte, 2) | |||
| 555 | + | binary.BigEndian.PutUint16(numLen, uint16(len(num))) | |||
| 556 | + | buf.Write(numLen) | |||
| 557 | + | buf.WriteString(num) | |||
| 558 | + | // Record ends here — no name or data fields follow. | |||
| 559 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 560 | + | t.Fatal(err) | |||
| 561 | + | } | |||
| 562 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 563 | + | if got := testHasX11Auth(); got { | |||
| 564 | + | t.Errorf("testHasX11Auth() = %v, want false for truncated record", got) | |||
| 565 | + | } | |||
| 566 | + | }) | |||
| 567 | + | ||||
| 568 | + | t.Run("multiple entries with one matching and valid", func(t *testing.T) { | |||
| 569 | + | t.Setenv("DISPLAY", ":3") | |||
| 570 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 571 | + | var buf bytes.Buffer | |||
| 572 | + | writeXauthRecord(&buf, "0", "MIT-MAGIC-COOKIE-1", []byte{0x01, 0x02, 0x03, 0x04}) | |||
| 573 | + | writeXauthRecord(&buf, "1", "MIT-MAGIC-COOKIE-1", []byte{0x05, 0x06, 0x07, 0x08}) | |||
| 574 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |||
| 575 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 576 | + | t.Fatal(err) | |||
| 577 | + | } | |||
| 578 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 579 | + | if got := testHasX11Auth(); !got { | |||
| 580 | + | t.Errorf("testHasX11Auth() = %v, want true for third entry matching display :3", got) | |||
| 581 | + | } | |||
| 582 | + | }) | |||
| 583 | + | ||||
| 584 | + | t.Run("invalid matching entry does not hide later valid match", func(t *testing.T) { | |||
| 585 | + | t.Setenv("DISPLAY", ":3") | |||
| 586 | + | authFile := filepath.Join(t.TempDir(), ".Xauthority") | |||
| 587 | + | var buf bytes.Buffer | |||
| 588 | + | writeXauthRecord(&buf, "3", "", []byte{0x01, 0x02, 0x03, 0x04}) | |||
| 589 | + | writeXauthRecord(&buf, "3", "MIT-MAGIC-COOKIE-1", []byte{0x09, 0x0a, 0x0b, 0x0c}) | |||
| 590 | + | if err := os.WriteFile(authFile, buf.Bytes(), 0644); err != nil { | |||
| 591 | + | t.Fatal(err) | |||
| 592 | + | } | |||
| 593 | + | t.Setenv("XAUTHORITY", authFile) | |||
| 594 | + | if got := testHasX11Auth(); !got { | |||
| 595 | + | t.Errorf("testHasX11Auth() = %v, want true for later valid matching display", got) | |||
| 596 | + | } | |||
| 597 | + | }) | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | func Test_testHasUsableWaylandDisplay_PureProbe(t *testing.T) { | |||
| 601 | + | t.Setenv("GDK_BACKEND", "") | |||
| 602 | + | ||||
| 603 | + | t.Run("no WAYLAND_DISPLAY", func(t *testing.T) { | |||
| 604 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 605 | + | if got := testHasUsableWaylandDisplay(); got { | |||
| 606 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want false", got) | |||
| 607 | + | } | |||
| 608 | + | }) | |||
| 609 | + | ||||
| 610 | + | t.Run("WAYLAND_DISPLAY with existing socket", func(t *testing.T) { | |||
| 611 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 612 | + | tmpDir := t.TempDir() | |||
| 613 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 614 | + | testCreateUnixSocket(t, socketPath) | |||
| 615 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 616 | + | if got := testHasUsableWaylandDisplay(); !got { | |||
| 617 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with existing wayland socket", got) | |||
| 618 | + | } | |||
| 619 | + | }) | |||
| 620 | + | ||||
| 621 | + | t.Run("WAYLAND_DISPLAY absolute path", func(t *testing.T) { | |||
| 622 | + | socketPath := filepath.Join(t.TempDir(), "wayland.sock") | |||
| 623 | + | testCreateUnixSocket(t, socketPath) | |||
| 624 | + | t.Setenv("WAYLAND_DISPLAY", socketPath) | |||
| 625 | + | t.Setenv("XDG_RUNTIME_DIR", "/nonexistent") | |||
| 626 | + | if got := testHasUsableWaylandDisplay(); !got { | |||
| 627 | + | t.Errorf("testHasUsableWaylandDisplay() = %v, want true with absolute path", got) | |||
| 628 | + | } | |||
| 629 | + | }) | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | func Test_testHasUsableX11Display_PureProbe(t *testing.T) { | |||
| 633 | + | t.Setenv("GDK_BACKEND", "") | |||
| 634 | + | ||||
| 635 | + | t.Run("no DISPLAY", func(t *testing.T) { | |||
| 636 | + | t.Setenv("DISPLAY", "") | |||
| 637 | + | if got := testHasUsableX11Display(); got { | |||
| 638 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |||
| 639 | + | } | |||
| 640 | + | }) | |||
| 641 | + | ||||
| 642 | + | t.Run("DISPLAY numeric without socket", func(t *testing.T) { | |||
| 643 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 644 | + | t.Setenv("DISPLAY", ":"+displayNum) | |||
| 645 | + | if got := testHasUsableX11Display(); got { | |||
| 646 | + | t.Errorf("testHasUsableX11Display() = %v, want false", got) | |||
| 647 | + | } | |||
| 648 | + | }) | |||
| 649 | + | ||||
| 650 | + | t.Run("DISPLAY TCP always passes socket check", func(t *testing.T) { | |||
| 651 | + | t.Setenv("DISPLAY", "remote-host:0") | |||
| 652 | + | if got := testHasUsableX11Display(); !got { | |||
| 653 | + | t.Errorf("testHasUsableX11Display() = %v, want true for TCP display", got) | |||
| 654 | + | } | |||
| 655 | + | }) | |||
| 656 | + | } | |||
| 657 | + | ||||
| 658 | + | func Test_testHasUsableGTKDisplay_GDKBackendSelection(t *testing.T) { | |||
| 659 | + | t.Run("GDK_BACKEND=x11 ignores Wayland-only socket", func(t *testing.T) { | |||
| 660 | + | t.Setenv("GDK_BACKEND", "x11") | |||
| 661 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 662 | + | tmpDir := t.TempDir() | |||
| 663 | + | testCreateUnixSocket(t, filepath.Join(tmpDir, "wayland-1")) | |||
| 664 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 665 | + | t.Setenv("DISPLAY", ":"+testUnusedX11DisplayNumber(t)) | |||
| 666 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 667 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false when x11 is forced without an X11 socket", got) | |||
| 668 | + | } | |||
| 669 | + | }) | |||
| 670 | + | ||||
| 671 | + | t.Run("GDK_BACKEND=wayland ignores X11-only socket", func(t *testing.T) { | |||
| 672 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 673 | + | t.Setenv("GDK_BACKEND", "wayland") | |||
| 674 | + | t.Setenv("DISPLAY", ":"+displayNum) | |||
| 675 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-missing") | |||
| 676 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 677 | + | testCreateX11Socket(t, displayNum) | |||
| 678 | + | if got := testHasUsableGTKDisplay(); got { | |||
| 679 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want false when wayland is forced without a Wayland socket", got) | |||
| 680 | + | } | |||
| 681 | + | }) | |||
| 682 | + | ||||
| 683 | + | t.Run("GDK_BACKEND=wayland,x11 allows X11 fallback", func(t *testing.T) { | |||
| 684 | + | displayNum := testUnusedX11DisplayNumber(t) | |||
| 685 | + | t.Setenv("GDK_BACKEND", "wayland,x11") | |||
| 686 | + | t.Setenv("DISPLAY", ":"+displayNum) | |||
| 687 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-missing") | |||
| 688 | + | t.Setenv("XDG_RUNTIME_DIR", t.TempDir()) | |||
| 689 | + | testCreateX11Socket(t, displayNum) | |||
| 690 | + | if got := testHasUsableGTKDisplay(); !got { | |||
| 691 | + | t.Errorf("testHasUsableGTKDisplay() = %v, want true with X11 fallback socket", got) | |||
| 692 | + | } | |||
| 693 | + | if got := testNeedsX11Auth(); !got { | |||
| 694 | + | t.Errorf("testNeedsX11Auth() = %v, want true when X11 is the only usable backend", got) | |||
| 695 | + | } | |||
| 696 | + | }) | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | func Test_testNeedsX11Auth_PureProbe(t *testing.T) { | |||
| 700 | + | t.Run("Wayland available forces no X11 auth", func(t *testing.T) { | |||
| 701 | + | t.Setenv("GDK_BACKEND", "") | |||
| 702 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 703 | + | tmpDir := t.TempDir() | |||
| 704 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 705 | + | testCreateUnixSocket(t, socketPath) | |||
| 706 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 707 | + | t.Setenv("DISPLAY", ":0") | |||
| 708 | + | if got := testNeedsX11Auth(); got { | |||
| 709 | + | t.Errorf("testNeedsX11Auth() = %v, want false when Wayland is available and GDK_BACKEND is unset", got) | |||
| 710 | + | } | |||
| 711 | + | }) | |||
| 712 | + | ||||
| 713 | + | t.Run("GDK_BACKEND=x11 forces X11 auth even with Wayland", func(t *testing.T) { | |||
| 714 | + | t.Setenv("GDK_BACKEND", "x11") | |||
| 715 | + | t.Setenv("WAYLAND_DISPLAY", "wayland-1") | |||
| 716 | + | tmpDir := t.TempDir() | |||
| 717 | + | socketPath := filepath.Join(tmpDir, "wayland-1") | |||
| 718 | + | testCreateUnixSocket(t, socketPath) | |||
| 719 | + | t.Setenv("XDG_RUNTIME_DIR", tmpDir) | |||
| 720 | + | if got := testNeedsX11Auth(); !got { | |||
| 721 | + | t.Errorf("testNeedsX11Auth() = %v, want true when GDK_BACKEND=x11", got) | |||
| 722 | + | } | |||
| 723 | + | }) | |||
| 724 | + | ||||
| 725 | + | t.Run("No Wayland fallback needs X11 auth", func(t *testing.T) { | |||
| 726 | + | t.Setenv("GDK_BACKEND", "") | |||
| 727 | + | t.Setenv("WAYLAND_DISPLAY", "") | |||
| 728 | + | t.Setenv("DISPLAY", ":0") | |||
| 729 | + | if got := testNeedsX11Auth(); !got { | |||
| 730 | + | t.Errorf("testNeedsX11Auth() = %v, want true when only X11 is available", got) | |||
| 731 | + | } | |||
| 732 | + | }) | |||
| 733 | + | } | |||
| 734 | + | ||||
| 735 | + | // createXauthFile writes a minimal .Xauthority file with one entry. | |||
| 736 | + | // family=256 (FamilyLocal), address=hostname, number=displayNum, name=proto, data=cookie. | |||
| 737 | + | func createXauthFile(t *testing.T, path, displayNum, proto string, cookie []byte) { | |||
| 738 | + | t.Helper() | |||
| 739 | + | ||||
| 740 | + | hostname, err := os.Hostname() | |||
| 741 | + | if err != nil { | |||
| 742 | + | hostname = "localhost" | |||
| 743 | + | } | |||
| 744 | + | ||||
| 745 | + | var buf bytes.Buffer | |||
| 746 | + | // family (2 bytes, big-endian) - 256 = FamilyLocal | |||
| 747 | + | family := make([]byte, 2) | |||
| 748 | + | binary.BigEndian.PutUint16(family, 256) | |||
| 749 | + | buf.Write(family) | |||
| 750 | + | ||||
| 751 | + | // address length + address (hostname) | |||
| 752 | + | addrLen := make([]byte, 2) | |||
| 753 | + | binary.BigEndian.PutUint16(addrLen, uint16(len(hostname))) | |||
| 754 | + | buf.Write(addrLen) | |||
| 755 | + | buf.WriteString(hostname) | |||
| 756 | + | ||||
| 757 | + | // number length + number (display number) | |||
| 758 | + | numLen := make([]byte, 2) | |||
| 759 | + | binary.BigEndian.PutUint16(numLen, uint16(len(displayNum))) | |||
| 760 | + | buf.Write(numLen) | |||
| 761 | + | buf.WriteString(displayNum) | |||
| 762 | + | ||||
| 763 | + | // name length + name (auth protocol) | |||
| 764 | + | nameLen := make([]byte, 2) | |||
| 765 | + | binary.BigEndian.PutUint16(nameLen, uint16(len(proto))) | |||
| 766 | + | buf.Write(nameLen) | |||
| 767 | + | buf.WriteString(proto) | |||
| 768 | + | ||||
| 769 | + | // data length + data (auth cookie) | |||
| 770 | + | dataLen := make([]byte, 2) | |||
| 771 | + | binary.BigEndian.PutUint16(dataLen, uint16(len(cookie))) | |||
| 772 | + | buf.Write(dataLen) | |||
| 773 | + | buf.Write(cookie) | |||
| 774 | + | ||||
| 775 | + | if err := os.WriteFile(path, buf.Bytes(), 0644); err != nil { | |||
| 776 | + | t.Fatal(err) | |||
| 777 | + | } | |||
| 778 | + | } | |||
| 779 | + | ||||
| 136 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | 780 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | ||
| 137 | // Keep this test-only so production visibility stays narrow. | 781 | // Keep this test-only so production visibility stays narrow. | ||
| 138 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | 782 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | ||