GTK 3/4 settings, qt6ct, portals.conf and the default cursor as owned files. GTK_THEME, GTK2_RC_FILES, the Qt words and XCURSOR_* as environment contributions. The GSettings keys the portal serves go in the xinitrc slot, replacing the predecessor's appearance script, and the cursor in the xresources slot. Qt is drawn by Fusion with qt6ct's darker palette instead of the user-repository adwaita-qt, which is no longer developed. qt5ct is dropped. The fonts are research 026's Inter and JetBrains Mono, and portals.conf routes the Secret interface to gnome-keyring, which nothing answered. The tools are appearance (dark or light per audience, switched for the session), cursor, icons, and portal-check (which backend answers which interface, and why).
256 lines
9.3 KiB
Go
256 lines
9.3 KiB
Go
package desktop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// A machine in a directory: a process table, the X servers' socket directory and a runtime base.
|
|
type fakeMachine struct {
|
|
t *testing.T
|
|
proc, x11, runtime string
|
|
uid int
|
|
}
|
|
|
|
func newMachine(t *testing.T) *fakeMachine {
|
|
root, err := os.MkdirTemp("", "desk")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { os.RemoveAll(root) })
|
|
m := &fakeMachine{t: t, proc: filepath.Join(root, "p"), x11: filepath.Join(root, "x"), runtime: filepath.Join(root, "r"), uid: os.Getuid()}
|
|
for _, d := range []string{m.proc, m.x11, filepath.Join(m.runtime, strconv.Itoa(m.uid))} {
|
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *fakeMachine) socket(path string) {
|
|
l, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
m.t.Fatal(err)
|
|
}
|
|
m.t.Cleanup(func() { l.Close() })
|
|
}
|
|
|
|
func (m *fakeMachine) process(pid int, comm string, start int, env ...string) {
|
|
dir := filepath.Join(m.proc, strconv.Itoa(pid))
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
m.t.Fatal(err)
|
|
}
|
|
os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600)
|
|
os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
|
// pid (comm) state ppid pgrp session tty tpgid flags minflt cminflt majflt cmajflt utime stime
|
|
// cutime cstime priority nice threads itrealvalue starttime ...
|
|
stat := strconv.Itoa(pid) + " (" + comm + ") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 " + strconv.Itoa(start) + " 0 0"
|
|
os.WriteFile(filepath.Join(dir, "stat"), []byte(stat), 0o644)
|
|
}
|
|
|
|
func (m *fakeMachine) finder(logind func(string) (map[string]string, error), prefer ...string) Finder {
|
|
return Finder{Proc: m.proc, X11Sockets: m.x11, RuntimeBase: m.runtime, UID: m.uid, Prefer: prefer, Logind: logind}
|
|
}
|
|
|
|
func active(id string) (map[string]string, error) {
|
|
return map[string]string{"Active": "yes", "State": "active", "Remote": "no", "Type": "x11"}, nil
|
|
}
|
|
|
|
func TestTheSessionIsFoundInTheWindowManagersEnvironmentAndOnlyItsWordsAreRead(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X1"))
|
|
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
|
m.socket(filepath.Join(run, "bus"))
|
|
m.process(100, "lemurs-child", 5, "DISPLAY=:1", "XDG_SESSION_ID=1")
|
|
m.process(200, "i3", 10, "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "XDG_SESSION_ID=1",
|
|
"XDG_SESSION_TYPE=x11", "XDG_CURRENT_DESKTOP=i3", "XDG_RUNTIME_DIR="+run,
|
|
"DBUS_SESSION_BUS_ADDRESS=unix:path=/tmp/dbus-private", "NPM_TOKEN=secret", "OPENAI_API_KEY=secret")
|
|
m.process(300, "zsh", 50, "TERM=xterm") // no display: not a candidate
|
|
|
|
s, err := m.finder(active, "i3").Find()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if s.FoundIn.PID != 200 || s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.ID != "1" || s.Type != "x11" || s.Desktop != "i3" {
|
|
t.Fatalf("session: %+v", s)
|
|
}
|
|
if s.Bus != "unix:path="+filepath.Join(run, "bus") || s.SessionBus != "unix:path=/tmp/dbus-private" {
|
|
t.Fatalf("the user manager's bus first, the session's private one reported beside it: %q %q", s.Bus, s.SessionBus)
|
|
}
|
|
if s.Active == nil || !*s.Active {
|
|
t.Fatal("logind's word is carried")
|
|
}
|
|
env := strings.Join(s.Env([]string{"PATH=/usr/bin", "DISPLAY=:9", "HOME=/home/op"}), "\n")
|
|
for _, want := range []string{"PATH=/usr/bin", "HOME=/home/op", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", "DBUS_SESSION_BUS_ADDRESS=unix:path=" + filepath.Join(run, "bus"), "XDG_RUNTIME_DIR=" + run} {
|
|
if !strings.Contains(env, want) {
|
|
t.Errorf("env lacks %s:\n%s", want, env)
|
|
}
|
|
}
|
|
if strings.Contains(env, ":9") || strings.Contains(env, "secret") || strings.Contains(env, "NPM_TOKEN") {
|
|
t.Fatalf("the base's display is replaced and no other word of the session's process passes:\n%s", env)
|
|
}
|
|
b, _ := json.Marshal(s)
|
|
if strings.Contains(string(b), "secret") {
|
|
t.Fatal("the answer carries a word outside the session's")
|
|
}
|
|
}
|
|
|
|
func TestWithoutAPreferenceTheOldestProcessOfTheLiveSessionWins(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X0"))
|
|
m.process(410, "xterm", 90, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
|
m.process(400, "openbox", 20, "DISPLAY=:0", "XDG_SESSION_ID=3")
|
|
s, err := m.finder(nil).Find()
|
|
if err != nil || s.FoundIn.PID != 400 {
|
|
t.Fatalf("%+v %v", s, err)
|
|
}
|
|
if s.RuntimeDir != filepath.Join(m.runtime, strconv.Itoa(m.uid)) || s.Bus != "" {
|
|
t.Fatalf("an absent runtime directory word falls back to the account's, and no bus socket means no bus: %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestALeftoverProcessOfAnEndedSessionIsNotTheSession(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.process(500, "i3", 10, "DISPLAY=:2", "XDG_SESSION_ID=7") // no X2 socket
|
|
_, err := m.finder(active, "i3").Find()
|
|
if !IsNoSession(err) || !strings.Contains(err.Error(), "socket is gone") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
var answer map[string]any
|
|
if json.Unmarshal([]byte(err.Error()), &answer) != nil || answer["error"] != "no-graphical-session" {
|
|
t.Fatalf("the refusal is structured: %s", err)
|
|
}
|
|
}
|
|
|
|
func TestNoProcessWithADisplayIsAClearNoSession(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.process(600, "sshd", 1, "SSH_CONNECTION=x")
|
|
_, err := m.finder(active).Find()
|
|
if !IsNoSession(err) || !strings.Contains(err.Error(), "no process of uid") {
|
|
t.Fatalf("%v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnActiveLocalSessionBeatsAnInactiveOneAndARemoteOneIsRefused(t *testing.T) {
|
|
m := newMachine(t)
|
|
m.socket(filepath.Join(m.x11, "X0"))
|
|
m.socket(filepath.Join(m.x11, "X1"))
|
|
m.process(700, "i3", 5, "DISPLAY=:0", "XDG_SESSION_ID=a")
|
|
m.process(800, "i3", 9, "DISPLAY=:1", "XDG_SESSION_ID=b")
|
|
logind := func(id string) (map[string]string, error) {
|
|
if id == "a" {
|
|
return map[string]string{"Active": "no", "State": "online", "Remote": "no"}, nil
|
|
}
|
|
return map[string]string{"Active": "yes", "State": "active", "Remote": "no"}, nil
|
|
}
|
|
s, err := m.finder(logind, "i3").Find()
|
|
if err != nil || s.FoundIn.PID != 800 || s.Display != ":1" {
|
|
t.Fatalf("the active session: %+v %v", s, err)
|
|
}
|
|
remote := func(string) (map[string]string, error) {
|
|
return map[string]string{"Active": "yes", "Remote": "yes"}, nil
|
|
}
|
|
if _, err := m.finder(remote).Find(); !IsNoSession(err) {
|
|
t.Fatalf("a remote session is not the operator's desktop: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAWaylandSessionIsFoundByItsCompositorsSocket(t *testing.T) {
|
|
m := newMachine(t)
|
|
run := filepath.Join(m.runtime, strconv.Itoa(m.uid))
|
|
m.socket(filepath.Join(run, "wayland-1"))
|
|
m.process(900, "sway", 3, "WAYLAND_DISPLAY=wayland-1", "XDG_RUNTIME_DIR="+run, "SWAYSOCK=/run/x.sock")
|
|
s, err := m.finder(nil, "sway").Find()
|
|
if err != nil || s.Type != "wayland" || s.WaylandDisplay != "wayland-1" {
|
|
t.Fatalf("%+v %v", s, err)
|
|
}
|
|
if !strings.Contains(strings.Join(s.Env(nil), " "), "SWAYSOCK=/run/x.sock") {
|
|
t.Fatal("the compositor's socket word passes")
|
|
}
|
|
}
|
|
|
|
func TestADisplayOnAnotherHostIsNotTheLocalSession(t *testing.T) {
|
|
for d, want := range map[string]bool{":0": true, ":1.0": true, "unix:2": true, "localhost:10.0": false, "host:0": false, "": false, ":x": false} {
|
|
if _, ok := DisplayNumber(d); ok != want {
|
|
t.Errorf("%q: %v", d, ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestACommandIsBoundedAndItsFailureNamed(t *testing.T) {
|
|
r := Exec(context.Background(), os.Environ(), []byte("hello"), "cat")
|
|
if !r.OK() || r.Stdout != "hello" {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "echo no >&2; exit 3")
|
|
if r.OK() || r.Code != 3 || !strings.Contains(r.Err().Error(), "exited 3: no") {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "no-such-program-here")
|
|
if r.OK() || r.Code != 127 {
|
|
t.Fatalf("%+v", r)
|
|
}
|
|
r = Exec(context.Background(), os.Environ(), nil, "sh", "-c", "head -c 400000 /dev/zero")
|
|
if !r.Truncated || len(r.Stdout) != MostOutput {
|
|
t.Fatalf("cut at %d: %d %v", MostOutput, len(r.Stdout), r.Truncated)
|
|
}
|
|
}
|
|
|
|
func TestArgumentsAreReadStrictly(t *testing.T) {
|
|
a := Args{"name": " x ", "n": float64(3), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
|
if v, err := a.Text("name"); err != nil || v != "x" {
|
|
t.Fatal(v, err)
|
|
}
|
|
if _, err := a.Text("missing"); err == nil {
|
|
t.Fatal("a missing required text")
|
|
}
|
|
if n, err := a.Whole("n", 0, 1, 5); err != nil || n != 3 {
|
|
t.Fatal(n, err)
|
|
}
|
|
if _, err := a.Whole("f", 0, 0, 5); err == nil {
|
|
t.Fatal("1.5 is not whole")
|
|
}
|
|
if _, err := a.Whole("n", 0, 4, 5); err == nil {
|
|
t.Fatal("out of range")
|
|
}
|
|
if b, given, err := a.Bool("b"); !b || !given || err != nil {
|
|
t.Fatal("bool")
|
|
}
|
|
if _, _, err := a.Bool("name"); err == nil {
|
|
t.Fatal("text is not a bool")
|
|
}
|
|
if l, err := a.Strings("l"); err != nil || len(l) != 2 {
|
|
t.Fatal(l, err)
|
|
}
|
|
if _, err := a.OneOf("name", "", "y", "z"); err == nil {
|
|
t.Fatal("not one of")
|
|
}
|
|
}
|
|
|
|
func TestAPathIsKeptInsideTheHome(t *testing.T) {
|
|
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
|
for in, want := range map[string]string{"~/a.png": "/home/op/a.png", "b/c": "/home/op/b/c", "/home/op/d": "/home/op/d", "~": "/home/op"} {
|
|
if got, err := InHome(in); err != nil || got != want {
|
|
t.Errorf("%s: %s %v", in, got, err)
|
|
}
|
|
}
|
|
for _, out := range []string{"/etc/passwd", "~/../other", "../x"} {
|
|
if _, err := InHome(out); err == nil {
|
|
t.Errorf("%s was accepted", out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPropertiesAreParsed(t *testing.T) {
|
|
p := ParseProperties("Active=yes\nState=active\nDisplay=\n")
|
|
if p["Active"] != "yes" || p["State"] != "active" || p["Display"] != "" {
|
|
t.Fatal(p)
|
|
}
|
|
}
|