diff --git a/modules/adwaita/README.md b/modules/adwaita/README.md new file mode 100644 index 0000000..c8ff628 --- /dev/null +++ b/modules/adwaita/README.md @@ -0,0 +1,103 @@ +# adwaita + +The desktop's theme as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 6): Adwaita +for GTK, Qt, the portal and the cursor, dark by default. It claims no seat, because themes coexist. + +- **Packages:** `gnome-themes-extra` (Adwaita-dark for GTK 2 and 3), `adwaita-icon-theme`, + `adwaita-cursors`, `qt6ct`, `xdg-desktop-portal-gtk`. +- **Environment** (ADR 0203), the five words today's `~/.xinitrc` exported plus the cursor: + - `GTK_THEME=Adwaita:dark`; + - `GTK2_RC_FILES=/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc`; + - `QT_QPA_PLATFORMTHEME=qt6ct`; + - `QT_STYLE_OVERRIDE=Fusion`; + - `QT_SELECT=6`; + - `XCURSOR_THEME=Adwaita`, `XCURSOR_SIZE=24`. +- **Session code** (ADR 0208 §4): + - in the `xinitrc` slot `normal`, the GSettings keys the portal serves (dark, the GTK and icon theme, + the cursor, the UI and monospace fonts), set at every session start. This replaces the + predecessor's `~/scripts/xdg-appearance`; + - in the `xresources` slot `normal`, `Xcursor.theme` and `Xcursor.size`. + +## What it owns + +| path | class | from | +|---|---|---| +| `~/.config/gtk-3.0/settings.ini` | owned (the found file kept once) | [`config/gtk-settings.ini`](config/gtk-settings.ini) | +| `~/.config/gtk-4.0/settings.ini` | owned | the same file | +| `~/.config/qt6ct/qt6ct.conf` | owned | [`config/qt6ct.conf`](config/qt6ct.conf) | +| `~/.config/xdg-desktop-portal/portals.conf` | owned | [`config/portals.conf`](config/portals.conf) | +| `~/.icons/default/index.theme` | owned | [`config/cursor-index.theme`](config/cursor-index.theme): the cursor for programs that read neither `XCURSOR_THEME` nor the resources | + +**`qt6ct.conf` is the mesh's now.** A change made in qt6ct's own window is replaced at the next push, +and the window's saved geometry goes with it. The theme is this module's to say. Settings will make it +the operator's (issue 168). + +## What it improves + +- **No package from the user repository.** Today's Qt style, `adwaita-dark` (`QT_STYLE_OVERRIDE` and + qt6ct's `Adwaita-Dark`), comes from the user repository's `adwaita-qt5`/`adwaita-qt6-git`, a + project that is no longer developed. The module uses Qt's own **Fusion** style with qt6ct's + **`darker`** palette, both shipped with Qt and qt6ct. **This is the one visible change:** Qt + programs keep a dark palette, drawn by Fusion instead of Adwaita-Qt. +- **One Qt tool.** `qt5ct` is gone (installed on the desktop only, with a configuration on both). + `QT_SELECT=6` and `qt6ct` cover the Qt 6 programs. A Qt 5 program gets Fusion through + `QT_STYLE_OVERRIDE`, but not the palette. +- **The fonts research 026/04 chose:** Inter 11 for GTK, Qt and GSettings' interface font, and + JetBrains Mono Nerd Font for Qt's fixed font and GSettings' monospace. Today these are Noto Sans 12, + Adwaita Sans 11 and nothing. +- **The cursor said everywhere**: GTK's settings, GSettings, `XCURSOR_*`, the X resources and the + default theme. Today only the resources and GSettings said it. +- **The portal answers secrets.** `portals.conf` routes `org.freedesktop.impl.portal.Secret` to + gnome-keyring. Today `adwaita_portal_check` shows no backend answering it: gtk does not implement + it, and gnome-keyring's backend names only GNOME. + +## Tools + +| tool | | what | +|---|---|---| +| `adwaita_appearance` | r/a | dark or light as each audience sees it (GSettings, the portal's own answer, the GTK files, Qt's style and palette, the theme words in the user manager). `mode` switches GSettings for the session, and the answer says what follows live (programs asking the portal) and what stays dark (GTK 3 under `GTK_THEME`, the declared files) | +| `adwaita_cursor` | r/a | the cursor in GSettings, the resources and the environment, and the cursor themes installed; set theme or size for new windows (GSettings, and the resources when a session runs) | +| `adwaita_icons` | r | icon themes installed (where, what each inherits, whether it has cursors), and the one GSettings, GTK and Qt use | +| `adwaita_portal_check` | r | the backends installed and what each implements, which `portals.conf` decides (the first that exists, in xdg-desktop-portal's order), the backend answering each interface, what runs on the bus, and the colour scheme the portal answers | + +Each reaches GSettings, the portal and the user manager on the account's bus. Only the cursor's X +resources need the session. From the user manager it reads only the theme's own words. + +**The appearance is a session's choice.** A persistent dark or light, for the files too, is a setting +and waits for issue 168. Until then the module's default is dark, and `mode` lasts until the next +login. + +## What it leaves found + +`~/.config/qt5ct/`, `~/.config/gtk-3.0/bookmarks` and everything else in those directories, +`~/scripts/xdg-appearance`, and the user repository's `adwaita-qt*` packages. + +## The one-off migration (ADR 0182) + +**Once `adwaita` is assigned:** + +1. In `~/.xinitrc`, the theme exports and `~/scripts/xdg-appearance || true` go (see `xorg`'s list). +2. Delete `~/scripts/xdg-appearance`. +3. In `~/.Xresources`, delete the two `Xcursor` lines. +4. Delete `~/.config/qt5ct/`. +5. Remove the user repository's packages: `sudo pacman -Rns adwaita-qt5-git adwaita-qt6-git` + (laptop), `sudo pacman -Rns adwaita-qt5 adwaita-qt6-git adwaita-dark qt5ct` (desktop). Check first + that nothing else needs them (`pacman -Qi`). + +## What changes when it is assigned + +| | g14 | shanks | +|---|---|---| +| packages | none (all present) | the same | +| GTK settings, `portals.conf` | the found files kept once, then the module's: adds the cursor and Inter, keeps Adwaita dark; `portals.conf` adds the Secret line | the same | +| `qt6ct.conf` | Fusion with the `darker` palette, Inter and JetBrains Mono, instead of Adwaita-Dark with Noto Sans | the same | +| `~/.icons/default/index.theme` | new | new | +| environment | `QT_STYLE_OVERRIDE` becomes `Fusion`; `XCURSOR_*` added; the rest as `~/.xinitrc` exported them | the same | +| running programs | **nothing**: settings are read at a program's start, and GSettings is set at the next login | the same | +| next login | GSettings' fonts become Inter and JetBrains Mono. A secret request through the portal finds gnome-keyring | the same | + +## Blockers + +- **`fonts` first**, for Inter and JetBrains Mono. Without them, GTK and Qt fall back to the nearest + installed face. +- **Light is a session's choice only**, until settings (issue 168). diff --git a/modules/adwaita/cmd/adwaita-tools/adwaita_test.go b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go new file mode 100644 index 0000000..fc1b326 --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/adwaita_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "adwaita/internal/desktop" +) + +type manifest struct { + Capabilities []string `json:"capabilities"` + Claims []any `json:"claims"` + Tools []string `json:"tools"` + Environment struct { + Variables map[string]string `json:"variables"` + } `json:"environment"` + Shell []struct { + For, Slot, Code string + } `json:"shell"` + Resources []map[string]any `json:"resources"` +} + +func readManifest(t *testing.T) manifest { + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestItContributesTheThemesWordsAndClaimsNoSeat(t *testing.T) { + m := readManifest(t) + if m.Claims != nil { + t.Fatal("a theme is not a seat: several coexist") + } + want := map[string]string{"GTK_THEME": "Adwaita:dark", "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", + "QT_QPA_PLATFORMTHEME": "qt6ct", "QT_STYLE_OVERRIDE": "Fusion", "QT_SELECT": "6", "XCURSOR_THEME": "Adwaita", "XCURSOR_SIZE": "24"} + if len(m.Environment.Variables) != len(want) { + t.Fatalf("%v", m.Environment.Variables) + } + for k, v := range want { + if m.Environment.Variables[k] != v { + t.Errorf("%s=%q", k, m.Environment.Variables[k]) + } + } + served := map[string]bool{} + for _, tool := range tools(adwaita{}) { + served[tool.Name] = true + } + if len(served) != len(m.Tools) { + t.Fatalf("%v %v", served, m.Tools) + } + for _, n := range m.Tools { + if !served[n] { + t.Errorf("%s", n) + } + } +} + +func TestTheSessionLinesSetGSettingsAndTheResourcesTheCursor(t *testing.T) { + m := readManifest(t) + if len(m.Shell) != 2 || m.Shell[0].For != "xresources" || m.Shell[1].For != "xinitrc" || m.Shell[0].Slot != "normal" || m.Shell[1].Slot != "normal" { + t.Fatalf("%+v", m.Shell) + } + if m.Shell[0].Code != "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" { + t.Fatalf("%q", m.Shell[0].Code) + } + x := m.Shell[1].Code + for _, want := range []string{"color-scheme 'prefer-dark'", "gtk-theme 'Adwaita'", "icon-theme 'Adwaita'", "cursor-theme 'Adwaita'", "font-name 'Inter 11'", "monospace-font-name 'JetBrainsMono Nerd Font 11'"} { + if !strings.Contains(x, want) { + t.Errorf("lacks %s", want) + } + } + for _, l := range strings.Split(strings.TrimSpace(x), "\n") { + if !strings.HasPrefix(l, "#") && !strings.HasSuffix(l, "|| true") { + t.Errorf("a session line that can stop the session's start: %q", l) + } + } +} + +func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { + m := readManifest(t) + sources := map[string]string{"gtk3": "gtk-settings.ini", "gtk4": "gtk-settings.ini", "qt6ct": "qt6ct.conf", "portals": "portals.conf", "cursor": "cursor-index.theme"} + pkgs := []string{} + for _, r := range m.Resources { + id := r["id"].(string) + if r["type"] == "package" { + pkgs = append(pkgs, r["package"].(string)) + continue + } + raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id])) + if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" { + t.Errorf("%s is not config/%s, whole and the account's", id, sources[id]) + } + if strings.Contains(r["path"].(string), "qt5ct") { + t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate") + } + } + if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" { + t.Fatalf("%v", pkgs) + } + qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf")) + if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") { + t.Fatalf("%v", qt) + } + if _, err := os.Stat("/usr/share/qt6ct/colors"); err == nil { + if _, err := os.Stat(qt["Appearance"]["color_scheme_path"]); err != nil { + t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"]) + } + } + gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"] + if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" { + t.Fatalf("%v", gtk) + } +} + +func TestKeyFilesAreReadWithTheirComments(t *testing.T) { + ini := ParseINI("# c\n[A]\nk = v\n; also a comment\n[B]\nx=1=2\n") + if ini["A"]["k"] != "v" || ini["B"]["x"] != "1=2" || len(ini) != 2 { + t.Fatalf("%v", ini) + } +} + +func TestThePortalsAnswerIsResolvedAsXdgDesktopPortalDoes(t *testing.T) { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "gtk.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.gtk\nInterfaces=org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Settings;\nUseIn=gnome\n"), 0o644) + os.WriteFile(filepath.Join(dir, "gnome-keyring.portal"), []byte("[portal]\nDBusName=org.freedesktop.secrets\nInterfaces=org.freedesktop.impl.portal.Secret;\nUseIn=gnome\n"), 0o644) + os.WriteFile(filepath.Join(dir, "kde.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.kde\nInterfaces=org.freedesktop.impl.portal.FileChooser;\nUseIn=KDE\n"), 0o644) + b := Backends([]string{dir}) + if len(b) != 3 || b[0].Name != "gnome-keyring" || len(b[1].Interfaces) != 2 { + t.Fatalf("%+v", b) + } + got := Resolve(b, map[string]string{"default": "gtk", "org.freedesktop.impl.portal.Secret": "gnome-keyring"}, []string{"i3"}) + if got["org.freedesktop.impl.portal.FileChooser"] != "gtk" || got["org.freedesktop.impl.portal.Secret"] != "gnome-keyring" || got["org.freedesktop.impl.portal.Settings"] != "gtk" { + t.Fatalf("%v", got) + } + got = Resolve(b, map[string]string{"default": "gtk"}, []string{"i3"}) + if got["org.freedesktop.impl.portal.Secret"] != "(none)" { + t.Fatalf("gtk does not implement secrets: %v", got) + } + got = Resolve(b, map[string]string{"default": "none;gtk"}, nil) + if got["org.freedesktop.impl.portal.FileChooser"] != "(none)" { + t.Fatalf("none stops the list: %v", got) + } + got = Resolve(b, nil, []string{"KDE"}) + if got["org.freedesktop.impl.portal.FileChooser"] != "kde" || got["org.freedesktop.impl.portal.Settings"] != "(none)" { + t.Fatalf("with no configuration, UseIn decides: %v", got) + } + c := PortalConfigs("/h", []string{"i3", "GNOME"}) + if c[0] != "/h/.config/xdg-desktop-portal/i3-portals.conf" || c[1] != "/h/.config/xdg-desktop-portal/gnome-portals.conf" || c[2] != "/h/.config/xdg-desktop-portal/portals.conf" { + t.Fatalf("%v", c[:3]) + } +} + +func TestThemesAreFoundOnceEachWithCursorsAndIcons(t *testing.T) { + a, b := t.TempDir(), t.TempDir() + os.MkdirAll(filepath.Join(a, "Adwaita", "cursors"), 0o755) + os.MkdirAll(filepath.Join(b, "Adwaita"), 0o755) + os.WriteFile(filepath.Join(b, "Adwaita", "index.theme"), []byte("[Icon Theme]\nName=Adwaita\nInherits=hicolor\nDirectories=16x16\n"), 0o644) + os.MkdirAll(filepath.Join(b, "hicolor"), 0o755) + os.WriteFile(filepath.Join(b, "hicolor", "index.theme"), []byte("[Icon Theme]\nName=Hicolor\nDirectories=16x16\n"), 0o644) + os.MkdirAll(filepath.Join(b, "empty"), 0o755) + got := Themes([]string{a, b}) + if len(got) != 2 || got[0].Name != "Adwaita" || !got[0].Cursors || got[0].Icons || got[0].Dir != filepath.Join(a, "Adwaita") || got[1].Name != "hicolor" { + t.Fatalf("the first directory's Adwaita hides the second's: %+v", got) + } +} + +type fake struct { + ran []string + get map[string]string +} + +func (f *fake) desk() desktop.Desk { + return desktop.Desk{ + Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} }, + Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result { + line := strings.Join(append([]string{name}, args...), " ") + f.ran = append(f.ran, line) + switch { + case name == "gsettings" && args[0] == "get": + return desktop.Result{Stdout: "'" + f.get[args[2]] + "'\n"} + case name == "gsettings" && args[0] == "set": + f.get[args[2]] = args[3] + case name == "systemctl": + return desktop.Result{Stdout: "GTK_THEME=Adwaita:dark\nNPM_TOKEN=secret\nXDG_CURRENT_DESKTOP=i3\n"} + case name == "busctl" && args[1] == "call": + return desktop.Result{Stdout: "v u 1\n"} + } + return desktop.Result{} + }, + } +} + +func TestAppearanceSwitchesGSettingsAndSaysWhatFollowsAndWhatStays(t *testing.T) { + f := &fake{get: map[string]string{"color-scheme": "prefer-dark", "gtk-theme": "Adwaita"}} + a := adwaita{d: f.desk(), home: t.TempDir()} + got, err := a.appearance(context.Background(), desktop.Args{"mode": "light"}) + if err != nil { + t.Fatal(err) + } + j := asJSON(got) + if f.get["color-scheme"] != "prefer-light" || !strings.Contains(j, `"switched":"light"`) || !strings.Contains(j, "GTK_THEME=Adwaita:dark") || !strings.Contains(j, `"lasts"`) { + t.Fatalf("%s", j) + } + if strings.Contains(j, "secret") || strings.Contains(j, "NPM_TOKEN") { + t.Fatal("only the theme's words are read back from the user manager") + } + if _, err := a.appearance(context.Background(), desktop.Args{"mode": "blue"}); err == nil { + t.Fatal("mode is dark or light") + } + got, _ = a.appearance(context.Background(), desktop.Args{}) + if strings.Contains(asJSON(got), "switched") { + t.Fatal("reading changes nothing") + } +} + +func TestACursorThemeMustBeInstalledAndWithoutASessionOnlyGSettingsChanges(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, "Adwaita", "cursors"), 0o755) + f := &fake{get: map[string]string{}} + a := adwaita{d: f.desk(), home: t.TempDir(), iconDirs: []string{dir}} + if _, err := a.cursor(context.Background(), desktop.Args{"theme": "Bibata"}); err == nil { + t.Fatal("a theme that is not installed") + } + got, err := a.cursor(context.Background(), desktop.Args{"theme": "Adwaita", "size": float64(32)}) + if err != nil { + t.Fatal(err) + } + if f.get["cursor-theme"] != "Adwaita" || f.get["cursor-size"] != "32" || !strings.Contains(asJSON(got), "no graphical session") { + t.Fatalf("%v %s", f.get, asJSON(got)) + } +} + +func asJSON(v any) string { + b, _ := json.Marshal(v) + return string(b) +} diff --git a/modules/adwaita/cmd/adwaita-tools/main.go b/modules/adwaita/cmd/adwaita-tools/main.go new file mode 100644 index 0000000..269dc1c --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/main.go @@ -0,0 +1,84 @@ +// adwaita's tools (novox/hq ADR 0208, research 026/05): appearance (dark or light for GTK, Qt and the +// portal at once), cursor, icons and portal-check. The predecessor's appearance script is folded into +// the first, and into the module's session line. +// +// None needs the display except setting the cursor's X resources: GSettings, the portal and the user +// manager are reached on the account's own bus, which exists whenever the operator's user manager +// runs. +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + + "adwaita/internal/desktop" +) + +func main() { + home := desktop.Home() + a := adwaita{ + d: desktop.Machine("i3", "sway"), home: home, + iconDirs: []string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons"), "/usr/local/share/icons", "/usr/share/icons"}, + portalDirs: []string{"/usr/share/xdg-desktop-portal/portals"}, + uid: os.Getuid(), + } + if err := stdio.Serve("", tools(a)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func call(run func(ctx context.Context, a desktop.Args) (any, error)) func(map[string]any) (any, error) { + return func(args map[string]any) (any, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + return run(ctx, desktop.Args(args)) + } +} + +func tools(a adwaita) []stdio.Tool { + return []stdio.Tool{ + { + Name: "adwaita_appearance", + Description: "Dark or light, as each audience sees it now: GSettings (which the portal serves to " + + "Electron, Firefox and flatpaks), the portal's own answer, the GTK settings files, Qt's palette " + + "and the theme words in the user manager's environment. With mode, switch GSettings for the " + + "running session and answer which audiences follow live and which keep the module's dark " + + "default until it is a setting.", + Input: desktop.Schema(map[string]any{"mode": desktop.Enum("switch to (optional)", "dark", "light")}), + Run: call(a.appearance), + }, + { + Name: "adwaita_cursor", + Description: "The cursor theme and size in force (GSettings, the X resources, XCURSOR_* in the user " + + "manager) and the cursor themes installed. With theme and/or size, set them for windows opened " + + "from now on; the module's defaults return at the next login.", + Input: desktop.Schema(map[string]any{ + "theme": desktop.Str("an installed cursor theme"), + "size": desktop.Int("pixels, 8 to 256"), + }), + Run: call(a.cursor), + }, + { + Name: "adwaita_icons", + Description: "The icon themes installed (name, where, what each inherits, whether it carries cursors) " + + "and the one GTK and Qt are set to use.", + Input: desktop.Schema(map[string]any{}), + Run: call(a.icons), + }, + { + Name: "adwaita_portal_check", + Description: "Which xdg-desktop-portal backend answers which interface for this desktop: the backends " + + "installed and what they implement, the portals.conf that decides (and which one won), the " + + "resulting backend per interface, whether the portal and each backend are running on the " + + "account's bus, and the colour scheme the portal answers.", + Input: desktop.Schema(map[string]any{}), + Run: call(a.portalCheck), + }, + } +} diff --git a/modules/adwaita/cmd/adwaita-tools/tools.go b/modules/adwaita/cmd/adwaita-tools/tools.go new file mode 100644 index 0000000..f9d29c4 --- /dev/null +++ b/modules/adwaita/cmd/adwaita-tools/tools.go @@ -0,0 +1,434 @@ +package main + +import ( + "bufio" + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + + "adwaita/internal/desktop" +) + +type adwaita struct { + d desktop.Desk + home string + iconDirs []string + portalDirs []string + uid int +} + +const iface = "org.gnome.desktop.interface" + +// ParseINI reads a key file (GTK's settings.ini, qt6ct.conf, a .portal, index.theme): sections of +// key=value, `#` and `;` comments. +func ParseINI(text string) map[string]map[string]string { + out := map[string]map[string]string{} + section := "" + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + l := strings.TrimSpace(sc.Text()) + if l == "" || strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";") { + continue + } + if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { + section = l[1 : len(l)-1] + continue + } + if k, v, ok := strings.Cut(l, "="); ok { + if out[section] == nil { + out[section] = map[string]string{} + } + out[section][strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return out +} + +func readINI(path string) map[string]map[string]string { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + return ParseINI(string(b)) +} + +// gsetting is one GSettings value, unquoted. +func (a adwaita) gsetting(ctx context.Context, schema, key string) (string, error) { + r := a.d.AsUser(ctx, "gsettings", "get", schema, key) + if !r.OK() { + return "", r.Err() + } + return strings.Trim(strings.TrimSpace(r.Stdout), "'"), nil +} + +// themeWords are the words of the user manager's environment the theme is about; nothing else of it +// is read back. +var themeWords = []string{"GTK_THEME", "GTK2_RC_FILES", "QT_QPA_PLATFORMTHEME", "QT_STYLE_OVERRIDE", "QT_SELECT", + "XCURSOR_THEME", "XCURSOR_SIZE", "XDG_CURRENT_DESKTOP"} + +func (a adwaita) userEnvironment(ctx context.Context) map[string]string { + r := a.d.AsUser(ctx, "systemctl", "--user", "show-environment") + all := desktop.ParseProperties(r.Stdout) + out := map[string]string{} + for _, w := range themeWords { + if v, ok := all[w]; ok { + out[w] = v + } + } + return out +} + +var portalScheme = regexp.MustCompile(`^v u (\d)`) + +// portalColourScheme asks the portal what it tells applications: 0 no preference, 1 dark, 2 light. +func (a adwaita) portalColourScheme(ctx context.Context) string { + r := a.d.AsUser(ctx, "busctl", "--user", "call", "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.Settings", "ReadOne", "ss", "org.freedesktop.appearance", "color-scheme") + m := portalScheme.FindStringSubmatch(strings.TrimSpace(r.Stdout)) + if !r.OK() || m == nil { + return "unanswered" + } + return map[string]string{"0": "no-preference", "1": "dark", "2": "light"}[m[1]] +} + +func (a adwaita) appearance(ctx context.Context, args desktop.Args) (any, error) { + mode := args.Opt("mode", "") + if mode != "" && mode != "dark" && mode != "light" { + return nil, fmt.Errorf("mode is dark or light") + } + if mode != "" { + scheme := map[string]string{"dark": "prefer-dark", "light": "prefer-light"}[mode] + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "color-scheme", scheme); !r.OK() { + return nil, r.Err() + } + } + scheme, err := a.gsetting(ctx, iface, "color-scheme") + if err != nil { + return nil, fmt.Errorf("GSettings does not answer on the account's bus: %w", err) + } + gtkTheme, _ := a.gsetting(ctx, iface, "gtk-theme") + gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] + gtk4 := readINI(filepath.Join(a.home, ".config", "gtk-4.0", "settings.ini"))["Settings"] + qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] + env := a.userEnvironment(ctx) + answer := map[string]any{ + "gsettings": map[string]string{"color-scheme": scheme, "gtk-theme": gtkTheme}, + "portal": a.portalColourScheme(ctx), + "gtk3": map[string]string{"theme": gtk3["gtk-theme-name"], "prefer-dark": gtk3["gtk-application-prefer-dark-theme"]}, + "gtk4": map[string]string{"theme": gtk4["gtk-theme-name"], "prefer-dark": gtk4["gtk-application-prefer-dark-theme"]}, + "qt": map[string]string{"style": qt["style"], "palette": filepath.Base(qt["color_scheme_path"])}, + "environment": env, + } + if mode != "" { + var stays []string + if strings.HasSuffix(env["GTK_THEME"], ":dark") && mode == "light" { + stays = append(stays, "GTK 3 programs: GTK_THEME="+env["GTK_THEME"]+" in the environment pins them dark") + } + if mode == "light" { + stays = append(stays, "the GTK settings files and Qt's palette, which the module declares dark") + } + answer["switched"] = mode + answer["follows_live"] = "programs asking the portal: Electron, Chromium, Firefox, libadwaita and flatpaks" + if len(stays) > 0 { + answer["stays"] = stays + } + answer["lasts"] = "until the next login, which sets the module's default (dark) again; a persistent choice waits for settings (hq issue 168)" + } + return answer, nil +} + +// Theme is one installed icon or cursor theme. +type Theme struct { + Name string `json:"name"` + Dir string `json:"dir"` + Title string `json:"title,omitempty"` + Inherits []string `json:"inherits,omitempty"` + Cursors bool `json:"cursors"` + Icons bool `json:"icons"` +} + +// Themes lists the themes in dirs; a name found earlier hides the same name later, as lookups do. +func Themes(dirs []string) []Theme { + seen := map[string]bool{} + var out []Theme + for _, d := range dirs { + entries, _ := os.ReadDir(d) + for _, e := range entries { + if !e.IsDir() && e.Type()&os.ModeSymlink == 0 || seen[e.Name()] { + continue + } + dir := filepath.Join(d, e.Name()) + t := Theme{Name: e.Name(), Dir: dir} + if info, err := os.Stat(filepath.Join(dir, "cursors")); err == nil && info.IsDir() { + t.Cursors = true + } + if ini := readINI(filepath.Join(dir, "index.theme")); ini != nil { + th := ini["Icon Theme"] + t.Title = th["Name"] + if th["Directories"] != "" { + t.Icons = true + } + for _, i := range strings.Split(th["Inherits"], ",") { + if i = strings.TrimSpace(i); i != "" { + t.Inherits = append(t.Inherits, i) + } + } + } + if !t.Cursors && !t.Icons && t.Title == "" { + continue + } + seen[e.Name()] = true + out = append(out, t) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +var cursorName = regexp.MustCompile(`^[A-Za-z0-9._ -]{1,64}$`) + +func (a adwaita) cursor(ctx context.Context, args desktop.Args) (any, error) { + theme := args.Opt("theme", "") + size, err := args.Whole("size", 0, 8, 256) + if err != nil { + return nil, err + } + var cursors []string + installed := map[string]bool{} + for _, t := range Themes(a.iconDirs) { + if t.Cursors { + cursors = append(cursors, t.Name) + installed[t.Name] = true + } + } + changed := theme != "" || size != 0 + if theme != "" && (!cursorName.MatchString(theme) || !installed[theme]) { + return nil, fmt.Errorf("%q is not an installed cursor theme; installed: %s", theme, strings.Join(cursors, ", ")) + } + var resources []string + if theme != "" { + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-theme", theme); !r.OK() { + return nil, r.Err() + } + resources = append(resources, "Xcursor.theme: "+theme) + } + if size != 0 { + if r := a.d.AsUser(ctx, "gsettings", "set", iface, "cursor-size", strconv.Itoa(size)); !r.OK() { + return nil, r.Err() + } + resources = append(resources, "Xcursor.size: "+strconv.Itoa(size)) + } + answer := map[string]any{"installed": cursors} + gTheme, _ := a.gsetting(ctx, iface, "cursor-theme") + gSize, _ := a.gsetting(ctx, iface, "cursor-size") + answer["gsettings"] = map[string]string{"cursor-theme": gTheme, "cursor-size": gSize} + env := a.userEnvironment(ctx) + answer["environment"] = map[string]string{"XCURSOR_THEME": env["XCURSOR_THEME"], "XCURSOR_SIZE": env["XCURSOR_SIZE"]} + if s, err := a.d.Find(); err == nil { + senv := s.Env(a.d.Base) + if len(resources) > 0 { + if r := a.d.Run(ctx, senv, []byte(strings.Join(resources, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() { + return nil, r.Err() + } + } + q := a.d.Run(ctx, senv, nil, "xrdb", "-query") + x := map[string]string{} + for _, l := range strings.Split(q.Stdout, "\n") { + if k, v, ok := strings.Cut(l, ":"); ok && strings.HasPrefix(k, "Xcursor.") { + x[k] = strings.TrimSpace(v) + } + } + answer["x_resources"] = x + } else { + answer["x_resources"] = nil + if changed { + answer["note"] = "no graphical session: GSettings changed, the X resources not" + } + } + if changed { + answer["lasts"] = "for windows opened from now on, until the next login" + } + return answer, nil +} + +func (a adwaita) icons(ctx context.Context, args desktop.Args) (any, error) { + var themes []Theme + for _, t := range Themes(a.iconDirs) { + if t.Icons { + themes = append(themes, t) + } + } + gtk3 := readINI(filepath.Join(a.home, ".config", "gtk-3.0", "settings.ini"))["Settings"] + qt := readINI(filepath.Join(a.home, ".config", "qt6ct", "qt6ct.conf"))["Appearance"] + g, _ := a.gsetting(ctx, iface, "icon-theme") + return map[string]any{ + "installed": themes, + "in_use": map[string]string{"gsettings": g, "gtk": gtk3["gtk-icon-theme-name"], "qt": qt["icon_theme"]}, + }, nil +} + +// Backend is one installed portal backend. +type Backend struct { + Name string `json:"name"` + DBusName string `json:"dbus_name"` + Interfaces []string `json:"interfaces"` + UseIn []string `json:"use_in,omitempty"` + Running bool `json:"running"` +} + +func splitList(v string) []string { + var out []string + for _, x := range strings.Split(v, ";") { + if x = strings.TrimSpace(x); x != "" { + out = append(out, x) + } + } + return out +} + +// Backends reads the installed `.portal` files. +func Backends(dirs []string) []Backend { + var out []Backend + for _, d := range dirs { + files, _ := filepath.Glob(filepath.Join(d, "*.portal")) + sort.Strings(files) + for _, f := range files { + p := readINI(f)["portal"] + out = append(out, Backend{Name: strings.TrimSuffix(filepath.Base(f), ".portal"), DBusName: p["DBusName"], + Interfaces: splitList(p["Interfaces"]), UseIn: splitList(p["UseIn"])}) + } + } + return out +} + +// PortalConfigs are the files xdg-desktop-portal looks for, in its order (portals.conf(5)): for each +// directory, `-portals.conf` for each of the desktops named, then `portals.conf`. The first +// that exists decides everything. +func PortalConfigs(home string, desktops []string) []string { + dirs := []string{ + filepath.Join(home, ".config", "xdg-desktop-portal"), "/etc/xdg/xdg-desktop-portal", "/etc/xdg-desktop-portal", + filepath.Join(home, ".local", "share", "xdg-desktop-portal"), "/usr/local/share/xdg-desktop-portal", "/usr/share/xdg-desktop-portal", + } + var out []string + for _, d := range dirs { + for _, desk := range desktops { + out = append(out, filepath.Join(d, strings.ToLower(desk)+"-portals.conf")) + } + out = append(out, filepath.Join(d, "portals.conf")) + } + return out +} + +// Resolve says which backend answers each interface the backends implement, given the deciding +// file's [preferred] section: an interface's own key first, else `default`; each a list of backend +// names, the first one that implements the interface wins; `none` answers nothing, `*` any. +// With no file, a backend whose UseIn names the desktop answers. +func Resolve(backends []Backend, preferred map[string]string, desktops []string) map[string]string { + out := map[string]string{} + implements := func(b Backend, i string) bool { + for _, x := range b.Interfaces { + if x == i { + return true + } + } + return false + } + var all []string + seen := map[string]bool{} + for _, b := range backends { + for _, i := range b.Interfaces { + if !seen[i] { + seen[i] = true + all = append(all, i) + } + } + } + sort.Strings(all) + for _, i := range all { + var want []string + if preferred != nil { + if v, ok := preferred[i]; ok { + want = splitList(v) + } else { + want = splitList(preferred["default"]) + } + } else { + for _, b := range backends { + for _, u := range b.UseIn { + for _, d := range desktops { + if strings.EqualFold(u, d) { + want = append(want, b.Name) + } + } + } + } + } + out[i] = "(none)" + pick: + for _, w := range want { + if w == "none" { + break + } + for _, b := range backends { + if (w == "*" || w == b.Name) && implements(b, i) { + out[i] = b.Name + break pick + } + } + } + } + return out +} + +func (a adwaita) portalCheck(ctx context.Context, args desktop.Args) (any, error) { + env := a.userEnvironment(ctx) + desktops := splitColon(env["XDG_CURRENT_DESKTOP"]) + backends := Backends(a.portalDirs) + names := map[string]bool{} + if r := a.d.AsUser(ctx, "busctl", "--user", "list", "--no-legend", "--no-pager"); r.OK() { + for _, l := range strings.Split(r.Stdout, "\n") { + if f := strings.Fields(l); len(f) > 1 && f[1] != "-" { + names[f[0]] = true + } + } + } + for i := range backends { + backends[i].Running = names[backends[i].DBusName] + } + var decided string + var preferred map[string]string + looked := PortalConfigs(a.home, desktops) + for _, f := range looked { + if ini := readINI(f); ini != nil { + decided, preferred = f, ini["preferred"] + if preferred == nil { + preferred = map[string]string{} + } + break + } + } + return map[string]any{ + "desktop": env["XDG_CURRENT_DESKTOP"], + "portal": map[string]bool{"running": names["org.freedesktop.portal.Desktop"]}, + "backends": backends, + "decided_by": decided, + "preferred": preferred, + "answers": Resolve(backends, preferred, desktops), + "colour_scheme": a.portalColourScheme(ctx), + }, nil +} + +func splitColon(v string) []string { + var out []string + for _, x := range strings.Split(v, ":") { + if x = strings.TrimSpace(x); x != "" { + out = append(out, x) + } + } + return out +} diff --git a/modules/adwaita/config/cursor-index.theme b/modules/adwaita/config/cursor-index.theme new file mode 100644 index 0000000..d848240 --- /dev/null +++ b/modules/adwaita/config/cursor-index.theme @@ -0,0 +1,5 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that +# read neither XCURSOR_THEME nor the X resources. +[Icon Theme] +Name=Default +Inherits=Adwaita diff --git a/modules/adwaita/config/gtk-settings.ini b/modules/adwaita/config/gtk-settings.ini new file mode 100644 index 0000000..a535ba4 --- /dev/null +++ b/modules/adwaita/config/gtk-settings.ini @@ -0,0 +1,9 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at +# every push; adwaita_appearance switches dark and light for the running session. +[Settings] +gtk-theme-name=Adwaita +gtk-icon-theme-name=Adwaita +gtk-cursor-theme-name=Adwaita +gtk-cursor-theme-size=24 +gtk-font-name=Inter 11 +gtk-application-prefer-dark-theme=1 diff --git a/modules/adwaita/config/portals.conf b/modules/adwaita/config/portals.conf new file mode 100644 index 0000000..d2e813f --- /dev/null +++ b/modules/adwaita/config/portals.conf @@ -0,0 +1,11 @@ +# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push. +# +# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with +# no preference it uses whichever backend happens to be installed: fine while gtk is the only one, +# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves +# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets. +[preferred] +default=gtk +# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers +# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop. +org.freedesktop.impl.portal.Secret=gnome-keyring diff --git a/modules/adwaita/config/qt6ct.conf b/modules/adwaita/config/qt6ct.conf new file mode 100644 index 0000000..7f396d2 --- /dev/null +++ b/modules/adwaita/config/qt6ct.conf @@ -0,0 +1,29 @@ +[Appearance] +color_scheme_path=/usr/share/qt6ct/colors/darker.conf +custom_palette=true +icon_theme=Adwaita +standard_dialogs=default +style=Fusion + +[Fonts] +fixed="JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0" +general="Inter,11,-1,5,50,0,0,0,0,0" + +[Interface] +activate_item_on_single_click=1 +buttonbox_layout=0 +cursor_flash_time=1000 +dialog_buttons_have_icons=1 +double_click_interval=400 +gui_effects=@Invalid() +keyboard_scheme=2 +menus_have_icons=true +show_shortcuts_in_context_menus=true +stylesheets=@Invalid() +toolbutton_style=4 +underline_shortcut=1 +wheel_scroll_lines=3 + +[Troubleshooting] +force_raster_widgets=1 +ignored_applications=@Invalid() diff --git a/modules/adwaita/go.mod b/modules/adwaita/go.mod new file mode 100644 index 0000000..ed78144 --- /dev/null +++ b/modules/adwaita/go.mod @@ -0,0 +1,5 @@ +module adwaita + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/adwaita/go.sum b/modules/adwaita/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/adwaita/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/adwaita/internal/desktop/args.go b/modules/adwaita/internal/desktop/args.go new file mode 100644 index 0000000..d6be351 --- /dev/null +++ b/modules/adwaita/internal/desktop/args.go @@ -0,0 +1,160 @@ +package desktop + +import ( + "fmt" + "math" + "os" + "path/filepath" + "strings" +) + +// Args reads a tool's arguments as JSON decoded them: strings, float64 numbers, booleans. +type Args map[string]any + +// Text is a required string, trimmed. +func (a Args) Text(name string) (string, error) { + v, ok := a[name].(string) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("%s is required, as text", name) + } + return strings.TrimSpace(v), nil +} + +// Opt is an optional string, trimmed, or def. +func (a Args) Opt(name, def string) string { + if v, ok := a[name].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return def +} + +// Has is whether the caller gave the argument at all. +func (a Args) Has(name string) bool { + v, ok := a[name] + return ok && v != nil +} + +// Bool is an optional boolean: its value, and whether it was given. +func (a Args) Bool(name string) (bool, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return false, false, nil + } + b, isBool := v.(bool) + if !isBool { + return false, false, fmt.Errorf("%s is true or false", name) + } + return b, true, nil +} + +// Number is an optional number: its value, and whether it was given. +func (a Args) Number(name string) (float64, bool, error) { + v, ok := a[name] + if !ok || v == nil { + return 0, false, nil + } + f, isNum := v.(float64) + if !isNum || math.IsNaN(f) || math.IsInf(f, 0) { + return 0, false, fmt.Errorf("%s is a number", name) + } + return f, true, nil +} + +// Whole is an optional whole number within [lo, hi], or def. +func (a Args) Whole(name string, def, lo, hi int) (int, error) { + f, given, err := a.Number(name) + if err != nil { + return 0, err + } + if !given { + return def, nil + } + if f != math.Trunc(f) || f < float64(lo) || f > float64(hi) { + return 0, fmt.Errorf("%s is a whole number from %d to %d", name, lo, hi) + } + return int(f), nil +} + +// OneOf is an optional string that must be one of choices, or def. +func (a Args) OneOf(name, def string, choices ...string) (string, error) { + v := a.Opt(name, def) + for _, c := range choices { + if v == c { + return v, nil + } + } + return "", fmt.Errorf("%s is one of %s", name, strings.Join(choices, ", ")) +} + +// Strings is an optional list of strings. +func (a Args) Strings(name string) ([]string, error) { + v, ok := a[name] + if !ok || v == nil { + return nil, nil + } + list, isList := v.([]any) + if !isList { + return nil, fmt.Errorf("%s is a list of text", name) + } + out := make([]string, 0, len(list)) + for _, x := range list { + s, isText := x.(string) + if !isText { + return nil, fmt.Errorf("%s is a list of text", name) + } + out = append(out, s) + } + return out, nil +} + +// Home is the operator account's home: the runtime's word for it, else this process's. +func Home() string { + if h := os.Getenv("MESH_OPERATOR_HOME"); h != "" { + return h + } + if h, err := os.UserHomeDir(); err == nil { + return h + } + return "/" +} + +// InHome resolves a path the caller gave: `~/x` and a relative path are under the home. A path +// that leaves the home through `..` is refused, so a tool that writes never writes outside it. +func InHome(path string) (string, error) { + home := Home() + switch { + case path == "~": + path = home + case strings.HasPrefix(path, "~/"): + path = filepath.Join(home, path[2:]) + case !filepath.IsAbs(path): + path = filepath.Join(home, path) + } + path = filepath.Clean(path) + if path != home && !strings.HasPrefix(path, home+string(filepath.Separator)) { + return "", fmt.Errorf("%s is outside the account's home", path) + } + return path, nil +} + +// Schema builds a tool's input schema from property descriptions; required names those that must +// be given. A property is a string unless its description object says otherwise. +func Schema(props map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": props} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// Str, Num, Flag, List and Enum describe one property. +func Str(desc string) map[string]any { return map[string]any{"type": "string", "description": desc} } +func Num(desc string) map[string]any { return map[string]any{"type": "number", "description": desc} } +func Int(desc string) map[string]any { return map[string]any{"type": "integer", "description": desc} } +func Flag(desc string) map[string]any { return map[string]any{"type": "boolean", "description": desc} } +func List(desc string) map[string]any { + return map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": desc} +} +func Enum(desc string, values ...string) map[string]any { + return map[string]any{"type": "string", "enum": values, "description": desc} +} diff --git a/modules/adwaita/internal/desktop/copies_test.go b/modules/adwaita/internal/desktop/copies_test.go new file mode 100644 index 0000000..d6bc1b3 --- /dev/null +++ b/modules/adwaita/internal/desktop/copies_test.go @@ -0,0 +1,42 @@ +package desktop + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// The desktop modules that carry this package. Each builds alone, so each has its own copy; this +// test, itself one of the copied files, holds them to one text wherever the siblings are present. +var carriers = []string{"xorg", "lemurs", "i3", "xterm", "adwaita"} + +func TestEveryDesktopModuleCarriesTheSameCopy(t *testing.T) { + mine, err := filepath.Glob("*.go") + if err != nil || len(mine) == 0 { + t.Fatal("no files of this package found", err) + } + compared := 0 + for _, module := range carriers { + dir := filepath.Join("..", "..", "..", module, "internal", "desktop") + if _, err := os.Stat(dir); err != nil { + continue + } + theirs, _ := filepath.Glob(filepath.Join(dir, "*.go")) + if len(theirs) != len(mine) { + t.Errorf("%s carries %d files of this package, this copy %d", module, len(theirs), len(mine)) + continue + } + for _, f := range mine { + a, _ := os.ReadFile(f) + b, err := os.ReadFile(filepath.Join(dir, f)) + if err != nil || !bytes.Equal(a, b) { + t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f) + } + } + compared++ + } + if compared == 0 { + t.Log("no sibling copies beside this module") + } +} diff --git a/modules/adwaita/internal/desktop/run.go b/modules/adwaita/internal/desktop/run.go new file mode 100644 index 0000000..cb9898c --- /dev/null +++ b/modules/adwaita/internal/desktop/run.go @@ -0,0 +1,232 @@ +package desktop + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "syscall" + "time" +) + +// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that +// fits in a tool's reply. +const ( + DefaultTimeout = 10 * time.Second + MostOutput = 256 << 10 +) + +// Result is what one command did. +type Result struct { + Command []string `json:"command"` + Code int `json:"exit_code"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Truncated bool `json:"truncated,omitempty"` + TimedOut bool `json:"timed_out,omitempty"` +} + +// OK is whether the command ran and exited 0. +func (r Result) OK() bool { return r.Code == 0 && !r.TimedOut } + +// Err is the command's failure as an error naming it and what it said, or nil. +func (r Result) Err() error { + if r.OK() { + return nil + } + said := strings.TrimSpace(r.Stderr) + if said == "" { + said = strings.TrimSpace(r.Stdout) + } + if r.TimedOut { + return fmt.Errorf("%s did not finish in time", strings.Join(r.Command, " ")) + } + return fmt.Errorf("%s exited %d: %s", strings.Join(r.Command, " "), r.Code, said) +} + +// Runner runs a command with an environment and answers what it did. Tools take one, so their +// tests replace the machine with a table of answers. +type Runner func(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result + +// Exec is the machine's Runner: the command in its own process group, ended with everything it +// started at the deadline, each stream cut at MostOutput. +func Exec(ctx context.Context, env []string, stdin []byte, name string, args ...string) Result { + if _, ok := ctx.Deadline(); !ok { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, DefaultTimeout) + defer cancel() + } + res := Result{Command: append([]string{name}, args...)} + cmd := exec.Command(name, args...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if stdin != nil { + cmd.Stdin = bytes.NewReader(stdin) + } + out, errb := &capped{}, &capped{} + cmd.Stdout, cmd.Stderr = out, errb + if err := cmd.Start(); err != nil { + res.Code = 127 + res.Stderr = err.Error() + return res + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + var err error + select { + case err = <-done: + case <-ctx.Done(): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + err = <-done + res.TimedOut = true + } + res.Stdout, res.Stderr = out.String(), errb.String() + res.Truncated = out.cut || errb.cut + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + res.Code = exit.ExitCode() + if res.Code < 0 { + res.Code = 128 + } + default: + res.Code = 1 + if res.Stderr == "" { + res.Stderr = err.Error() + } + } + return res +} + +// capped keeps the first MostOutput bytes written to it. Its buffer is a field, not embedded: an +// embedded bytes.Buffer brings ReadFrom along, and io.Copy would use it and never call Write. +type capped struct { + buf bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := MostOutput - c.buf.Len(); room < len(p) { + if room > 0 { + c.buf.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.buf.Write(p) +} + +func (c *capped) String() string { return c.buf.String() } + +// Desk is what a desktop tool needs: how to find the session, and how to run a command. +type Desk struct { + Find func() (*Session, error) + Run Runner + // Base is the environment a command starts from, before the session's words. + Base []string +} + +// Machine is the real Desk, preferring the named processes as the session's. +func Machine(prefer ...string) Desk { + return Desk{ + Find: func() (*Session, error) { return Find(prefer...) }, + Run: Exec, + Base: os.Environ(), + } +} + +// InSession runs a command in the operator's session, or answers NoSession. +func (d Desk) InSession(ctx context.Context, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), nil, name, args...), s, nil +} + +// InSessionWith is InSession with standard input. +func (d Desk) InSessionWith(ctx context.Context, stdin []byte, name string, args ...string) (Result, *Session, error) { + s, err := d.Find() + if err != nil { + return Result{}, nil, err + } + return d.Run(ctx, s.Env(d.Base), stdin, name, args...), s, nil +} + +// Plain runs a command with the base environment: for what needs no session. +func (d Desk) Plain(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, d.Base, nil, name, args...) +} + +// AsUser runs a command with the account's own runtime directory and bus, and no display. +func (d Desk) AsUser(ctx context.Context, name string, args ...string) Result { + return d.Run(ctx, UserEnv(d.Base, os.Getuid()), nil, name, args...) +} + +// Launched is how a program was started in the session. +type Launched struct { + Unit string `json:"unit,omitempty"` + PID int `json:"pid,omitempty"` + How string `json:"how"` +} + +// Launch starts a program in the operator's session that outlives the call and the runtime. +// +// **Not as a child of this process.** The runtime is a system service; everything it starts is in +// its control group, and the service manager ends that group whenever the runtime restarts — which +// is every push that changes it. So the program is handed to the account's own service manager as a +// transient unit (`systemd-run --user`), with the session's words set on it, and lives as long as the +// operator's user manager does. Without a user manager it is started detached as a last resort, and +// the answer says it will end with the runtime. +func (d Desk) Launch(ctx context.Context, s *Session, name string, argv ...string) (Launched, error) { + if len(argv) == 0 { + return Launched{}, errors.New("nothing to launch") + } + env := s.Env(d.Base) + unit := "mesh-" + name + "-" + token() + args := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, w := range []string{"DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "I3SOCK", "SWAYSOCK"} { + if v := lookup(env, w); v != "" { + args = append(args, "--setenv="+w+"="+v) + } + } + args = append(args, "--") + args = append(args, argv...) + res := d.Run(ctx, env, nil, "systemd-run", args...) + if res.OK() { + return Launched{Unit: unit, How: "a transient unit of the account's service manager; ends when it exits or when the operator logs out"}, nil + } + if s.Bus != "" { + return Launched{}, res.Err() + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = env + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + if err := cmd.Start(); err != nil { + return Launched{}, err + } + pid := cmd.Process.Pid + go func() { _ = cmd.Wait() }() + return Launched{PID: pid, How: "detached from the runtime with no user manager to hand it to; it ends when the runtime restarts"}, nil +} + +func lookup(env []string, name string) string { + for i := len(env) - 1; i >= 0; i-- { + if k, v, ok := strings.Cut(env[i], "="); ok && k == name { + return v + } + } + return "" +} + +func token() string { + b := make([]byte, 4) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/modules/adwaita/internal/desktop/session.go b/modules/adwaita/internal/desktop/session.go new file mode 100644 index 0000000..22d432e --- /dev/null +++ b/modules/adwaita/internal/desktop/session.go @@ -0,0 +1,445 @@ +// Package desktop is how a desktop module's tools act in the operator's graphical session +// (novox/hq ADR 0208, research 026/05). +// +// **One question, answered once for every desktop tool.** A tool runs inside the node's runtime: a +// process of node-tools.service, started by the system's service manager as the operator account, +// with no session around it — no DISPLAY, no XAUTHORITY, no session bus. The session it must act in +// was started elsewhere, by the login manager, and the only place its values are written down is +// the environment of the processes it started. So this package finds the session the way a person +// would: it looks at the operator account's own processes, takes the one that is plainly the +// session's (the window manager, or the oldest process carrying a display), confirms with logind +// that its session is a live local one, and checks that the display's socket is really there. +// +// **Only the session's own words are read.** A session's processes also carry whatever its start +// script exported — on the workstations that was a file of secrets — so the environment is filtered +// to a fixed list of names while it is read, and nothing else ever leaves /proc. +// +// The D-Bus address handed on is the user manager's socket, `unix:path=$XDG_RUNTIME_DIR/bus`, +// whenever it exists, because that is where the portal, the notifier and every user service +// listen. A session started on a private bus (a stale session, measured on one workstation) is +// reported as `session_bus` beside it, so the difference is visible rather than guessed at. +// +// The same copy of this package is vendored into every desktop module (xorg, lemurs, i3, xterm, +// adwaita); the catalogue builds each module alone, so it cannot be imported across them. Change +// every copy together — the modules' tests compare them. +package desktop + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// SessionWords are the only environment words read from a session's process: the ones that say +// where the session is. Everything else in that environment is the operator's, and is never read. +var SessionWords = []string{ + "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", + "XDG_SESSION_ID", "XDG_SESSION_TYPE", "XDG_SESSION_DESKTOP", "XDG_CURRENT_DESKTOP", + "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "XDG_SEAT", "XDG_VTNR", + "I3SOCK", "SWAYSOCK", +} + +// Session is the operator's running graphical session, as a tool needs it. +type Session struct { + UID int `json:"uid"` + ID string `json:"session_id,omitempty"` + Type string `json:"type"` + Display string `json:"display,omitempty"` + WaylandDisplay string `json:"wayland_display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + RuntimeDir string `json:"runtime_dir"` + Bus string `json:"bus,omitempty"` + SessionBus string `json:"session_bus,omitempty"` + Desktop string `json:"desktop,omitempty"` + // FoundIn is the process whose environment named the session. + FoundIn Process `json:"found_in"` + // Active is logind's word on the session, when logind answered. + Active *bool `json:"active,omitempty"` + + words map[string]string +} + +// Process is one process the search looked at. +type Process struct { + PID int `json:"pid"` + Command string `json:"command"` + start uint64 +} + +// NoSession is the answer when there is no graphical session to act in. Its text is JSON, so a tool +// that returns it as its error still answers structured data. +type NoSession struct { + Reason string `json:"reason"` + Looked []string `json:"looked"` +} + +func (e *NoSession) Error() string { + b, _ := json.Marshal(map[string]any{"error": "no-graphical-session", "reason": e.Reason, "looked": e.Looked}) + return string(b) +} + +// IsNoSession is whether err says there is no session. +func IsNoSession(err error) bool { + var n *NoSession + return errors.As(err, &n) +} + +// Finder holds where the search looks, so a test can point it at a tree of its own. +type Finder struct { + Proc string // the process table: /proc + X11Sockets string // where X servers listen: /tmp/.X11-unix + RuntimeBase string // the parent of every XDG_RUNTIME_DIR: /run/user + UID int // whose session + // Prefer names the processes that are the session's own, best first: the session's holder. + Prefer []string + // Logind answers `loginctl show-session` for one id; nil skips the check. + Logind func(id string) (map[string]string, error) +} + +// DefaultFinder is the machine's: the account this process runs as, or — when it runs as root — the +// operator account the runtime names (MESH_OPERATOR_ACCOUNT). +func DefaultFinder(prefer ...string) Finder { + uid := os.Getuid() + if uid == 0 { + if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" { + if u, err := user.Lookup(name); err == nil { + if n, err := strconv.Atoi(u.Uid); err == nil { + uid = n + } + } + } + } + return Finder{ + Proc: "/proc", X11Sockets: "/tmp/.X11-unix", RuntimeBase: "/run/user", + UID: uid, Prefer: prefer, Logind: loginctl, + } +} + +// Find is the operator's session on this machine, preferring a process named in prefer. +func Find(prefer ...string) (*Session, error) { + return DefaultFinder(prefer...).Find() +} + +type candidate struct { + proc Process + words map[string]string + rank int + logind map[string]string +} + +// Find looks for the session. +func (f Finder) Find() (*Session, error) { + entries, err := os.ReadDir(f.Proc) + if err != nil { + return nil, &NoSession{Reason: "the process table cannot be read: " + err.Error(), Looked: []string{f.Proc}} + } + looked := []string{fmt.Sprintf("the processes of uid %d in %s", f.UID, f.Proc)} + var found []candidate + stale := 0 + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(f.Proc, e.Name()) + info, err := os.Stat(dir) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != f.UID { + continue + } + words := readWords(filepath.Join(dir, "environ")) + if words["DISPLAY"] == "" && words["WAYLAND_DISPLAY"] == "" { + continue + } + if !f.reachable(words) { + stale++ + continue + } + found = append(found, candidate{proc: Process{PID: pid, Command: comm(dir), start: startTime(dir)}, words: words}) + } + if len(found) == 0 { + reason := fmt.Sprintf("no process of uid %d carries a display", f.UID) + if stale > 0 { + reason = fmt.Sprintf("%d process(es) of uid %d name a display whose socket is gone: the session they belonged to has ended", stale, f.UID) + } + return nil, &NoSession{Reason: reason, Looked: append(looked, f.X11Sockets, f.RuntimeBase)} + } + + // logind's word on each session the candidates name, asked once per session. + asked := map[string]map[string]string{} + for i := range found { + id := found[i].words["XDG_SESSION_ID"] + if f.Logind == nil || id == "" { + found[i].rank = 1 + continue + } + props, done := asked[id] + if !done { + props, _ = f.Logind(id) + asked[id] = props + } + found[i].logind = props + switch { + case props == nil: + found[i].rank = 1 + case props["Remote"] == "yes": + found[i].rank = 3 + case props["Active"] == "yes" && props["State"] != "closing": + found[i].rank = 0 + case props["State"] == "closing": + found[i].rank = 3 + default: + found[i].rank = 2 + } + } + if f.Logind != nil { + looked = append(looked, "logind's sessions") + } + preferred := func(c candidate) int { + for i, p := range f.Prefer { + if c.proc.Command == p { + return i + } + } + return len(f.Prefer) + } + sort.SliceStable(found, func(i, j int) bool { + a, b := found[i], found[j] + if a.rank != b.rank { + return a.rank < b.rank + } + if pa, pb := preferred(a), preferred(b); pa != pb { + return pa < pb + } + if a.proc.start != b.proc.start { + return a.proc.start < b.proc.start + } + return a.proc.PID < b.proc.PID + }) + best := found[0] + if best.rank == 3 { + return nil, &NoSession{Reason: "the only sessions found are remote or closing", Looked: looked} + } + return f.session(best), nil +} + +func (f Finder) session(c candidate) *Session { + w := c.words + s := &Session{ + UID: f.UID, ID: w["XDG_SESSION_ID"], Display: w["DISPLAY"], WaylandDisplay: w["WAYLAND_DISPLAY"], + XAuthority: w["XAUTHORITY"], RuntimeDir: w["XDG_RUNTIME_DIR"], FoundIn: c.proc, words: w, + } + s.Desktop = w["XDG_CURRENT_DESKTOP"] + if s.Desktop == "" { + s.Desktop = w["XDG_SESSION_DESKTOP"] + } + switch { + case w["XDG_SESSION_TYPE"] != "": + s.Type = w["XDG_SESSION_TYPE"] + case s.WaylandDisplay != "": + s.Type = "wayland" + default: + s.Type = "x11" + } + if s.RuntimeDir == "" { + s.RuntimeDir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + if isSocket(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + if own := w["DBUS_SESSION_BUS_ADDRESS"]; own != "" && own != s.Bus { + s.SessionBus = own + } + if c.logind != nil { + active := c.logind["Active"] == "yes" + s.Active = &active + } + return s +} + +// reachable is whether the display a process names is still served: the X server's socket, or the +// Wayland compositor's. A process outliving its session still carries the session's words. +func (f Finder) reachable(w map[string]string) bool { + if d := w["WAYLAND_DISPLAY"]; d != "" { + path := d + if !filepath.IsAbs(d) { + dir := w["XDG_RUNTIME_DIR"] + if dir == "" { + dir = filepath.Join(f.RuntimeBase, strconv.Itoa(f.UID)) + } + path = filepath.Join(dir, d) + } + if isSocket(path) { + return true + } + } + n, ok := DisplayNumber(w["DISPLAY"]) + return ok && isSocket(filepath.Join(f.X11Sockets, "X"+strconv.Itoa(n))) +} + +// DisplayNumber is the server number of a local X display (":1", ":1.0", "unix:1"); a display on +// another host — an ssh session's forwarded one — is not the local session and answers false. +func DisplayNumber(display string) (int, bool) { + host, rest, ok := strings.Cut(display, ":") + if !ok || (host != "" && host != "unix") { + return 0, false + } + num, _, _ := strings.Cut(rest, ".") + n, err := strconv.Atoi(num) + if err != nil || n < 0 { + return 0, false + } + return n, true +} + +// Word is one of the session's words as its process had it ("" when it had none). +func (s *Session) Word(name string) string { return s.words[name] } + +// Env is base with the session's words in place of whatever base said for them. +func (s *Session) Env(base []string) []string { + drop := map[string]bool{} + for _, w := range SessionWords { + drop[w] = true + } + out := make([]string, 0, len(base)+8) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if !drop[k] { + out = append(out, kv) + } + } + bus := s.Bus + if bus == "" { + bus = s.SessionBus + } + for _, kv := range [][2]string{ + {"DISPLAY", s.Display}, {"WAYLAND_DISPLAY", s.WaylandDisplay}, {"XAUTHORITY", s.XAuthority}, + {"XDG_RUNTIME_DIR", s.RuntimeDir}, {"DBUS_SESSION_BUS_ADDRESS", bus}, + {"XDG_SESSION_TYPE", s.Type}, {"XDG_SESSION_ID", s.ID}, + {"XDG_CURRENT_DESKTOP", s.words["XDG_CURRENT_DESKTOP"]}, + {"XDG_SESSION_DESKTOP", s.words["XDG_SESSION_DESKTOP"]}, + {"I3SOCK", s.words["I3SOCK"]}, {"SWAYSOCK", s.words["SWAYSOCK"]}, + } { + if kv[1] != "" { + out = append(out, kv[0]+"="+kv[1]) + } + } + return out +} + +// UserEnv is base with the account's own runtime directory and bus, for a tool that talks to the +// user manager or the session bus and needs no display — it works with no session at all. +func UserEnv(base []string, uid int) []string { + dir := filepath.Join("/run/user", strconv.Itoa(uid)) + out := make([]string, 0, len(base)+2) + for _, kv := range base { + k, _, _ := strings.Cut(kv, "=") + if k != "XDG_RUNTIME_DIR" && k != "DBUS_SESSION_BUS_ADDRESS" { + out = append(out, kv) + } + } + return append(out, "XDG_RUNTIME_DIR="+dir, "DBUS_SESSION_BUS_ADDRESS=unix:path="+filepath.Join(dir, "bus")) +} + +// readWords reads a process's environment and keeps only SessionWords. +func readWords(path string) map[string]string { + raw, err := os.ReadFile(path) + if err != nil { + return nil + } + keep := map[string]bool{} + for _, w := range SessionWords { + keep[w] = true + } + out := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + k, v, ok := bytes.Cut(kv, []byte{'='}) + if ok && keep[string(k)] { + out[string(k)] = string(v) + } + } + return out +} + +func comm(dir string) string { + b, err := os.ReadFile(filepath.Join(dir, "comm")) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// startTime is field 22 of /proc//stat: when the process started, in clock ticks since boot. +// Read after the command's closing parenthesis, because the command may hold spaces. +func startTime(dir string) uint64 { + b, err := os.ReadFile(filepath.Join(dir, "stat")) + if err != nil { + return ^uint64(0) + } + i := bytes.LastIndexByte(b, ')') + if i < 0 { + return ^uint64(0) + } + fields := strings.Fields(string(b[i+1:])) + // fields[0] is the state, field 3 of the line; start time is field 22. + if len(fields) < 20 { + return ^uint64(0) + } + n, err := strconv.ParseUint(fields[19], 10, 64) + if err != nil { + return ^uint64(0) + } + return n +} + +func isSocket(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode()&os.ModeSocket != 0 +} + +// loginctl asks logind about one session, by its property lines. +func loginctl(id string) (map[string]string, error) { + cmd := exec.Command("loginctl", "show-session", id, "-p", "Active", "-p", "State", "-p", "Remote", "-p", "Type", "-p", "Class") + var out bytes.Buffer + cmd.Stdout = &out + done := make(chan error, 1) + if err := cmd.Start(); err != nil { + return nil, err + } + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + if err != nil { + return nil, err + } + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + return nil, errors.New("loginctl did not answer in 3s") + } + return ParseProperties(out.String()), nil +} + +// ParseProperties reads `Key=Value` lines, as loginctl and systemctl show print them. +func ParseProperties(text string) map[string]string { + out := map[string]string{} + sc := bufio.NewScanner(strings.NewReader(text)) + for sc.Scan() { + if k, v, ok := strings.Cut(sc.Text(), "="); ok { + out[k] = v + } + } + return out +} diff --git a/modules/adwaita/internal/desktop/session_test.go b/modules/adwaita/internal/desktop/session_test.go new file mode 100644 index 0000000..119c16d --- /dev/null +++ b/modules/adwaita/internal/desktop/session_test.go @@ -0,0 +1,255 @@ +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) + } +} diff --git a/modules/adwaita/module.json b/modules/adwaita/module.json new file mode 100644 index 0000000..c02971e --- /dev/null +++ b/modules/adwaita/module.json @@ -0,0 +1,118 @@ +{ + "module": "adwaita", + "version": "1", + "capabilities": [ + "package-manager" + ], + "tools": [ + "adwaita_appearance", + "adwaita_cursor", + "adwaita_icons", + "adwaita_portal_check" + ], + "environment": { + "variables": { + "GTK_THEME": "Adwaita:dark", + "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", + "QT_QPA_PLATFORMTHEME": "qt6ct", + "QT_STYLE_OVERRIDE": "Fusion", + "QT_SELECT": "6", + "XCURSOR_THEME": "Adwaita", + "XCURSOR_SIZE": "24" + } + }, + "shell": [ + { + "for": "xresources", + "slot": "normal", + "code": "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" + }, + { + "for": "xinitrc", + "slot": "normal", + "code": "# The appearance (module adwaita): GSettings is where the portal reads dark or light, and the portal is\n# the only way it reaches Electron, Chromium, Firefox and flatpaks. Set at every session start to the\n# module's default; adwaita_appearance switches it for a session.\ngsettings set org.gnome.desktop.interface color-scheme 'prefer-dark' || true\ngsettings set org.gnome.desktop.interface gtk-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface icon-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-theme 'Adwaita' || true\ngsettings set org.gnome.desktop.interface cursor-size 24 || true\ngsettings set org.gnome.desktop.interface font-name 'Inter 11' || true\ngsettings set org.gnome.desktop.interface monospace-font-name 'JetBrainsMono Nerd Font 11' || true\n" + } + ], + "resources": [ + { + "id": "package-gnome-themes-extra", + "type": "package", + "package": "gnome-themes-extra" + }, + { + "id": "package-adwaita-icon-theme", + "type": "package", + "package": "adwaita-icon-theme" + }, + { + "id": "package-adwaita-cursors", + "type": "package", + "package": "adwaita-cursors" + }, + { + "id": "package-qt6ct", + "type": "package", + "package": "qt6ct" + }, + { + "id": "package-xdg-desktop-portal-gtk", + "type": "package", + "package": "xdg-desktop-portal-gtk" + }, + { + "id": "gtk3", + "type": "file", + "path": "${machine:account-home}/.config/gtk-3.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + }, + { + "id": "gtk4", + "type": "file", + "path": "${machine:account-home}/.config/gtk-4.0/settings.ini", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208), for GTK 3 and GTK 4 alike. Replaced at\n# every push; adwaita_appearance switches dark and light for the running session.\n[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-cursor-theme-name=Adwaita\ngtk-cursor-theme-size=24\ngtk-font-name=Inter 11\ngtk-application-prefer-dark-theme=1\n" + }, + { + "id": "qt6ct", + "type": "file", + "path": "${machine:account-home}/.config/qt6ct/qt6ct.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "[Appearance]\ncolor_scheme_path=/usr/share/qt6ct/colors/darker.conf\ncustom_palette=true\nicon_theme=Adwaita\nstandard_dialogs=default\nstyle=Fusion\n\n[Fonts]\nfixed=\"JetBrainsMono Nerd Font,11,-1,5,50,0,0,0,0,0\"\ngeneral=\"Inter,11,-1,5,50,0,0,0,0,0\"\n\n[Interface]\nactivate_item_on_single_click=1\nbuttonbox_layout=0\ncursor_flash_time=1000\ndialog_buttons_have_icons=1\ndouble_click_interval=400\ngui_effects=@Invalid()\nkeyboard_scheme=2\nmenus_have_icons=true\nshow_shortcuts_in_context_menus=true\nstylesheets=@Invalid()\ntoolbutton_style=4\nunderline_shortcut=1\nwheel_scroll_lines=3\n\n[Troubleshooting]\nforce_raster_widgets=1\nignored_applications=@Invalid()\n" + }, + { + "id": "portals", + "type": "file", + "path": "${machine:account-home}/.config/xdg-desktop-portal/portals.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208). Replaced at every push.\n#\n# Which portal backend answers each interface. i3 is not a desktop xdg-desktop-portal knows, so with\n# no preference it uses whichever backend happens to be installed: fine while gtk is the only one,\n# wrong the day another arrives as somebody else's dependency. Named instead. gtk also serves\n# org.freedesktop.appearance (dark or light) from GSettings, which the session's start sets.\n[preferred]\ndefault=gtk\n# Secrets for sandboxed programs come from the keyring's backend. Without this line no backend answers\n# the interface: gtk does not implement it, and gnome-keyring's names only GNOME as its desktop.\norg.freedesktop.impl.portal.Secret=gnome-keyring\n" + }, + { + "id": "cursor", + "type": "file", + "path": "${machine:account-home}/.icons/default/index.theme", + "owner": "${machine:account}", + "mode": "0644", + "content": "# Written by the mesh (module adwaita, novox/hq ADR 0208): the default cursor theme, for programs that\n# read neither XCURSOR_THEME nor the X resources.\n[Icon Theme]\nName=Default\nInherits=Adwaita\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/adwaita-tools", + "binary": "adwaita-tools", + "loads": [ + "adwaita-tools" + ] + } + ] + } +}