Add Fleet Desktop support for Wayland display sessions (#25998)

For #19043.

See the versions and distributions tested during development on the QA
notes of #19043.

---

- [X] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [X] Manual QA for all new/changed functionality
- For Orbit and Fleet Desktop changes:
- [X] Orbit runs on macOS, Linux and Windows. Check if the orbit
feature/bugfix should only apply to one platform (`runtime.GOOS`).
- [X] Manual QA must be performed in the three main OSs, macOS, Windows
and Linux.
- [X] Auto-update manual QA, from released version of component to new
version (see [tools/tuf/test](../tools/tuf/test/README.md)).
This commit is contained in:
Lucas Manuel Rodriguez
2025-02-05 14:00:13 -03:00
committed by GitHub
parent da1fabfdce
commit c81117b9b2
5 changed files with 184 additions and 30 deletions
@@ -0,0 +1 @@
* Added support for Wayland display sessions.
+1 -1
View File
@@ -537,7 +537,7 @@ func (m *mdmMigrationHandler) NotifyRemote() error {
func (m *mdmMigrationHandler) ShowInstructions() error {
openURL := m.client.BrowserDeviceURL(m.tokenReader.GetCached())
if err := open.Browser(openURL); err != nil {
log.Error().Err(err).Str("url", openURL).Msg("open browser")
log.Error().Err(err).Str("url", openURL).Msg("open browser my device (mdm migration handler)")
return err
}
return nil
+112 -26
View File
@@ -10,6 +10,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
@@ -123,31 +124,43 @@ func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) {
return nil, fmt.Errorf("get user: %w", err)
}
// TODO(lucas): Default to display :0 if user DISPLAY environment variable
// could not be found, revisit when working on multi-user/multi-session support.
// This assumes there's only one desktop session and belongs to the
// user returned in `getLoginUID'.
defaultDisplay := ":0"
log.Info().Str("user", user.name).Int64("id", user.id).Msg("attempting to get user session type and display")
log.Info().
Str("user", user.name).
Int64("id", user.id).
Msg("attempting to get user's DISPLAY")
display, err := getUserDisplay(user.name, opts)
// Get user's display session type (x11 vs. wayland).
uid := strconv.FormatInt(user.id, 10)
userDisplaySessionType, err := getUserDisplaySessionType(uid)
if err != nil {
log.Error().
Str("user", user.name).
Int64("id", user.id).
Err(err).
Msgf("failed to get user's DISPLAY, using default %s", defaultDisplay)
display = defaultDisplay
} else if display == "" {
log.Warn().
Str("user", user.name).
Int64("id", user.id).
Msgf("user's DISPLAY not found, using default %s", defaultDisplay)
display = defaultDisplay
// Wayland is the default for most distributions, thus we assume
// wayland if we couldn't determine the session type.
log.Error().Err(err).Msg("assuming wayland session")
userDisplaySessionType = guiSessionTypeWayland
}
var display string
if userDisplaySessionType == guiSessionTypeX11 {
x11Display, err := getUserX11Display(user.name)
if err != nil {
log.Error().Err(err).Msg("failed to get X11 display, using default :0")
// TODO(lucas): Revisit when working on multi-user/multi-session support.
// Default to display ':0' if user display could not be found.
// This assumes there's only one desktop session and belongs to the
// user returned in `getLoginUID'.
display = ":0"
} else {
display = x11Display
}
} else {
waylandDisplay, err := getUserWaylandDisplay(uid)
if err != nil {
log.Error().Err(err).Msg("failed to get wayland display, using default wayland-0")
// TODO(lucas): Revisit when working on multi-user/multi-session support.
// Default to display 'wayland-0' if user display could not be found.
// This assumes there's only one desktop session and belongs to the
// user returned in `getLoginUID'.
display = "wayland-0"
} else {
display = waylandDisplay
}
}
log.Info().
@@ -155,12 +168,21 @@ func getUserAndDisplayArgs(path string, opts eopts) ([]string, error) {
Str("user", user.name).
Int64("id", user.id).
Str("display", display).
Str("session_type", userDisplaySessionType.String()).
Msg("running sudo")
args := argsForSudo(user, opts)
if userDisplaySessionType == guiSessionTypeWayland {
args = append(args, "WAYLAND_DISPLAY="+display)
// For xdg-open to work on a Wayland session we still need to set the DISPLAY variable.
x11Display := ":" + strings.TrimPrefix(display, "wayland-")
args = append(args, "DISPLAY="+x11Display)
} else {
args = append(args, "DISPLAY="+display)
}
args = append(args,
"DISPLAY="+display,
// DBUS_SESSION_BUS_ADDRESS sets the location of the user login session bus.
// Required by the libayatana-appindicator3 library to display a tray icon
// on the desktop session.
@@ -247,7 +269,71 @@ func parseIDOutput(s string) (int64, error) {
var whoLineRegexp = regexp.MustCompile(`(\w+)\s+(:\d+)\s+`)
func getUserDisplay(user string, opts eopts) (string, error) {
type guiSessionType int
const (
guiSessionTypeX11 guiSessionType = iota + 1
guiSessionTypeWayland
)
func (s guiSessionType) String() string {
if s == guiSessionTypeX11 {
return "x11"
}
return "wayland"
}
// getUserDisplaySessionType returns the display session type (X11 or Wayland) of the given user.
func getUserDisplaySessionType(uid string) (guiSessionType, error) {
cmd := exec.Command("loginctl", "show-user", uid, "-p", "Display", "--value")
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, fmt.Errorf("run 'loginctl' to get user GUI session: %w", err)
}
guiSessionID := strings.TrimSpace(stdout.String())
if guiSessionID == "" {
return 0, errors.New("empty GUI session")
}
cmd = exec.Command("loginctl", "show-session", guiSessionID, "-p", "Type", "--value")
stdout.Reset()
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return 0, fmt.Errorf("run 'loginctl' to get user GUI session type: %w", err)
}
guiSessionType := strings.TrimSpace(stdout.String())
switch guiSessionType {
case "":
return 0, errors.New("empty GUI session type")
case "x11":
return guiSessionTypeX11, nil
case "wayland":
return guiSessionTypeWayland, nil
default:
return 0, fmt.Errorf("unknown GUI session type: %q", guiSessionType)
}
}
// getUserWaylandDisplay returns the value to set on WAYLAND_DISPLAY for the given user.
func getUserWaylandDisplay(uid string) (string, error) {
matches, err := filepath.Glob("/run/user/" + uid + "/wayland-*")
if err != nil {
return "", fmt.Errorf("list wayland socket files: %w", err)
}
sort.Slice(matches, func(i, j int) bool {
return matches[i] < matches[j]
})
for _, match := range matches {
if strings.HasSuffix(match, ".lock") {
continue
}
return filepath.Base(match), nil
}
return "", errors.New("wayland socket not found")
}
// getUserX11Display returns the value to set on DISPLAY for the given user.
func getUserX11Display(user string) (string, error) {
cmd := exec.Command("who")
var stdout bytes.Buffer
cmd.Stdout = &stdout
@@ -269,5 +355,5 @@ func parseWhoOutputForDisplay(output io.Reader, user string) (string, error) {
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("scanner error: %w", err)
}
return "", nil
return "", errors.New("display not found on who output")
}
+11 -1
View File
@@ -13,18 +13,21 @@ func TestParseWhoOutputForDisplay(t *testing.T) {
output string
user string
expectedDisplay string
expectedErr bool
}{
{
"Ubuntu 22.04.2 (X11)",
`foo :0 2024-05-14 17:34 (:0)`,
"foo",
":0",
false,
},
{
"Ubuntu 22.04.2 (X11) - user not listed",
`foo :0 2024-05-14 17:34 (:0)`,
"bar",
"",
true,
},
{
"Ubuntu 24.04 (X11)",
@@ -32,6 +35,7 @@ func TestParseWhoOutputForDisplay(t *testing.T) {
foo :1 2024-05-14 17:42 (:1)`,
"foo",
":1",
false,
},
{
"Ubuntu 24.04 (Wayland) - DISPLAY not found",
@@ -39,19 +43,25 @@ foo :1 2024-05-14 17:42 (:1)`,
foo tty2 2024-05-14 18:11 (tty2)`,
"foo",
"",
true,
},
{
"Empty",
``,
"foo",
"",
true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
display, err := parseWhoOutputForDisplay(bytes.NewReader([]byte(tc.output)), tc.user)
require.NoError(t, err)
require.Equal(t, tc.expectedDisplay, display)
if tc.expectedErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
+59 -2
View File
@@ -1,8 +1,65 @@
package open
import "os/exec"
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/fleetdm/fleet/v4/orbit/pkg/platform"
"github.com/rs/zerolog/log"
)
func browser(url string) error {
// xdg-open requires XAUTHORITY set when running on a Wayland session (compatibility mode).
// We get XAUTHORITY from the Xwayland process environment.
//
// We have to do this here instead of when executing fleet-desktop because the Xwayland process
// may not be running yet when orbit is executing fleet-desktop.
xAuthority, err := getXWaylandAuthority()
log.Info().Str("XAUTHORITY", xAuthority).Err(err).Msg("Xwayland process")
if err == nil {
os.Setenv("XAUTHORITY", xAuthority)
}
// xdg-open is available on most Linux-y systems
return exec.Command("xdg-open", url).Run()
cmd := exec.Command("xdg-open", url)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Must be asynchronous (Start, not Run) because xdg-open will continue running
// and block this goroutine if it was the process that opened the browser.
if err := cmd.Start(); err != nil {
return fmt.Errorf("xdg-open failed to start: %w", err)
}
go func() {
// We must call wait to avoid defunct processes.
cmd.Wait() //nolint:errcheck
}()
return nil
}
// getXWaylandAuthority retrieves the X authority file path from
// the running XWayland process environment.
func getXWaylandAuthority() (xAuthorityPath string, err error) {
xWaylandProcess, err := platform.GetProcessByName("Xwayland")
if err != nil {
return "", fmt.Errorf("get process by name: %w", err)
}
executablePath, err := xWaylandProcess.Exe()
if err != nil {
return "", fmt.Errorf("get executable path: %w", err)
}
if executablePath != "/usr/bin/Xwayland" {
return "", fmt.Errorf("invalid Xwayland path: %q", executablePath)
}
envs, err := xWaylandProcess.Environ()
if err != nil {
return "", fmt.Errorf("get environment: %w", err)
}
for _, env := range envs {
if strings.HasPrefix(env, "XAUTHORITY=") {
return strings.TrimPrefix(env, "XAUTHORITY="), nil
}
}
return "", errors.New("XAUTHORITY not found")
}