Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44a1fc51e | ||
|
|
e4abc6eb88 | ||
|
|
34829e39fe | ||
|
|
98eb3fe33c | ||
|
|
b2c170acda |
@@ -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).
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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, `<desktop>-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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
module adwaita
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
# i3
|
||||
|
||||
The window manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 4).
|
||||
|
||||
- **Claims `node-display-session`** and serves its verbs `reload`, `workspaces` and `windows`.
|
||||
- **Requires `x11-display`**, which only `xorg` on the same machine provides.
|
||||
- **Packages:** `i3-wm`, and `dex`, which the configuration has always run for XDG autostart. `dex`
|
||||
was missing on the desktop, so its autostart entries never started there.
|
||||
- **Environment** (ADR 0203): `XDG_CURRENT_DESKTOP=i3` and `XDG_SESSION_DESKTOP=i3`. They reach
|
||||
shells, the session (through `xorg`'s block) and the user manager (`environment.d`). The portal
|
||||
keys off the first.
|
||||
- **Session code** (ADR 0208 §4): `exec i3 --shmlog-size=26214400` in the `xinitrc` slot `last`.
|
||||
That is the end of `xorg`'s block in `~/.xinitrc`.
|
||||
|
||||
## What it owns
|
||||
|
||||
| path | class (ADR 0182) | what |
|
||||
|---|---|---|
|
||||
| `~/.config/i3/` | owned directory | its contents other than `config` are found and kept |
|
||||
| `~/.config/i3/config.d/` | owned directory | other modules' drop-ins, and yours |
|
||||
| `~/.config/i3/config` | owned (the found file kept once) | the main configuration, from [`config/config`](config/config) |
|
||||
| `/etc/lemurs/wms/i3` | owned | the login manager's session entry: `exec /bin/sh "$HOME/.xinitrc"` |
|
||||
|
||||
**Drop-ins.** Another module adds to i3 with its own `~/.config/i3/config.d/<NN>-<module>.conf`. The
|
||||
`include` is the last line of the main file, so every variable set there (`$mod`, `$ws1`…`$ws10`) is
|
||||
in scope. Files are read in name order. A file of yours there is yours.
|
||||
|
||||
## The reload watcher
|
||||
|
||||
The bundle runs the watcher for as long as the runtime runs it (ADR 0198). It replaces the
|
||||
predecessor's `i3-reload-watcher` script and its user unit, so this needs **no user-scoped unit**:
|
||||
|
||||
- every 2 s it looks at `config` and `config.d/*.conf` (size and time);
|
||||
- at its start, if the files differ from what the running i3 loaded, that counts as a change;
|
||||
- after a change, once the files have been still for one more interval, it checks the result with
|
||||
`i3 -C`;
|
||||
- it **reloads only a configuration without errors**. Otherwise it keeps the running one and records
|
||||
the errors.
|
||||
|
||||
What it has done is in the answers of `reload` and `i3_config_check`. It polls rather than using
|
||||
inotify, so a file replaced by rename and a directory created later are seen without a fresh watch.
|
||||
It reloads i3 only. Re-running the bar, the compositor and the notifier is each of those modules'
|
||||
own business.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-display-session.reload` | a | checks, then reloads, keeping windows. Refused with the errors when the check fails; `force` reloads anyway |
|
||||
| `node-display-session.workspaces` | r | number, name, output, visible, focused, urgent |
|
||||
| `node-display-session.windows` | r | container id, X id, class, instance, role, title, workspace, output, focused, urgent, floating, fullscreen, scratchpad, marks; narrowed to one workspace |
|
||||
| `i3_focus` | d | a window by criteria, or a workspace |
|
||||
| `i3_move` | d | windows to a workspace or output; a workspace to an output |
|
||||
| `i3_layout_save` | d | a workspace's arrangement as a named layout of placeholders (class, instance, role), in `~/.local/state/mesh/i3/layouts/` |
|
||||
| `i3_layout_restore` | d | lays a saved layout back; `name: list` lists them |
|
||||
| `i3_exec` | d | starts a program as i3's child, optionally on a workspace |
|
||||
| `i3_kill` | d | closes the matching windows, or the focused one when asked; with no arguments it closes nothing |
|
||||
| `i3_bindings` | r | every binding by mode, with its command and file, from what i3 loaded |
|
||||
| `i3_config_check` | r | `i3 -C` on the files on disk (errors with file and line), the files i3 loaded, the watcher's record |
|
||||
| `i3_marks` | r | each mark and its window |
|
||||
| `i3_scratchpad` | r/d | list, show or move into the scratchpad |
|
||||
|
||||
The tools speak i3's IPC on its socket in the account's runtime directory, and need no display. Every
|
||||
value a caller gives is quoted before it reaches an i3 command. With no i3 running, the answer is
|
||||
`{"error": "no-graphical-session", …}`. The bundle's binary is `i3-tools`, so nothing that looks for
|
||||
`i3` by name finds it.
|
||||
|
||||
## What it improves over today's configuration
|
||||
|
||||
Both workstations ran the same configuration. These changes are against it:
|
||||
|
||||
- **dead:** `exec lxpolkit` (installed on neither machine), the unused `$refresh_i3status`, and the
|
||||
predecessor's `rice_set` comment;
|
||||
- **duplicates:**
|
||||
- `exec xdg-desktop-portal` (it is D-Bus activated);
|
||||
- `exec xrdb -merge ~/.Xresources` (`xorg`'s session start merges it);
|
||||
- the `picom`, `nm-applet`, `blueman-applet` and `nextcloud` execs. Each also has an XDG autostart
|
||||
entry that `dex` starts, and both machines carry those entries;
|
||||
- **font:** `JetBrainsMono Nerd Font 11` for titles, replacing Hack (research 026/04);
|
||||
- **terminal:** `$mod+Return` runs `i3-sensible-terminal`, which starts whatever `$TERMINAL` names
|
||||
(the terminal module's contribution), instead of naming xterm;
|
||||
- **reloads:** the watcher never reloads into a broken configuration.
|
||||
|
||||
**Moved to their own modules' drop-ins** (the companion modules of research 026). These leave this
|
||||
file because each module carries them now:
|
||||
|
||||
| lines | now in |
|
||||
|---|---|
|
||||
| the launcher bindings (`$mod+d`, `$mod+t`, `$mod+Shift+t`) and the power menu (`$mod+Escape`) | `rofi`'s `50-rofi.conf`, which also adds `$mod+Shift+w` |
|
||||
| the greenclip daemon and its menu (`$mod+period`) | `clipmenu`'s `50-clipmenu.conf` and its session line |
|
||||
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
|
||||
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
|
||||
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
|
||||
|
||||
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
|
||||
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
|
||||
because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDropInLoadTogether`
|
||||
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
|
||||
key.
|
||||
|
||||
**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
|
||||
operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
|
||||
leaves when the module that owns it is written.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
`~/.config/i3/scripts/`, `~/.config/i3/unlock-keyring.sh`, every file in `config.d/`, the
|
||||
`/usr/share/xsessions` entries (the package's, no longer offered by `lemurs`), and i3's restart
|
||||
state and logs.
|
||||
|
||||
## The one-off migration (ADR 0182)
|
||||
|
||||
1. **Before the push that assigns `i3`:** do `xorg`'s migration (its README). From that push on, your
|
||||
lines below `xorg`'s block in `~/.xinitrc` no longer run.
|
||||
2. **Disable the predecessor's watcher:** `systemctl --user disable --now i3-reload-watcher.service`.
|
||||
Then remove `~/.config/systemd/user/i3-reload-watcher.service` and `~/scripts/i3-reload-watcher`.
|
||||
One thing reloads i3 now. Kept running, it would also `i3-msg restart` on every change, without
|
||||
checking first. **Keep `i3-bar-watchdog.service` as it is**: the bar is `i3status-rust`'s, and that
|
||||
module decides.
|
||||
3. **Delete `/etc/lemurs/wms/i3wm`** (see `lemurs`).
|
||||
4. **`config.d/` fragments:**
|
||||
- `10-asus.conf` and `20-g14.conf` (the laptop) belong to that machine model's hardware module.
|
||||
Keep them until it exists. The desktop no longer carries them.
|
||||
- `50-slack.conf` (both) is yours, or a future `slack` module's. Keep it.
|
||||
- `99-tmp-wine-focus.conf` (the desktop), a test of a predecessor change by its own comment:
|
||||
**delete** it, or keep it as yours.
|
||||
5. **The main file's predecessor copy** is kept once by the host and written over. Nothing to do.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| packages | none (`i3-wm` and `dex` present) | `dex` installed |
|
||||
| `~/.config/i3/config` | written: the improved configuration | the same |
|
||||
| running i3 | **the watcher reloads it once the file changes**. i3 keeps every window, and the title font becomes JetBrains Mono. **Assigned without `rofi`, `clipmenu`, `feh` and `i3status-rust`, the reload takes away the bars and those keys** until they are assigned too, so assign them in the same push. The dropped duplicate execs end nothing that runs | the same |
|
||||
| `/etc/lemurs/wms/i3` | new: offered as `i3` at the next boot | the same |
|
||||
| `~/.xinitrc` | `xorg`'s block now ends in `exec i3`: the lines below it stop running at the next login | the same |
|
||||
| `~/.config/environment.d/50-mesh.conf`, `environment.sh` | gain `XDG_CURRENT_DESKTOP=i3`, `XDG_SESSION_DESKTOP=i3` | the same. Its user manager lacked them, and gets them at the next login |
|
||||
| next login | XDG autostart as before | **XDG autostart runs for the first time**: Slack, JetBrains Toolbox, Nextcloud, the FortiClient tray, the print applet, the geoclue demo agent and snap's user daemon start from their entries |
|
||||
|
||||
**One reload on assignment is the one live change.** To avoid it, assign during a session you are
|
||||
about to end, or accept it: a reload keeps every window and workspace.
|
||||
|
||||
## Blockers
|
||||
|
||||
- **`fonts` first** (to-be 42 orders it first). Without `ttf-jetbrains-mono-nerd`, i3's titles and
|
||||
`i3status-rust`'s bars fall back to pango's default face. On 2026-10-04 the laptop had the package, and the desktop
|
||||
only a hand-copied file of the face.
|
||||
- **None for the watcher.** The runtime restarts with every push that changes it, after the push has
|
||||
written the files. So at its start the watcher compares the files on disk with what the running i3
|
||||
loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its
|
||||
configuration, is therefore reloaded although it also restarted the watcher.
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Binding is one key binding of the configuration in force.
|
||||
type Binding struct {
|
||||
Mode string `json:"mode"`
|
||||
Kind string `json:"kind"` // bindsym or bindcode
|
||||
Keys string `json:"keys"`
|
||||
Command string `json:"command"`
|
||||
Release bool `json:"release,omitempty"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
var modeOpen = regexp.MustCompile(`^mode\s+(?:--pango_markup\s+)?("(?:[^"\\]|\\.)*"|\S+)\s*\{$`)
|
||||
|
||||
// Bindings reads the bindings out of configuration text whose variables i3 has already replaced
|
||||
// (GET_CONFIG's variable_replaced_contents), mode blocks included.
|
||||
func Bindings(file, text string) []Binding {
|
||||
var out []Binding
|
||||
mode, depth := "default", 0
|
||||
sc := bufio.NewScanner(strings.NewReader(joinContinued(text)))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if m := modeOpen.FindStringSubmatch(line); m != nil {
|
||||
if s, err := strconv.Unquote(m[1]); err == nil {
|
||||
mode = s
|
||||
} else {
|
||||
mode = m[1]
|
||||
}
|
||||
depth = 1
|
||||
continue
|
||||
}
|
||||
if depth > 0 {
|
||||
depth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
if depth <= 0 {
|
||||
mode, depth = "default", 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 || (f[0] != "bindsym" && f[0] != "bindcode") {
|
||||
continue
|
||||
}
|
||||
b := Binding{Mode: mode, Kind: f[0], File: file}
|
||||
i := 1
|
||||
for ; i < len(f) && strings.HasPrefix(f[i], "--"); i++ {
|
||||
if f[i] == "--release" {
|
||||
b.Release = true
|
||||
}
|
||||
}
|
||||
if i >= len(f)-1 {
|
||||
continue
|
||||
}
|
||||
b.Keys = f[i]
|
||||
b.Command = strings.Join(f[i+1:], " ")
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func joinContinued(text string) string {
|
||||
return strings.ReplaceAll(text, "\\\n", " ")
|
||||
}
|
||||
|
||||
// ConfigError is one problem `i3 -C` reports.
|
||||
type ConfigError struct {
|
||||
File string `json:"file,omitempty"`
|
||||
Line int `json:"line,omitempty"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
var (
|
||||
checkError = regexp.MustCompile(`ERROR: (?:CONFIG: )?(.*)$`)
|
||||
checkFile = regexp.MustCompile(`^\(in file (.+)\)$`)
|
||||
checkLine = regexp.MustCompile(`^Line\s+(\d+): (.*)$`)
|
||||
checkMark = regexp.MustCompile(`^\s*\^+\s*$`)
|
||||
)
|
||||
|
||||
// ParseCheck reads what `i3 -C` printed: each error with the file and line it points at. i3 prints
|
||||
// the lines around an error as context; the one marked with carets beneath is the error's.
|
||||
func ParseCheck(text string) []ConfigError {
|
||||
var out []ConfigError
|
||||
var cur *ConfigError
|
||||
file := ""
|
||||
lastLine, lastText := 0, ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
m := checkError.FindStringSubmatch(raw)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
msg := m[1]
|
||||
switch {
|
||||
case checkFile.MatchString(msg):
|
||||
file = checkFile.FindStringSubmatch(msg)[1]
|
||||
if cur != nil && cur.File == "" {
|
||||
cur.File = file
|
||||
}
|
||||
case checkLine.MatchString(msg):
|
||||
lm := checkLine.FindStringSubmatch(msg)
|
||||
lastLine, _ = strconv.Atoi(lm[1])
|
||||
lastText = lm[2]
|
||||
case checkMark.MatchString(msg):
|
||||
if cur != nil {
|
||||
cur.Line = lastLine
|
||||
cur.Text = strings.TrimSpace(cur.Text + " — at: " + strings.TrimSpace(lastText))
|
||||
}
|
||||
default:
|
||||
out = append(out, ConfigError{File: file, Text: msg})
|
||||
cur = &out[len(out)-1]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Watched is the configuration's files the reload watcher looks at: the main file and every drop-in.
|
||||
func Watched(dir string) map[string]string {
|
||||
out := map[string]string{}
|
||||
paths := []string{filepath.Join(dir, "config")}
|
||||
drop, _ := filepath.Glob(filepath.Join(dir, "config.d", "*.conf"))
|
||||
paths = append(paths, drop...)
|
||||
for _, p := range paths {
|
||||
if info, err := os.Stat(p); err == nil {
|
||||
out[p] = strconv.FormatInt(info.Size(), 10) + "@" + strconv.FormatInt(info.ModTime().UnixNano(), 10)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Watcher reloads i3 when its configuration changes on disk, after checking it (ADR 0198: the
|
||||
// module's own long-running code, inside its tools bundle). It replaces the predecessor's inotify
|
||||
// script and user unit: it polls, so a file replaced by rename is seen as surely as one written in
|
||||
// place, and a directory that appears later (config.d) is picked up without a new watch.
|
||||
//
|
||||
// **It never reloads into a broken configuration.** i3 would load what it can and show its error bar;
|
||||
// the watcher instead keeps the running configuration and records why.
|
||||
type Watcher struct {
|
||||
Dir string
|
||||
Every time.Duration
|
||||
Check func() ([]ConfigError, error)
|
||||
Reload func() error
|
||||
// Loaded is what the running i3 loaded, by file (GET_CONFIG). At the watcher's start, a file on
|
||||
// disk that differs from it — or a drop-in added or gone — is a change still to apply: the runtime
|
||||
// restarts with every push, after the push has written the files, so a watcher that only compared
|
||||
// the files with themselves would never reload what the push that started it wrote.
|
||||
Loaded func() (map[string]string, error)
|
||||
|
||||
mu sync.Mutex
|
||||
status WatcherStatus
|
||||
}
|
||||
|
||||
// WatcherStatus is what the watcher has done, for the tools to answer.
|
||||
type WatcherStatus struct {
|
||||
Since string `json:"since"`
|
||||
Files int `json:"files_watched"`
|
||||
LastChange string `json:"last_change,omitempty"`
|
||||
Changed []string `json:"changed,omitempty"`
|
||||
LastReload string `json:"last_reload,omitempty"`
|
||||
Reloads int `json:"reloads"`
|
||||
Refused []ConfigError `json:"refused,omitempty"`
|
||||
LastProblem string `json:"last_problem,omitempty"`
|
||||
}
|
||||
|
||||
// Status is a copy of what the watcher has done.
|
||||
func (w *Watcher) Status() WatcherStatus {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.status
|
||||
}
|
||||
|
||||
// Run watches until stop closes. A change is acted on once the files have been still for one more
|
||||
// interval, so a push writing several files is one reload.
|
||||
func (w *Watcher) Run(stop <-chan struct{}) {
|
||||
seen := Watched(w.Dir)
|
||||
w.mu.Lock()
|
||||
w.status.Since = time.Now().Format(time.RFC3339)
|
||||
w.status.Files = len(seen)
|
||||
w.mu.Unlock()
|
||||
pending := w.stale(seen)
|
||||
tick := time.NewTicker(w.Every)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-tick.C:
|
||||
}
|
||||
now := Watched(w.Dir)
|
||||
changed := diff(seen, now)
|
||||
seen = now
|
||||
if len(changed) > 0 {
|
||||
pending = true
|
||||
w.mu.Lock()
|
||||
w.status.LastChange = time.Now().Format(time.RFC3339)
|
||||
w.status.Changed = changed
|
||||
w.status.Files = len(now)
|
||||
w.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
pending = false
|
||||
w.act()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) act() {
|
||||
problems, err := w.Check()
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
switch {
|
||||
case err != nil:
|
||||
w.status.LastProblem = "the configuration could not be checked: " + err.Error()
|
||||
return
|
||||
case len(problems) > 0:
|
||||
w.status.Refused = problems
|
||||
w.status.LastProblem = "not reloaded: the configuration has errors"
|
||||
return
|
||||
}
|
||||
w.status.Refused = nil
|
||||
w.mu.Unlock()
|
||||
err = w.Reload()
|
||||
w.mu.Lock()
|
||||
if err != nil {
|
||||
w.status.LastProblem = "reload: " + err.Error()
|
||||
return
|
||||
}
|
||||
w.status.LastProblem = ""
|
||||
w.status.Reloads++
|
||||
w.status.LastReload = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// stale is whether the files on disk are not what the running i3 loaded.
|
||||
func (w *Watcher) stale(onDisk map[string]string) bool {
|
||||
if w.Loaded == nil {
|
||||
return false
|
||||
}
|
||||
loaded, err := w.Loaded()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if len(loaded) != len(onDisk) {
|
||||
return true
|
||||
}
|
||||
for path := range onDisk {
|
||||
text, ok := loaded[path]
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
disk, err := os.ReadFile(path)
|
||||
if err != nil || string(disk) != text {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func diff(a, b map[string]string) []string {
|
||||
var out []string
|
||||
for k, v := range b {
|
||||
if a[k] != v {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
for k := range a {
|
||||
if _, ok := b[k]; !ok {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
// fakeI3 answers i3's IPC on a socket of its own: a fixed tree, workspaces and config, and every
|
||||
// RUN_COMMAND recorded and answered with success unless it contains "nonsense".
|
||||
type fakeI3 struct {
|
||||
path string
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
}
|
||||
|
||||
const tree = `{"id":1,"type":"root","name":"root","nodes":[
|
||||
{"id":2,"type":"output","name":"__i3","nodes":[{"id":3,"type":"con","name":"content","nodes":[
|
||||
{"id":4,"type":"workspace","name":"__i3_scratch","nodes":[],"floating_nodes":[
|
||||
{"id":5,"type":"floating_con","floating":"user_on","nodes":[{"id":6,"type":"con","name":"notes","window":101,"window_properties":{"class":"XTerm","instance":"notes"},"scratchpad_state":"fresh"}]}]}]}]},
|
||||
{"id":10,"type":"output","name":"eDP-1","nodes":[
|
||||
{"id":11,"type":"dockarea","name":"topdock","nodes":[{"id":12,"type":"con","name":"i3bar for output eDP-1","window":200,"window_properties":{"class":"i3bar"}}]},
|
||||
{"id":13,"type":"con","name":"content","nodes":[
|
||||
{"id":20,"type":"workspace","name":"1","layout":"splith","nodes":[
|
||||
{"id":21,"type":"con","name":"Mail - Thunderbird","layout":"splith","percent":0.6,"border":"pixel","current_border_width":1,"floating":"auto_off","window":301,"window_properties":{"class":"thunderbird","instance":"Mail","window_role":"3pane"},"marks":["mail"]},
|
||||
{"id":22,"type":"con","name":null,"layout":"splitv","percent":0.4,"border":"pixel","floating":"auto_off","nodes":[
|
||||
{"id":23,"type":"con","name":"op: ~ (main)","window":302,"focused":true,"window_properties":{"class":"XTerm","instance":"xterm"}},
|
||||
{"id":24,"type":"con","name":"a.b (c)","window":303,"window_properties":{"class":"Foo.Bar","instance":"x"}}]}]}]}]}]}`
|
||||
|
||||
func startFake(t *testing.T) *fakeI3 {
|
||||
dir, err := os.MkdirTemp("", "i3ipc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
f := &fakeI3{path: filepath.Join(dir, "ipc")}
|
||||
l, err := net.Listen("unix", f.path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { l.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := l.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go f.serve(c)
|
||||
}
|
||||
}()
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeI3) serve(c net.Conn) {
|
||||
defer c.Close()
|
||||
head := make([]byte, 14)
|
||||
if _, err := io.ReadFull(c, head); err != nil {
|
||||
return
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(head[6:])
|
||||
kind := binary.LittleEndian.Uint32(head[10:])
|
||||
body := make([]byte, n)
|
||||
io.ReadFull(c, body)
|
||||
var reply string
|
||||
switch kind {
|
||||
case RunCommand:
|
||||
f.mu.Lock()
|
||||
f.commands = append(f.commands, string(body))
|
||||
f.mu.Unlock()
|
||||
if strings.Contains(string(body), "nonsense") {
|
||||
reply = `[{"success":false,"error":"Expected one of these tokens"}]`
|
||||
} else {
|
||||
reply = `[{"success":true}]`
|
||||
}
|
||||
case GetWorkspaces:
|
||||
reply = `[{"num":1,"name":"1","output":"eDP-1","visible":true,"focused":true,"urgent":false}]`
|
||||
case GetTree:
|
||||
reply = tree
|
||||
case GetVersion:
|
||||
reply = `{"human_readable":"4.25.1","loaded_config_file_name":"/c/config","included_config_file_names":["/c/config.d/50-x.conf"]}`
|
||||
case GetConfig:
|
||||
reply = `{"config":"x","included_configs":[{"path":"/c/config","variable_replaced_contents":"bindsym Mod4+Return exec i3-sensible-terminal\nmode \"resize\" {\n bindsym h resize shrink width 10 px\n bindsym Escape mode \"default\"\n}\nbindsym --release Control+Shift+x exec shot\n"},{"path":"/c/config.d/50-x.conf","variable_replaced_contents":"bindcode 133 exec rofi\n"}]}`
|
||||
}
|
||||
out := make([]byte, 14+len(reply))
|
||||
copy(out, "i3-ipc")
|
||||
binary.LittleEndian.PutUint32(out[6:], uint32(len(reply)))
|
||||
binary.LittleEndian.PutUint32(out[10:], kind)
|
||||
copy(out[14:], reply)
|
||||
c.Write(out)
|
||||
}
|
||||
|
||||
func (f *fakeI3) ran() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.commands...)
|
||||
}
|
||||
|
||||
func withFake(t *testing.T) (*i3, *fakeI3) {
|
||||
f := startFake(t)
|
||||
dir := t.TempDir()
|
||||
return &i3{
|
||||
d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
|
||||
socket: func() (string, error) { return f.path, nil },
|
||||
configDir: dir, layouts: filepath.Join(dir, "layouts"),
|
||||
}, f
|
||||
}
|
||||
|
||||
func run(t *testing.T, x *i3, tool string, args map[string]any) (any, error) {
|
||||
for _, tl := range tools(x) {
|
||||
if tl.Name == tool {
|
||||
if args == nil {
|
||||
args = map[string]any{}
|
||||
}
|
||||
return tl.Run(args)
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", tool)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestWindowsAreTheTreesWindowsWithTheirWorkspaceAndNotTheBars(t *testing.T) {
|
||||
var root Node
|
||||
if err := json.Unmarshal([]byte(tree), &root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := Windows(root)
|
||||
if len(w) != 4 {
|
||||
t.Fatalf("%d windows: %+v", len(w), w)
|
||||
}
|
||||
if w[0].Title != "notes" || !w[0].Scratchpad || !w[0].Floating || w[0].Workspace != "__i3_scratch" {
|
||||
t.Fatalf("the scratchpad's window: %+v", w[0])
|
||||
}
|
||||
if w[1].Class != "thunderbird" || w[1].Workspace != "1" || w[1].Output != "eDP-1" || w[1].Window != "0x12d" || w[1].Marks[0] != "mail" {
|
||||
t.Fatalf("%+v", w[1])
|
||||
}
|
||||
if !w[2].Focused {
|
||||
t.Fatalf("%+v", w[2])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSeatsVerbsAnswerFromI3(t *testing.T) {
|
||||
x, _ := withFake(t)
|
||||
got, err := run(t, x, "node-display-session.windows", map[string]any{"workspace": "1"})
|
||||
if err != nil || len(got.(map[string]any)["windows"].([]Window)) != 3 {
|
||||
t.Fatalf("%v %v", asJSON(got), err)
|
||||
}
|
||||
got, err = run(t, x, "node-display-session.workspaces", nil)
|
||||
if err != nil || !strings.Contains(asJSON(got), `"focused":true`) {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadIsRefusedWhenTheConfigurationHasErrors(t *testing.T) {
|
||||
x, f := withFake(t)
|
||||
x.d.Run = func(_ context.Context, _ []string, _ []byte, name string, args ...string) desktop.Result {
|
||||
return desktop.Result{Code: 1, Stderr: "10/04/2026 - ERROR: CONFIG: Expected one of these tokens\n" +
|
||||
"10/04/2026 - ERROR: CONFIG: (in file /c/config.d/99-x.conf)\n10/04/2026 - ERROR: CONFIG: Line 3: foo bar\n" +
|
||||
"10/04/2026 - ERROR: CONFIG: ^^^^^^^\n"}
|
||||
}
|
||||
got, err := run(t, x, "node-display-session.reload", nil)
|
||||
if err != nil || got.(map[string]any)["reloaded"] != false || len(f.ran()) != 0 {
|
||||
t.Fatalf("%v %v %v", asJSON(got), err, f.ran())
|
||||
}
|
||||
e := got.(map[string]any)["errors"].([]ConfigError)
|
||||
if len(e) != 1 || e[0].File != "/c/config.d/99-x.conf" || e[0].Line != 3 || !strings.Contains(e[0].Text, "foo bar") {
|
||||
t.Fatalf("%+v", e)
|
||||
}
|
||||
if _, err := run(t, x, "node-display-session.reload", map[string]any{"force": true}); err != nil || f.ran()[0] != "reload" {
|
||||
t.Fatalf("forced: %v %v", err, f.ran())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandsQuoteWhatTheCallerGave(t *testing.T) {
|
||||
x, f := withFake(t)
|
||||
steps := []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
want string
|
||||
}{
|
||||
{"i3_focus", map[string]any{"class": `Fire"fox`}, `[class="Fire\"fox"] focus`},
|
||||
{"i3_focus", map[string]any{"workspace": "2: mail"}, `workspace "2: mail"`},
|
||||
{"i3_move", map[string]any{"con_id": float64(21), "to_workspace": "3"}, `[con_id=21] move container to workspace "3"`},
|
||||
{"i3_move", map[string]any{"to_output": "right"}, `move container to output right`},
|
||||
{"i3_move", map[string]any{"workspace_to_output": "1", "to_output": "DP-2"}, `[workspace="^1$"] move workspace to output "DP-2"`},
|
||||
{"i3_exec", map[string]any{"command": "xterm -e 'a; b'", "workspace": "4"}, `workspace "4"; exec --no-startup-id "xterm -e 'a; b'"`},
|
||||
{"i3_kill", map[string]any{"window": "0x12d"}, `[id=301] kill`},
|
||||
{"i3_kill", map[string]any{"focused": true, "force": true}, `kill client`},
|
||||
{"i3_scratchpad", map[string]any{"action": "show", "mark": "notes"}, `[con_mark="notes"] scratchpad show`},
|
||||
{"i3_scratchpad", map[string]any{"action": "move"}, `move scratchpad`},
|
||||
}
|
||||
for i, s := range steps {
|
||||
if _, err := run(t, x, s.tool, s.args); err != nil {
|
||||
t.Fatalf("%s: %v", s.tool, err)
|
||||
}
|
||||
if got := f.ran()[i]; got != s.want {
|
||||
t.Errorf("%s: %q, want %q", s.tool, got, s.want)
|
||||
}
|
||||
}
|
||||
for _, refused := range []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{"i3_kill", nil}, {"i3_focus", nil}, {"i3_move", map[string]any{"class": "x"}},
|
||||
{"i3_focus", map[string]any{"window": "301"}}, {"i3_layout_save", map[string]any{"name": "../x"}},
|
||||
} {
|
||||
if _, err := run(t, x, refused.tool, refused.args); err == nil {
|
||||
t.Errorf("%s %v was accepted", refused.tool, refused.args)
|
||||
}
|
||||
}
|
||||
if _, err := run(t, x, "i3_exec", map[string]any{"command": "nonsense"}); err == nil {
|
||||
t.Fatal("i3's refusal is the tool's")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALayoutIsSavedAsPlaceholdersAndLaidBackOnItsWorkspace(t *testing.T) {
|
||||
x, f := withFake(t)
|
||||
got, err := run(t, x, "i3_layout_save", map[string]any{"name": "mail"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := got.(map[string]any); m["workspace"] != "1" || m["windows"] != 3 {
|
||||
t.Fatalf("the focused workspace, its three windows: %v", m)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(x.layouts, "mail.json"))
|
||||
text := string(b)
|
||||
if SavedWorkspace(text) != "1" || !strings.Contains(text, `"class": "^thunderbird$"`) || !strings.Contains(text, `"class": "^Foo\\.Bar$"`) ||
|
||||
!strings.Contains(text, `"window_role": "^3pane$"`) || !strings.Contains(text, `"layout": "splitv"`) || strings.Contains(text, `"window": `) {
|
||||
t.Fatalf("%s", text)
|
||||
}
|
||||
got, err = run(t, x, "i3_layout_restore", map[string]any{"name": "mail"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if last := f.ran()[len(f.ran())-1]; last != `workspace "1"; append_layout "`+filepath.Join(x.layouts, "mail.json")+`"` {
|
||||
t.Fatalf("%q", last)
|
||||
}
|
||||
got, _ = run(t, x, "i3_layout_restore", map[string]any{"name": "list"})
|
||||
if !strings.Contains(asJSON(got), `"name":"mail"`) {
|
||||
t.Fatalf("%v", asJSON(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingsAreReadByModeWithTheirFiles(t *testing.T) {
|
||||
x, _ := withFake(t)
|
||||
got, err := run(t, x, "i3_bindings", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := got.(map[string]any)["bindings"].([]Binding)
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("%+v", b)
|
||||
}
|
||||
if b[1].Mode != "resize" || b[1].Keys != "h" || b[3].Mode != "default" || !b[3].Release || b[3].Keys != "Control+Shift+x" {
|
||||
t.Fatalf("%+v", b)
|
||||
}
|
||||
if b[4].Kind != "bindcode" || b[4].File != "/c/config.d/50-x.conf" {
|
||||
t.Fatalf("%+v", b[4])
|
||||
}
|
||||
got, _ = run(t, x, "i3_bindings", map[string]any{"match": "rofi"})
|
||||
if len(got.(map[string]any)["bindings"].([]Binding)) != 1 {
|
||||
t.Fatal("match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithNoI3RunningTheToolsSaySo(t *testing.T) {
|
||||
x := &i3{d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
|
||||
socket: func() (string, error) { return FindSocket(t.TempDir(), "") }, configDir: t.TempDir()}
|
||||
for _, tool := range []string{"node-display-session.windows", "node-display-session.workspaces", "i3_marks", "i3_bindings"} {
|
||||
if _, err := run(t, x, tool, nil); !desktop.IsNoSession(err) {
|
||||
t.Errorf("%s: %v", tool, err)
|
||||
}
|
||||
}
|
||||
got, err := run(t, x, "i3_config_check", nil)
|
||||
if err != nil || got.(map[string]any)["valid"] != true || got.(map[string]any)["running"] != nil {
|
||||
t.Fatalf("the check needs no running i3: %v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWatcherReloadsOnceAfterAChangeAndNeverIntoErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "config"), []byte("a"), 0o644)
|
||||
var mu sync.Mutex
|
||||
reloads, broken := 0, false
|
||||
w := &Watcher{Dir: dir, Every: 20 * time.Millisecond,
|
||||
Check: func() ([]ConfigError, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if broken {
|
||||
return []ConfigError{{Text: "bad"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
},
|
||||
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
defer close(stop)
|
||||
go w.Run(stop)
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
os.MkdirAll(filepath.Join(dir, "config.d"), 0o755)
|
||||
os.WriteFile(filepath.Join(dir, "config.d", "50-x.conf"), []byte("b"), 0o644)
|
||||
os.WriteFile(filepath.Join(dir, "config"), []byte("aa"), 0o644)
|
||||
waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return reloads == 1 })
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
mu.Lock()
|
||||
if reloads != 1 {
|
||||
t.Fatalf("one reload for one change of two files: %d", reloads)
|
||||
}
|
||||
broken = true
|
||||
mu.Unlock()
|
||||
os.WriteFile(filepath.Join(dir, "config"), []byte("aaa"), 0o644)
|
||||
waitFor(t, func() bool { return len(w.Status().Refused) == 1 })
|
||||
if s := w.Status(); s.Reloads != 1 || s.Files != 2 || !strings.Contains(s.LastProblem, "not reloaded") {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfg := filepath.Join(dir, "config")
|
||||
os.WriteFile(cfg, []byte("new"), 0o644)
|
||||
for _, c := range []struct {
|
||||
loaded map[string]string
|
||||
want int
|
||||
}{
|
||||
{map[string]string{cfg: "old"}, 1},
|
||||
{map[string]string{cfg: "new"}, 0},
|
||||
{map[string]string{cfg: "new", filepath.Join(dir, "config.d", "gone.conf"): "x"}, 1},
|
||||
} {
|
||||
var mu sync.Mutex
|
||||
reloads := 0
|
||||
w := &Watcher{Dir: dir, Every: 10 * time.Millisecond,
|
||||
Check: func() ([]ConfigError, error) { return nil, nil },
|
||||
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
|
||||
Loaded: func() (map[string]string, error) { return c.loaded, nil },
|
||||
}
|
||||
stop := make(chan struct{})
|
||||
go w.Run(stop)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
close(stop)
|
||||
mu.Lock()
|
||||
if reloads != c.want {
|
||||
t.Errorf("loaded %v: %d reloads, want %d", c.loaded, reloads, c.want)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, ok func() bool) {
|
||||
for i := 0; i < 200; i++ {
|
||||
if ok() {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
// i3's IPC (https://i3wm.org/docs/ipc.html): "i3-ipc", a little-endian length and type, a JSON
|
||||
// payload; the reply carries the same header. Spoken directly, so the tools need no i3-msg and no
|
||||
// display — only the socket, which lives under the account's runtime directory.
|
||||
const (
|
||||
RunCommand = 0
|
||||
GetWorkspaces = 1
|
||||
GetTree = 4
|
||||
GetMarks = 5
|
||||
GetVersion = 7
|
||||
GetConfig = 9
|
||||
)
|
||||
|
||||
const magic = "i3-ipc"
|
||||
|
||||
// Ask sends one message to i3 at socket and decodes its reply into out.
|
||||
func Ask(socket string, kind uint32, payload string, out any) error {
|
||||
conn, err := net.DialTimeout("unix", socket, 2*time.Second)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
msg := make([]byte, 14+len(payload))
|
||||
copy(msg, magic)
|
||||
binary.LittleEndian.PutUint32(msg[6:], uint32(len(payload)))
|
||||
binary.LittleEndian.PutUint32(msg[10:], kind)
|
||||
copy(msg[14:], payload)
|
||||
if _, err := conn.Write(msg); err != nil {
|
||||
return err
|
||||
}
|
||||
head := make([]byte, 14)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return fmt.Errorf("i3 did not answer: %w", err)
|
||||
}
|
||||
if string(head[:6]) != magic {
|
||||
return errors.New("the socket's answer is not i3's")
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(head[6:])
|
||||
if n > 64<<20 {
|
||||
return fmt.Errorf("an answer of %d bytes", n)
|
||||
}
|
||||
body := make([]byte, n)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return err
|
||||
}
|
||||
if got := binary.LittleEndian.Uint32(head[10:]); got != kind {
|
||||
return fmt.Errorf("asked %d, answered %d", kind, got)
|
||||
}
|
||||
return json.Unmarshal(body, out)
|
||||
}
|
||||
|
||||
// FindSocket is the running i3's IPC socket. The session's I3SOCK when its process carries one;
|
||||
// else the newest `ipc-socket.<pid>` under the runtime directory whose i3 is alive and answers.
|
||||
func FindSocket(runtimeDir, i3sock string) (string, error) {
|
||||
if i3sock != "" {
|
||||
if _, err := os.Stat(i3sock); err == nil {
|
||||
return i3sock, nil
|
||||
}
|
||||
}
|
||||
matches, _ := filepath.Glob(filepath.Join(runtimeDir, "i3", "ipc-socket.*"))
|
||||
type cand struct {
|
||||
path string
|
||||
pid int
|
||||
}
|
||||
var alive []cand
|
||||
for _, m := range matches {
|
||||
pid, err := strconv.Atoi(strings.TrimPrefix(filepath.Ext(m), "."))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join("/proc", strconv.Itoa(pid))); err != nil {
|
||||
continue
|
||||
}
|
||||
alive = append(alive, cand{m, pid})
|
||||
}
|
||||
sort.Slice(alive, func(i, j int) bool { return alive[i].pid > alive[j].pid })
|
||||
for _, c := range alive {
|
||||
var v map[string]any
|
||||
if Ask(c.path, GetVersion, "", &v) == nil {
|
||||
return c.path, nil
|
||||
}
|
||||
}
|
||||
return "", &desktop.NoSession{
|
||||
Reason: "i3 is not running: no live IPC socket",
|
||||
Looked: []string{filepath.Join(runtimeDir, "i3", "ipc-socket.*")},
|
||||
}
|
||||
}
|
||||
|
||||
// Outcome is one command's result as i3 answers RUN_COMMAND.
|
||||
type Outcome struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Quote makes a value safe inside an i3 command: double quotes, with backslashes and quotes escaped.
|
||||
func Quote(s string) string {
|
||||
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// i3's tools (novox/hq ADR 0208, research 026/05): node-display-session's verbs `reload`, `workspaces`
|
||||
// and `windows`, the module's own tools for focus, moving, layouts, exec, kill, bindings, the
|
||||
// configuration check, marks and the scratchpad — and, running for as long as the bundle does, the
|
||||
// watcher that reloads i3 when its configuration changes (ADR 0198).
|
||||
//
|
||||
// The tools speak i3's IPC on its socket under the account's runtime directory; they need no display.
|
||||
// With no i3 running they answer that there is no session, as structured data.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
t := machine()
|
||||
w := &Watcher{
|
||||
Dir: t.configDir, Every: 2 * time.Second,
|
||||
Check: func() ([]ConfigError, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
return t.check(ctx)
|
||||
},
|
||||
Reload: func() error {
|
||||
_, err := t.command("reload")
|
||||
return err
|
||||
},
|
||||
Loaded: t.loaded,
|
||||
}
|
||||
t.watcher = w
|
||||
go w.Run(make(chan struct{}))
|
||||
if err := stdio.Serve("", tools(t)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// machine is the i3 of this machine's operator account.
|
||||
func machine() *i3 {
|
||||
home := desktop.Home()
|
||||
cfg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if cfg == "" {
|
||||
cfg = filepath.Join(home, ".config")
|
||||
}
|
||||
state := os.Getenv("XDG_STATE_HOME")
|
||||
if state == "" {
|
||||
state = filepath.Join(home, ".local", "state")
|
||||
}
|
||||
t := &i3{d: desktop.Machine("i3"), configDir: filepath.Join(cfg, "i3"), layouts: filepath.Join(state, "mesh", "i3", "layouts")}
|
||||
t.socket = func() (string, error) {
|
||||
runtime := filepath.Join("/run/user", fmt.Sprint(os.Getuid()))
|
||||
i3sock := ""
|
||||
if s, err := t.d.Find(); err == nil {
|
||||
runtime, i3sock = s.RuntimeDir, s.Word("I3SOCK")
|
||||
}
|
||||
return FindSocket(runtime, i3sock)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
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(t *i3) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-display-session.reload",
|
||||
Description: "Reload i3's configuration in place, keeping every window: checked first with i3 -C, and " +
|
||||
"refused with the errors when it would not load cleanly (force reloads anyway). Answers i3's result " +
|
||||
"and what the reload watcher has done.",
|
||||
Input: desktop.Schema(map[string]any{"force": desktop.Flag("reload even when the check finds errors")}),
|
||||
Run: call(t.reload),
|
||||
},
|
||||
{
|
||||
Name: "node-display-session.workspaces",
|
||||
Description: "The session's workspaces: number, name, output, and whether each is visible, focused or urgent.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(t.workspaces),
|
||||
},
|
||||
{
|
||||
Name: "node-display-session.windows",
|
||||
Description: "The session's windows: container id, X id, class, instance, role, title, workspace and output, " +
|
||||
"and whether each is focused, urgent, floating, fullscreen or in the scratchpad, with its marks. " +
|
||||
"Narrowed to one workspace when named.",
|
||||
Input: desktop.Schema(map[string]any{"workspace": desktop.Str("one workspace, by name (optional)")}),
|
||||
Run: call(t.windows),
|
||||
},
|
||||
{
|
||||
Name: "i3_focus",
|
||||
Description: "Focus a window (by con_id, window, class, instance, title or mark) or a workspace (by name; " +
|
||||
"created if absent, as i3 does).",
|
||||
Input: desktop.Schema(criteriaProps(map[string]any{"workspace": desktop.Str("the workspace to show")})),
|
||||
Run: call(t.focus),
|
||||
},
|
||||
{
|
||||
Name: "i3_move",
|
||||
Description: "Move windows (the focused one, or those the criteria match) to a workspace or an output; or, " +
|
||||
"with workspace_to_output, move a whole workspace to an output.",
|
||||
Input: desktop.Schema(criteriaProps(map[string]any{
|
||||
"to_workspace": desktop.Str("the workspace to move to"),
|
||||
"to_output": desktop.Str("the output to move to (a name, or left/right/up/down)"),
|
||||
"workspace_to_output": desktop.Str("move this workspace (by name) to to_output instead of a window"),
|
||||
})),
|
||||
Run: call(t.move),
|
||||
},
|
||||
{
|
||||
Name: "i3_layout_save",
|
||||
Description: "Save a workspace's arrangement (the focused one when none is named) as a named layout: its " +
|
||||
"containers, splits and shares, each window as a placeholder for the next window of its class, " +
|
||||
"instance and role. Kept in the account's state directory (~/.local/state/mesh/i3/layouts).",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"name": desktop.Str("the layout's name"),
|
||||
"workspace": desktop.Str("the workspace to save (optional; the focused one)"),
|
||||
}, "name"),
|
||||
Run: call(t.layoutSave),
|
||||
},
|
||||
{
|
||||
Name: "i3_layout_restore",
|
||||
Description: "Lay a saved layout onto a workspace (the one it was saved from, unless named): its placeholders " +
|
||||
"wait there and swallow matching windows as they open. With layout list, answers the saved layouts.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"name": desktop.Str("the layout's name, or list"),
|
||||
"workspace": desktop.Str("the workspace to lay it on (optional)"),
|
||||
}, "name"),
|
||||
Run: call(t.layoutRestore),
|
||||
},
|
||||
{
|
||||
Name: "i3_exec",
|
||||
Description: "Start a program in the session, through i3 (so it is the session's child, with the session's " +
|
||||
"environment, and outlives the call). Optionally on a given workspace.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"command": desktop.Str("the command line, as a shell reads it"),
|
||||
"workspace": desktop.Str("switch to this workspace first (optional)"),
|
||||
}, "command"),
|
||||
Run: call(t.exec),
|
||||
},
|
||||
{
|
||||
Name: "i3_kill",
|
||||
Description: "Close the windows the criteria match (politely, as the window's close button), or the focused " +
|
||||
"one when focused is true. Refused with neither, so a call without arguments closes nothing.",
|
||||
Input: desktop.Schema(criteriaProps(map[string]any{
|
||||
"focused": desktop.Flag("close the focused window"),
|
||||
"force": desktop.Flag("kill the client instead of asking the window to close"),
|
||||
})),
|
||||
Run: call(t.kill),
|
||||
},
|
||||
{
|
||||
Name: "i3_bindings",
|
||||
Description: "Every key binding in force and what it runs, by mode, from the configuration i3 loaded " +
|
||||
"(main file and drop-ins, variables replaced), with the file each comes from. Narrowed by a text " +
|
||||
"found in the keys or the command.",
|
||||
Input: desktop.Schema(map[string]any{"match": desktop.Str("only bindings whose keys or command contain this (optional)")}),
|
||||
Run: call(t.bindings),
|
||||
},
|
||||
{
|
||||
Name: "i3_config_check",
|
||||
Description: "Check the configuration on disk (main file and every drop-in) with i3 -C, as the next reload " +
|
||||
"would load it: valid or the errors with file and line; the files i3 loaded last; and what the " +
|
||||
"reload watcher has done. Needs no running session.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(t.configCheck),
|
||||
},
|
||||
{
|
||||
Name: "i3_marks",
|
||||
Description: "Every mark set on a window, with the window it is on.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(t.marks),
|
||||
},
|
||||
{
|
||||
Name: "i3_scratchpad",
|
||||
Description: "The scratchpad: list its windows; show (toggle) one — the criteria pick it, else i3 cycles; or " +
|
||||
"move a window (the criteria's, else the focused one) into it.",
|
||||
Input: desktop.Schema(criteriaProps(map[string]any{
|
||||
"action": desktop.Enum("list (default), show or move", "list", "show", "move"),
|
||||
})),
|
||||
Run: call(t.scratchpad),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package main
|
||||
|
||||
// i3's shape (novox/hq ADR 0208): it holds node-display-session and requires the X display on its own
|
||||
// machine; it contributes the session's exec to the last xinitrc slot and the desktop's identity to
|
||||
// the environment; it owns the main configuration, which ends by including the drop-in directory, and
|
||||
// the login manager's session entry, which runs the session's start.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Seats any `json:"seats"`
|
||||
Tools []string
|
||||
Environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
} `json:"environment"`
|
||||
Shell []struct {
|
||||
For, Slot, Code string
|
||||
} `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
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 resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItHoldsTheSessionSeatOnAMachineWithAnXDisplay(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-session" || strings.Join(m.Claims[0].Serves, ",") != "reload,workspaces,windows" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Requires, ",") != "x11-display" {
|
||||
t.Fatalf("requires %v", m.Requires)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(&i3{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
for _, v := range m.Claims[0].Serves {
|
||||
if !served["node-display-session."+v] {
|
||||
t.Errorf("the seat's %s is not served", v)
|
||||
}
|
||||
}
|
||||
if len(served) != len(m.Tools)+3 {
|
||||
t.Fatalf("served %d, listed %d", len(served), len(m.Tools))
|
||||
}
|
||||
for _, n := range m.Tools {
|
||||
if !served[n] || !strings.HasPrefix(n, "i3_") {
|
||||
t.Errorf("%s", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItContributesTheSessionsExecLastAndTheDesktopsIdentity(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "last" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
var code []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(m.Shell[0].Code), "\n") {
|
||||
if !strings.HasPrefix(l, "#") {
|
||||
code = append(code, l)
|
||||
}
|
||||
}
|
||||
if len(code) != 1 || !strings.HasPrefix(code[0], "exec i3") {
|
||||
t.Fatalf("one line, the exec: %q", code)
|
||||
}
|
||||
if v := m.Environment.Variables; len(v) != 2 || v["XDG_CURRENT_DESKTOP"] != "i3" || v["XDG_SESSION_DESKTOP"] != "i3" {
|
||||
t.Fatalf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
r := resource(t, m, "config")
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
|
||||
if r["content"] != string(raw) || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil {
|
||||
t.Fatal("the manifest carries config/config whole, as an owned file")
|
||||
}
|
||||
c := string(raw)
|
||||
lines := strings.Split(strings.TrimSpace(c), "\n")
|
||||
if lines[len(lines)-1] != "include ~/.config/i3/config.d/*.conf" {
|
||||
t.Fatal("the drop-ins are read last, with every variable in scope")
|
||||
}
|
||||
var lines2 []string
|
||||
for _, l := range lines {
|
||||
if !strings.HasPrefix(strings.TrimSpace(l), "#") {
|
||||
lines2 = append(lines2, l)
|
||||
}
|
||||
}
|
||||
code := strings.Join(lines2, "\n")
|
||||
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
|
||||
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
|
||||
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
|
||||
"rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
|
||||
if strings.Contains(code, gone) {
|
||||
t.Errorf("the configuration still holds %q", gone)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(c, "\nfont pango:JetBrainsMono Nerd Font 11\n") || !strings.Contains(c, "exec --no-startup-id dex --autostart --environment i3") {
|
||||
t.Fatal("the chosen face for titles; XDG autostart through dex")
|
||||
}
|
||||
if !strings.Contains(c, "bindsym $mod+Delete exec --no-startup-id loginctl lock-session") {
|
||||
t.Fatal("the lock key goes through logind, which the lock screen's module relies on")
|
||||
}
|
||||
if i3, err := exec.LookPath("i3"); err == nil {
|
||||
out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput()
|
||||
if err != nil || len(ParseCheck(string(out))) > 0 {
|
||||
t.Fatalf("i3 -C: %v %s", err, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
r := resource(t, m, "session")
|
||||
if r["path"] != "/etc/lemurs/wms/i3" || r["mode"] != "0755" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
if !strings.Contains(r["content"].(string), `exec /bin/sh "$HOME/.xinitrc"`) {
|
||||
t.Fatal("the entry runs the session's start, never i3 bare")
|
||||
}
|
||||
pkgs := map[string]bool{}
|
||||
for _, x := range m.Resources {
|
||||
if x["type"] == "package" {
|
||||
pkgs[x["package"].(string)] = true
|
||||
}
|
||||
}
|
||||
if !pkgs["i3-wm"] || !pkgs["dex"] || len(pkgs) != 2 {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
a := m.Build.Artifacts[0]
|
||||
if a["from"] != "cmd/i3-tools" || a["binary"] != "i3-tools" || a["language"] != "go" {
|
||||
t.Fatalf("a binary not named i3, so nothing that looks for i3 by name finds the tools: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as
|
||||
// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses.
|
||||
func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) {
|
||||
i3, err := exec.LookPath("i3")
|
||||
if err != nil {
|
||||
t.Skip("no i3 here to check with")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
drop := filepath.Join(dir, "config.d")
|
||||
os.MkdirAll(drop, 0o755)
|
||||
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
|
||||
var found []string
|
||||
for _, p := range manifests {
|
||||
raw, _ := os.ReadFile(p)
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
json.Unmarshal(raw, &m)
|
||||
for _, r := range m.Resources {
|
||||
path, _ := r["path"].(string)
|
||||
if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") {
|
||||
os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644)
|
||||
found = append(found, filepath.Base(path))
|
||||
}
|
||||
}
|
||||
}
|
||||
main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config"))
|
||||
text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1)
|
||||
os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644)
|
||||
out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput()
|
||||
if err != nil || len(ParseCheck(string(out))) > 0 {
|
||||
t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
type i3 struct {
|
||||
d desktop.Desk
|
||||
socket func() (string, error)
|
||||
configDir string
|
||||
layouts string
|
||||
watcher *Watcher
|
||||
}
|
||||
|
||||
func (t *i3) ask(kind uint32, payload string, out any) error {
|
||||
sock, err := t.socket()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return Ask(sock, kind, payload, out)
|
||||
}
|
||||
|
||||
// command runs i3 commands and fails when any of them did.
|
||||
func (t *i3) command(cmd string) ([]Outcome, error) {
|
||||
var out []Outcome
|
||||
if err := t.ask(RunCommand, cmd, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, o := range out {
|
||||
if !o.Success {
|
||||
return out, fmt.Errorf("i3 refused %q: %s", cmd, o.Error)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *i3) tree() (Node, error) {
|
||||
var root Node
|
||||
err := t.ask(GetTree, "", &root)
|
||||
return root, err
|
||||
}
|
||||
|
||||
// check runs `i3 -C` on the configuration on disk. It needs no display.
|
||||
func (t *i3) check(ctx context.Context) ([]ConfigError, error) {
|
||||
res := t.d.Plain(ctx, "i3", "-C", "-c", filepath.Join(t.configDir, "config"))
|
||||
if res.Code == 127 {
|
||||
return nil, fmt.Errorf("i3 is not installed here")
|
||||
}
|
||||
problems := append([]ConfigError{}, ParseCheck(res.Stdout+"\n"+res.Stderr)...)
|
||||
if !res.OK() && len(problems) == 0 {
|
||||
problems = []ConfigError{{Text: strings.TrimSpace(res.Stdout + res.Stderr)}}
|
||||
}
|
||||
return problems, nil
|
||||
}
|
||||
|
||||
func (t *i3) watcherStatus() any {
|
||||
if t.watcher == nil {
|
||||
return nil
|
||||
}
|
||||
return t.watcher.Status()
|
||||
}
|
||||
|
||||
func (t *i3) reload(ctx context.Context, a desktop.Args) (any, error) {
|
||||
force, _, err := a.Bool("force")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
problems, err := t.check(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(problems) > 0 && !force {
|
||||
return map[string]any{"reloaded": false, "errors": problems, "why": "the configuration on disk has errors; fix them, or force"}, nil
|
||||
}
|
||||
out, err := t.command("reload")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"reloaded": true, "i3": out, "errors": problems, "watcher": t.watcherStatus()}, nil
|
||||
}
|
||||
|
||||
// WorkspaceInfo is one workspace as GET_WORKSPACES answers it.
|
||||
type WorkspaceInfo struct {
|
||||
Num int `json:"num"`
|
||||
Name string `json:"name"`
|
||||
Output string `json:"output"`
|
||||
Visible bool `json:"visible"`
|
||||
Focused bool `json:"focused"`
|
||||
Urgent bool `json:"urgent"`
|
||||
}
|
||||
|
||||
func (t *i3) workspaces(ctx context.Context, a desktop.Args) (any, error) {
|
||||
var ws []WorkspaceInfo
|
||||
if err := t.ask(GetWorkspaces, "", &ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"workspaces": ws}, nil
|
||||
}
|
||||
|
||||
func (t *i3) windows(ctx context.Context, a desktop.Args) (any, error) {
|
||||
root, err := t.tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
all := Windows(root)
|
||||
ws := a.Opt("workspace", "")
|
||||
out := []Window{}
|
||||
for _, w := range all {
|
||||
if ws == "" || w.Workspace == ws {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return map[string]any{"windows": out}, nil
|
||||
}
|
||||
|
||||
func (t *i3) focus(ctx context.Context, a desktop.Args) (any, error) {
|
||||
crit, err := Criteria(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cmd string
|
||||
switch ws := a.Opt("workspace", ""); {
|
||||
case crit != "" && ws != "":
|
||||
return nil, fmt.Errorf("a window or a workspace, not both")
|
||||
case crit != "":
|
||||
cmd = crit + " focus"
|
||||
case ws != "":
|
||||
cmd = "workspace " + Quote(ws)
|
||||
default:
|
||||
return nil, fmt.Errorf("name a window (con_id, window, class, instance, title, mark) or a workspace")
|
||||
}
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"command": cmd, "i3": out}, nil
|
||||
}
|
||||
|
||||
var direction = regexp.MustCompile(`^(left|right|up|down|current|primary|next|prev)$`)
|
||||
|
||||
func target(output string) string {
|
||||
if direction.MatchString(output) {
|
||||
return output
|
||||
}
|
||||
return Quote(output)
|
||||
}
|
||||
|
||||
func (t *i3) move(ctx context.Context, a desktop.Args) (any, error) {
|
||||
crit, err := Criteria(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
toWS, toOut, wsMove := a.Opt("to_workspace", ""), a.Opt("to_output", ""), a.Opt("workspace_to_output", "")
|
||||
var cmd string
|
||||
switch {
|
||||
case wsMove != "":
|
||||
if toOut == "" {
|
||||
return nil, fmt.Errorf("workspace_to_output needs to_output")
|
||||
}
|
||||
cmd = "[workspace=" + Quote("^"+regexp.QuoteMeta(wsMove)+"$") + "] move workspace to output " + target(toOut)
|
||||
case toWS != "" && toOut != "":
|
||||
return nil, fmt.Errorf("to a workspace or to an output, not both")
|
||||
case toWS != "":
|
||||
cmd = strings.TrimSpace(crit + " move container to workspace " + Quote(toWS))
|
||||
case toOut != "":
|
||||
cmd = strings.TrimSpace(crit + " move container to output " + target(toOut))
|
||||
default:
|
||||
return nil, fmt.Errorf("name to_workspace or to_output")
|
||||
}
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"command": cmd, "i3": out}, nil
|
||||
}
|
||||
|
||||
var layoutName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
func (t *i3) layoutSave(ctx context.Context, a desktop.Args) (any, error) {
|
||||
name, err := a.Text("name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !layoutName.MatchString(name) {
|
||||
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
|
||||
}
|
||||
root, err := t.tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws, ok := Workspace(root, a.Opt("workspace", ""))
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no workspace %q", a.Opt("workspace", "(focused)"))
|
||||
}
|
||||
top, windows := Layout(ws)
|
||||
if windows == 0 {
|
||||
return nil, fmt.Errorf("workspace %s holds no windows to save", str(ws.Name))
|
||||
}
|
||||
if err := os.MkdirAll(t.layouts, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(t.layouts, name+".json")
|
||||
if err := os.WriteFile(path, []byte(LayoutFile(str(ws.Name), time.Now().Format(time.RFC3339), top)), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"name": name, "workspace": str(ws.Name), "windows": windows, "path": path}, nil
|
||||
}
|
||||
|
||||
func (t *i3) savedLayouts() []map[string]string {
|
||||
files, _ := filepath.Glob(filepath.Join(t.layouts, "*.json"))
|
||||
sort.Strings(files)
|
||||
out := []map[string]string{}
|
||||
for _, f := range files {
|
||||
b, _ := os.ReadFile(f)
|
||||
out = append(out, map[string]string{"name": strings.TrimSuffix(filepath.Base(f), ".json"), "workspace": SavedWorkspace(string(b)), "path": f})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *i3) layoutRestore(ctx context.Context, a desktop.Args) (any, error) {
|
||||
name, err := a.Text("name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "list" {
|
||||
return map[string]any{"layouts": t.savedLayouts()}, nil
|
||||
}
|
||||
if !layoutName.MatchString(name) {
|
||||
return nil, fmt.Errorf("a layout's name is letters, digits, dot, dash and underscore")
|
||||
}
|
||||
path := filepath.Join(t.layouts, name+".json")
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("no saved layout %q (i3_layout_restore with name list shows them)", name)
|
||||
}
|
||||
ws := a.Opt("workspace", SavedWorkspace(string(b)))
|
||||
if ws == "" {
|
||||
return nil, fmt.Errorf("name the workspace to lay it on")
|
||||
}
|
||||
cmd := "workspace " + Quote(ws) + "; append_layout " + Quote(path)
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"layout": name, "workspace": ws, "i3": out,
|
||||
"then": "each placeholder swallows the next window of its class as it opens; start the programs (i3_exec) to fill them"}, nil
|
||||
}
|
||||
|
||||
func (t *i3) exec(ctx context.Context, a desktop.Args) (any, error) {
|
||||
command, err := a.Text("command")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd := "exec --no-startup-id " + Quote(command)
|
||||
if ws := a.Opt("workspace", ""); ws != "" {
|
||||
cmd = "workspace " + Quote(ws) + "; " + cmd
|
||||
}
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"started": command, "i3": out, "how": "a child of i3, in the session; i3 does not answer whether the program itself started"}, nil
|
||||
}
|
||||
|
||||
func (t *i3) kill(ctx context.Context, a desktop.Args) (any, error) {
|
||||
crit, err := Criteria(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
focused, _, err := a.Bool("focused")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if crit == "" && !focused {
|
||||
return nil, fmt.Errorf("name the windows to close, or focused: true")
|
||||
}
|
||||
force, _, _ := a.Bool("force")
|
||||
cmd := strings.TrimSpace(crit + " kill")
|
||||
if force {
|
||||
cmd += " client"
|
||||
}
|
||||
before, _ := t.tree()
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"command": cmd, "i3": out, "windows_before": len(Windows(before))}, nil
|
||||
}
|
||||
|
||||
type configReply struct {
|
||||
Config string `json:"config"`
|
||||
Included []struct {
|
||||
Path string `json:"path"`
|
||||
Raw string `json:"raw_contents"`
|
||||
Replaced string `json:"variable_replaced_contents"`
|
||||
} `json:"included_configs"`
|
||||
}
|
||||
|
||||
// loaded is each file the running i3 loaded, with the text it read.
|
||||
func (t *i3) loaded() (map[string]string, error) {
|
||||
var c configReply
|
||||
if err := t.ask(GetConfig, "", &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, inc := range c.Included {
|
||||
out[inc.Path] = inc.Raw
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out[filepath.Join(t.configDir, "config")] = c.Config
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (t *i3) bindings(ctx context.Context, a desktop.Args) (any, error) {
|
||||
var c configReply
|
||||
if err := t.ask(GetConfig, "", &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []Binding
|
||||
if len(c.Included) == 0 {
|
||||
all = Bindings("(main)", c.Config)
|
||||
}
|
||||
for _, inc := range c.Included {
|
||||
all = append(all, Bindings(inc.Path, inc.Replaced)...)
|
||||
}
|
||||
match := strings.ToLower(a.Opt("match", ""))
|
||||
out := []Binding{}
|
||||
for _, b := range all {
|
||||
if match == "" || strings.Contains(strings.ToLower(b.Keys+" "+b.Command), match) {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return map[string]any{"bindings": out, "from": "the configuration i3 loaded at its last start or reload"}, nil
|
||||
}
|
||||
|
||||
func (t *i3) configCheck(ctx context.Context, a desktop.Args) (any, error) {
|
||||
problems, err := t.check(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"valid": len(problems) == 0, "errors": problems, "config": filepath.Join(t.configDir, "config"),
|
||||
"on_disk": sortedKeys(Watched(t.configDir)), "watcher": t.watcherStatus()}
|
||||
var v struct {
|
||||
Loaded string `json:"loaded_config_file_name"`
|
||||
Included []string `json:"included_config_file_names"`
|
||||
Human string `json:"human_readable"`
|
||||
}
|
||||
if err := t.ask(GetVersion, "", &v); err == nil {
|
||||
answer["running"] = map[string]any{"version": v.Human, "loaded": append([]string{v.Loaded}, v.Included...)}
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *i3) marks(ctx context.Context, a desktop.Args) (any, error) {
|
||||
root, err := t.tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type marked struct {
|
||||
Mark string `json:"mark"`
|
||||
Window Window `json:"window"`
|
||||
}
|
||||
out := []marked{}
|
||||
for _, w := range Windows(root) {
|
||||
for _, m := range w.Marks {
|
||||
out = append(out, marked{m, w})
|
||||
}
|
||||
}
|
||||
return map[string]any{"marks": out}, nil
|
||||
}
|
||||
|
||||
func (t *i3) scratchpad(ctx context.Context, a desktop.Args) (any, error) {
|
||||
action, err := a.OneOf("action", "list", "list", "show", "move")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
crit, err := Criteria(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch action {
|
||||
case "list":
|
||||
root, err := t.tree()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Window{}
|
||||
for _, w := range Windows(root) {
|
||||
if w.Scratchpad {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return map[string]any{"scratchpad": out}, nil
|
||||
case "show":
|
||||
cmd := strings.TrimSpace(crit + " scratchpad show")
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"command": cmd, "i3": out}, nil
|
||||
default:
|
||||
cmd := strings.TrimSpace(crit + " move scratchpad")
|
||||
out, err := t.command(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"command": cmd, "i3": out}, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"i3/internal/desktop"
|
||||
)
|
||||
|
||||
// Node is a container of i3's tree, as GET_TREE answers it.
|
||||
type Node struct {
|
||||
ID int64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name *string `json:"name"`
|
||||
Layout string `json:"layout"`
|
||||
Orientation string `json:"orientation"`
|
||||
Percent *float64 `json:"percent"`
|
||||
Border string `json:"border"`
|
||||
BorderWidth int `json:"current_border_width"`
|
||||
Floating string `json:"floating"`
|
||||
Focused bool `json:"focused"`
|
||||
Urgent bool `json:"urgent"`
|
||||
Marks []string `json:"marks"`
|
||||
Window *int64 `json:"window"`
|
||||
WindowProperties map[string]any `json:"window_properties"`
|
||||
Fullscreen int `json:"fullscreen_mode"`
|
||||
Scratchpad string `json:"scratchpad_state"`
|
||||
Geometry map[string]int `json:"geometry"`
|
||||
Rect map[string]int `json:"rect"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
FloatingNodes []Node `json:"floating_nodes"`
|
||||
}
|
||||
|
||||
// Window is one window, as the windows verb answers it.
|
||||
type Window struct {
|
||||
ConID int64 `json:"con_id"`
|
||||
Window string `json:"window"`
|
||||
Class string `json:"class,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Workspace string `json:"workspace"`
|
||||
Output string `json:"output"`
|
||||
Focused bool `json:"focused,omitempty"`
|
||||
Urgent bool `json:"urgent,omitempty"`
|
||||
Floating bool `json:"floating,omitempty"`
|
||||
Fullscreen bool `json:"fullscreen,omitempty"`
|
||||
Scratchpad bool `json:"scratchpad,omitempty"`
|
||||
Marks []string `json:"marks,omitempty"`
|
||||
}
|
||||
|
||||
func str(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
func prop(n Node, key string) string {
|
||||
if v, ok := n.WindowProperties[key].(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Windows is every window of the tree with the workspace and output it is on. The scratchpad's
|
||||
// windows are on the workspace "__i3_scratch" and say so; the bars, in the dock areas, are not windows
|
||||
// a person arranges and are left out.
|
||||
func Windows(root Node) []Window {
|
||||
var out []Window
|
||||
var walk func(n Node, output, workspace string, dock, floating bool)
|
||||
walk = func(n Node, output, workspace string, dock, floating bool) {
|
||||
switch n.Type {
|
||||
case "output":
|
||||
output = str(n.Name)
|
||||
case "workspace":
|
||||
workspace = str(n.Name)
|
||||
case "dockarea":
|
||||
dock = true
|
||||
case "floating_con":
|
||||
floating = true
|
||||
}
|
||||
if n.Window != nil && !dock {
|
||||
out = append(out, Window{
|
||||
ConID: n.ID, Window: "0x" + strconv.FormatInt(*n.Window, 16),
|
||||
Class: prop(n, "class"), Instance: prop(n, "instance"), Role: prop(n, "window_role"),
|
||||
Title: str(n.Name), Workspace: workspace, Output: output,
|
||||
Focused: n.Focused, Urgent: n.Urgent, Floating: floating || strings.HasSuffix(n.Floating, "_on"),
|
||||
Fullscreen: n.Fullscreen != 0, Scratchpad: workspace == "__i3_scratch", Marks: n.Marks,
|
||||
})
|
||||
}
|
||||
for _, c := range n.Nodes {
|
||||
walk(c, output, workspace, dock, floating)
|
||||
}
|
||||
for _, c := range n.FloatingNodes {
|
||||
walk(c, output, workspace, dock, true)
|
||||
}
|
||||
}
|
||||
walk(root, "", "", false, false)
|
||||
return out
|
||||
}
|
||||
|
||||
// Workspace is the subtree of one workspace, by name; or the focused one's when name is empty.
|
||||
func Workspace(root Node, name string) (Node, bool) {
|
||||
var found *Node
|
||||
var walk func(n Node, ws *Node)
|
||||
walk = func(n Node, ws *Node) {
|
||||
if found != nil {
|
||||
return
|
||||
}
|
||||
if n.Type == "workspace" {
|
||||
nn := n
|
||||
ws = &nn
|
||||
if name != "" && str(n.Name) == name {
|
||||
found = ws
|
||||
return
|
||||
}
|
||||
}
|
||||
if name == "" && n.Focused && ws != nil {
|
||||
found = ws
|
||||
return
|
||||
}
|
||||
for _, c := range append(append([]Node{}, n.Nodes...), n.FloatingNodes...) {
|
||||
walk(c, ws)
|
||||
}
|
||||
}
|
||||
walk(root, nil)
|
||||
if found == nil {
|
||||
return Node{}, false
|
||||
}
|
||||
return *found, true
|
||||
}
|
||||
|
||||
// Layout is a workspace's arrangement in the form i3's append_layout reads (i3's layout saving
|
||||
// docs): each container with its layout, share and border, each window replaced by a placeholder that
|
||||
// swallows the next window matching its class, instance and role. One JSON object per top-level
|
||||
// container, as i3-save-tree writes them.
|
||||
func Layout(ws Node) ([]map[string]any, int) {
|
||||
windows := 0
|
||||
var conv func(n Node) map[string]any
|
||||
conv = func(n Node) map[string]any {
|
||||
out := map[string]any{"border": n.Border, "current_border_width": n.BorderWidth, "floating": n.Floating, "layout": n.Layout, "type": "con"}
|
||||
if n.Percent != nil {
|
||||
out["percent"] = *n.Percent
|
||||
}
|
||||
if n.Name != nil {
|
||||
out["name"] = *n.Name
|
||||
}
|
||||
if len(n.Marks) > 0 {
|
||||
out["marks"] = n.Marks
|
||||
}
|
||||
if n.Window != nil {
|
||||
windows++
|
||||
swallow := map[string]string{}
|
||||
for key, field := range map[string]string{"class": "class", "instance": "instance", "window_role": "window_role"} {
|
||||
if v := prop(n, key); v != "" {
|
||||
swallow[field] = "^" + regexp.QuoteMeta(v) + "$"
|
||||
}
|
||||
}
|
||||
out["swallows"] = []map[string]string{swallow}
|
||||
return out
|
||||
}
|
||||
var kids []map[string]any
|
||||
for _, c := range n.Nodes {
|
||||
kids = append(kids, conv(c))
|
||||
}
|
||||
if kids != nil {
|
||||
out["nodes"] = kids
|
||||
}
|
||||
return out
|
||||
}
|
||||
var top []map[string]any
|
||||
for _, c := range ws.Nodes {
|
||||
top = append(top, conv(c))
|
||||
}
|
||||
for _, f := range ws.FloatingNodes {
|
||||
fc := conv(f)
|
||||
fc["type"] = "floating_con"
|
||||
if g := f.Rect; g != nil {
|
||||
fc["rect"] = g
|
||||
}
|
||||
top = append(top, fc)
|
||||
}
|
||||
return top, windows
|
||||
}
|
||||
|
||||
// LayoutFile is a saved layout as written to disk: a comment naming the workspace, then the objects.
|
||||
func LayoutFile(workspace, saved string, top []map[string]any) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "// mesh i3 layout of workspace %s, saved %s\n", strconv.Quote(workspace), saved)
|
||||
for _, t := range top {
|
||||
raw, _ := json.MarshalIndent(t, "", " ")
|
||||
b.Write(raw)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var savedWorkspace = regexp.MustCompile(`^// mesh i3 layout of workspace ("(?:[^"\\]|\\.)*")`)
|
||||
|
||||
// SavedWorkspace is the workspace a saved layout was taken from.
|
||||
func SavedWorkspace(file string) string {
|
||||
if m := savedWorkspace.FindStringSubmatch(file); m != nil {
|
||||
if s, err := strconv.Unquote(m[1]); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Criteria builds i3's window criteria from a tool's arguments: con_id, window (X id), class,
|
||||
// instance, title, mark, each quoted. Empty when none is given.
|
||||
func Criteria(a desktop.Args) (string, error) {
|
||||
var parts []string
|
||||
if a.Has("con_id") {
|
||||
f, _, err := a.Number("con_id")
|
||||
if err != nil || f <= 0 {
|
||||
return "", fmt.Errorf("con_id is a container's id, as windows answers it")
|
||||
}
|
||||
parts = append(parts, "con_id="+strconv.FormatInt(int64(f), 10))
|
||||
}
|
||||
if w := a.Opt("window", ""); w != "" {
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(strings.ToLower(w), "0x"), 16, 64)
|
||||
if err != nil || !strings.HasPrefix(strings.ToLower(w), "0x") {
|
||||
return "", fmt.Errorf("window is an X id, e.g. 0x3a00007")
|
||||
}
|
||||
parts = append(parts, "id="+strconv.FormatInt(n, 10))
|
||||
}
|
||||
for _, k := range []string{"class", "instance", "title", "con_mark"} {
|
||||
arg := k
|
||||
if k == "con_mark" {
|
||||
arg = "mark"
|
||||
}
|
||||
if v := a.Opt(arg, ""); v != "" {
|
||||
parts = append(parts, k+"="+Quote(v))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return "[" + strings.Join(parts, " ") + "]", nil
|
||||
}
|
||||
|
||||
// criteriaProps are the arguments every window-targeting tool takes.
|
||||
func criteriaProps(extra map[string]any) map[string]any {
|
||||
p := map[string]any{
|
||||
"con_id": desktop.Int("the container's id, as windows answers it"),
|
||||
"window": desktop.Str("the X window id, e.g. 0x3a00007"),
|
||||
"class": desktop.Str("windows whose class matches this (a regular expression)"),
|
||||
"instance": desktop.Str("windows whose instance matches this"),
|
||||
"title": desktop.Str("windows whose title matches this"),
|
||||
"mark": desktop.Str("the window holding this mark"),
|
||||
}
|
||||
for k, v := range extra {
|
||||
p[k] = v
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;
|
||||
# change the module instead. i3's user guide is the reference.
|
||||
#
|
||||
# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named
|
||||
# <NN>-<module>.conf and read in name order by the include at the end, where every variable set here
|
||||
# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.
|
||||
#
|
||||
# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the
|
||||
# result with i3 -C; it never reloads into a configuration with errors.
|
||||
|
||||
# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).
|
||||
font pango:JetBrainsMono Nerd Font 11
|
||||
|
||||
# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.
|
||||
exec --no-startup-id dex --autostart --environment i3
|
||||
|
||||
#########################################
|
||||
###### Keys ####
|
||||
#########################################
|
||||
# To find key symbols: xmodmap -pke / xmodmap -pm
|
||||
set $mod Mod4
|
||||
set $alt Mod1
|
||||
set $shift Shift
|
||||
set $ctrl Control
|
||||
|
||||
# use these keys for focus, movement, and resize directions when reaching for
|
||||
# the arrows is not convenient
|
||||
set $left h
|
||||
set $down j
|
||||
set $up k
|
||||
set $right l
|
||||
|
||||
# use Mouse+$mod to drag floating windows to their wanted position
|
||||
floating_modifier $mod
|
||||
|
||||
# move tiling windows via drag & drop by left-clicking into the title bar,
|
||||
# or left-clicking anywhere into the window while holding the floating modifier.
|
||||
tiling_drag modifier titlebar
|
||||
|
||||
# start a terminal: whichever the terminal module names in $TERMINAL
|
||||
bindsym $mod+Return exec i3-sensible-terminal
|
||||
|
||||
# kill focused window
|
||||
bindsym $mod+$shift+q kill
|
||||
|
||||
# change focus
|
||||
bindsym $mod+$left focus left
|
||||
bindsym $mod+$down focus down
|
||||
bindsym $mod+$up focus up
|
||||
bindsym $mod+$right focus right
|
||||
|
||||
bindsym $mod+Left focus left
|
||||
bindsym $mod+Down focus down
|
||||
bindsym $mod+Up focus up
|
||||
bindsym $mod+Right focus right
|
||||
|
||||
# move focused window
|
||||
bindsym $mod+$shift+$left move left
|
||||
bindsym $mod+$shift+$down move down
|
||||
bindsym $mod+$shift+$up move up
|
||||
bindsym $mod+$shift+$right move right
|
||||
|
||||
bindsym $mod+$shift+Left move left
|
||||
bindsym $mod+$shift+Down move down
|
||||
bindsym $mod+$shift+Up move up
|
||||
bindsym $mod+$shift+Right move right
|
||||
|
||||
# split in horizontal orientation
|
||||
bindsym $mod+c split h
|
||||
# split in vertical orientation
|
||||
bindsym $mod+v split v
|
||||
|
||||
# enter fullscreen mode for the focused container
|
||||
bindsym $mod+f fullscreen toggle
|
||||
|
||||
# change container layout (stacked, tabbed, toggle split)
|
||||
bindsym $mod+s layout stacking
|
||||
bindsym $mod+w layout tabbed
|
||||
bindsym $mod+e layout toggle split
|
||||
|
||||
# toggle tiling / floating
|
||||
bindsym $mod+$shift+space floating toggle
|
||||
|
||||
# change focus between tiling / floating windows
|
||||
bindsym $mod+space focus mode_toggle
|
||||
|
||||
# focus the parent container
|
||||
bindsym $mod+a focus parent
|
||||
|
||||
# alt-tab functionality
|
||||
bindsym $mod+Tab workspace back_and_forth
|
||||
|
||||
#########################################
|
||||
###### Workspace mgmt ####
|
||||
#########################################
|
||||
set $ws1 "1"
|
||||
set $ws2 "2"
|
||||
set $ws3 "3"
|
||||
set $ws4 "4"
|
||||
set $ws5 "5"
|
||||
set $ws6 "6"
|
||||
set $ws7 "7"
|
||||
set $ws8 "8"
|
||||
set $ws9 "9"
|
||||
set $ws10 "10"
|
||||
|
||||
bindsym $mod+1 workspace number $ws1
|
||||
bindsym $mod+2 workspace number $ws2
|
||||
bindsym $mod+3 workspace number $ws3
|
||||
bindsym $mod+4 workspace number $ws4
|
||||
bindsym $mod+5 workspace number $ws5
|
||||
bindsym $mod+6 workspace number $ws6
|
||||
bindsym $mod+7 workspace number $ws7
|
||||
bindsym $mod+8 workspace number $ws8
|
||||
bindsym $mod+9 workspace number $ws9
|
||||
bindsym $mod+0 workspace number $ws10
|
||||
|
||||
bindsym $mod+$shift+1 move container to workspace number $ws1
|
||||
bindsym $mod+$shift+2 move container to workspace number $ws2
|
||||
bindsym $mod+$shift+3 move container to workspace number $ws3
|
||||
bindsym $mod+$shift+4 move container to workspace number $ws4
|
||||
bindsym $mod+$shift+5 move container to workspace number $ws5
|
||||
bindsym $mod+$shift+6 move container to workspace number $ws6
|
||||
bindsym $mod+$shift+7 move container to workspace number $ws7
|
||||
bindsym $mod+$shift+8 move container to workspace number $ws8
|
||||
bindsym $mod+$shift+9 move container to workspace number $ws9
|
||||
bindsym $mod+$shift+0 move container to workspace number $ws10
|
||||
|
||||
#########################################
|
||||
###### Window mgmt ####
|
||||
#########################################
|
||||
set $resize_px 10 px
|
||||
bindsym $mod+$ctrl+$left resize shrink width $resize_px
|
||||
bindsym $mod+$ctrl+$down resize grow height $resize_px
|
||||
bindsym $mod+$ctrl+$up resize shrink height $resize_px
|
||||
bindsym $mod+$ctrl+$right resize grow width $resize_px
|
||||
|
||||
#########################################
|
||||
###### Session mgmt ####
|
||||
#########################################
|
||||
bindsym $mod+$shift+e exec "i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'"
|
||||
|
||||
# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and
|
||||
# suspend and lid close too).
|
||||
bindsym $mod+Delete exec --no-startup-id loginctl lock-session
|
||||
|
||||
# reload the configuration file
|
||||
bindsym $mod+$shift+c reload
|
||||
|
||||
# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)
|
||||
bindsym $mod+$shift+r restart
|
||||
|
||||
#########################################
|
||||
###### Borders ####
|
||||
#########################################
|
||||
default_border pixel 1
|
||||
smart_borders on
|
||||
|
||||
#########################################
|
||||
###### Gaps ####
|
||||
#########################################
|
||||
gaps inner 0
|
||||
gaps outer 0
|
||||
|
||||
# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are
|
||||
# `pixel`, so no title bar shows: the colour says which window has focus.
|
||||
# border background text indicator child_border
|
||||
client.focused #de5200 #de5200 #1E2127 #de5200 #de5200
|
||||
client.urgent #900000 #900000 #ffffff #900000 #900000
|
||||
|
||||
#########################################
|
||||
###### Until their modules carry them ##
|
||||
#########################################
|
||||
# Each line below belongs to something other than i3, named on its line. When that module is written
|
||||
# it contributes the line as its own drop-in in config.d, and the line goes from here in the same
|
||||
# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs
|
||||
# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).
|
||||
|
||||
# the peripherals' tray (the operator's application)
|
||||
exec --no-startup-id polychromatic-tray-applet
|
||||
|
||||
# the operator's scripts: volume, games volume, sessions, screenshot
|
||||
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
|
||||
bindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down
|
||||
bindsym XF86AudioMute exec --no-startup-id volume-notify mute
|
||||
bindsym XF86AudioMicMute exec --no-startup-id mic-notify
|
||||
bindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5
|
||||
bindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5
|
||||
bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh
|
||||
bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh
|
||||
|
||||
#########################################
|
||||
###### Other modules' drop-ins ####
|
||||
#########################################
|
||||
include ~/.config/i3/config.d/*.conf
|
||||
@@ -0,0 +1,5 @@
|
||||
module i3
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,101 @@
|
||||
# lemurs
|
||||
|
||||
The login manager as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 3).
|
||||
|
||||
- **Claims `node-login-manager`** and serves its verb `sessions`.
|
||||
- **Package `lemurs`**, from the official repositories. It replaces the user repository's
|
||||
`lemurs-git` that both workstations run.
|
||||
- **Service `lemurs.service`, enabled at boot.** It is its own `display-manager.service` alias. Its
|
||||
state is declared as nothing: a push never starts, stops or restarts the login manager, because
|
||||
restarting it ends the session it started. A change to its configuration applies at its next
|
||||
start (a reboot).
|
||||
- **Gated by `package-manager`, `service-manager` and `seat`.** A machine without a display has
|
||||
nothing to log into.
|
||||
|
||||
## What it owns
|
||||
|
||||
| path | class | what |
|
||||
|---|---|---|
|
||||
| `/etc/lemurs/config.toml` | owned (the found file is kept once, ADR 0102) | lemurs 0.4's configuration in its current format: the structure of the shipped file, every option present, as lemurs requires. The source is [`config/config.toml`](config/config.toml), carried whole in the manifest |
|
||||
| `/etc/lemurs/xsessions/`, `/etc/lemurs/wayland-sessions/` | owned, empty | where the configuration points lemurs for desktop entries, so none is offered |
|
||||
|
||||
**Sessions are drop-ins.** The sessions offered are the executable files session modules place in
|
||||
`/etc/lemurs/wms/` (X) and `/etc/lemurs/wayland/` (Wayland). The file's name is the session's name.
|
||||
`i3` places `/etc/lemurs/wms/i3`, and `sway` will place its own in `wayland/`. The desktop entries
|
||||
packages install (`/usr/share/xsessions/i3.desktop`, `i3-with-shmlog.desktop`) are no longer offered.
|
||||
They start the window manager bare, skipping `~/.xinitrc`, which holds the environment, the
|
||||
resources and every module's session lines. Today the workstations log in through exactly such an
|
||||
entry (`i3`). It reaches the session's start only because `~/.xprofile` sources `~/.xinitrc`.
|
||||
|
||||
**What the configuration changes** from the shipped file, each marked `mesh:` in it:
|
||||
|
||||
- the two desktop-entry directories, as above;
|
||||
- `switcher_visibility = "F3"`. The session switcher stays hidden as today, and F3 shows it once a
|
||||
second session (sway) exists.
|
||||
|
||||
Everything else is lemurs's default, which is also what runs today: X on `:1`, tty 2, the cache in
|
||||
`/var/cache/lemurs`, `remember = true`. The workstations' current file is two releases old. The
|
||||
running `lemurs-git` ignores its X keys and uses these defaults already, which is why X is on `:1`
|
||||
although the file says `:0`.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-login-manager.sessions` | r | each session offered: name, X11 or Wayland, script or desktop entry, the file and what it runs, whether lemurs can run it (a script that is not executable is skipped); the default session and account from the cache |
|
||||
| `lemurs_default_session` | r/a | the preselected session; set it to one that is offered. It writes the cache through `sudo -n`, and shows when lemurs next starts |
|
||||
| `lemurs_logins` | r | logins from the journal (opened, closed, failed passwords), and which entry lemurs started each session with (its own log) |
|
||||
|
||||
None needs the graphical session.
|
||||
|
||||
## What it improves
|
||||
|
||||
- the official package instead of a `-git` build from the user repository;
|
||||
- the configuration in the format the binary reads. Today's file is mostly ignored, and the unmerged
|
||||
`.pacnew` sits beside it;
|
||||
- one session per session module, each starting through the session's start, and no bare desktop
|
||||
entries;
|
||||
- a dead entry gone: `/etc/lemurs/wms/i3wm` (`exec startx`) would start a second X server inside
|
||||
the one lemurs started.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
- `/etc/lemurs/xsetup.sh`, the package's;
|
||||
- `/etc/pam.d/lemurs`, the package's (it unlocks the login keyring through `pam_gnome_keyring`);
|
||||
- `/var/cache/lemurs`, lemurs's own.
|
||||
|
||||
## The one-off migration (ADR 0182)
|
||||
|
||||
1. **Replace the package by hand, once per workstation, at a moment you choose:**
|
||||
`sudo pacman -S lemurs`, answering yes to removing `lemurs-git` (and `lemurs-git-debug` on the
|
||||
laptop). The host cannot do this. `pacman -Q lemurs` answers with `lemurs-git`, which provides
|
||||
`lemurs`, so the declared package already reads as installed. A non-interactive install would
|
||||
also refuse the conflict. The binary is replaced on disk, and the running login manager keeps
|
||||
running the old one until the next boot.
|
||||
2. **Delete `/etc/lemurs/config.toml.pacnew`.** The module's file is that structure.
|
||||
3. **Delete `/etc/lemurs/wms/i3wm`** once `i3` is assigned and `/etc/lemurs/wms/i3` exists.
|
||||
4. **Delete `~/.xprofile`**, or its `. ~/.xinitrc` line (see `xorg`'s README). Until then the X setup
|
||||
runs `~/.xinitrc` from `.xprofile` before it reaches the session's entry. That still works, once.
|
||||
|
||||
Steps 1, 2 and 3 are harmless in any order. Step 4 waits for `i3`.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | nothing until step 1 (`lemurs-git` reads as installed) | the same |
|
||||
| `/etc/lemurs/config.toml` | the found file kept once, then the module's written | the same |
|
||||
| `/etc/lemurs/xsessions/`, `wayland-sessions/` | created, empty | the same |
|
||||
| `lemurs.service` | already enabled: nothing | the same |
|
||||
| the running login manager and session | **nothing** | **nothing** |
|
||||
| next boot | the login screen offers `i3wm` until step 3, and `i3` once the `i3` module is assigned. It no longer offers the two desktop entries. The remembered session `i3` matches the `i3` module's entry by name | the same |
|
||||
|
||||
**Order matters at one point:** assign `i3` before the next reboot after `lemurs`. Otherwise the
|
||||
screen offers only `i3wm`, which runs `startx` inside lemurs's own X server and fails. Assigned in
|
||||
to-be 42's order (`xorg`, `lemurs`, `i3` in one sitting), this cannot happen.
|
||||
|
||||
## Blockers
|
||||
|
||||
- **The package swap is a person's act** (step 1). The host's package resource cannot replace a
|
||||
package that provides the same name.
|
||||
- **No restart, by design.** A configuration change takes a reboot to show.
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
// aMachine lays out a login manager in a directory: its configuration naming directories under it,
|
||||
// two X scripts (one not executable), a package's desktop entry and a cache.
|
||||
func aMachine(t *testing.T) (lemurs, string, *[]string) {
|
||||
root := t.TempDir()
|
||||
for _, d := range []string{"wms", "wayland", "xsessions", "wayland-sessions"} {
|
||||
os.MkdirAll(filepath.Join(root, d), 0o755)
|
||||
}
|
||||
os.WriteFile(filepath.Join(root, "wms", "i3"), []byte("#!/bin/sh\n# the session\nexec /bin/sh \"$HOME/.xinitrc\"\n"), 0o755)
|
||||
os.WriteFile(filepath.Join(root, "wms", "notes"), []byte("not a session\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "xsessions", "i3.desktop"), []byte("[Desktop Entry]\nName=i3\nExec=i3\nType=Application\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(root, "cache"), []byte("i3\nop\n"), 0o644)
|
||||
config := strings.Join([]string{
|
||||
`tty = 2`, `cache_path = "` + root + `/cache"`,
|
||||
`[x11]`, `x11_display = ":1"`, `scripts_path = "` + root + `/wms"`, `xsessions_path = "` + root + `/xsessions"`,
|
||||
`[wayland]`, `scripts_path = "` + root + `/wayland"`, `wayland_sessions_path = "` + root + `/wayland-sessions"`,
|
||||
}, "\n")
|
||||
os.WriteFile(filepath.Join(root, "config.toml"), []byte(config), 0o644)
|
||||
var ran []string
|
||||
d := desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} },
|
||||
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
ran = append(ran, line+" <<"+string(stdin))
|
||||
if name == "journalctl" {
|
||||
return desktop.Result{Stdout: journal}
|
||||
}
|
||||
return desktop.Result{}
|
||||
},
|
||||
}
|
||||
return lemurs{d: d, config: filepath.Join(root, "config.toml"), log: filepath.Join(root, "lemurs.log"), uid: 1000}, root, &ran
|
||||
}
|
||||
|
||||
const journal = `-- Boot a0 --
|
||||
2026-10-02T13:08:44+02:00 host lemurs[1001]: gkr-pam: unable to locate daemon control file
|
||||
2026-10-02T13:08:40+02:00 host lemurs[1001]: pam_unix(lemurs:auth): authentication failure; logname= uid=0 euid=0 tty=tty2 ruser= rhost= user=op
|
||||
2026-10-02T13:08:44+02:00 host lemurs[2141]: pam_unix(lemurs:session): session opened for user op(uid=1000) by op(uid=0)
|
||||
2026-10-02T18:00:01+02:00 host lemurs[2141]: pam_unix(lemurs:session): session closed for user op
|
||||
`
|
||||
|
||||
func TestTheSessionsAreTheEntriesLemursOffersInItsOrder(t *testing.T) {
|
||||
l, root, _ := aMachine(t)
|
||||
got, err := l.sessions(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := got.(map[string]any)["sessions"].([]Session)
|
||||
if len(s) != 3 || s[0].Source != "desktop-entry" || s[0].Exec != "i3" || s[1].Name != "i3" || s[1].Source != "script" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s[1].Exec != `exec /bin/sh "$HOME/.xinitrc"` || !s[1].Offered {
|
||||
t.Fatalf("a script answers what it runs: %+v", s[1])
|
||||
}
|
||||
if s[2].Name != "notes" || s[2].Executable || s[2].Offered {
|
||||
t.Fatalf("a script that is not executable is not offered: %+v", s[2])
|
||||
}
|
||||
if d := got.(map[string]any)["default"].(Cached); d.Session != "i3" || d.Account != "op" {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
_ = root
|
||||
}
|
||||
|
||||
func TestTheModulesConfigurationIsReadAsLemursReadsIt(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := ParseConfig(string(raw))
|
||||
want := Config{Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/etc/lemurs/xsessions",
|
||||
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/etc/lemurs/wayland-sessions", Display: ":1", TTY: 2}
|
||||
if c != want {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDefaultSessionIsOneLemursOffersAndIsWrittenThroughSudo(t *testing.T) {
|
||||
l, root, ran := aMachine(t)
|
||||
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "notes"}); err == nil {
|
||||
t.Fatal("a session lemurs does not offer is refused")
|
||||
}
|
||||
if _, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3", "account": "op; rm"}); err == nil {
|
||||
t.Fatal("an account that is not a name is refused")
|
||||
}
|
||||
got, err := l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(*ran) != 1 || (*ran)[0] != "sudo -n tee "+root+"/cache <<i3\nop\n" {
|
||||
t.Fatalf("%q", *ran)
|
||||
}
|
||||
if !strings.Contains(asJSON(got), `"shown"`) {
|
||||
t.Fatalf("it says when it shows: %s", asJSON(got))
|
||||
}
|
||||
l.uid = 0
|
||||
*ran = nil
|
||||
l.defaultSession(context.Background(), desktop.Args{"session": "i3"})
|
||||
if !strings.HasPrefix((*ran)[0], "tee ") {
|
||||
t.Fatalf("as root, no sudo: %q", *ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginsAreReadFromTheJournalAndLemursLog(t *testing.T) {
|
||||
l, root, _ := aMachine(t)
|
||||
os.WriteFile(filepath.Join(root, "lemurs.log"), []byte(
|
||||
"[2026-10-02T11:08:44Z INFO lemurs] Starting new session for 'op' in environment 'X { xinitrc_path: \"i3\" }'\n"+
|
||||
"[2026-10-02T11:08:44Z INFO lemurs::auth] Login attempt for 'op'\n"), 0o644)
|
||||
got, err := l.logins(context.Background(), desktop.Args{"limit": float64(2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := got.(map[string]any)
|
||||
ev := m["events"].([]Login)
|
||||
if len(ev) != 2 || ev[0].Event != "opened" || ev[1].Event != "closed" || ev[1].Account != "op" || m["cut"] != true {
|
||||
t.Fatalf("the newest two, cut: %+v", ev)
|
||||
}
|
||||
all := ParseJournal(journal)
|
||||
if len(all) != 3 || all[0].Event != "failed" || all[0].Account != "op" {
|
||||
t.Fatalf("%+v", all)
|
||||
}
|
||||
st := m["started"].([]Started)
|
||||
if len(st) != 1 || st[0].Environment != `X { xinitrc_path: "i3" }` {
|
||||
t.Fatalf("%+v", st)
|
||||
}
|
||||
if _, err := l.logins(context.Background(), desktop.Args{"since": "-1d; reboot"}); err == nil {
|
||||
t.Fatal("since is a time")
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// lemurs's tools (novox/hq ADR 0208, research 026/05): node-login-manager's verb `sessions`, and the
|
||||
// module's own `default_session` and `logins`.
|
||||
//
|
||||
// None of them needs the graphical session: they read the login manager's configuration, its session
|
||||
// directories, its cache and the journal. Changing the default session writes the login manager's
|
||||
// cache, which is root's, through `sudo -n` as the packet filter's tools escalate (to-be 38 WP4).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
l := lemurs{d: desktop.Machine(), config: "/etc/lemurs/config.toml", log: "/var/log/lemurs.log", uid: os.Getuid()}
|
||||
if err := stdio.Serve("", tools(l)); 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(l lemurs) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-login-manager.sessions",
|
||||
Description: "The sessions the login screen offers on this machine — each with its name, X11 or Wayland, " +
|
||||
"the file it runs and whether that file is executable (lemurs skips one that is not) — and the " +
|
||||
"session and account it starts with by default (its cache).",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(l.sessions),
|
||||
},
|
||||
{
|
||||
Name: "lemurs_default_session",
|
||||
Description: "The session the login screen preselects. With session (one of the names sessions lists), " +
|
||||
"make it the default: written into lemurs's cache, which lemurs reads when it starts, so it shows at " +
|
||||
"the next start of the login screen (a reboot, or lemurs restarted). Escalates with sudo -n.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"session": desktop.Str("the session to preselect (optional)"),
|
||||
"account": desktop.Str("the account to preselect with it (optional; the cached one)"),
|
||||
}),
|
||||
Run: call(l.defaultSession),
|
||||
},
|
||||
{
|
||||
Name: "lemurs_logins",
|
||||
Description: "Who logged in through the login screen and when, newest last: sessions opened and closed " +
|
||||
"and failed passwords, from the journal, and the sessions lemurs started (which entry, from its own " +
|
||||
"log since it last started).",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"since": desktop.Str("how far back, as journalctl reads it (default -7d)"),
|
||||
"limit": desktop.Int("at most this many events (default 50, at most 500)"),
|
||||
}),
|
||||
Run: call(l.logins),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package main
|
||||
|
||||
// lemurs's shape (novox/hq ADR 0208): it holds node-login-manager; it owns the configuration in
|
||||
// lemurs 0.4's format and enables the service without ever starting, stopping or restarting it,
|
||||
// because the running login manager is the operator's way in.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Seats any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
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 resource(t *testing.T, m manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItHoldsTheLoginManagerSeatAndServesSessions(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-login-manager" || strings.Join(m.Claims[0].Serves, ",") != "sessions" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(lemurs{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
if !served["node-login-manager.sessions"] || len(served) != 1+len(m.Tools) {
|
||||
t.Fatalf("served %v, manifest %v", served, m.Tools)
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("%s is listed and not served", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOfficialPackageAndItsServiceEnabledButNeverRestarted(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if p := resource(t, m, "package"); p["package"] != "lemurs" {
|
||||
t.Fatalf("the official package, not lemurs-git: %v", p)
|
||||
}
|
||||
s := resource(t, m, "service")
|
||||
if s["unit"] != "lemurs.service" || s["boot"] != "enabled" {
|
||||
t.Fatalf("%v", s)
|
||||
}
|
||||
for _, k := range []string{"state", "restart-on", "reload-on"} {
|
||||
if _, ok := s[k]; ok {
|
||||
t.Fatalf("the login manager is never started, stopped or restarted by a push (%s): a change applies at its next start", k)
|
||||
}
|
||||
}
|
||||
if strings.Join(m.Capabilities, ",") != "package-manager,service-manager,seat" {
|
||||
t.Fatalf("%v", m.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheConfigurationIsTheModulesFileAndOffersOnlyTheSessionsModulesPlace(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
c := resource(t, m, "config")
|
||||
raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config.toml"))
|
||||
if c["path"] != "/etc/lemurs/config.toml" || c["content"] != string(raw) || c["into"] != nil {
|
||||
t.Fatal("the manifest carries config/config.toml whole, as an owned file")
|
||||
}
|
||||
text := c["content"].(string)
|
||||
for _, want := range []string{"[x11]", "[wayland]", `xsetup_path = "/etc/lemurs/xsetup.sh"`, `scripts_path = "/etc/lemurs/wms"`,
|
||||
`scripts_path = "/etc/lemurs/wayland"`, `xsessions_path = "/etc/lemurs/xsessions"`, `wayland_sessions_path = "/etc/lemurs/wayland-sessions"`,
|
||||
`tty = 2`, `x11_display = ":1"`, `remember = true`} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("the configuration lacks %s", want)
|
||||
}
|
||||
}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if (strings.HasPrefix(l, "xsessions_path") || strings.HasPrefix(l, "wayland_sessions_path")) && strings.Contains(l, "/usr/share") {
|
||||
t.Fatalf("a package's bare desktop entry would be offered: %s", l)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"xsessions", "wayland-sessions"} {
|
||||
if d := resource(t, m, id); d["type"] != "directory" || d["path"] != "/etc/lemurs/"+id {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"lemurs/internal/desktop"
|
||||
)
|
||||
|
||||
type lemurs struct {
|
||||
d desktop.Desk
|
||||
config string
|
||||
log string
|
||||
uid int
|
||||
}
|
||||
|
||||
// Config is the part of lemurs's configuration the tools read, with lemurs 0.4's defaults.
|
||||
type Config struct {
|
||||
Cache string `json:"cache_path"`
|
||||
X11Scripts string `json:"x11_scripts"`
|
||||
X11Sessions string `json:"x11_desktop_entries"`
|
||||
WaylandScripts string `json:"wayland_scripts"`
|
||||
WaylandEntries string `json:"wayland_desktop_entries"`
|
||||
Display string `json:"x11_display"`
|
||||
TTY int `json:"tty"`
|
||||
}
|
||||
|
||||
// ParseConfig reads the keys it needs from lemurs's TOML: `[section]` headers and `key = value`
|
||||
// lines, a quoted value unquoted. Enough for this file, which holds no nested values it needs.
|
||||
func ParseConfig(text string) Config {
|
||||
c := Config{
|
||||
Cache: "/var/cache/lemurs", X11Scripts: "/etc/lemurs/wms", X11Sessions: "/usr/share/xsessions",
|
||||
WaylandScripts: "/etc/lemurs/wayland", WaylandEntries: "/usr/share/wayland-sessions", Display: ":1", TTY: 2,
|
||||
}
|
||||
section := ""
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") {
|
||||
section = strings.Trim(line, "[] ")
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if uq, err := strconv.Unquote(v); err == nil {
|
||||
v = uq
|
||||
}
|
||||
switch section + "." + k {
|
||||
case ".cache_path":
|
||||
c.Cache = v
|
||||
case ".tty":
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
c.TTY = n
|
||||
}
|
||||
case "x11.scripts_path":
|
||||
c.X11Scripts = v
|
||||
case "x11.xsessions_path":
|
||||
c.X11Sessions = v
|
||||
case "x11.x11_display":
|
||||
c.Display = v
|
||||
case "wayland.scripts_path":
|
||||
c.WaylandScripts = v
|
||||
case "wayland.wayland_sessions_path":
|
||||
c.WaylandEntries = v
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Session is one entry of the login screen.
|
||||
type Session struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // x11 or wayland
|
||||
Source string `json:"source"` // script or desktop-entry
|
||||
Path string `json:"path"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Executable bool `json:"executable"`
|
||||
Offered bool `json:"offered"`
|
||||
}
|
||||
|
||||
// ListSessions is every entry lemurs offers, in its order: X desktop entries, Wayland desktop
|
||||
// entries, X scripts, Wayland scripts (lemurs's get_envs). A script that is not executable is listed
|
||||
// as not offered, because lemurs skips it with only a warning in its log.
|
||||
func ListSessions(c Config) []Session {
|
||||
var out []Session
|
||||
entries := func(dir, kind string) {
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f.Name())
|
||||
name, exec, ok := desktopEntry(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, Session{Name: name, Kind: kind, Source: "desktop-entry", Path: p, Exec: exec, Executable: true, Offered: true})
|
||||
}
|
||||
}
|
||||
scripts := func(dir, kind string) {
|
||||
files, _ := os.ReadDir(dir)
|
||||
for _, f := range files {
|
||||
p := filepath.Join(dir, f.Name())
|
||||
info, err := os.Stat(p)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
x := info.Mode()&0o111 != 0
|
||||
out = append(out, Session{Name: f.Name(), Kind: kind, Source: "script", Path: p, Exec: firstCommand(p), Executable: x, Offered: x})
|
||||
}
|
||||
}
|
||||
entries(c.X11Sessions, "x11")
|
||||
entries(c.WaylandEntries, "wayland")
|
||||
scripts(c.X11Scripts, "x11")
|
||||
scripts(c.WaylandScripts, "wayland")
|
||||
return out
|
||||
}
|
||||
|
||||
// desktopEntry reads Name and Exec from a session's desktop entry, as lemurs does.
|
||||
func desktopEntry(path string) (string, string, bool) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
in, name, exec := false, "", ""
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
if strings.HasPrefix(l, "[") {
|
||||
in = l == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !in {
|
||||
continue
|
||||
}
|
||||
if v, ok := strings.CutPrefix(l, "Name="); ok && name == "" {
|
||||
name = v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(l, "Exec="); ok && exec == "" {
|
||||
exec = v
|
||||
}
|
||||
}
|
||||
if exec == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if name == "" {
|
||||
name = exec
|
||||
}
|
||||
return name, exec, true
|
||||
}
|
||||
|
||||
// firstCommand is a script's first line that is neither blank nor a comment: what it runs.
|
||||
func firstCommand(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, l := range strings.Split(string(b), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Cached is lemurs's cache file: the session on its first line, the account on its second.
|
||||
type Cached struct {
|
||||
Session string `json:"session"`
|
||||
Account string `json:"account"`
|
||||
}
|
||||
|
||||
func readCache(path string) (Cached, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Cached{}, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
|
||||
c := Cached{Session: strings.TrimSpace(lines[0])}
|
||||
if len(lines) > 1 {
|
||||
c.Account = strings.TrimSpace(lines[1])
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (l lemurs) readConfig() (Config, error) {
|
||||
b, err := os.ReadFile(l.config)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("lemurs's configuration cannot be read (%v): is the lemurs module's package installed?", err)
|
||||
}
|
||||
return ParseConfig(string(b)), nil
|
||||
}
|
||||
|
||||
func (l lemurs) sessions(ctx context.Context, a desktop.Args) (any, error) {
|
||||
c, err := l.readConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"config": l.config, "sessions": ListSessions(c), "directories": c}
|
||||
if cached, err := readCache(c.Cache); err == nil {
|
||||
answer["default"] = cached
|
||||
} else {
|
||||
answer["default"] = nil
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
func (l lemurs) defaultSession(ctx context.Context, a desktop.Args) (any, error) {
|
||||
c, err := l.readConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cached, _ := readCache(c.Cache)
|
||||
want := a.Opt("session", "")
|
||||
if want == "" {
|
||||
return map[string]any{"default": cached, "cache": c.Cache}, nil
|
||||
}
|
||||
known := false
|
||||
var names []string
|
||||
for _, s := range ListSessions(c) {
|
||||
if s.Offered {
|
||||
names = append(names, s.Name)
|
||||
known = known || s.Name == want
|
||||
}
|
||||
}
|
||||
if !known {
|
||||
sort.Strings(names)
|
||||
return nil, fmt.Errorf("%q is not a session lemurs offers; it offers %s", want, strings.Join(names, ", "))
|
||||
}
|
||||
account := a.Opt("account", cached.Account)
|
||||
if account == "" {
|
||||
account = os.Getenv("MESH_OPERATOR_ACCOUNT")
|
||||
}
|
||||
if !accountName.MatchString(account) {
|
||||
return nil, fmt.Errorf("%q is not an account name", account)
|
||||
}
|
||||
content := []byte(want + "\n" + account + "\n")
|
||||
var res desktop.Result
|
||||
if l.uid == 0 {
|
||||
res = l.d.Run(ctx, l.d.Base, content, "tee", c.Cache)
|
||||
} else {
|
||||
res = l.d.Run(ctx, l.d.Base, content, "sudo", "-n", "tee", c.Cache)
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, fmt.Errorf("writing %s: %w", c.Cache, res.Err())
|
||||
}
|
||||
return map[string]any{
|
||||
"default": Cached{Session: want, Account: account}, "was": cached, "cache": c.Cache,
|
||||
"shown": "when lemurs next starts (a reboot, or the login manager restarted); lemurs rewrites it after each login when remember is on",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Login is one event of the login screen.
|
||||
type Login struct {
|
||||
Time string `json:"time"`
|
||||
Event string `json:"event"` // opened, closed or failed
|
||||
Account string `json:"account,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
opened = regexp.MustCompile(`pam_unix\(lemurs:session\): session opened for user ([^(\s]+)`)
|
||||
closed = regexp.MustCompile(`pam_unix\(lemurs:session\): session closed for user ([^(\s]+)`)
|
||||
failed = regexp.MustCompile(`pam_unix\(lemurs:auth\): authentication failure;.*?user=(\S+)`)
|
||||
started = regexp.MustCompile(`^\[(\S+) INFO\s+lemurs\] Starting new session for '([^']*)' in environment '(.*)'$`)
|
||||
)
|
||||
|
||||
// ParseJournal reads `journalctl -o short-iso` lines of lemurs into login events.
|
||||
func ParseJournal(text string) []Login {
|
||||
var out []Login
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) < 3 || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
for _, p := range []struct {
|
||||
re *regexp.Regexp
|
||||
event string
|
||||
}{{opened, "opened"}, {closed, "closed"}, {failed, "failed"}} {
|
||||
if m := p.re.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, Login{Time: f[0], Event: p.event, Account: m[1]})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Started is one session lemurs started, from its own log.
|
||||
type Started struct {
|
||||
Time string `json:"time"`
|
||||
Account string `json:"account"`
|
||||
Environment string `json:"environment"`
|
||||
}
|
||||
|
||||
// ParseStarts reads lemurs's own log for the sessions it started and which entry each ran.
|
||||
func ParseStarts(text string) []Started {
|
||||
var out []Started
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if m := started.FindStringSubmatch(line); m != nil {
|
||||
out = append(out, Started{Time: m[1], Account: m[2], Environment: m[3]})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var since = regexp.MustCompile(`^[-+]?[0-9A-Za-z :.]{1,40}$`)
|
||||
|
||||
func (l lemurs) logins(ctx context.Context, a desktop.Args) (any, error) {
|
||||
from := a.Opt("since", "-7d")
|
||||
if !since.MatchString(from) {
|
||||
return nil, fmt.Errorf("since is a time journalctl reads, e.g. -7d or 2026-10-01")
|
||||
}
|
||||
limit, err := a.Whole("limit", 50, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res := l.d.Plain(ctx, "journalctl", "_COMM=lemurs", "--since", from, "-o", "short-iso", "--no-pager", "-q")
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
events := ParseJournal(res.Stdout)
|
||||
cut := false
|
||||
if len(events) > limit {
|
||||
events, cut = events[len(events)-limit:], true
|
||||
}
|
||||
answer := map[string]any{"since": from, "events": events}
|
||||
if cut {
|
||||
answer["cut"] = true
|
||||
}
|
||||
if b, err := os.ReadFile(l.log); err == nil {
|
||||
answer["started"] = ParseStarts(string(b))
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
# The login manager's configuration, written by the mesh (module lemurs, novox/hq ADR 0208). Replaced
|
||||
# at every push; change the module instead. The structure is lemurs 0.4's own (the shipped file, with
|
||||
# every option, as lemurs requires); what the mesh changes from it is marked "mesh:".
|
||||
#
|
||||
# The sessions offered are the executable files other modules place in the two scripts directories
|
||||
# below (/etc/lemurs/wms for X, /etc/lemurs/wayland for Wayland), one per session module. A file there
|
||||
# is named as the session is offered. Desktop entries that packages install (/usr/share/xsessions) are
|
||||
# not offered: they start the window manager bare, skipping the session's start (~/.xinitrc), which is
|
||||
# where the account's environment, the X resources and every module's session lines are.
|
||||
#
|
||||
# Lemurs configuration file.
|
||||
# Contains all the customization options of lemurs.
|
||||
#
|
||||
# Note: that as of now you need to have all options in the selected
|
||||
# configuration file. Otherwise Lemurs will not work.
|
||||
#
|
||||
# Colors:
|
||||
# ---------
|
||||
# There is a list of predefined colors. These include:
|
||||
# - black
|
||||
# - white
|
||||
# - (dark) gray
|
||||
# - (light) red
|
||||
# - (light) blue
|
||||
# - (light) green
|
||||
# - (light) magenta
|
||||
# - (light) cyan
|
||||
# - (light) yellow
|
||||
# - orange
|
||||
#
|
||||
# You can also utilize custom colors with hex color codes.
|
||||
# "#87CEEB" will create a Sky Blue color.
|
||||
#
|
||||
# Note: If the color wasn't recognized, it will default to white.
|
||||
# ---------
|
||||
#
|
||||
# Modifiers:
|
||||
# ---------
|
||||
# There is a number of modifiers you can use. These can be combined by
|
||||
# delimiting them with a comma (e.g. "bold,italic"). The modifiers are:
|
||||
# - bold
|
||||
# - dim
|
||||
# - italic
|
||||
# - underlined
|
||||
# - reverse
|
||||
# - crossed out
|
||||
# - hidden
|
||||
# ---------
|
||||
#
|
||||
|
||||
# The tty which contains lemurs. This has to be mirrored in the lemurs.service
|
||||
tty = 2
|
||||
|
||||
# Where to log the main lemurs control flow.
|
||||
main_log_path = "/var/log/lemurs.log"
|
||||
|
||||
# Where to log to for the client. The Client is the Desktop Environment or
|
||||
# Window Manager for Xorg, the Compositor for Wayland and the Shell for TTY.
|
||||
client_log_path = "/var/log/lemurs.client.log"
|
||||
|
||||
# At which point to point the cache. If you want to disable the cache globally
|
||||
# you can use `/dev/null`.
|
||||
cache_path = "/var/cache/lemurs"
|
||||
|
||||
# Disable all logging. This is overwritten by the `--no-log` flag.
|
||||
do_log = true
|
||||
|
||||
# The PAM service that should be used to login
|
||||
pam_service = "lemurs"
|
||||
|
||||
# Path to system shell that gets used to execute linux commands. In almost all
|
||||
# cases, this should refer to a bash shell.
|
||||
system_shell = "/bin/sh"
|
||||
|
||||
# Initial state of the `PATH` environment variable.
|
||||
initial_path = "/usr/local/sbin:/usr/local/bin:/usr/bin"
|
||||
|
||||
# The type flag that will be appended to the shell that calls the session
|
||||
# environment. This may depend on your shell. Options:
|
||||
# - 'none'. Disables calling a login shell
|
||||
# - 'short'. Produces the `-l` flag. Supported by most shells.
|
||||
# - 'long'. This produces the `--login` flag and is suited for bash and zsh.
|
||||
shell_login_flag = "short"
|
||||
|
||||
# Focus behaviour of fields when Lemurs is initially started
|
||||
#
|
||||
# Possible values:
|
||||
# - default: Initially focus on first non-cached value
|
||||
# - no-focus: No initial focus
|
||||
# - environment: Initially focus on the environment selector
|
||||
# - username: Initially focus on the username field
|
||||
# - password: Initially focus on the password field
|
||||
focus_behaviour = "default"
|
||||
|
||||
# General settings for background style
|
||||
[background]
|
||||
|
||||
# Control whether to render background widget or not
|
||||
show_background = false
|
||||
|
||||
[background.style]
|
||||
# Allow to set the default background color for the login shell
|
||||
color = "black"
|
||||
# Settings for the background block's borders
|
||||
show_border = true
|
||||
border_color = "white"
|
||||
|
||||
[power_controls]
|
||||
# The margin between hints
|
||||
hint_margin = 2
|
||||
|
||||
# There are no additional entries by default
|
||||
entries = []
|
||||
|
||||
# Example
|
||||
# Reboot to another os option
|
||||
#[[power_controls.entries]]
|
||||
## The text in the top-left to display how to reboot.
|
||||
#hint = "Reboot to OS"
|
||||
#
|
||||
## The color and modifiers of the hint in the top-left corner
|
||||
#hint_color = "dark gray"
|
||||
#hint_modifiers = ""
|
||||
#
|
||||
## The key used to reboot. Possibilities are F1 to F12.
|
||||
#key = "F3"
|
||||
## The command that is executed when the key is pressed
|
||||
#cmd = "efibootmgr -n0 && systemctl reboot -l"
|
||||
|
||||
|
||||
# If you want to remove the base_entries
|
||||
# base_entries = []
|
||||
|
||||
# Shutdown option
|
||||
[[power_controls.base_entries]]
|
||||
# The text in the top-left to display how to shutdown.
|
||||
hint = "Shutdown"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
|
||||
# The key used to shutdown. Possibilities are F1 to F12.
|
||||
key = "F1"
|
||||
# The command that is executed when the key is pressed
|
||||
cmd = "systemctl poweroff -l"
|
||||
|
||||
# Reboot option
|
||||
[[power_controls.base_entries]]
|
||||
# The text in the top-left to display how to reboot.
|
||||
hint = "Reboot"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
hint_color = "dark gray"
|
||||
hint_modifiers = ""
|
||||
|
||||
# The key used to reboot. Possibilities are F1 to F12.
|
||||
key = "F2"
|
||||
# The command that is executed when the key is pressed
|
||||
cmd = "systemctl reboot -l"
|
||||
|
||||
# Setting for the selector of the desktop environment you are using.
|
||||
[environment_switcher]
|
||||
# Terms:
|
||||
# ---------
|
||||
# Movers: indicators which show which direction one can move whilst selecting
|
||||
# the desktop environment
|
||||
# Selected: The currently selected desktop environment.
|
||||
# Neighbours: The adjacent desktop environment to the one current selected
|
||||
#
|
||||
# Visualisation:
|
||||
#
|
||||
# < i3 bspwm awesome >
|
||||
#
|
||||
# ^ ^ ^ ^ ^
|
||||
# | | | | |
|
||||
# mover | selected | mover
|
||||
# | |
|
||||
# neighbour neighbour
|
||||
# ---------
|
||||
#
|
||||
|
||||
# Control the visibility of the switcher
|
||||
# Options:
|
||||
# - "visible" - Always show the switcher [default]
|
||||
# - "hidden" - Always hide the switcher
|
||||
# - [key] - F1-F12 to be able to toggle the visibility
|
||||
# mesh: hidden, as both workstations had it, but F3 shows it, so a second session can be chosen.
|
||||
switcher_visibility = "F3"
|
||||
|
||||
# The text in the top-left to display how to toggle the switcher. The text
|
||||
# '%key%' will be replaced with the switcher_visibility key. This is not shown
|
||||
# if switcher_visibility is set to "visible" or "hidden".
|
||||
toggle_hint = "Switcher %key%"
|
||||
|
||||
# The color and modifiers of the hint in the top-left corner
|
||||
toggle_hint_color = "dark gray"
|
||||
toggle_hint_modifiers = ""
|
||||
|
||||
|
||||
# Show an option for the TTY shell when logging in as one of the environments.
|
||||
# NOTE: it is always shown when no viable options are found.
|
||||
include_tty_shell = false
|
||||
|
||||
# Remember the selected environment after logging in for the next time
|
||||
remember = true
|
||||
|
||||
# Enables showing the movers
|
||||
show_movers = true
|
||||
|
||||
# Mover's color and modifiers whilst the selector is unfocused
|
||||
mover_color = "dark gray"
|
||||
mover_modifiers = ""
|
||||
|
||||
# Mover's color and modifiers whilst the selector is focused
|
||||
mover_color_focused = "orange"
|
||||
mover_modifiers_focused = "bold"
|
||||
|
||||
# The characters used to display the movers. Suggestions are:
|
||||
# - "<" ">"
|
||||
# - "<-" "->"
|
||||
# - "<<" ">>"
|
||||
# - "[" "]"
|
||||
left_mover = "<"
|
||||
right_mover = ">"
|
||||
|
||||
# The margin between the movers and the neighbours or selected (depending on
|
||||
# `show_neighbours`)
|
||||
mover_margin = 1
|
||||
|
||||
# Enables showing the neighbours
|
||||
show_neighbours = true
|
||||
|
||||
# Neighbours' color and modifiers whilst the selector is unfocused
|
||||
neighbour_color = "dark gray"
|
||||
neighbour_modifiers = ""
|
||||
|
||||
# Neighbours' color and modifiers whilst the selector is focused
|
||||
neighbour_color_focused = "gray"
|
||||
neighbour_modifiers_focused = ""
|
||||
|
||||
# Margin between neighbours and selected
|
||||
neighbour_margin = 1
|
||||
|
||||
# Selected's color and modifiers whilst the selector is unfocused
|
||||
selected_color = "gray"
|
||||
selected_modifiers = "underlined"
|
||||
|
||||
# Selected's color and modifiers whilst the selector is focused
|
||||
selected_color_focused = "white"
|
||||
selected_modifiers_focused = "bold"
|
||||
|
||||
# The length of the name of the desktop environment which is displayed.
|
||||
max_display_length = 8
|
||||
|
||||
# The text used when no desktop environments are available
|
||||
no_envs_text = "No environments..."
|
||||
|
||||
# The color and modifiers of the 'no desktop environments available text'
|
||||
# whilst the selector is unfocused
|
||||
no_envs_color = "white"
|
||||
no_envs_modifiers = ""
|
||||
|
||||
# The color and modifiers of the 'no desktop environments available text'
|
||||
# whilst the selector is focused
|
||||
no_envs_color_focused = "red"
|
||||
no_envs_modifiers_focused = ""
|
||||
|
||||
[username_field]
|
||||
|
||||
# Remember the username for the next time after a successful login attempt.
|
||||
remember = true
|
||||
|
||||
[username_field.style]
|
||||
# Enables showing a title
|
||||
show_title = true
|
||||
# The text used within the title
|
||||
title = "Login"
|
||||
|
||||
# The title's color and modifiers whilst the username field is unfocused
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
|
||||
# The title's color and modifiers whilst the username field is focused
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
|
||||
# Enables showing the borders
|
||||
show_border = true
|
||||
# The borders' color and modifiers whilst the username field is unfocused
|
||||
border_color = "white"
|
||||
# The borders' color and modifiers whilst the username field is focused
|
||||
border_color_focused = "orange"
|
||||
|
||||
# Constrain the width of the username field
|
||||
use_max_width = true
|
||||
# The constraint of the username field's width
|
||||
max_width = 48
|
||||
|
||||
[password_field]
|
||||
|
||||
# The character used for replacement when typing a password. Leave empty for no
|
||||
# feedback.
|
||||
# Note: Only one character is accepted.
|
||||
content_replacement_character = "*"
|
||||
|
||||
[password_field.style]
|
||||
# Enables showing a title
|
||||
show_title = true
|
||||
# The text used within the title
|
||||
title = "Password"
|
||||
|
||||
# The title's color and modifiers whilst the password field is unfocused
|
||||
title_color = "white"
|
||||
content_color = "white"
|
||||
|
||||
# The title's color and modifiers whilst the password field is focused
|
||||
title_color_focused = "orange"
|
||||
content_color_focused = "orange"
|
||||
|
||||
# Enables showing the borders
|
||||
show_border = true
|
||||
# The borders' color and modifiers whilst the password field is unfocused
|
||||
border_color = "white"
|
||||
# The borders' color and modifiers whilst the password field is focused
|
||||
border_color_focused = "orange"
|
||||
|
||||
# Constrain the width of the password field
|
||||
use_max_width = true
|
||||
# The constraint of the password field's width
|
||||
max_width = 48
|
||||
|
||||
[x11]
|
||||
# Where to log to for the XServer.
|
||||
xserver_log_path = "/var/log/lemurs.xorg.log"
|
||||
|
||||
# The value of the `DISPLAY` environment variable for X11 sessions
|
||||
x11_display = ":1"
|
||||
|
||||
# How many seconds to give the X server to start. To make it infinitely, put it
|
||||
# to 0.
|
||||
xserver_timeout_secs = 60
|
||||
|
||||
# Where to find the X11 server binary
|
||||
xserver_path = "/usr/bin/X"
|
||||
|
||||
# Where to find the X11 xauth binary
|
||||
xauth_path = "/usr/bin/xauth"
|
||||
|
||||
# Path to the directory where the startup scripts for the X11 sessions are found
|
||||
scripts_path = "/etc/lemurs/wms"
|
||||
|
||||
# Path to the xsetup script that is needed for the environment setup of the
|
||||
# window manager.
|
||||
xsetup_path = "/etc/lemurs/xsetup.sh"
|
||||
|
||||
# The directory to use for desktop entries X11 sessions.
|
||||
# mesh: an empty directory of the module's own, so no package's desktop entry is offered.
|
||||
xsessions_path = "/etc/lemurs/xsessions"
|
||||
|
||||
[wayland]
|
||||
# Path to the directory where the startup scripts for the Wayland sessions are
|
||||
# found
|
||||
scripts_path = "/etc/lemurs/wayland"
|
||||
|
||||
# The directory to use for desktop entries wayland sessions.
|
||||
# mesh: likewise for Wayland.
|
||||
wayland_sessions_path = "/etc/lemurs/wayland-sessions"
|
||||
@@ -0,0 +1,5 @@
|
||||
module lemurs
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,189 @@
|
||||
# xorg
|
||||
|
||||
The X display server as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2 step 2).
|
||||
|
||||
- **Claims `node-display-server`** and serves its verbs `displays` and `layout`.
|
||||
- **Provides `x11-display` with the machine's reach**: i3, xterm, picom and the X lock screen require
|
||||
it, and a requirement for it is met only by this module on the same machine. It is never pulled in
|
||||
for whatever asked.
|
||||
- **Gated by the capability `seat`**, which the host reports for a machine with a graphics device
|
||||
and a connected display. See *Blockers* for why not `graphical-session`.
|
||||
- **Packages:** `xorg-server`, `xorg-xinit`, `xorg-xrandr`, `xorg-xset`, `xorg-xrdb`, `xorg-xinput`,
|
||||
`xorg-setxkbmap`, `xorg-xwd` and `autorandr`. The GPU's driver is not here. It follows the machine,
|
||||
so it belongs to that machine model's hardware module.
|
||||
|
||||
## What it owns
|
||||
|
||||
| path | class (ADR 0182) | what |
|
||||
|---|---|---|
|
||||
| `~/.xinitrc`, a block at the start | written into (`block`, `at: start`) | the session's start, below |
|
||||
| `~/.config/xorg/` | owned directory | |
|
||||
| `~/.config/xorg/xresources` | owned | the mesh's X resources: font rendering (`Xft.*`, DPI 96) and the three `xresources` slots |
|
||||
|
||||
**The session's start**, in ADR 0208 §5's order:
|
||||
|
||||
1. It sources `~/.config/mesh/environment.sh` (ADR 0203). If pam did not hand over a session bus, it
|
||||
takes the user manager's socket, never a second bus.
|
||||
2. It imports an explicit list of the session's words into the user manager and D-Bus activation,
|
||||
only those that are set. The list is `DISPLAY`, `XAUTHORITY`, `XDG_SESSION_TYPE`,
|
||||
`XDG_CURRENT_DESKTOP`, `XDG_SESSION_DESKTOP`, `XDG_CONFIG_HOME`, `XDG_DATA_DIRS`, `GTK_THEME`,
|
||||
`GTK2_RC_FILES`, `QT_QPA_PLATFORMTHEME`, `QT_STYLE_OVERRIDE`, `QT_SELECT`, `XCURSOR_THEME`,
|
||||
`XCURSOR_SIZE` and `TERMINAL`. It never uses `--all`.
|
||||
3. It merges `~/.config/xorg/xresources` with `xrdb -nocpp`, then `~/.Xresources` if you keep one,
|
||||
so yours win.
|
||||
4. It runs `autorandr --change`, which applies the saved profile matching the connected monitors.
|
||||
5. It runs the `xinitrc` slots `first` and `normal`, then sources **`~/.xinitrc.local`** if it exists.
|
||||
6. It runs the `last` slot, where the holder of `node-display-session` starts the session (`exec i3`).
|
||||
|
||||
**Who contributes where**, among the desktop's modules:
|
||||
|
||||
| slot | module | what |
|
||||
|---|---|---|
|
||||
| `first` | `gnome-keyring` | `SSH_AUTH_SOCK` |
|
||||
| `normal` | `adwaita` | its GSettings keys |
|
||||
| `normal` | `clipmenu` | `clipmenud &` |
|
||||
| `normal` | `feh` | the wallpaper |
|
||||
| `normal` | `i3status-rust` | the bar watchdog |
|
||||
| `normal` | `screen-lock` | the timeouts and the `xss-lock` loop |
|
||||
| `last` | `i3` | `exec i3` |
|
||||
|
||||
**Slot `last` is the session's.** A module contributing session lines uses `first` or `normal`.
|
||||
Code in `last` that sorts after the session holder's name would come after its `exec` and never run.
|
||||
|
||||
**Why `~/.xinitrc.local`.** The block ends by starting the session, so no line below it runs. Your own
|
||||
session lines go into `~/.xinitrc.local`. Like `~/.zshrc.local`, it is yours: found, never written.
|
||||
This is the one step this module adds to ADR 0208 §5. Without it, a line no module carries yet (a
|
||||
`DOTNET_ROOT`, a wallpaper) would have nowhere to run.
|
||||
|
||||
**Resources without the preprocessor.** `xrdb -nocpp` needs no C compiler on the machine, and it
|
||||
skips a line starting with `#`. The controller precedes each contribution with a `# <module>` line,
|
||||
which is a cpp directive error under plain `xrdb -merge` but is skipped here.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-display-server.displays` | r | the screen and every output: position, rotation, size, DPI, the monitor's EDID identity (manufacturer, product, serial, name, fingerprint), current and preferred mode, every mode and rate, the autorandr profile in force |
|
||||
| `node-display-server.layout` | r/a | autorandr profiles: `list` (each with the monitors it is for, which match now, which is current), `save` (`replace` to overwrite), `apply` |
|
||||
| `xorg_set_mode` | a | one output's mode, rate, rotation, scale, position (x/y or beside another), off, primary; `dry_run` |
|
||||
| `xorg_primary` | r/a | which output is primary; set it |
|
||||
| `xorg_dpi` | r/a | `Xft.dpi` beside each monitor's physical DPI; set it for the running session |
|
||||
| `xorg_input_devices` | r | the input devices, and each pointer's libinput settings |
|
||||
| `xorg_input_set` | a | tap, tap drag, natural scroll, disable while typing, left handed, middle emulation, enabled, acceleration, by device id or name |
|
||||
| `xorg_keyboard` | r/a | the XKB map; set layout, variant, model, options |
|
||||
| `xorg_screenshot` | d | a PNG of the screen, one output or one window, inside the home (default `~/Pictures/Screenshots/`) |
|
||||
| `xorg_x_log` | r | the X server's log for the running session: version, start, errors (and warnings) |
|
||||
|
||||
All answers are structured. Each tool that changes the running server says how long the change lasts.
|
||||
|
||||
**How a tool reaches the session** (`internal/desktop`, the same copy in every desktop module). The
|
||||
runtime is a system service running as the operator account. It has no `DISPLAY`, no `XAUTHORITY` and
|
||||
no session bus. The tool reads them from the session's own processes:
|
||||
|
||||
1. It looks at the account's processes, preferring the window manager.
|
||||
2. It reads only a fixed list of words, never the rest. A session's environment held secrets on these
|
||||
machines.
|
||||
3. It asks logind whether that session is active and local.
|
||||
4. It checks that the display's socket still exists.
|
||||
|
||||
The bus it hands on is the user manager's (`unix:path=/run/user/<uid>/bus`). With no session, a tool
|
||||
answers `{"error": "no-graphical-session", "reason": …, "looked": […]}`.
|
||||
|
||||
The screenshot needs no screenshot program. `xwd` dumps the screen, and the module turns the dump
|
||||
into a PNG itself.
|
||||
|
||||
## What it improves
|
||||
|
||||
- **One environment.** The session sources the shells' environment file. It no longer exports a
|
||||
hand-kept second copy, and never sources the predecessor's secrets file.
|
||||
- **No compiler needed for the mesh's resources**, and contributions are placed in order rather than
|
||||
`#include`d.
|
||||
- **Monitor layouts by the monitors' identity** (autorandr, research 026/04), not scripts with port
|
||||
names baked in. The package's own udev rule and service apply the matching profile on hotplug.
|
||||
- **No second bus.** The `dbus-launch` fallback that once ran the whole session on a private bus is
|
||||
gone. That stale session can still be seen today on one workstation: `session_bus` in a tool's
|
||||
answer shows it.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
`~/.xprofile`, `~/.Xresources`, `~/.Xresources.d/`, `~/.screenlayout/`, the arandr scripts, every
|
||||
line of yours below the block, and `/etc/X11/xinit/xinitrc.d/`.
|
||||
|
||||
## The one-off migration (ADR 0182)
|
||||
|
||||
**Assign the desktop's modules together** (`xorg`, `lemurs`, `i3`, `xterm`, `adwaita` and the
|
||||
companions above) and do this migration first. **Until `i3` is assigned, nothing changes for you.** The block's `last` slot is empty, so the block
|
||||
runs and falls through to your own lines below it, which still end in `exec i3`. The environment is
|
||||
sourced twice and the import runs twice, which is harmless.
|
||||
|
||||
**Once `i3` is assigned, nothing below the block runs.** Before that push, sort today's
|
||||
`~/.xinitrc` lines (both workstations hold the same file):
|
||||
|
||||
| today's line | where it goes |
|
||||
|---|---|
|
||||
| the `/etc/X11/xinit/xinitrc.d/?*.sh` loop | **delete**: the block imports `DISPLAY` and `XAUTHORITY` itself, and the login manager's X setup runs that directory too |
|
||||
| `eval $(gnome-keyring-daemon --start …)` and `export SSH_AUTH_SOCK` | **delete** once `gnome-keyring` is assigned. PAM starts and unlocks the keyring, and that module names the ssh agent's socket in the `xinitrc` slot `first`. Until then, `~/.xinitrc.local` |
|
||||
| `export PATH=…` (nine entries) | `~/.local/bin`, `~/scripts` and `~/scripts/bin` come from `zsh`'s environment already. **Delete** `~/scripts/i3-sessions/commands` and `~/scripts/mediahuis`: neither exists on either workstation. Move `~/.cargo/bin`, `~/.config/rofi/scripts`, `~/.dotnet` and `~/.dotnet/tools` to `~/.xinitrc.local` until a module carries them |
|
||||
| `DOTNET_ROOT`, `DOTNET_CLI_TELEMETRY_OPTOUT` | `~/.xinitrc.local` |
|
||||
| `XDG_CONFIG_HOME` | **delete**: `zsh` contributes it |
|
||||
| `XDG_DATA_DIRS` with the flatpak directories | `~/.xinitrc.local` until the `flatpak` module |
|
||||
| `QT_QPA_PLATFORMTHEME`, `GTK_THEME`, `QT_STYLE_OVERRIDE`, `GTK2_RC_FILES`, `QT_SELECT` | **delete** once `adwaita` is assigned (its environment contributions) |
|
||||
| `XDG_SESSION_DESKTOP`, `XDG_CURRENT_DESKTOP` and their comment | **delete** once `i3` is assigned |
|
||||
| `dbus-update-activation-environment --systemd …` and its comment | **delete**: the block's step 2 |
|
||||
| `~/scripts/xdg-appearance \|\| true` | **delete** once `adwaita` is assigned |
|
||||
| `MY_KV_PATH`, `MY_LIB_PATH`, `MY_STREAMING_PATH` | `~/.xinitrc.local` (they are yours) |
|
||||
| `[ -f "$HOME/.config/hal/env" ] && . …` (the secrets file) | **delete** (ADR 0208 §5, research 027 Q2). Whatever in the session needed one of those tokens gets it the way research 027 settles |
|
||||
| `xset s 1800`, `xset dpms 1800 1800 3600` | **delete** once `screen-lock` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` |
|
||||
| `~/.fehbg &` | **delete** once `feh` is assigned (its `normal` slot line). Until then, `~/.xinitrc.local` |
|
||||
| the `xss-lock` respawn loop | **delete** once `screen-lock` is assigned. Until then, `~/.xinitrc.local` |
|
||||
| `exec i3 --shmlog-size=26214400` | **delete**: `i3` contributes `exec i3` to the `last` slot |
|
||||
|
||||
Then **delete everything below the block**. The old `#!/bin/sh` line now sits below the block too and
|
||||
means nothing there. The file is run with `sh` (by the login manager's entry and by `startx`), never
|
||||
executed by its first line.
|
||||
|
||||
**`~/.xprofile`.** The login manager's X setup sources it, and it sources `~/.xinitrc`. Once `i3` and
|
||||
`lemurs` are assigned, the session entry `/etc/lemurs/wms/i3` runs `~/.xinitrc` itself, so **delete
|
||||
`~/.xprofile`**. Its bus logic is the block's now. If you keep it, delete its `. ~/.xinitrc` line, or
|
||||
the session starts from `.xprofile` and the login manager's entry is never reached. Either way works
|
||||
once, but only one should.
|
||||
|
||||
**`~/.Xresources`** holds `#include ".Xresources.d/xterm"`, `#include ".Xresources.d/xft"` and the
|
||||
two `Xcursor` lines:
|
||||
|
||||
- The `xft` include and `~/.Xresources.d/xft` are **deleted** now. This module's file carries the
|
||||
same five values.
|
||||
- The `xterm` include and `~/.Xresources.d/xterm` are **deleted** once `xterm` is assigned. Kept,
|
||||
they win over the module's font and colours.
|
||||
- The `Xcursor` lines are **deleted** once `adwaita` is assigned.
|
||||
- A file left empty is deleted.
|
||||
|
||||
**Monitor layouts.** For each place you use, arrange the monitors (`xorg_set_mode`, or arandr once
|
||||
more), then `layout` `save` it under a name. Once every place has a profile, these retire: the
|
||||
`~/.screenlayout/` and `~/scripts/.screenlayouts/` scripts, the laptop's hotplug rule, and its i3
|
||||
bindings. Those bindings belong to that machine's hardware module, not here.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 (laptop) | shanks (desktop) |
|
||||
|---|---|---|
|
||||
| packages | `autorandr` installed; the rest are there already | the same |
|
||||
| files | `~/.config/xorg/xresources` new; a block added at the start of `~/.xinitrc` | the same |
|
||||
| running session | nothing: everything takes effect at the next login | nothing |
|
||||
| next login, before `i3` is assigned | the block runs, then the old file below it. Fonts unchanged (96 DPI); `autorandr --change` does nothing with no profiles | the same. Its session moves to the user manager's bus at the next login, as it should have |
|
||||
| next login, after `i3` is assigned | only the block: what the table above did not move is gone | the same |
|
||||
|
||||
## Blockers
|
||||
|
||||
- **The capability.** ADR 0208 §3 says `graphical-session` gates the display server. The host
|
||||
reports that capability from its own environment. It is a root daemon with no `DISPLAY`, so the
|
||||
capability is **no on both workstations** (`node show`, 2026-10-04). Gated on it, `xorg` could
|
||||
never be assigned. This manifest uses **`seat`** instead: graphics hardware with a connected
|
||||
display, yes on both. The ADR's wording needs a progressive insight, or the host needs a probe
|
||||
that finds the session the way `internal/desktop` does.
|
||||
- **Unassigning removes the packages.** The host takes a package away when its declaration goes,
|
||||
and `xorg-server` is among them. Do not unassign `xorg` from a workstation you are sitting at
|
||||
without another display server assigned.
|
||||
- **The `# <module>` naming lines in the `xresources` slots** are safe only because this module
|
||||
merges with `-nocpp`. If the controller rendered `!` for `xresources`, that would be the
|
||||
format's own comment.
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Screen is the X screen as xrandr reports it.
|
||||
type Screen struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
MaxWidth int `json:"max_width"`
|
||||
MaxHeight int `json:"max_height"`
|
||||
}
|
||||
|
||||
// Output is one connector: a monitor when connected.
|
||||
type Output struct {
|
||||
Name string `json:"name"`
|
||||
Connected bool `json:"connected"`
|
||||
Primary bool `json:"primary,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Geometry *Geometry `json:"geometry,omitempty"`
|
||||
Rotation string `json:"rotation,omitempty"`
|
||||
Reflect string `json:"reflect,omitempty"`
|
||||
WidthMM int `json:"width_mm,omitempty"`
|
||||
HeightMM int `json:"height_mm,omitempty"`
|
||||
// DPI is the physical density of the current mode, from the size the monitor reports.
|
||||
DPI float64 `json:"dpi,omitempty"`
|
||||
Monitor *Identity `json:"monitor,omitempty"`
|
||||
Current *ModeRate `json:"current,omitempty"`
|
||||
Preferred *ModeRate `json:"preferred,omitempty"`
|
||||
Modes []Mode `json:"modes,omitempty"`
|
||||
|
||||
edid []byte
|
||||
}
|
||||
|
||||
// Geometry is where an output's picture sits on the screen.
|
||||
type Geometry struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
X int `json:"x"`
|
||||
Y int `json:"y"`
|
||||
}
|
||||
|
||||
// Mode is one resolution and the refresh rates it is offered at.
|
||||
type Mode struct {
|
||||
Size string `json:"size"`
|
||||
Rates []float64 `json:"rates"`
|
||||
}
|
||||
|
||||
// ModeRate is one resolution at one rate.
|
||||
type ModeRate struct {
|
||||
Size string `json:"size"`
|
||||
Rate float64 `json:"rate"`
|
||||
}
|
||||
|
||||
// Identity is who the monitor is, from its EDID: what an autorandr profile is keyed by, and a name a
|
||||
// person recognises.
|
||||
type Identity struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Product string `json:"product"`
|
||||
Serial string `json:"serial,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
// Fingerprint is the first 12 hex characters of the EDID's sha256: one monitor, whatever port.
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
}
|
||||
|
||||
var (
|
||||
screenLine = regexp.MustCompile(`^Screen \d+: minimum \d+ x \d+, current (\d+) x (\d+), maximum (\d+) x (\d+)`)
|
||||
geometryRe = regexp.MustCompile(`^(\d+)x(\d+)\+(-?\d+)\+(-?\d+)$`)
|
||||
sizeMM = regexp.MustCompile(`\)\s+(\d+)mm x (\d+)mm`)
|
||||
)
|
||||
|
||||
// ParseXrandr reads `xrandr --prop` (or `--query`): the screen, and every output with its modes and,
|
||||
// where the properties carry one, its EDID decoded.
|
||||
func ParseXrandr(text string) (Screen, []Output) {
|
||||
var screen Screen
|
||||
var outs []Output
|
||||
var cur *Output
|
||||
inEDID := false
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<22)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
switch {
|
||||
case strings.HasPrefix(line, "Screen "):
|
||||
if m := screenLine.FindStringSubmatch(line); m != nil {
|
||||
screen = Screen{atoi(m[1]), atoi(m[2]), atoi(m[3]), atoi(m[4])}
|
||||
}
|
||||
inEDID = false
|
||||
case line != "" && line[0] != ' ' && line[0] != '\t':
|
||||
outs = append(outs, parseOutputLine(line))
|
||||
cur = &outs[len(outs)-1]
|
||||
inEDID = false
|
||||
case cur == nil:
|
||||
case strings.HasPrefix(line, "\t\t"):
|
||||
if inEDID {
|
||||
if b, err := hex.DecodeString(strings.TrimSpace(line)); err == nil {
|
||||
cur.edid = append(cur.edid, b...)
|
||||
}
|
||||
}
|
||||
case strings.HasPrefix(line, "\t"):
|
||||
inEDID = strings.HasPrefix(strings.TrimSpace(line), "EDID:")
|
||||
case strings.HasPrefix(line, " "):
|
||||
inEDID = false
|
||||
parseModeLine(cur, line)
|
||||
}
|
||||
}
|
||||
for i := range outs {
|
||||
o := &outs[i]
|
||||
if len(o.edid) >= 128 {
|
||||
o.Monitor = DecodeEDID(o.edid)
|
||||
}
|
||||
if o.Geometry != nil && o.WidthMM > 0 {
|
||||
w := o.Geometry.Width
|
||||
if o.Rotation == "left" || o.Rotation == "right" {
|
||||
w = o.Geometry.Height
|
||||
}
|
||||
o.DPI = float64(int(float64(w)/(float64(o.WidthMM)/25.4)*10+0.5)) / 10
|
||||
}
|
||||
}
|
||||
return screen, outs
|
||||
}
|
||||
|
||||
func parseOutputLine(line string) Output {
|
||||
f := strings.Fields(line)
|
||||
o := Output{Name: f[0], Connected: len(f) > 1 && f[1] == "connected"}
|
||||
for _, tok := range f[2:] {
|
||||
if strings.HasPrefix(tok, "(") {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case tok == "primary":
|
||||
o.Primary = true
|
||||
case geometryRe.MatchString(tok):
|
||||
g := geometryRe.FindStringSubmatch(tok)
|
||||
o.Geometry = &Geometry{atoi(g[1]), atoi(g[2]), atoi(g[3]), atoi(g[4])}
|
||||
o.Enabled = true
|
||||
case tok == "normal" || tok == "left" || tok == "inverted" || tok == "right":
|
||||
o.Rotation = tok
|
||||
case tok == "X" || tok == "Y" || tok == "and" || tok == "axis":
|
||||
o.Reflect = strings.TrimSpace(o.Reflect + " " + tok)
|
||||
}
|
||||
}
|
||||
if o.Enabled && o.Rotation == "" {
|
||||
o.Rotation = "normal"
|
||||
}
|
||||
if m := sizeMM.FindStringSubmatch(line); m != nil {
|
||||
o.WidthMM, o.HeightMM = atoi(m[1]), atoi(m[2])
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// parseModeLine reads ` 2880x1800 60.00*+ 120.00 +`: a rate marked * is in use, + preferred,
|
||||
// and xrandr writes the + as a token of its own after a rate not in use. A panel may mark several
|
||||
// rates preferred; the first is the monitor's own preference.
|
||||
func parseModeLine(o *Output, line string) {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 0 || !strings.Contains(f[0], "x") {
|
||||
return
|
||||
}
|
||||
mode := Mode{Size: f[0]}
|
||||
for _, tok := range f[1:] {
|
||||
if tok == "+" {
|
||||
if n := len(mode.Rates); n > 0 && o.Preferred == nil {
|
||||
o.Preferred = &ModeRate{mode.Size, mode.Rates[n-1]}
|
||||
}
|
||||
continue
|
||||
}
|
||||
current := strings.Contains(tok, "*")
|
||||
preferred := strings.Contains(tok, "+")
|
||||
rate, err := strconv.ParseFloat(strings.Trim(tok, "*+"), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
mode.Rates = append(mode.Rates, rate)
|
||||
if current {
|
||||
o.Current = &ModeRate{mode.Size, rate}
|
||||
}
|
||||
if preferred && o.Preferred == nil {
|
||||
o.Preferred = &ModeRate{mode.Size, rate}
|
||||
}
|
||||
}
|
||||
o.Modes = append(o.Modes, mode)
|
||||
}
|
||||
|
||||
// DecodeEDID reads the vendor block and the descriptors of an EDID.
|
||||
func DecodeEDID(e []byte) *Identity {
|
||||
if len(e) < 128 {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256(e)
|
||||
id := &Identity{Fingerprint: hex.EncodeToString(sum[:])[:12]}
|
||||
v := uint16(e[8])<<8 | uint16(e[9])
|
||||
id.Manufacturer = string([]byte{
|
||||
byte('A' - 1 + (v>>10)&0x1f), byte('A' - 1 + (v>>5)&0x1f), byte('A' - 1 + v&0x1f),
|
||||
})
|
||||
id.Product = fmt.Sprintf("%04X", uint16(e[10])|uint16(e[11])<<8)
|
||||
if serial := uint32(e[12]) | uint32(e[13])<<8 | uint32(e[14])<<16 | uint32(e[15])<<24; serial != 0 {
|
||||
id.Serial = strconv.FormatUint(uint64(serial), 10)
|
||||
}
|
||||
if e[17] != 0 {
|
||||
id.Year = 1990 + int(e[17])
|
||||
}
|
||||
for _, at := range []int{54, 72, 90, 108} {
|
||||
d := e[at : at+18]
|
||||
if d[0] != 0 || d[1] != 0 || d[2] != 0 {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(strings.SplitN(string(d[5:18]), "\n", 2)[0])
|
||||
switch d[3] {
|
||||
case 0xfc:
|
||||
id.Name = text
|
||||
case 0xff:
|
||||
if text != "" {
|
||||
id.Serial = text
|
||||
}
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n, _ := strconv.Atoi(s)
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Device is one input device the X server knows.
|
||||
type Device struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"` // master or slave
|
||||
Kind string `json:"kind"` // pointer or keyboard
|
||||
Settings map[string]any `json:"settings,omitempty"`
|
||||
}
|
||||
|
||||
var deviceLine = regexp.MustCompile(`^[^A-Za-z0-9]*(.+?)\s+id=(\d+)\s+\[(master|slave|floating)\s+(pointer|keyboard)?`)
|
||||
|
||||
// ParseDevices reads `xinput list`, the tree's drawing characters stripped.
|
||||
func ParseDevices(text string) []Device {
|
||||
var out []Device
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
m := deviceLine.FindStringSubmatch(sc.Text())
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.Atoi(m[2])
|
||||
kind := m[4]
|
||||
if kind == "" {
|
||||
kind = "floating"
|
||||
}
|
||||
out = append(out, Device{ID: id, Name: strings.TrimSpace(m[1]), Role: m[3], Kind: kind})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Knob is one setting input-set changes, and the libinput property that holds it.
|
||||
type Knob struct {
|
||||
Arg, Property, Kind string // Kind: bool or float
|
||||
}
|
||||
|
||||
// Knobs are what input-set may change, by libinput's own property names (libinput(4)).
|
||||
var Knobs = []Knob{
|
||||
{"enabled", "Device Enabled", "bool"},
|
||||
{"tap", "libinput Tapping Enabled", "bool"},
|
||||
{"tap_drag", "libinput Tapping Drag Enabled", "bool"},
|
||||
{"natural_scroll", "libinput Natural Scrolling Enabled", "bool"},
|
||||
{"disable_while_typing", "libinput Disable While Typing Enabled", "bool"},
|
||||
{"left_handed", "libinput Left Handed Enabled", "bool"},
|
||||
{"middle_emulation", "libinput Middle Emulation Enabled", "bool"},
|
||||
{"accel_speed", "libinput Accel Speed", "float"},
|
||||
}
|
||||
|
||||
var propLine = regexp.MustCompile(`^\s+(.+?) \(\d+\):\s*(.*)$`)
|
||||
|
||||
// ParseProps reads `xinput list-props` into the settings Knobs name, as booleans and numbers. A
|
||||
// device without a property (a keyboard has no tapping) simply lacks the setting.
|
||||
func ParseProps(text string) map[string]any {
|
||||
byProp := map[string]string{}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
if m := propLine.FindStringSubmatch(sc.Text()); m != nil {
|
||||
byProp[m[1]] = strings.TrimSpace(m[2])
|
||||
}
|
||||
}
|
||||
out := map[string]any{}
|
||||
for _, k := range Knobs {
|
||||
v, ok := byProp[k.Property]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
first := strings.TrimSpace(strings.Split(v, ",")[0])
|
||||
switch k.Kind {
|
||||
case "bool":
|
||||
out[k.Arg] = first == "1"
|
||||
case "float":
|
||||
if f, err := strconv.ParseFloat(first, 64); err == nil {
|
||||
out[k.Arg] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := byProp["libinput Accel Profile Enabled"]; ok {
|
||||
profiles := []string{"adaptive", "flat", "custom"}
|
||||
for i, bit := range strings.Split(v, ",") {
|
||||
if strings.TrimSpace(bit) == "1" && i < len(profiles) {
|
||||
out["accel_profile"] = profiles[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Keyboard is the X keyboard map as setxkbmap reports it.
|
||||
type Keyboard struct {
|
||||
Rules string `json:"rules,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Layout string `json:"layout,omitempty"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
Options []string `json:"options"`
|
||||
}
|
||||
|
||||
// ParseKeyboard reads `setxkbmap -query`.
|
||||
func ParseKeyboard(text string) Keyboard {
|
||||
k := Keyboard{Options: []string{}}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
for sc.Scan() {
|
||||
key, value, ok := strings.Cut(sc.Text(), ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "rules":
|
||||
k.Rules = value
|
||||
case "model":
|
||||
k.Model = value
|
||||
case "layout":
|
||||
k.Layout = value
|
||||
case "variant":
|
||||
k.Variant = value
|
||||
case "options":
|
||||
for _, o := range strings.Split(value, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
k.Options = append(k.Options, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
// xkbName is what setxkbmap accepts as a layout, variant, model or option: letters, digits and the
|
||||
// punctuation XKB names use. Anything else is refused before it reaches a command line.
|
||||
var xkbName = regexp.MustCompile(`^[A-Za-z0-9_:+(),.-]+$`)
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Profile is one saved autorandr layout: the monitors it is for, by output and EDID fingerprint.
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Monitors map[string]string `json:"monitors"`
|
||||
Detected bool `json:"detected,omitempty"`
|
||||
Current bool `json:"current,omitempty"`
|
||||
}
|
||||
|
||||
// profileName is what a profile may be called: it becomes a directory name.
|
||||
var profileName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ProfileDirs are where autorandr keeps profiles, the account's first (autorandr(1)).
|
||||
func ProfileDirs(home string) []string {
|
||||
cfg := os.Getenv("XDG_CONFIG_HOME")
|
||||
if cfg == "" {
|
||||
cfg = filepath.Join(home, ".config")
|
||||
}
|
||||
return []string{filepath.Join(cfg, "autorandr"), "/etc/xdg/autorandr"}
|
||||
}
|
||||
|
||||
// ReadProfiles lists the profiles in dirs; a name in an earlier directory hides a later one, as in
|
||||
// autorandr itself. Each profile's monitors come from its `setup` file: `<output> <edid>` per line.
|
||||
func ReadProfiles(dirs []string) []Profile {
|
||||
seen := map[string]bool{}
|
||||
var out []Profile
|
||||
for _, dir := range dirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || seen[e.Name()] {
|
||||
continue
|
||||
}
|
||||
setup := filepath.Join(dir, e.Name(), "setup")
|
||||
f, err := os.Open(setup)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
p := Profile{Name: e.Name(), Path: filepath.Join(dir, e.Name()), Monitors: map[string]string{}}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) == 2 {
|
||||
p.Monitors[fields[0]] = shortEDID(fields[1])
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
seen[e.Name()] = true
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// shortEDID is the fingerprint DecodeEDID gives for the same monitor, so a profile's monitors and the
|
||||
// connected ones are compared by one value.
|
||||
func shortEDID(hexEDID string) string {
|
||||
b := make([]byte, 0, len(hexEDID)/2)
|
||||
for i := 0; i+1 < len(hexEDID); i += 2 {
|
||||
var v byte
|
||||
for _, c := range hexEDID[i : i+2] {
|
||||
v <<= 4
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
v |= byte(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
v |= byte(c - 'a' + 10)
|
||||
case c >= 'A' && c <= 'F':
|
||||
v |= byte(c - 'A' + 10)
|
||||
default:
|
||||
return hexEDID
|
||||
}
|
||||
}
|
||||
b = append(b, v)
|
||||
}
|
||||
if id := DecodeEDID(b); id != nil {
|
||||
return id.Fingerprint
|
||||
}
|
||||
return hexEDID
|
||||
}
|
||||
|
||||
// names reads autorandr's one-name-per-line answers (--detected, --current).
|
||||
func names(text string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
out[strings.Fields(l)[0]] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// xorg's tools (novox/hq ADR 0208, research 026/05): node-display-server's verbs `displays` and
|
||||
// `layout`, and the module's own tools for one output's mode, the primary output, DPI, input devices,
|
||||
// the keyboard map, a screenshot and the server's log.
|
||||
//
|
||||
// Every tool that touches the screen acts in the operator's running session, which it finds through
|
||||
// internal/desktop: the node's runtime has none of its own. With no session, the answer says so as
|
||||
// structured data. A tool that changes the running server changes it until the next login; the
|
||||
// answer says what makes it last.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
// The session's holders this module prefers as the session's own process.
|
||||
var sessionHolders = []string{"i3", "sway", "openbox", "bspwm", "awesome", "xmonad"}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(desktop.Machine(sessionHolders...))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// call bounds one tool call below the runtime's 30 s limit.
|
||||
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(d desktop.Desk) []stdio.Tool {
|
||||
x := xorg{d: d}
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-display-server.displays",
|
||||
Description: "The X screen and every output: connected or not, primary, where it sits, its rotation, " +
|
||||
"physical size and DPI, the monitor's identity from its EDID (manufacturer, product, serial, name, " +
|
||||
"fingerprint), the current and preferred mode, and every mode with its rates. Disconnected outputs " +
|
||||
"are listed by name only unless all is true. Also the autorandr profile in force, when one matches.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"all": desktop.Flag("list disconnected outputs in full (default false)"),
|
||||
"modes": desktop.Flag("list every mode of each output (default true)"),
|
||||
}),
|
||||
Run: call(x.displays),
|
||||
},
|
||||
{
|
||||
Name: "node-display-server.layout",
|
||||
Description: "Monitor layout profiles (autorandr), keyed by the connected monitors' identities, so one " +
|
||||
"profile needs no machine's name. list: every profile with the monitors it is for, which match " +
|
||||
"the monitors connected now and which is in force. save: the current arrangement under a name " +
|
||||
"(replace true to overwrite). apply: load one. The profiles are the operator's data, never the mesh's.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"action": desktop.Enum("list, save or apply", "list", "save", "apply"),
|
||||
"name": desktop.Str("the profile to save or apply"),
|
||||
"replace": desktop.Flag("save over an existing profile of that name (default false)"),
|
||||
}, "action"),
|
||||
Run: call(x.layout),
|
||||
},
|
||||
{
|
||||
Name: "xorg_set_mode",
|
||||
Description: "Change one output now, through xrandr: its mode (WxH, or auto for the preferred one), rate, " +
|
||||
"rotation, scale, position (x/y, or beside another output), on or off, primary. dry_run answers " +
|
||||
"the command without running it. Lasts until the next login or hotplug: save a layout profile to keep it.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"output": desktop.Str("the output, as displays names it (e.g. eDP-1)"),
|
||||
"mode": desktop.Str("WxH, or auto for the monitor's preferred mode"),
|
||||
"rate": desktop.Num("refresh rate in Hz"),
|
||||
"rotate": desktop.Enum("rotation", "normal", "left", "right", "inverted"),
|
||||
"scale": desktop.Num("scale factor, 0.25 to 4 (1 = none)"),
|
||||
"x": desktop.Int("left edge on the screen, in pixels"),
|
||||
"y": desktop.Int("top edge on the screen, in pixels"),
|
||||
"relation": desktop.Enum("place it beside another output instead of at x/y", "right-of", "left-of", "above", "below", "same-as"),
|
||||
"of": desktop.Str("the output the relation is to"),
|
||||
"off": desktop.Flag("switch the output off"),
|
||||
"primary": desktop.Flag("make it the primary output"),
|
||||
"dry_run": desktop.Flag("answer the command, run nothing"),
|
||||
}, "output"),
|
||||
Run: call(x.setMode),
|
||||
},
|
||||
{
|
||||
Name: "xorg_primary",
|
||||
Description: "Which output is primary (the one the bar's tray and new windows prefer); with output, make it primary now.",
|
||||
Input: desktop.Schema(map[string]any{"output": desktop.Str("the output to make primary (optional)")}),
|
||||
Run: call(x.primary),
|
||||
},
|
||||
{
|
||||
Name: "xorg_dpi",
|
||||
Description: "The DPI X programs are told (Xft.dpi) beside each monitor's physical DPI. With value, set " +
|
||||
"Xft.dpi and the screen's DPI now: programs started after it use it. The module's resources file " +
|
||||
"sets it again at the next login (96 until it is a setting).",
|
||||
Input: desktop.Schema(map[string]any{"value": desktop.Int("dots per inch, 48 to 480 (optional)")}),
|
||||
Run: call(x.dpi),
|
||||
},
|
||||
{
|
||||
Name: "xorg_input_devices",
|
||||
Description: "The input devices the X server knows: id, name, pointer or keyboard, master or slave, and " +
|
||||
"for each pointer its libinput settings (tap, natural scroll, acceleration, disable while typing, " +
|
||||
"left handed, enabled).",
|
||||
Input: desktop.Schema(map[string]any{"name": desktop.Str("only devices whose name contains this (optional)")}),
|
||||
Run: call(x.inputDevices),
|
||||
},
|
||||
{
|
||||
Name: "xorg_input_set",
|
||||
Description: "Change libinput settings of a device now: tap, tap_drag, natural_scroll, disable_while_typing, " +
|
||||
"left_handed, middle_emulation, enabled (true/false), accel_speed (-1 to 1). The device is an id or a " +
|
||||
"name; a name applies to every device of that name that has the setting. Lasts until the device " +
|
||||
"reconnects or the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"device": desktop.Str("the device's id or exact name"),
|
||||
"tap": desktop.Flag("tap to click"),
|
||||
"tap_drag": desktop.Flag("tap and drag"),
|
||||
"natural_scroll": desktop.Flag("natural (reversed) scrolling"),
|
||||
"disable_while_typing": desktop.Flag("ignore the touchpad while typing"),
|
||||
"left_handed": desktop.Flag("swap the buttons"),
|
||||
"middle_emulation": desktop.Flag("both buttons together are the middle one"),
|
||||
"enabled": desktop.Flag("the device takes input at all"),
|
||||
"accel_speed": desktop.Num("pointer acceleration, -1 to 1"),
|
||||
}, "device"),
|
||||
Run: call(x.inputSet),
|
||||
},
|
||||
{
|
||||
Name: "xorg_keyboard",
|
||||
Description: "The X keyboard map: rules, model, layout, variant and options. With layout, variant, model " +
|
||||
"or options, set them now (options replace the current ones; an empty list clears them). Lasts " +
|
||||
"until the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"layout": desktop.Str("e.g. us, be, us,be"),
|
||||
"variant": desktop.Str("e.g. intl"),
|
||||
"model": desktop.Str("e.g. pc105"),
|
||||
"options": desktop.List("e.g. [\"caps:escape\", \"compose:ralt\"]"),
|
||||
}),
|
||||
Run: call(x.keyboard),
|
||||
},
|
||||
{
|
||||
Name: "xorg_screenshot",
|
||||
Description: "Take a screenshot to a PNG file: the whole screen, one output, or one window by its X id. " +
|
||||
"Written under the account's home (default ~/Pictures/Screenshots/screenshot-<time>.png). Answers " +
|
||||
"the path, size and bytes, never the image.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"output": desktop.Str("only this output (optional)"),
|
||||
"window": desktop.Str("only this window, by its X id, e.g. 0x3a00007 (optional)"),
|
||||
"path": desktop.Str("where to write it, inside the home (optional)"),
|
||||
}),
|
||||
Run: call(x.screenshot),
|
||||
},
|
||||
{
|
||||
Name: "xorg_x_log",
|
||||
Description: "The X server's log for the running session (or the newest one): its version, when it " +
|
||||
"started, and its errors — with warnings when asked — newest last.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"warnings": desktop.Flag("include warnings (default false)"),
|
||||
"limit": desktop.Int("at most this many lines (default 100, at most 1000)"),
|
||||
}),
|
||||
Run: call(x.xlog),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package main
|
||||
|
||||
// xorg's shape (novox/hq ADR 0208): it holds node-display-server and provides the display with the
|
||||
// machine's reach; it writes the session's start as a block at the start of ~/.xinitrc in the order
|
||||
// §5 gives, and the resources file every module contributes to. The block is also run, with the
|
||||
// programs it calls replaced by recorders, to prove what it does rather than what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Provides []map[string]any `json:"provides"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Seats any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment map[string]any `json:"environment"`
|
||||
Shell []map[string]any `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestItHoldsTheDisplayServerSeatAndServesItsVerbs(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-server" || m.Claims[0].Scope != "node" {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Claims[0].Serves, ",") != "displays,layout" {
|
||||
t.Fatalf("serves: %v", m.Claims[0].Serves)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools((&fakeDesk{}).desk(false)) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
for _, v := range m.Claims[0].Serves {
|
||||
if !served["node-display-server."+v] {
|
||||
t.Errorf("the bundle does not serve the seat's %s", v)
|
||||
}
|
||||
}
|
||||
own := 0
|
||||
for name := range served {
|
||||
if !strings.HasPrefix(name, "node-display-server.") {
|
||||
own++
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] || !strings.HasPrefix(name, "xorg_") {
|
||||
t.Errorf("the manifest lists %s, which the bundle does not serve as its own", name)
|
||||
}
|
||||
}
|
||||
if own != len(m.Tools) {
|
||||
t.Errorf("the bundle serves %d tools of its own, the manifest lists %d", own, len(m.Tools))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDisplayIsProvidedOnThisMachineOnlyAndGatedByASeatOfHardware(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Provides) != 1 || m.Provides[0]["name"] != "x11-display" || m.Provides[0]["reach"] != "machine" {
|
||||
t.Fatalf("provides: %v", m.Provides)
|
||||
}
|
||||
caps := strings.Join(m.Capabilities, ",")
|
||||
if caps != "package-manager,seat" {
|
||||
t.Fatalf("capabilities %s: seat is the hardware a display server needs; graphical-session is whether one runs now", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItInstallsTheServerItsStartAndTheToolsItsVerbsRun(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
var got []string
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
got = append(got, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
want := "autorandr,xorg-server,xorg-setxkbmap,xorg-xinit,xorg-xinput,xorg-xrandr,xorg-xrdb,xorg-xset,xorg-xwd"
|
||||
if strings.Join(got, ",") != want {
|
||||
t.Fatalf("packages: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheResourcesFileHoldsTheDefaultsAndTheThreeSlots(t *testing.T) {
|
||||
r := readManifest(t).resource(t, "xresources")
|
||||
c := r["content"].(string)
|
||||
if r["path"] != "${machine:account-home}/.config/xorg/xresources" || r["into"] != nil {
|
||||
t.Fatalf("an owned file: %v", r)
|
||||
}
|
||||
first, normal, last := strings.Index(c, "${shell:xresources:first}"), strings.Index(c, "${shell:xresources:normal}"), strings.Index(c, "${shell:xresources:last}")
|
||||
if !(first > strings.Index(c, "Xft.dpi: 96") && first < normal && normal < last) {
|
||||
t.Fatal("the defaults, then first, normal and last")
|
||||
}
|
||||
for _, line := range strings.Split(c, "\n") {
|
||||
if strings.HasPrefix(line, "#") {
|
||||
t.Errorf("merged without cpp, a # line is skipped: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionStartIsABlockAtTheStartInTheOrderTheDecisionGives(t *testing.T) {
|
||||
r := readManifest(t).resource(t, "xinitrc")
|
||||
if r["path"] != "${machine:account-home}/.xinitrc" || r["into"] != "block" || r["at"] != "start" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
c := r["content"].(string)
|
||||
order := []string{
|
||||
". \"${machine:account-home}/.config/mesh/environment.sh\"",
|
||||
"dbus-update-activation-environment --systemd $mesh_words",
|
||||
"xrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"",
|
||||
"autorandr --change",
|
||||
"${shell:xinitrc:first}",
|
||||
"${shell:xinitrc:normal}",
|
||||
". \"$HOME/.xinitrc.local\"",
|
||||
"${shell:xinitrc:last}",
|
||||
}
|
||||
at := -1
|
||||
for _, step := range order {
|
||||
i := strings.Index(c, step)
|
||||
if i <= at {
|
||||
t.Fatalf("%q is not after the step before it", step)
|
||||
}
|
||||
at = i
|
||||
if strings.Count(c, step) != 1 {
|
||||
t.Fatalf("%q appears more than once", step)
|
||||
}
|
||||
}
|
||||
var code []string
|
||||
for _, line := range strings.Split(c, "\n") {
|
||||
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||
code = append(code, line)
|
||||
}
|
||||
}
|
||||
lines := strings.Join(code, "\n")
|
||||
if strings.Contains(lines, "--all") || strings.Contains(lines, "exec ") || strings.Contains(lines, "dbus-launch") {
|
||||
t.Fatal("never --all, no exec of the block's own (the session's is contributed), no second bus")
|
||||
}
|
||||
if strings.Contains(c, "hal/env") || strings.Contains(c, "gnome-keyring") {
|
||||
t.Fatal("the predecessor's secrets file and the keyring are not the display server's")
|
||||
}
|
||||
}
|
||||
|
||||
// render fills the block's placeholders as the controller would, with one contribution per slot.
|
||||
func render(c, home string, slots map[string]string) string {
|
||||
c = strings.ReplaceAll(c, "${machine:account-home}", home)
|
||||
for _, slot := range []string{"first", "normal", "last"} {
|
||||
code := ""
|
||||
if s, ok := slots[slot]; ok {
|
||||
code = "# a-module\n" + s + "\n"
|
||||
}
|
||||
c = strings.ReplaceAll(c, "${shell:xinitrc:"+slot+"}", code)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestTheBlockRunsTheEnvironmentTheImportTheResourcesTheSlotsAndEndsInTheSession(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh")
|
||||
}
|
||||
home := t.TempDir()
|
||||
bin := filepath.Join(home, "bin")
|
||||
log := filepath.Join(home, "log")
|
||||
os.MkdirAll(filepath.Join(home, ".config", "mesh"), 0o755)
|
||||
os.MkdirAll(bin, 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".config", "mesh", "environment.sh"),
|
||||
[]byte("export XDG_CURRENT_DESKTOP=i3\nexport GTK_THEME=Adwaita:dark\n"), 0o644)
|
||||
os.WriteFile(filepath.Join(home, ".xinitrc.local"), []byte("echo local >> \""+log+"\"\n"), 0o644)
|
||||
for _, prog := range []string{"dbus-update-activation-environment", "xrdb", "autorandr", "the-session"} {
|
||||
os.WriteFile(filepath.Join(bin, prog), []byte("#!/bin/sh\necho \""+prog+" $*\" >> \""+log+"\"\n"), 0o755)
|
||||
}
|
||||
block := render(readManifest(t).resource(t, "xinitrc")["content"].(string), home, map[string]string{
|
||||
"normal": "echo normal >> \"" + log + "\"",
|
||||
"last": "exec the-session",
|
||||
})
|
||||
file := "#!/bin/sh\n# BEGIN mesh xorg.xinitrc\n" + block + "# END mesh xorg.xinitrc\n" +
|
||||
"export SECRET_TOKEN=x\necho operator-below >> \"" + log + "\"\nexec i3\n"
|
||||
os.WriteFile(filepath.Join(home, ".xinitrc"), []byte(file), 0o755)
|
||||
os.WriteFile(filepath.Join(home, ".Xresources"), []byte("XTerm*saveLines: 1\n"), 0o644)
|
||||
cmd := exec.Command(sh, filepath.Join(home, ".xinitrc"))
|
||||
cmd.Env = []string{"HOME=" + home, "PATH=" + bin + ":/usr/bin:/bin", "DISPLAY=:7", "XAUTHORITY=" + home + "/.Xauthority",
|
||||
"XDG_SESSION_TYPE=x11", "DBUS_SESSION_BUS_ADDRESS=unix:path=/nowhere", "SECRET_KEY=y"}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v: %s", err, out)
|
||||
}
|
||||
raw, _ := os.ReadFile(log)
|
||||
lines := strings.Split(strings.TrimSpace(string(raw)), "\n")
|
||||
want := []string{
|
||||
"dbus-update-activation-environment --systemd DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP GTK_THEME",
|
||||
"xrdb -nocpp -merge " + home + "/.config/xorg/xresources",
|
||||
"xrdb -merge " + home + "/.Xresources",
|
||||
"autorandr --change",
|
||||
"normal",
|
||||
"local",
|
||||
"the-session",
|
||||
}
|
||||
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("ran:\n%s\nwant:\n%s", strings.Join(lines, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBundleIsGoBuiltForArch(t *testing.T) {
|
||||
a := readManifest(t).Build.Artifacts
|
||||
if len(a) != 1 || a[0]["language"] != "go" || a[0]["system"] != "arch" || a[0]["from"] != "cmd/xorg" || a[0]["binary"] != "xorg" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
rules: evdev
|
||||
model: pc105
|
||||
layout: us
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
⎡ Virtual core pointer id=2 [master pointer (3)]
|
||||
⎜ ↳ Virtual core XTEST pointer id=4 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Master 3 id=7 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Master 3 id=8 [slave pointer (2)]
|
||||
⎜ ↳ Logitech MX Keys id=9 [slave pointer (2)]
|
||||
⎜ ↳ Razer Razer Basilisk V3 Pro id=10 [slave pointer (2)]
|
||||
⎜ ↳ Razer Razer Basilisk V3 Pro id=11 [slave pointer (2)]
|
||||
⎜ ↳ ITE Tech. Inc. ITE Device(8910) id=13 [slave pointer (2)]
|
||||
⎜ ↳ ASUP1208:00 093A:3011 Mouse id=15 [slave pointer (2)]
|
||||
⎜ ↳ ASUP1208:00 093A:3011 Touchpad id=16 [slave pointer (2)]
|
||||
⎜ ↳ ydotoold virtual device id=20 [slave pointer (2)]
|
||||
⎣ Virtual core keyboard id=3 [master keyboard (2)]
|
||||
↳ Virtual core XTEST keyboard id=5 [slave keyboard (3)]
|
||||
↳ Video Bus id=6 [slave keyboard (3)]
|
||||
↳ Razer Razer Basilisk V3 Pro id=12 [slave keyboard (3)]
|
||||
↳ Video Bus id=14 [slave keyboard (3)]
|
||||
↳ Power Button id=17 [slave keyboard (3)]
|
||||
↳ Sleep Button id=18 [slave keyboard (3)]
|
||||
↳ Asus WMI hotkeys id=19 [slave keyboard (3)]
|
||||
↳ Logitech MX Master 3 id=21 [slave keyboard (3)]
|
||||
↳ Logitech MX Master 3 id=22 [slave keyboard (3)]
|
||||
↳ Logitech MX Keys id=23 [slave keyboard (3)]
|
||||
↳ Razer Razer Basilisk V3 Pro id=24 [slave keyboard (3)]
|
||||
↳ ITE Tech. Inc. ITE Device(8910) id=25 [slave keyboard (3)]
|
||||
↳ ydotoold virtual device id=26 [slave keyboard (3)]
|
||||
@@ -0,0 +1,48 @@
|
||||
Device 'ASUP1208:00 093A:3011 Touchpad':
|
||||
Device Enabled (187): 1
|
||||
Coordinate Transformation Matrix (189): 1.000000, 0.000000, 0.000000, 0.000000, 1.000000, 0.000000, 0.000000, 0.000000, 1.000000
|
||||
libinput Tapping Enabled (347): 1
|
||||
libinput Tapping Enabled Default (348): 0
|
||||
libinput Tapping Drag Enabled (349): 1
|
||||
libinput Tapping Drag Enabled Default (350): 1
|
||||
libinput Tapping Drag Lock Enabled (351): 0
|
||||
libinput Tapping Drag Lock Enabled Default (352): 0
|
||||
libinput Tapping Button Mapping Enabled (353): 1, 0
|
||||
libinput Tapping Button Mapping Default (354): 1, 0
|
||||
libinput Natural Scrolling Enabled (318): 1
|
||||
libinput Natural Scrolling Enabled Default (319): 0
|
||||
libinput Disable While Typing Enabled (355): 1
|
||||
libinput Disable While Typing Enabled Default (356): 1
|
||||
libinput Scroll Methods Available (320): 1, 1, 0
|
||||
libinput Scroll Method Enabled (321): 1, 0, 0
|
||||
libinput Scroll Method Enabled Default (322): 1, 0, 0
|
||||
libinput Click Methods Available (357): 1, 1
|
||||
libinput Click Method Enabled (358): 1, 0
|
||||
libinput Click Method Enabled Default (359): 1, 0
|
||||
libinput Clickfinger Button Mapping Enabled (360): 1, 0
|
||||
libinput Clickfinger Button Mapping Default (361): 1, 0
|
||||
libinput Middle Emulation Enabled (327): 0
|
||||
libinput Middle Emulation Enabled Default (328): 0
|
||||
libinput Accel Speed (329): 0.150000
|
||||
libinput Accel Speed Default (330): 0.000000
|
||||
libinput Accel Profiles Available (331): 1, 1, 1
|
||||
libinput Accel Profile Enabled (332): 1, 0, 0
|
||||
libinput Accel Profile Enabled Default (333): 1, 0, 0
|
||||
libinput Accel Custom Fallback Points (334): <no items>
|
||||
libinput Accel Custom Fallback Step (335): 0.000000
|
||||
libinput Accel Custom Motion Points (336): <no items>
|
||||
libinput Accel Custom Motion Step (337): 0.000000
|
||||
libinput Accel Custom Scroll Points (338): <no items>
|
||||
libinput Accel Custom Scroll Step (339): 0.000000
|
||||
libinput Left Handed Enabled (340): 0
|
||||
libinput Left Handed Enabled Default (341): 0
|
||||
libinput Send Events Modes Available (303): 1, 1
|
||||
libinput Send Events Mode Enabled (304): 0, 0
|
||||
libinput Send Events Mode Enabled Default (305): 0, 0
|
||||
Device Node (306): "/dev/input/event20"
|
||||
Device Product ID (307): 2362, 12305
|
||||
libinput Drag Lock Buttons (342): <no items>
|
||||
libinput Horizontal Scroll Enabled (343): 1
|
||||
libinput Scrolling Pixel Distance (344): 15
|
||||
libinput Scrolling Pixel Distance Default (345): 15
|
||||
libinput High Resolution Wheel Scroll Enabled (346): 1
|
||||
+437
@@ -0,0 +1,437 @@
|
||||
Screen 0: minimum 320 x 200, current 2880 x 1800, maximum 16384 x 16384
|
||||
eDP-1 connected primary 2880x1800+0+0 (normal left inverted right x axis y axis) 302mm x 189mm
|
||||
EDID:
|
||||
00ffffffffffff004c839c4100000000
|
||||
00210104b51e137803cfd1ae513eb623
|
||||
0b505400000001010101010101010101
|
||||
010101010101cbfe4064b00838772008
|
||||
88002ebd1000001b000000fd003078da
|
||||
da420100000000000000000000fe0053
|
||||
44430a202020202020202020000000fc
|
||||
0041544e413430435530352d30200127
|
||||
702079020020000c4c83009c41000000
|
||||
00002100260009040000000000500000
|
||||
220014e7f309853f0b63001f00070007
|
||||
071700070007002b000c270030770000
|
||||
2700303b000081001f731a0000030330
|
||||
7800a07402600278000000008de30580
|
||||
00e60605017460022e00061810405ed0
|
||||
6000000000000000000000000000fc90
|
||||
panel_type: OLED
|
||||
supported: unknown, OLED
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 1
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 383
|
||||
supported: 383
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
2880x1800 60.00*+ 120.00 +
|
||||
2880x1620 59.96 59.97
|
||||
2560x1600 59.99 59.97
|
||||
2560x1440 59.99 59.99 59.96 59.95
|
||||
2048x1536 85.00 75.00 60.00
|
||||
1920x1440 85.00 75.00 60.00
|
||||
1856x1392 75.00 60.01
|
||||
1792x1344 75.00 60.01
|
||||
2048x1152 59.99 59.98 59.90 59.91
|
||||
1920x1200 120.00 59.88 59.95
|
||||
1920x1080 120.00 60.01 59.97 59.96 59.93
|
||||
1600x1200 120.00 85.00 75.00 70.00 65.00 60.00
|
||||
1680x1050 120.00 59.95 59.88
|
||||
1400x1050 74.76 59.98
|
||||
1600x900 59.99 59.94 59.95 59.82
|
||||
1280x1024 120.00 85.02 75.02 60.02
|
||||
1440x900 120.00
|
||||
1400x900 59.96 59.88
|
||||
1280x960 85.00 60.00
|
||||
1440x810 60.00 59.97
|
||||
1368x768 59.88 59.85
|
||||
1280x800 120.00 59.99 59.97 59.81 59.91
|
||||
1152x864 75.00
|
||||
1280x720 120.00 60.00 59.99 59.86 59.74
|
||||
1024x768 120.00 85.00 75.05 60.04 85.00 75.03 70.07 60.00
|
||||
1024x768i 86.96
|
||||
960x720 85.00 75.00 60.00
|
||||
928x696 75.00 60.05
|
||||
896x672 75.05 60.01
|
||||
1024x576 59.95 59.96 59.90 59.82
|
||||
960x600 59.93 60.00
|
||||
832x624 74.55
|
||||
960x540 59.96 59.99 59.63 59.82
|
||||
800x600 120.00 85.00 75.00 70.00 65.00 60.00 85.14 72.19 75.00 60.32 56.25
|
||||
840x525 60.01 59.88
|
||||
864x486 59.92 59.57
|
||||
700x525 74.76 59.98
|
||||
800x450 59.95 59.82
|
||||
640x512 85.02 75.02 60.02
|
||||
700x450 59.96 59.88
|
||||
640x480 120.00 85.09 60.00 85.01 72.81 75.00 59.94
|
||||
720x405 59.51 58.99
|
||||
720x400 85.04
|
||||
684x384 59.88 59.85
|
||||
640x400 59.88 59.98 85.08
|
||||
576x432 75.00
|
||||
640x360 59.86 59.83 59.84 59.32
|
||||
640x350 85.08
|
||||
512x384 85.00 75.03 70.07 60.00
|
||||
512x384i 87.06
|
||||
512x288 60.00 59.92
|
||||
416x312 74.66
|
||||
480x270 59.63 59.82
|
||||
400x300 85.27 72.19 75.12 60.32 56.34
|
||||
432x243 59.92 59.57
|
||||
320x240 85.18 72.81 75.00 60.05
|
||||
360x202 59.51 59.13
|
||||
360x200 85.04
|
||||
320x200 85.27
|
||||
320x180 59.84 59.32
|
||||
320x175 85.27
|
||||
DP-1 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 391
|
||||
supported: 391
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-2 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 398
|
||||
supported: 398
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-3 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 405
|
||||
supported: 405
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-4 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 411
|
||||
supported: 411
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-5 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 417
|
||||
supported: 417
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-6 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 423
|
||||
supported: 423
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-7 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 429
|
||||
supported: 429
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-8 disconnected (normal left inverted right x axis y axis)
|
||||
subconnector: Unknown
|
||||
supported: Unknown, VGA, DVI-D, HDMI, DP, Wireless, Native
|
||||
HDCP Content Type: HDCP Type0
|
||||
supported: HDCP Type0, HDCP Type1
|
||||
Content Protection: Undesired
|
||||
supported: Undesired, Desired, Enabled
|
||||
vrr_capable: 0
|
||||
range: (0, 1)
|
||||
Colorspace: Default
|
||||
supported: Default, BT709_YCC, opRGB, BT2020_RGB, BT2020_YCC
|
||||
max bpc: 16
|
||||
range: (8, 16)
|
||||
underscan vborder: 0
|
||||
range: (0, 128)
|
||||
underscan hborder: 0
|
||||
range: (0, 128)
|
||||
underscan: off
|
||||
supported: off, on, auto
|
||||
Broadcast RGB: Automatic
|
||||
supported: Automatic, Full, Limited 16:235
|
||||
scaling mode: None
|
||||
supported: None, Full, Center, Full aspect
|
||||
link-status: Good
|
||||
supported: Good, Bad
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CONNECTOR_ID: 435
|
||||
supported: 435
|
||||
non-desktop: 0
|
||||
range: (0, 1)
|
||||
DP-1-0 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: DisplayPort
|
||||
supported: DisplayPort
|
||||
ConnectorType: DisplayPort
|
||||
ConnectorNumber: 0
|
||||
_ConnectorLocation: 0
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
DP-1-1 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: TMDS
|
||||
supported: TMDS
|
||||
ConnectorType: DisplayPort
|
||||
ConnectorNumber: 0
|
||||
_ConnectorLocation: 0
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
HDMI-1-0 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: TMDS
|
||||
supported: TMDS
|
||||
ConnectorType: HDMI
|
||||
ConnectorNumber: 2
|
||||
_ConnectorLocation: 2
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
DP-1-2 disconnected (normal left inverted right x axis y axis)
|
||||
PRIME Synchronization: 1
|
||||
supported: 0, 1
|
||||
CTM: 1.000000 0.000000 0.000000
|
||||
0.000000 1.000000 0.000000
|
||||
0.000000 0.000000 1.000000
|
||||
CscMatrix: 65536 0 0 0 0 65536 0 0 0 0 65536 0
|
||||
BorderDimensions: 4
|
||||
supported: 4
|
||||
Border: 0 0 0 0
|
||||
range: (0, 65535)
|
||||
SignalFormat: DisplayPort
|
||||
supported: DisplayPort
|
||||
ConnectorType: Panel
|
||||
ConnectorNumber: 3
|
||||
_ConnectorLocation: 3
|
||||
non-desktop: 0
|
||||
supported: 0, 1
|
||||
@@ -0,0 +1,602 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
type xorg struct{ d desktop.Desk }
|
||||
|
||||
// restored is what every answer that changed the running server says about lasting.
|
||||
const untilLogin = "until the next login: the session's start applies the declared defaults and the matching layout profile again"
|
||||
|
||||
var outputName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
|
||||
func (x xorg) query(ctx context.Context) (Screen, []Output, *desktop.Session, error) {
|
||||
res, s, err := x.d.InSession(ctx, "xrandr", "--prop")
|
||||
if err != nil {
|
||||
return Screen{}, nil, nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return Screen{}, nil, s, res.Err()
|
||||
}
|
||||
screen, outs := ParseXrandr(res.Stdout)
|
||||
return screen, outs, s, nil
|
||||
}
|
||||
|
||||
func (x xorg) displays(ctx context.Context, a desktop.Args) (any, error) {
|
||||
all, _, err := a.Bool("all")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modes, given, err := a.Bool("modes")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !given {
|
||||
modes = true
|
||||
}
|
||||
screen, outs, s, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var shown []Output
|
||||
var off []string
|
||||
for _, o := range outs {
|
||||
if !o.Connected && !all {
|
||||
off = append(off, o.Name)
|
||||
continue
|
||||
}
|
||||
if !modes {
|
||||
o.Modes = nil
|
||||
}
|
||||
shown = append(shown, o)
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "screen": screen, "outputs": shown, "disconnected": off}
|
||||
if r := x.d.Run(ctx, s.Env(x.d.Base), nil, "autorandr", "--current"); r.OK() {
|
||||
answer["profile"] = firstName(r.Stdout)
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func firstName(text string) string {
|
||||
for n := range names(text) {
|
||||
return n
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x xorg) layout(ctx context.Context, a desktop.Args) (any, error) {
|
||||
action, err := a.OneOf("action", "", "list", "save", "apply")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dirs := ProfileDirs(desktop.Home())
|
||||
if action == "list" {
|
||||
profiles := ReadProfiles(dirs)
|
||||
answer := map[string]any{"profiles": profiles, "where": dirs[0]}
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
answer["session"] = nil
|
||||
answer["note"] = "no graphical session: which profile matches the monitors connected is not known"
|
||||
return answer, nil
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
det := x.d.Run(ctx, env, nil, "autorandr", "--detected")
|
||||
if det.Code == 127 {
|
||||
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
|
||||
}
|
||||
detected, current := names(det.Stdout), names(x.d.Run(ctx, env, nil, "autorandr", "--current").Stdout)
|
||||
for i := range profiles {
|
||||
profiles[i].Detected, profiles[i].Current = detected[profiles[i].Name], current[profiles[i].Name]
|
||||
}
|
||||
answer["profiles"] = profiles
|
||||
return answer, nil
|
||||
}
|
||||
name, err := a.Text("name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !profileName.MatchString(name) {
|
||||
return nil, fmt.Errorf("a profile name is letters, digits, dot, dash and underscore, up to 64")
|
||||
}
|
||||
args := []string{"--load", name}
|
||||
if action == "save" {
|
||||
replace, _, err := a.Bool("replace")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = []string{"--save", name}
|
||||
if replace {
|
||||
args = append(args, "--force")
|
||||
}
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "autorandr", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.Code == 127 {
|
||||
return nil, fmt.Errorf("autorandr is not installed here; the xorg module's package resource installs it")
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
answer := map[string]any{"action": action, "name": name, "session": s.Display, "said": strings.TrimSpace(res.Stdout + res.Stderr)}
|
||||
if action == "save" {
|
||||
answer["kept"] = "in " + dirs[0] + ": the operator's data; the session's start applies it whenever these monitors are connected"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// ModeArgs is the xrandr command line set-mode asks for, refused before anything runs when it is not
|
||||
// one xrandr would take.
|
||||
func ModeArgs(a desktop.Args) ([]string, error) {
|
||||
output, err := a.Text("output")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !outputName.MatchString(output) {
|
||||
return nil, fmt.Errorf("%q is not an output name", output)
|
||||
}
|
||||
args := []string{"--output", output}
|
||||
off, _, err := a.Bool("off")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if off {
|
||||
return append(args, "--off"), nil
|
||||
}
|
||||
switch mode := a.Opt("mode", ""); {
|
||||
case mode == "auto":
|
||||
args = append(args, "--auto")
|
||||
case mode != "":
|
||||
if !regexp.MustCompile(`^\d{2,5}x\d{2,5}i?$`).MatchString(mode) {
|
||||
return nil, fmt.Errorf("mode is WxH, e.g. 2560x1440, or auto")
|
||||
}
|
||||
args = append(args, "--mode", mode)
|
||||
}
|
||||
if rate, given, err := a.Number("rate"); err != nil {
|
||||
return nil, err
|
||||
} else if given {
|
||||
if rate < 1 || rate > 1000 {
|
||||
return nil, fmt.Errorf("rate is in Hz, 1 to 1000")
|
||||
}
|
||||
args = append(args, "--rate", strconv.FormatFloat(rate, 'f', 2, 64))
|
||||
}
|
||||
if a.Has("rotate") {
|
||||
r, err := a.OneOf("rotate", "", "normal", "left", "right", "inverted")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, "--rotate", r)
|
||||
}
|
||||
if scale, given, err := a.Number("scale"); err != nil {
|
||||
return nil, err
|
||||
} else if given {
|
||||
if scale < 0.25 || scale > 4 {
|
||||
return nil, fmt.Errorf("scale is from 0.25 to 4")
|
||||
}
|
||||
f := strconv.FormatFloat(scale, 'f', -1, 64)
|
||||
args = append(args, "--scale", f+"x"+f)
|
||||
}
|
||||
if a.Has("relation") {
|
||||
rel, err := a.OneOf("relation", "", "right-of", "left-of", "above", "below", "same-as")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
of, err := a.Text("of")
|
||||
if err != nil || !outputName.MatchString(of) {
|
||||
return nil, fmt.Errorf("relation needs of: the output it is beside")
|
||||
}
|
||||
args = append(args, "--"+rel, of)
|
||||
} else if a.Has("x") || a.Has("y") {
|
||||
px, err := a.Whole("x", 0, -32768, 32768)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
py, err := a.Whole("y", 0, -32768, 32768)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, "--pos", fmt.Sprintf("%dx%d", px, py))
|
||||
}
|
||||
if p, _, err := a.Bool("primary"); err != nil {
|
||||
return nil, err
|
||||
} else if p {
|
||||
args = append(args, "--primary")
|
||||
}
|
||||
if len(args) == 2 {
|
||||
return nil, fmt.Errorf("nothing to change: give a mode, rate, rotate, scale, position, off or primary")
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func (x xorg) setMode(ctx context.Context, a desktop.Args) (any, error) {
|
||||
args, err := ModeArgs(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dry, _, _ := a.Bool("dry_run"); dry {
|
||||
return map[string]any{"dry_run": true, "command": append([]string{"xrandr"}, args...)}, nil
|
||||
}
|
||||
res, s, err := x.d.InSession(ctx, "xrandr", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
_, outs, _, _ := x.query(ctx)
|
||||
var now *Output
|
||||
for i := range outs {
|
||||
if outs[i].Name == args[1] {
|
||||
o := outs[i]
|
||||
o.Modes = nil
|
||||
now = &o
|
||||
}
|
||||
}
|
||||
return map[string]any{"session": s.Display, "command": res.Command, "output": now, "lasts": untilLogin}, nil
|
||||
}
|
||||
|
||||
func (x xorg) primary(ctx context.Context, a desktop.Args) (any, error) {
|
||||
if name := a.Opt("output", ""); name != "" {
|
||||
if !outputName.MatchString(name) {
|
||||
return nil, fmt.Errorf("%q is not an output name", name)
|
||||
}
|
||||
res, _, err := x.d.InSession(ctx, "xrandr", "--output", name, "--primary")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
}
|
||||
_, outs, s, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "primary": nil}
|
||||
for _, o := range outs {
|
||||
if o.Primary {
|
||||
answer["primary"] = o.Name
|
||||
}
|
||||
}
|
||||
if a.Has("output") {
|
||||
answer["lasts"] = untilLogin
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// ResourceValue is one resource's value from `xrdb -query` output.
|
||||
func ResourceValue(query, name string) string {
|
||||
for _, l := range strings.Split(query, "\n") {
|
||||
if k, v, ok := strings.Cut(l, ":"); ok && strings.TrimSpace(k) == name {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x xorg) dpi(ctx context.Context, a desktop.Args) (any, error) {
|
||||
value, err := a.Whole("value", 0, 48, 480)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
if value != 0 {
|
||||
v := strconv.Itoa(value)
|
||||
if r := x.d.Run(ctx, env, []byte("Xft.dpi: "+v+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
if r := x.d.Run(ctx, env, nil, "xrandr", "--dpi", v); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
}
|
||||
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
|
||||
if !q.OK() {
|
||||
return nil, q.Err()
|
||||
}
|
||||
_, outs, _, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
physical := map[string]float64{}
|
||||
for _, o := range outs {
|
||||
if o.DPI > 0 {
|
||||
physical[o.Name] = o.DPI
|
||||
}
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "xft_dpi": ResourceValue(q.Stdout, "Xft.dpi"), "physical": physical}
|
||||
if value != 0 {
|
||||
answer["set"] = value
|
||||
answer["lasts"] = "for programs started from now on, " + untilLogin
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
func (x xorg) devices(ctx context.Context, env []string) ([]Device, error) {
|
||||
r := x.d.Run(ctx, env, nil, "xinput", "list")
|
||||
if !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
return ParseDevices(r.Stdout), nil
|
||||
}
|
||||
|
||||
func (x xorg) inputDevices(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
all, err := x.devices(ctx, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filter := strings.ToLower(a.Opt("name", ""))
|
||||
var out []Device
|
||||
for _, dev := range all {
|
||||
if filter != "" && !strings.Contains(strings.ToLower(dev.Name), filter) {
|
||||
continue
|
||||
}
|
||||
if dev.Role == "slave" && dev.Kind == "pointer" && !strings.Contains(dev.Name, "XTEST") {
|
||||
if r := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID)); r.OK() {
|
||||
dev.Settings = ParseProps(r.Stdout)
|
||||
}
|
||||
}
|
||||
out = append(out, dev)
|
||||
}
|
||||
return map[string]any{"session": s.Display, "devices": out}, nil
|
||||
}
|
||||
|
||||
func (x xorg) inputSet(ctx context.Context, a desktop.Args) (any, error) {
|
||||
which, err := a.Text("device")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
type change struct {
|
||||
knob Knob
|
||||
value string
|
||||
}
|
||||
var changes []change
|
||||
for _, k := range Knobs {
|
||||
if !a.Has(k.Arg) {
|
||||
continue
|
||||
}
|
||||
switch k.Kind {
|
||||
case "bool":
|
||||
b, _, err := a.Bool(k.Arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change{k, map[bool]string{true: "1", false: "0"}[b]})
|
||||
case "float":
|
||||
f, _, err := a.Number(k.Arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f < -1 || f > 1 {
|
||||
return nil, fmt.Errorf("%s is from -1 to 1", k.Arg)
|
||||
}
|
||||
changes = append(changes, change{k, strconv.FormatFloat(f, 'f', 3, 64)})
|
||||
}
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return nil, fmt.Errorf("nothing to change: name at least one setting")
|
||||
}
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
all, err := x.devices(ctx, env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ids []Device
|
||||
for _, dev := range all {
|
||||
if dev.Role == "slave" && (strconv.Itoa(dev.ID) == which || dev.Name == which) {
|
||||
ids = append(ids, dev)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("no input device is %q: xorg_input_devices lists them", which)
|
||||
}
|
||||
type outcome struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Set []string `json:"set"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
Now map[string]any `json:"now"`
|
||||
}
|
||||
var done []outcome
|
||||
for _, dev := range ids {
|
||||
props := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
|
||||
have := ParseProps(props.Stdout)
|
||||
o := outcome{ID: dev.ID, Name: dev.Name, Set: []string{}}
|
||||
for _, c := range changes {
|
||||
if _, ok := have[c.knob.Arg]; !ok {
|
||||
o.Skipped = append(o.Skipped, c.knob.Arg)
|
||||
continue
|
||||
}
|
||||
r := x.d.Run(ctx, env, nil, "xinput", "set-prop", strconv.Itoa(dev.ID), c.knob.Property, c.value)
|
||||
if !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
o.Set = append(o.Set, c.knob.Arg)
|
||||
}
|
||||
after := x.d.Run(ctx, env, nil, "xinput", "list-props", strconv.Itoa(dev.ID))
|
||||
o.Now = ParseProps(after.Stdout)
|
||||
done = append(done, o)
|
||||
}
|
||||
return map[string]any{"session": s.Display, "devices": done, "lasts": "until the device reconnects or the next login"}, nil
|
||||
}
|
||||
|
||||
func (x xorg) keyboard(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
var args []string
|
||||
for _, k := range []string{"layout", "variant", "model"} {
|
||||
if v := a.Opt(k, ""); v != "" {
|
||||
if !xkbName.MatchString(v) {
|
||||
return nil, fmt.Errorf("%s %q is not an XKB name", k, v)
|
||||
}
|
||||
args = append(args, "-"+k, v)
|
||||
}
|
||||
}
|
||||
if a.Has("options") {
|
||||
opts, err := a.Strings("options")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args = append(args, "-option", "")
|
||||
for _, o := range opts {
|
||||
if !xkbName.MatchString(o) {
|
||||
return nil, fmt.Errorf("option %q is not an XKB name", o)
|
||||
}
|
||||
args = append(args, "-option", o)
|
||||
}
|
||||
}
|
||||
if len(args) > 0 {
|
||||
if r := x.d.Run(ctx, env, nil, "setxkbmap", args...); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
}
|
||||
q := x.d.Run(ctx, env, nil, "setxkbmap", "-query")
|
||||
if !q.OK() {
|
||||
return nil, q.Err()
|
||||
}
|
||||
answer := map[string]any{"session": s.Display, "keyboard": ParseKeyboard(q.Stdout)}
|
||||
if len(args) > 0 {
|
||||
answer["lasts"] = untilLogin
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var windowID = regexp.MustCompile(`^(0x[0-9a-fA-F]{1,8}|[0-9]{1,10})$`)
|
||||
|
||||
func (x xorg) screenshot(ctx context.Context, a desktop.Args) (any, error) {
|
||||
stamp := time.Now().Format("20060102-150405")
|
||||
path, err := desktop.InHome(a.Opt("path", "~/Pictures/Screenshots/screenshot-"+stamp+".png"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(path), ".png") {
|
||||
return nil, fmt.Errorf("the screenshot is a PNG: the path ends in .png")
|
||||
}
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
dump, err := os.CreateTemp("", "xorg-screenshot-*.xwd")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dump.Close()
|
||||
defer os.Remove(dump.Name())
|
||||
args := []string{"-silent", "-out", dump.Name()}
|
||||
if w := a.Opt("window", ""); w != "" {
|
||||
if !windowID.MatchString(w) {
|
||||
return nil, fmt.Errorf("window is an X window id, e.g. 0x3a00007")
|
||||
}
|
||||
args = append(args, "-id", w)
|
||||
} else {
|
||||
args = append(args, "-root")
|
||||
}
|
||||
if r := x.d.Run(ctx, env, nil, "xwd", args...); !r.OK() {
|
||||
if r.Code == 127 {
|
||||
return nil, fmt.Errorf("xwd is not installed here; the xorg module's package resource (xorg-xwd) installs it")
|
||||
}
|
||||
return nil, r.Err()
|
||||
}
|
||||
raw, err := os.ReadFile(dump.Name())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := DecodeXWD(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the dump xwd wrote: %w", err)
|
||||
}
|
||||
var shot image.Image = img
|
||||
if name := a.Opt("output", ""); name != "" {
|
||||
_, outs, _, err := x.query(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var g *Geometry
|
||||
for _, o := range outs {
|
||||
if o.Name == name {
|
||||
g = o.Geometry
|
||||
}
|
||||
}
|
||||
if g == nil {
|
||||
return nil, fmt.Errorf("output %q is not on: displays lists the outputs in use", name)
|
||||
}
|
||||
r := image.Rect(g.X, g.Y, g.X+g.Width, g.Y+g.Height).Intersect(img.Bounds())
|
||||
crop := image.NewRGBA(image.Rect(0, 0, r.Dx(), r.Dy()))
|
||||
draw.Draw(crop, crop.Bounds(), img, r.Min, draw.Src)
|
||||
shot = crop
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, shot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := shot.Bounds()
|
||||
return map[string]any{"session": s.Display, "path": path, "width": b.Dx(), "height": b.Dy(), "bytes": buf.Len()}, nil
|
||||
}
|
||||
|
||||
func (x xorg) xlog(ctx context.Context, a desktop.Args) (any, error) {
|
||||
warnings, _, err := a.Bool("warnings")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := a.Whole("limit", 100, 1, 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
display := -1
|
||||
if s, err := x.d.Find(); err == nil {
|
||||
if n, ok := desktop.DisplayNumber(s.Display); ok {
|
||||
display = n
|
||||
}
|
||||
}
|
||||
files := XLogFiles(display, desktop.Home())
|
||||
if display >= 0 {
|
||||
files = append(files, XLogFiles(-1, desktop.Home())...)
|
||||
}
|
||||
for _, f := range files {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return ParseXLog(f, string(raw), warnings, limit), nil
|
||||
}
|
||||
sort.Strings(files)
|
||||
return nil, fmt.Errorf("no X server log is readable here; looked at %s", strings.Join(files, ", "))
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LogLine is one marked line of the X server's log.
|
||||
type LogLine struct {
|
||||
At float64 `json:"at_seconds"`
|
||||
Level string `json:"level"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// XLog is what x-log answers.
|
||||
type XLog struct {
|
||||
File string `json:"file"`
|
||||
Server string `json:"server,omitempty"`
|
||||
Started string `json:"started,omitempty"`
|
||||
Errors int `json:"errors"`
|
||||
Warnings int `json:"warnings"`
|
||||
Lines []LogLine `json:"lines"`
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
logMark = regexp.MustCompile(`^\[\s*([0-9.]+)\] \((EE|WW)\) (.*)$`)
|
||||
logStart = regexp.MustCompile(`\(==\) Log file: "[^"]*", Time: (.+)$`)
|
||||
)
|
||||
|
||||
// ParseXLog reads an Xorg log: the server's version, when it started, and its errors and (when
|
||||
// asked) warnings, the last `limit` of them. The legend line that explains the markers is not one.
|
||||
func ParseXLog(file, text string, warnings bool, limit int) XLog {
|
||||
out := XLog{File: file, Lines: []LogLine{}}
|
||||
sc := bufio.NewScanner(strings.NewReader(text))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<22)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if out.Server == "" && strings.HasPrefix(strings.TrimSpace(line), "X.Org X Server ") {
|
||||
out.Server = strings.TrimSpace(line)
|
||||
}
|
||||
if m := logStart.FindStringSubmatch(line); m != nil && out.Started == "" {
|
||||
out.Started = m[1]
|
||||
}
|
||||
m := logMark.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
at, _ := strconv.ParseFloat(m[1], 64)
|
||||
level := map[string]string{"EE": "error", "WW": "warning"}[m[2]]
|
||||
if level == "error" {
|
||||
out.Errors++
|
||||
} else {
|
||||
out.Warnings++
|
||||
if !warnings {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Lines = append(out.Lines, LogLine{At: at, Level: level, Text: m[3]})
|
||||
}
|
||||
if limit > 0 && len(out.Lines) > limit {
|
||||
out.Lines = out.Lines[len(out.Lines)-limit:]
|
||||
out.Cut = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// XLogFiles are where an X server on display n logs, best first: a root server's log, then a
|
||||
// rootless one's under the account's data directory. With no display, every log found, newest first.
|
||||
func XLogFiles(display int, home string) []string {
|
||||
dirs := []string{"/var/log", filepath.Join(home, ".local/share/xorg")}
|
||||
if display >= 0 {
|
||||
var out []string
|
||||
for _, d := range dirs {
|
||||
out = append(out, filepath.Join(d, "Xorg."+strconv.Itoa(display)+".log"))
|
||||
}
|
||||
return out
|
||||
}
|
||||
type found struct {
|
||||
path string
|
||||
mod int64
|
||||
}
|
||||
var all []found
|
||||
for _, d := range dirs {
|
||||
matches, _ := filepath.Glob(filepath.Join(d, "Xorg.*.log"))
|
||||
for _, p := range matches {
|
||||
if info, err := os.Stat(p); err == nil {
|
||||
all = append(all, found{p, info.ModTime().UnixNano()})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return all[i].mod > all[j].mod })
|
||||
var out []string
|
||||
for _, f := range all {
|
||||
out = append(out, f.path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xorg/internal/desktop"
|
||||
)
|
||||
|
||||
func fixture(t *testing.T, name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// fakeDesk answers commands from a table keyed by the command line, and records what ran.
|
||||
type fakeDesk struct {
|
||||
answers map[string]desktop.Result
|
||||
ran []string
|
||||
env [][]string
|
||||
}
|
||||
|
||||
func (f *fakeDesk) desk(session bool) desktop.Desk {
|
||||
return desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) {
|
||||
if !session {
|
||||
return nil, &desktop.NoSession{Reason: "no process of uid 1000 carries a display"}
|
||||
}
|
||||
return &desktop.Session{UID: 1000, Display: ":1", Type: "x11", XAuthority: "/home/op/.Xauthority", RuntimeDir: "/run/user/1000"}, nil
|
||||
},
|
||||
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)
|
||||
f.env = append(f.env, env)
|
||||
if r, ok := f.answers[line]; ok {
|
||||
r.Command = append([]string{name}, args...)
|
||||
return r
|
||||
}
|
||||
return desktop.Result{Command: append([]string{name}, args...), Code: 127, Stderr: "not in the table: " + line}
|
||||
},
|
||||
Base: []string{"PATH=/usr/bin"},
|
||||
}
|
||||
}
|
||||
|
||||
func byName(t *testing.T, d desktop.Desk, name string) func(map[string]any) (any, error) {
|
||||
for _, tool := range tools(d) {
|
||||
if tool.Name == name {
|
||||
return tool.Run
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestTheOutputsAndTheMonitorsIdentityAreReadFromXrandr(t *testing.T) {
|
||||
screen, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
|
||||
if screen.Width != 2880 || screen.Height != 1800 || screen.MaxWidth != 16384 {
|
||||
t.Fatalf("screen: %+v", screen)
|
||||
}
|
||||
if len(outs) != 13 {
|
||||
t.Fatalf("%d outputs", len(outs))
|
||||
}
|
||||
e := outs[0]
|
||||
if e.Name != "eDP-1" || !e.Connected || !e.Primary || !e.Enabled || e.Rotation != "normal" || e.WidthMM != 302 {
|
||||
t.Fatalf("eDP-1: %+v", e)
|
||||
}
|
||||
if *e.Geometry != (Geometry{2880, 1800, 0, 0}) || e.DPI != 242.2 {
|
||||
t.Fatalf("geometry and DPI: %+v %v", e.Geometry, e.DPI)
|
||||
}
|
||||
if e.Current == nil || e.Current.Rate != 60 || e.Preferred == nil || e.Preferred.Size != "2880x1800" || e.Preferred.Rate != 60 {
|
||||
t.Fatalf("current %+v preferred %+v", e.Current, e.Preferred)
|
||||
}
|
||||
if e.Monitor == nil || e.Monitor.Manufacturer != "SDC" || e.Monitor.Product != "419C" || e.Monitor.Name != "ATNA40CU05-0" || len(e.Monitor.Fingerprint) != 12 {
|
||||
t.Fatalf("identity: %+v", e.Monitor)
|
||||
}
|
||||
if len(e.Modes) < 20 || e.Modes[0].Size != "2880x1800" || len(e.Modes[0].Rates) != 2 {
|
||||
t.Fatalf("modes: %+v", e.Modes[:2])
|
||||
}
|
||||
if outs[1].Connected || outs[1].Enabled || outs[1].Name != "DP-1" {
|
||||
t.Fatalf("a disconnected output: %+v", outs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestARotatedSecondMonitorIsReadWithItsPlaceAndDensity(t *testing.T) {
|
||||
text := "Screen 0: minimum 8 x 8, current 4000 x 2560, maximum 32767 x 32767\n" +
|
||||
"DP-2 connected 1440x2560+2560+0 left (normal left inverted right x axis y axis) 597mm x 336mm\n" +
|
||||
" 2560x1440 59.95 + 74.97*\n"
|
||||
_, outs := ParseXrandr(text)
|
||||
o := outs[0]
|
||||
if o.Rotation != "left" || o.Geometry.X != 2560 || o.Current.Rate != 74.97 || o.Preferred.Rate != 59.95 {
|
||||
t.Fatalf("%+v %+v %+v", o, o.Current, o.Preferred)
|
||||
}
|
||||
if o.DPI < 108 || o.DPI > 110 {
|
||||
t.Fatalf("a rotated output's density is its long side over its long side: %v", o.DPI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetModeBuildsOnlyCommandsXrandrTakes(t *testing.T) {
|
||||
args, err := ModeArgs(desktop.Args{"output": "DP-2", "mode": "2560x1440", "rate": 144.0, "rotate": "left", "scale": 1.5, "relation": "right-of", "of": "eDP-1", "primary": true})
|
||||
want := "--output DP-2 --mode 2560x1440 --rate 144.00 --rotate left --scale 1.5x1.5 --right-of eDP-1 --primary"
|
||||
if err != nil || strings.Join(args, " ") != want {
|
||||
t.Fatalf("%v %v", args, err)
|
||||
}
|
||||
if args, _ := ModeArgs(desktop.Args{"output": "HDMI-1", "off": true, "mode": "1x1"}); strings.Join(args, " ") != "--output HDMI-1 --off" {
|
||||
t.Fatalf("off wins: %v", args)
|
||||
}
|
||||
if args, _ := ModeArgs(desktop.Args{"output": "DP-1", "x": 1920.0, "y": 0.0}); strings.Join(args, " ") != "--output DP-1 --pos 1920x0" {
|
||||
t.Fatalf("position: %v", args)
|
||||
}
|
||||
for _, bad := range []desktop.Args{
|
||||
{"output": "DP-1; rm -rf ~"}, {"output": "DP-1"}, {"output": "DP-1", "mode": "big"},
|
||||
{"output": "DP-1", "scale": 9.0}, {"output": "DP-1", "relation": "right-of"}, {"output": "DP-1", "rotate": "sideways"},
|
||||
} {
|
||||
if _, err := ModeArgs(bad); err == nil {
|
||||
t.Errorf("accepted %v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRunsNothing(t *testing.T) {
|
||||
f := &fakeDesk{}
|
||||
got, err := byName(t, f.desk(true), "xorg_set_mode")(map[string]any{"output": "eDP-1", "mode": "auto", "dry_run": true})
|
||||
if err != nil || len(f.ran) != 0 || !strings.Contains(asJSON(got), `"--auto"`) {
|
||||
t.Fatalf("%v %v %v", got, err, f.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputDevicesAndTheirLibinputSettingsAreRead(t *testing.T) {
|
||||
devs := ParseDevices(fixture(t, "xinput-list.txt"))
|
||||
if len(devs) != 25 {
|
||||
t.Fatalf("%d devices", len(devs))
|
||||
}
|
||||
var pad *Device
|
||||
for i := range devs {
|
||||
if devs[i].ID == 16 {
|
||||
pad = &devs[i]
|
||||
}
|
||||
}
|
||||
if pad == nil || pad.Name != "ASUP1208:00 093A:3011 Touchpad" || pad.Role != "slave" || pad.Kind != "pointer" {
|
||||
t.Fatalf("%+v", pad)
|
||||
}
|
||||
if devs[0].Name != "Virtual core pointer" || devs[0].Role != "master" {
|
||||
t.Fatalf("the tree's drawing is stripped: %+v", devs[0])
|
||||
}
|
||||
s := ParseProps(fixture(t, "xinput-props-16.txt"))
|
||||
if s["tap"] != true || s["natural_scroll"] != true || s["accel_speed"] != 0.15 || s["accel_profile"] != "adaptive" || s["left_handed"] != false {
|
||||
t.Fatalf("%v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputSetChangesOnlyWhatADeviceHasAndSaysHowLongItLasts(t *testing.T) {
|
||||
props := fixture(t, "xinput-props-16.txt")
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"xinput list": {Stdout: fixture(t, "xinput-list.txt")},
|
||||
"xinput list-props 16": {Stdout: props},
|
||||
"xinput list-props 15": {Stdout: "Device 'Mouse':\n\tDevice Enabled (187):\t1\n"},
|
||||
"xinput set-prop 16 libinput Tapping Enabled 0": {},
|
||||
}}
|
||||
got, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "tap": false})
|
||||
if err != nil || !strings.Contains(asJSON(got), `"set":["tap"]`) || !strings.Contains(asJSON(got), "lasts") {
|
||||
t.Fatalf("%s %v", asJSON(got), err)
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16"}); err == nil {
|
||||
t.Fatal("nothing to change is refused")
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "16", "accel_speed": 3.0}); err == nil {
|
||||
t.Fatal("accel_speed out of range")
|
||||
}
|
||||
got, err = byName(t, f.desk(true), "xorg_input_set")(map[string]any{"device": "15", "tap": true})
|
||||
if err != nil || !strings.Contains(asJSON(got), `"skipped":["tap"]`) {
|
||||
t.Fatalf("a mouse has no tapping: %s %v", asJSON(got), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyboardMapIsReadAndSetOnlyWithXKBNames(t *testing.T) {
|
||||
k := ParseKeyboard(fixture(t, "setxkbmap.txt") + "options: caps:escape,compose:ralt\n")
|
||||
if k.Layout != "us" || k.Model != "pc105" || len(k.Options) != 2 {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{
|
||||
"setxkbmap -layout us,be -option -option caps:escape": {},
|
||||
"setxkbmap -query": {Stdout: fixture(t, "setxkbmap.txt")},
|
||||
}}
|
||||
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us,be", "options": []any{"caps:escape"}}); err != nil {
|
||||
t.Fatal(err, f.ran)
|
||||
}
|
||||
if _, err := byName(t, f.desk(true), "xorg_keyboard")(map[string]any{"layout": "us; xterm"}); err == nil {
|
||||
t.Fatal("a layout that is not an XKB name")
|
||||
}
|
||||
}
|
||||
|
||||
// xwdOf writes a w×h dump of 32-bit pixels in the given byte order, coloured by colourAt.
|
||||
func xwdOf(w, h int, lsb bool, colourAt func(x, y int) (r, g, b uint8)) []byte {
|
||||
var buf bytes.Buffer
|
||||
name := []byte("xwdump\x00\x00")
|
||||
header := []uint32{uint32(100 + len(name)), 7, 2, 24, uint32(w), uint32(h), 0, 0, 32, 0, 32, 32, uint32(w * 4), 4,
|
||||
0xff0000, 0x00ff00, 0x0000ff, 8, 256, 0, uint32(w), uint32(h), 0, 0, 0}
|
||||
if lsb {
|
||||
header[7] = 0
|
||||
} else {
|
||||
header[7] = 1
|
||||
}
|
||||
for _, v := range header {
|
||||
binary.Write(&buf, binary.BigEndian, v)
|
||||
}
|
||||
buf.Write(name)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
r, g, b := colourAt(x, y)
|
||||
p := uint32(r)<<16 | uint32(g)<<8 | uint32(b)
|
||||
if lsb {
|
||||
binary.Write(&buf, binary.LittleEndian, p)
|
||||
} else {
|
||||
binary.Write(&buf, binary.BigEndian, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestAWindowDumpBecomesThePictureItHolds(t *testing.T) {
|
||||
paint := func(x, y int) (uint8, uint8, uint8) { return uint8(x * 40), uint8(y * 60), 0xde }
|
||||
for _, lsb := range []bool{true, false} {
|
||||
img, err := DecodeXWD(xwdOf(5, 3, lsb, paint))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() != 5 || img.Bounds().Dy() != 3 {
|
||||
t.Fatalf("bounds %v", img.Bounds())
|
||||
}
|
||||
if got := img.RGBAAt(4, 2); got != (color.RGBA{160, 120, 0xde, 0xff}) {
|
||||
t.Fatalf("lsb=%v: %v", lsb, got)
|
||||
}
|
||||
}
|
||||
dump := xwdOf(2, 2, true, paint)
|
||||
if _, err := DecodeXWD(dump[:len(dump)-3]); err == nil {
|
||||
t.Fatal("a cut dump is refused")
|
||||
}
|
||||
binary.BigEndian.PutUint32(dump[4:], 6)
|
||||
if _, err := DecodeXWD(dump); err == nil {
|
||||
t.Fatal("another file version is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheServersLogAnswersItsErrorsAndNotTheLegend(t *testing.T) {
|
||||
text := "[ 16.118] \nX.Org X Server 1.21.1.24\n[ 16.118] Markers: (--) probed,\n" +
|
||||
"[ 16.118] \t(WW) warning, (EE) error, (NI) not implemented, (??) unknown.\n" +
|
||||
"[ 16.118] (==) Log file: \"/var/log/Xorg.1.log\", Time: Fri Oct 2 13:08:45 2026\n" +
|
||||
"[ 16.123] (WW) The directory \"/usr/share/fonts/misc\" does not exist.\n" +
|
||||
"[ 16.181] (EE) Failed to load module \"ati\" (module does not exist, 0)\n" +
|
||||
"[ 16.184] (EE) Failed to load module \"nouveau\" (module does not exist, 0)\n"
|
||||
got := ParseXLog("/var/log/Xorg.1.log", text, false, 1)
|
||||
if got.Server != "X.Org X Server 1.21.1.24" || got.Started != "Fri Oct 2 13:08:45 2026" || got.Errors != 2 || got.Warnings != 1 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if len(got.Lines) != 1 || !got.Cut || !strings.Contains(got.Lines[0].Text, "nouveau") || got.Lines[0].At != 16.184 {
|
||||
t.Fatalf("the newest error, cut to the limit: %+v", got.Lines)
|
||||
}
|
||||
if all := ParseXLog("", text, true, 0); len(all.Lines) != 3 {
|
||||
t.Fatalf("with warnings: %+v", all.Lines)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfilesAreReadWithTheMonitorsTheyAreFor(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, outs := ParseXrandr(fixture(t, "xrandr-prop.txt"))
|
||||
os.MkdirAll(filepath.Join(dir, "laptop"), 0o755)
|
||||
os.WriteFile(filepath.Join(dir, "laptop", "setup"), []byte("eDP-1 "+hex.EncodeToString(outs[0].edid)+"\n"), 0o644)
|
||||
os.MkdirAll(filepath.Join(dir, "empty"), 0o755) // no setup: not a profile
|
||||
got := ReadProfiles([]string{dir, filepath.Join(dir, "missing")})
|
||||
if len(got) != 1 || got[0].Name != "laptop" || got[0].Monitors["eDP-1"] != outs[0].Monitor.Fingerprint {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if !profileName.MatchString("home-office_2") || profileName.MatchString("../x") || profileName.MatchString("") {
|
||||
t.Fatal("profile names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolThatNeedsTheScreenSaysPlainlyThatThereIsNoSession(t *testing.T) {
|
||||
f := &fakeDesk{}
|
||||
d := f.desk(false)
|
||||
for _, call := range []struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}{
|
||||
{"node-display-server.displays", nil},
|
||||
{"node-display-server.layout", map[string]any{"action": "apply", "name": "home"}},
|
||||
{"xorg_set_mode", map[string]any{"output": "eDP-1", "mode": "auto"}},
|
||||
{"xorg_primary", nil}, {"xorg_dpi", nil}, {"xorg_input_devices", nil},
|
||||
{"xorg_input_set", map[string]any{"device": "16", "tap": true}}, {"xorg_keyboard", nil},
|
||||
{"xorg_screenshot", nil},
|
||||
} {
|
||||
_, err := byName(t, d, call.tool)(orEmpty(call.args))
|
||||
if !desktop.IsNoSession(err) {
|
||||
t.Errorf("%s: %v", call.tool, err)
|
||||
}
|
||||
}
|
||||
got, err := byName(t, d, "node-display-server.layout")(map[string]any{"action": "list"})
|
||||
if err != nil || !strings.Contains(asJSON(got), "no graphical session") {
|
||||
t.Fatalf("listing profiles needs no session, and says what it could not tell: %v %v", got, err)
|
||||
}
|
||||
if len(f.ran) != 0 {
|
||||
t.Fatalf("nothing ran without a session: %v", f.ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandRunsWithTheSessionsDisplay(t *testing.T) {
|
||||
f := &fakeDesk{answers: map[string]desktop.Result{"xrandr --prop": {Stdout: fixture(t, "xrandr-prop.txt")}}}
|
||||
if _, err := byName(t, f.desk(true), "node-display-server.displays")(map[string]any{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strings.Join(f.env[0], " ")
|
||||
if !strings.Contains(env, "DISPLAY=:1") || !strings.Contains(env, "XAUTHORITY=/home/op/.Xauthority") {
|
||||
t.Fatalf("%s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func orEmpty(m map[string]any) map[string]any {
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math/bits"
|
||||
)
|
||||
|
||||
// DecodeXWD reads an X window dump as `xwd` writes it (XWDFile.h, file version 7): the screenshot
|
||||
// tool's one dependency is xwd, from the X apps the display server already ships, and the PNG is made
|
||||
// here — no screenshot program, no image library.
|
||||
//
|
||||
// Only what an X server dumps in practice is read: a ZPixmap of 24 or 32 bits per pixel with a
|
||||
// TrueColor or DirectColor visual, in either byte order. Anything else is refused by name.
|
||||
func DecodeXWD(b []byte) (*image.RGBA, error) {
|
||||
const headerWords = 25
|
||||
if len(b) < headerWords*4 {
|
||||
return nil, errors.New("too short for an xwd header")
|
||||
}
|
||||
h := make([]uint32, headerWords)
|
||||
for i := range h {
|
||||
h[i] = binary.BigEndian.Uint32(b[i*4:])
|
||||
}
|
||||
headerSize, version, format := h[0], h[1], h[2]
|
||||
width, height := int(h[4]), int(h[5])
|
||||
byteOrder, bpp, bytesPerLine, class := h[7], int(h[11]), int(h[12]), h[13]
|
||||
red, green, blue := h[14], h[15], h[16]
|
||||
ncolors := int(h[19])
|
||||
switch {
|
||||
case version != 7:
|
||||
return nil, fmt.Errorf("xwd file version %d; 7 is read", version)
|
||||
case format != 2:
|
||||
return nil, fmt.Errorf("pixmap format %d; ZPixmap (2) is read", format)
|
||||
case bpp != 24 && bpp != 32:
|
||||
return nil, fmt.Errorf("%d bits per pixel; 24 and 32 are read", bpp)
|
||||
case class != 4 && class != 5:
|
||||
return nil, fmt.Errorf("visual class %d; TrueColor and DirectColor are read", class)
|
||||
case width <= 0 || height <= 0 || width > 32768 || height > 32768:
|
||||
return nil, fmt.Errorf("a %dx%d image", width, height)
|
||||
case bytesPerLine < width*bpp/8:
|
||||
return nil, fmt.Errorf("%d bytes per line for %d pixels", bytesPerLine, width)
|
||||
}
|
||||
start := int(headerSize) + ncolors*12
|
||||
if start < 0 || len(b) < start+bytesPerLine*height {
|
||||
return nil, fmt.Errorf("the image data is cut short: %d bytes, %d needed", len(b), start+bytesPerLine*height)
|
||||
}
|
||||
pixels := b[start:]
|
||||
step := bpp / 8
|
||||
channel := func(p, mask uint32) uint8 {
|
||||
if mask == 0 {
|
||||
return 0
|
||||
}
|
||||
shift := bits.TrailingZeros32(mask)
|
||||
width := bits.OnesCount32(mask)
|
||||
v := (p & mask) >> shift
|
||||
if width >= 8 {
|
||||
return uint8(v >> (width - 8))
|
||||
}
|
||||
return uint8(v << (8 - width))
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
for y := 0; y < height; y++ {
|
||||
row := pixels[y*bytesPerLine:]
|
||||
for x := 0; x < width; x++ {
|
||||
px := row[x*step : x*step+step]
|
||||
var p uint32
|
||||
if byteOrder == 0 { // LSBFirst
|
||||
for i := step - 1; i >= 0; i-- {
|
||||
p = p<<8 | uint32(px[i])
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < step; i++ {
|
||||
p = p<<8 | uint32(px[i])
|
||||
}
|
||||
}
|
||||
img.SetRGBA(x, y, color.RGBA{channel(p, red), channel(p, green), channel(p, blue), 0xff})
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module xorg
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"module": "xorg",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"seat"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"name": "x11-display",
|
||||
"reach": "machine"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-display-server",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"displays",
|
||||
"layout"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"xorg_set_mode",
|
||||
"xorg_primary",
|
||||
"xorg_dpi",
|
||||
"xorg_input_devices",
|
||||
"xorg_input_set",
|
||||
"xorg_keyboard",
|
||||
"xorg_screenshot",
|
||||
"xorg_x_log"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package-xorg-server",
|
||||
"type": "package",
|
||||
"package": "xorg-server"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xinit",
|
||||
"type": "package",
|
||||
"package": "xorg-xinit"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xrandr",
|
||||
"type": "package",
|
||||
"package": "xorg-xrandr"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xset",
|
||||
"type": "package",
|
||||
"package": "xorg-xset"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xrdb",
|
||||
"type": "package",
|
||||
"package": "xorg-xrdb"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xinput",
|
||||
"type": "package",
|
||||
"package": "xorg-xinput"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-setxkbmap",
|
||||
"type": "package",
|
||||
"package": "xorg-setxkbmap"
|
||||
},
|
||||
{
|
||||
"id": "package-xorg-xwd",
|
||||
"type": "package",
|
||||
"package": "xorg-xwd"
|
||||
},
|
||||
{
|
||||
"id": "package-autorandr",
|
||||
"type": "package",
|
||||
"package": "autorandr"
|
||||
},
|
||||
{
|
||||
"id": "config",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/xorg",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "xresources",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/xorg/xresources",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "! The mesh's X resources (module xorg, novox/hq ADR 0208 §4), replaced at every push. The session's\n! start merges them with xrdb -nocpp before ~/.Xresources, so your own resources there win. Without\n! the preprocessor there is no #include or #define here, and a line starting with # is skipped,\n! which is what the line naming each contributing module is.\n!\n! Font rendering, as both workstations had it. 96 DPI becomes a setting with issue 168; xorg_dpi\n! changes it for the running session.\nXft.dpi: 96\nXft.antialias: 1\nXft.hinting: 1\nXft.hintstyle: hintslight\nXft.rgba: rgb\n${shell:xresources:first}${shell:xresources:normal}${shell:xresources:last}\n"
|
||||
},
|
||||
{
|
||||
"id": "xinitrc",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.xinitrc",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The mesh's block (module xorg, novox/hq ADR 0208 §5): the graphical session's start, replaced at\n# every push. It ends by starting the session, so no line below it in this file runs any more. A line\n# of your own that must run at every session start goes in ~/.xinitrc.local, which this block sources\n# just before the session starts (the module's README lists where each of today's lines went).\n\n# 1. The account's environment (ADR 0203): the file every shell sources, written by node-environment.\nif [ -r \"${machine:account-home}/.config/mesh/environment.sh\" ]; then\n . \"${machine:account-home}/.config/mesh/environment.sh\"\nfi\n\n# The session bus is the user manager's. pam_systemd normally hands it over; when it did not, take\n# the canonical socket rather than starting a second bus, which runs every D-Bus-activated service\n# twice (two notifiers, two portals) and hides one copy from the other.\nif [ -z \"$DBUS_SESSION_BUS_ADDRESS\" ] && [ -S \"/run/user/$(id -u)/bus\" ]; then\n DBUS_SESSION_BUS_ADDRESS=\"unix:path=/run/user/$(id -u)/bus\"\n export DBUS_SESSION_BUS_ADDRESS\nfi\n\n# 2. The session's own words into the user manager and D-Bus activation, so the portal, the notifier\n# and every user service see the display and the desktop's identity. An explicit list and never\n# --all: a session's environment may hold things no user service should be able to read back.\nmesh_words=\nfor mesh_word in DISPLAY XAUTHORITY XDG_SESSION_TYPE XDG_CURRENT_DESKTOP XDG_SESSION_DESKTOP \\\n XDG_CONFIG_HOME XDG_DATA_DIRS GTK_THEME GTK2_RC_FILES QT_QPA_PLATFORMTHEME QT_STYLE_OVERRIDE \\\n QT_SELECT XCURSOR_THEME XCURSOR_SIZE TERMINAL; do\n if printenv \"$mesh_word\" >/dev/null 2>&1; then\n mesh_words=\"$mesh_words $mesh_word\"\n fi\ndone\ndbus-update-activation-environment --systemd $mesh_words\nunset mesh_word mesh_words\n\n# 3. X resources: the mesh's, then yours in ~/.Xresources, which win. The mesh's file is merged\n# without the C preprocessor, so no compiler is needed for it.\nxrdb -nocpp -merge \"${machine:account-home}/.config/xorg/xresources\"\nif [ -r \"$HOME/.Xresources\" ]; then\n xrdb -merge \"$HOME/.Xresources\"\nfi\n\n# 4. The monitors: the saved layout profile that matches the monitors connected, if one does.\nif command -v autorandr >/dev/null 2>&1; then\n autorandr --change >/dev/null 2>&1 || true\nfi\n\n# 5. Other modules' session lines (ADR 0208 §4), then yours.\n${shell:xinitrc:first}${shell:xinitrc:normal}if [ -r \"$HOME/.xinitrc.local\" ]; then\n . \"$HOME/.xinitrc.local\"\nfi\n\n# 6. The session itself: the holder of node-display-session starts it from the last slot.\n${shell:xinitrc:last}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/xorg",
|
||||
"binary": "xorg",
|
||||
"loads": [
|
||||
"xorg"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# xterm
|
||||
|
||||
The terminal as a module (novox/hq ADR 0208, research 026, to-be 42 phase 2, step 5).
|
||||
|
||||
- **Claims `node-terminal-emulator`** and serves its verb `open`.
|
||||
- **Requires `x11-display`.** In a Wayland session, `xwayland` would provide it.
|
||||
- **Package `xterm`.**
|
||||
- **Environment** (ADR 0203): `TERMINAL=xterm`. i3's `$mod+Return` runs `i3-sensible-terminal`, which
|
||||
starts whatever `$TERMINAL` names, so the window manager no longer names the terminal.
|
||||
- **X resources**, contributed to the `xresources` slot `normal` and placed by `xorg` in its resources
|
||||
file. It owns no file of its own.
|
||||
|
||||
## The resources
|
||||
|
||||
They are those both workstations ran, with three changes:
|
||||
|
||||
- **face:** `JetBrainsMono Nerd Font` 12, replacing Hack (research 026/04);
|
||||
- **scope:** every resource is under `XTerm*`. Today they were `*faceName`, `*background` and so on,
|
||||
which reached every Xt program. `XTerm*` also outranks a `*name` resource of yours, so the old
|
||||
`~/.Xresources.d/xterm` cannot win over them while it is still merged;
|
||||
- **scrollback:** `saveLines` 10000 instead of 2000.
|
||||
|
||||
The palette (background, foreground and the sixteen colours) is today's, unchanged. So are the
|
||||
clipboard keys: Ctrl+Shift+C copies to the clipboard and Ctrl+Shift+V pastes from it. The bundle's
|
||||
`Resources()` is the one source of these values. The manifest test holds the manifest to it, and
|
||||
`xterm_colours` compares against it.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `node-terminal-emulator.open` | d | a terminal in the session: the login shell, or a command run through it (`$SHELL -lc`), in a directory (the home by default), with an optional title, and `hold` to keep it open. It starts as a transient unit of the account's service manager (`systemd-run --user`), so it outlives the call and every runtime restart |
|
||||
| `xterm_font` | r/a | the face and size in force and the installed font they resolve to (`fc-match`); set face or size for terminals opened from now on. Only installed faces are accepted |
|
||||
| `xterm_colours` | r | background, foreground, cursor and the sixteen colours as `#rrggbb`, each beside the module's default |
|
||||
|
||||
## What it leaves found
|
||||
|
||||
`~/.Xresources.d/xterm`, and the `#include` of it in `~/.Xresources`.
|
||||
|
||||
## The one-off migration
|
||||
|
||||
**Delete `~/.Xresources.d/xterm` and its `#include` line in `~/.Xresources`** once `xterm` is
|
||||
assigned (see `xorg`'s README for the rest of that file). Until then they are merged after the
|
||||
mesh's. Being `*`-scoped, they lose to this module's `XTerm*` values, and still reach every other Xt
|
||||
program.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none (`xterm` present) | the same |
|
||||
| `xorg`'s resources file | gains this module's resources | the same |
|
||||
| environment | gains `TERMINAL=xterm` | the same |
|
||||
| running terminals | **nothing**. The resources are merged at the next login | the same |
|
||||
| next login | new terminals in JetBrains Mono, with 10000 lines of scrollback | the same. The desktop has a hand-copied build of the face until `fonts` installs the package |
|
||||
|
||||
## Blockers
|
||||
|
||||
None. It needs `xorg` assigned for `x11-display`, and `fonts` for the face. Without `fonts`,
|
||||
fontconfig picks the nearest installed face, and `xterm_font` says which one in `resolves_to`.
|
||||
@@ -0,0 +1,68 @@
|
||||
// xterm's tools (novox/hq ADR 0208, research 026/05): node-terminal-emulator's verb `open`, and the
|
||||
// module's own `font` and `colours`.
|
||||
//
|
||||
// A terminal opened by `open` is handed to the account's own service manager (internal/desktop's
|
||||
// Launch), never left a child of the runtime, which the service manager empties at every restart.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(xterm{d: desktop.Machine("i3", "sway")})); 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(x xterm) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "node-terminal-emulator.open",
|
||||
Description: "Open a terminal window in the operator's session: the account's login shell, or a command " +
|
||||
"run through it, in a directory (the home when none is named). hold keeps the window open after " +
|
||||
"the command ends. Answers the unit it runs as.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"command": desktop.Str("what to run in it (optional; the login shell when absent)"),
|
||||
"directory": desktop.Str("where it starts (optional; the account's home)"),
|
||||
"title": desktop.Str("the window's title (optional)"),
|
||||
"hold": desktop.Flag("keep the window open after the command ends (default false)"),
|
||||
}),
|
||||
Run: call(x.open),
|
||||
},
|
||||
{
|
||||
Name: "xterm_font",
|
||||
Description: "The face and size xterm uses, and the installed font that face resolves to. With face " +
|
||||
"and/or size, set them for terminals opened from now on (an installed face only); the module's " +
|
||||
"resources set the default again at the next login.",
|
||||
Input: desktop.Schema(map[string]any{
|
||||
"face": desktop.Str("a font family, as fc-list names it"),
|
||||
"size": desktop.Num("points, 6 to 48"),
|
||||
}),
|
||||
Run: call(x.font),
|
||||
},
|
||||
{
|
||||
Name: "xterm_colours",
|
||||
Description: "xterm's colours in force: background, foreground, cursor and the sixteen palette entries, " +
|
||||
"as #rrggbb, each beside the module's default and whether it differs.",
|
||||
Input: desktop.Schema(map[string]any{}),
|
||||
Run: call(x.colours),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type xterm struct{ d desktop.Desk }
|
||||
|
||||
// The module's defaults: the face research 026/04 chose and both workstations' palette.
|
||||
const (
|
||||
Face = "JetBrainsMono Nerd Font"
|
||||
Size = 12
|
||||
)
|
||||
|
||||
// Palette is the colours both workstations ran, by resource name, in the order they are written.
|
||||
var Palette = [][2]string{
|
||||
{"background", "#000000"}, {"foreground", "#a8a8a8"},
|
||||
{"color0", "#000000"}, {"color8", "#666666"},
|
||||
{"color1", "#9e1828"}, {"color9", "#bc5766"},
|
||||
{"color2", "#008800"}, {"color10", "#61a171"},
|
||||
{"color3", "#d2bb4b"}, {"color11", "#e7db52"},
|
||||
{"color4", "#414171"}, {"color12", "#5085af"},
|
||||
{"color5", "#963c59"}, {"color13", "#a97a99"},
|
||||
{"color6", "#418179"}, {"color14", "#6ba4a4"},
|
||||
{"color7", "#bebebe"}, {"color15", "#ffffff"},
|
||||
}
|
||||
|
||||
// Resources is the module's contribution to the X resources (the xresources slot `normal`), as the
|
||||
// manifest carries it; the manifest test holds the two to one text.
|
||||
func Resources() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n")
|
||||
b.WriteString("! outrank any `*name` resource of yours and touch no other X program.\n")
|
||||
for _, kv := range [][2]string{
|
||||
{"termName", "xterm-256color"}, {"saveLines", "10000"}, {"renderFont", "true"},
|
||||
{"faceName", Face}, {"faceSize", strconv.Itoa(Size)},
|
||||
{"boldMode", "false"}, {"alwaysBoldMode", "false"}, {"allowBoldFonts", "true"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
for _, kv := range Palette {
|
||||
fmt.Fprintf(&b, "XTerm*%s: %s\n", kv[0], kv[1])
|
||||
}
|
||||
b.WriteString("XTerm*VT100.Translations: #override \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n")
|
||||
b.WriteString(" Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// openScript runs in the new terminal: into the directory, then the login shell, or a command
|
||||
// through it so the account's environment and aliases are there.
|
||||
const openScript = `cd -- "$1" || exit 1
|
||||
if [ -n "$2" ]; then exec "${SHELL:-/bin/sh}" -lc "$2"; fi
|
||||
exec "${SHELL:-/bin/sh}" -l`
|
||||
|
||||
// OpenArgs is the xterm command line open starts.
|
||||
func OpenArgs(dir, command, title string, hold bool) []string {
|
||||
args := []string{"xterm"}
|
||||
if title != "" {
|
||||
args = append(args, "-title", title)
|
||||
}
|
||||
if hold {
|
||||
args = append(args, "-hold")
|
||||
}
|
||||
return append(args, "-e", "/bin/sh", "-c", openScript, "sh", dir, command)
|
||||
}
|
||||
|
||||
func (x xterm) open(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := a.Opt("directory", desktop.Home())
|
||||
if strings.HasPrefix(dir, "~") {
|
||||
if dir, err = desktop.InHome(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||
return nil, fmt.Errorf("%s is not a directory here", dir)
|
||||
}
|
||||
hold, _, err := a.Bool("hold")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
argv := OpenArgs(dir, a.Opt("command", ""), a.Opt("title", ""), hold)
|
||||
launched, err := x.d.Launch(ctx, s, "xterm", argv...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"session": s.Display, "directory": dir, "command": a.Opt("command", "(the login shell)"), "launched": launched}, nil
|
||||
}
|
||||
|
||||
// Effective is a resource's value as an XTerm sees it in `xrdb -query`: its XTerm*-scoped value, else
|
||||
// the global *-scoped one.
|
||||
func Effective(query, name string) string {
|
||||
var global string
|
||||
for _, l := range strings.Split(query, "\n") {
|
||||
k, v, ok := strings.Cut(l, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "XTerm*" + name, "XTerm." + name, "XTerm*vt100." + name, "XTerm*VT100." + name:
|
||||
return strings.TrimSpace(v)
|
||||
case "*" + name:
|
||||
global = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return global
|
||||
}
|
||||
|
||||
var faceName = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,64}$`)
|
||||
|
||||
func (x xterm) font(ctx context.Context, a desktop.Args) (any, error) {
|
||||
s, err := x.d.Find()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
env := s.Env(x.d.Base)
|
||||
face := a.Opt("face", "")
|
||||
size, sized, err := a.Number("size")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if face != "" || sized {
|
||||
var res []string
|
||||
if face != "" {
|
||||
if !faceName.MatchString(face) {
|
||||
return nil, fmt.Errorf("a face is a family name, as fc-list names it")
|
||||
}
|
||||
if fam := x.d.Run(ctx, env, nil, "fc-list", face, "family"); strings.TrimSpace(fam.Stdout) == "" {
|
||||
return nil, fmt.Errorf("no installed font is %q: the fonts module installs the mesh's faces", face)
|
||||
}
|
||||
res = append(res, "XTerm*faceName: "+face)
|
||||
}
|
||||
if sized {
|
||||
if size < 6 || size > 48 {
|
||||
return nil, fmt.Errorf("size is from 6 to 48 points")
|
||||
}
|
||||
res = append(res, "XTerm*faceSize: "+strconv.FormatFloat(size, 'f', -1, 64))
|
||||
}
|
||||
if r := x.d.Run(ctx, env, []byte(strings.Join(res, "\n")+"\n"), "xrdb", "-nocpp", "-merge", "-"); !r.OK() {
|
||||
return nil, r.Err()
|
||||
}
|
||||
}
|
||||
q := x.d.Run(ctx, env, nil, "xrdb", "-query")
|
||||
if !q.OK() {
|
||||
return nil, q.Err()
|
||||
}
|
||||
inForce := Effective(q.Stdout, "faceName")
|
||||
answer := map[string]any{
|
||||
"session": s.Display, "face": inForce, "size": Effective(q.Stdout, "faceSize"),
|
||||
"default": map[string]any{"face": Face, "size": Size},
|
||||
}
|
||||
if inForce != "" {
|
||||
m := x.d.Run(ctx, env, nil, "fc-match", "-f", "%{family[0]}|%{style[0]}|%{file}", inForce)
|
||||
if parts := strings.SplitN(m.Stdout, "|", 3); m.OK() && len(parts) == 3 {
|
||||
answer["resolves_to"] = map[string]any{"family": parts[0], "style": parts[1], "file": parts[2],
|
||||
"exact": strings.EqualFold(parts[0], inForce)}
|
||||
}
|
||||
}
|
||||
if face != "" || sized {
|
||||
answer["lasts"] = "for terminals opened from now on, until the next login"
|
||||
}
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
var rgbSpec = regexp.MustCompile(`^rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})$`)
|
||||
|
||||
// Hex writes an X colour (`rgb:9e/18/28`, `#9e1828`) as #rrggbb; anything else (a colour name) as it is.
|
||||
func Hex(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if m := rgbSpec.FindStringSubmatch(v); m != nil {
|
||||
out := "#"
|
||||
for _, c := range m[1:] {
|
||||
n, _ := strconv.ParseUint(c, 16, 32)
|
||||
max := uint64(1)<<(4*len(c)) - 1
|
||||
out += fmt.Sprintf("%02x", (n*255+max/2)/max)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return strings.ToLower(v)
|
||||
}
|
||||
|
||||
func (x xterm) colours(ctx context.Context, a desktop.Args) (any, error) {
|
||||
res, s, err := x.d.InSession(ctx, "xrdb", "-query")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.OK() {
|
||||
return nil, res.Err()
|
||||
}
|
||||
type colour struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Default string `json:"default"`
|
||||
Differs bool `json:"differs,omitempty"`
|
||||
}
|
||||
var out []colour
|
||||
for _, kv := range Palette {
|
||||
v := Hex(Effective(res.Stdout, kv[0]))
|
||||
out = append(out, colour{kv[0], v, kv[1], v != kv[1]})
|
||||
}
|
||||
cursor := Hex(Effective(res.Stdout, "cursorColor"))
|
||||
return map[string]any{"session": s.Display, "colours": out, "cursor": cursor}, nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"xterm/internal/desktop"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `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"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
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 TestItHoldsTheTerminalSeatOnAnXDisplayAndNamesItselfTheTerminal(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "node-terminal-emulator" || strings.Join(m.Claims[0].Serves, ",") != "open" {
|
||||
t.Fatalf("%+v", m.Claims)
|
||||
}
|
||||
if strings.Join(m.Requires, ",") != "x11-display" || m.Environment.Variables["TERMINAL"] != "xterm" || len(m.Environment.Variables) != 1 {
|
||||
t.Fatalf("%v %v", m.Requires, m.Environment)
|
||||
}
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools(xterm{}) {
|
||||
served[tool.Name] = true
|
||||
}
|
||||
if !served["node-terminal-emulator.open"] || len(served) != len(m.Tools)+1 {
|
||||
t.Fatalf("%v %v", served, m.Tools)
|
||||
}
|
||||
for _, n := range m.Tools {
|
||||
if !served[n] {
|
||||
t.Errorf("%s", n)
|
||||
}
|
||||
}
|
||||
if len(m.Resources) != 1 || m.Resources[0]["package"] != "xterm" {
|
||||
t.Fatalf("%v", m.Resources)
|
||||
}
|
||||
if a := m.Build.Artifacts[0]; a["binary"] != "xterm-tools" || a["from"] != "cmd/xterm-tools" {
|
||||
t.Fatalf("a binary not named xterm, so nothing looking for xterm by name finds the tools: %v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItsResourcesAreOneContributionInTheNormalSlotScopedToXTerm(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xresources" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
if m.Shell[0].Code != Resources() {
|
||||
t.Fatal("the manifest's resources are not the ones the bundle describes: regenerate the manifest from Resources()")
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSpace(Resources()), "\n") {
|
||||
switch {
|
||||
case strings.HasPrefix(l, "!"), strings.HasPrefix(l, " "):
|
||||
case strings.HasPrefix(l, "#"):
|
||||
t.Errorf("merged with -nocpp, a # line is skipped: %q", l)
|
||||
case !strings.HasPrefix(l, "XTerm*"):
|
||||
t.Errorf("a resource of another program, or of every one: %q", l)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(Resources(), "XTerm*faceName: JetBrainsMono Nerd Font") || !strings.Contains(Resources(), "copy-selection(CLIPBOARD)") {
|
||||
t.Fatal("the chosen face and the clipboard keys")
|
||||
}
|
||||
}
|
||||
|
||||
type fake struct {
|
||||
ran []string
|
||||
stdin []string
|
||||
query string
|
||||
}
|
||||
|
||||
func (f *fake) desk(session bool) desktop.Desk {
|
||||
return desktop.Desk{
|
||||
Find: func() (*desktop.Session, error) {
|
||||
if !session {
|
||||
return nil, &desktop.NoSession{Reason: "none"}
|
||||
}
|
||||
return &desktop.Session{Display: ":1", XAuthority: "/h/.Xauthority", Type: "x11", RuntimeDir: "/run/user/1000", Bus: "unix:path=/run/user/1000/bus"}, nil
|
||||
},
|
||||
Run: func(_ context.Context, _ []string, stdin []byte, name string, args ...string) desktop.Result {
|
||||
f.ran = append(f.ran, strings.Join(append([]string{name}, args...), " "))
|
||||
f.stdin = append(f.stdin, string(stdin))
|
||||
switch name {
|
||||
case "xrdb":
|
||||
return desktop.Result{Stdout: f.query}
|
||||
case "fc-list":
|
||||
if strings.Contains(args[0], "Missing") {
|
||||
return desktop.Result{}
|
||||
}
|
||||
return desktop.Result{Stdout: args[0] + "\n"}
|
||||
case "fc-match":
|
||||
return desktop.Result{Stdout: "JetBrainsMono Nerd Font|Regular|/usr/share/fonts/TTF/J.ttf"}
|
||||
}
|
||||
return desktop.Result{}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenHandsTheTerminalToTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
f := &fake{}
|
||||
dir := t.TempDir()
|
||||
got, err := xterm{d: f.desk(true)}.open(context.Background(), desktop.Args{"directory": dir, "command": "htop", "hold": true, "title": "top"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.ran) != 1 || !strings.HasPrefix(f.ran[0], "systemd-run --user --collect --quiet --unit=mesh-xterm-") {
|
||||
t.Fatalf("%q", f.ran)
|
||||
}
|
||||
for _, want := range []string{"--setenv=DISPLAY=:1", "--setenv=XAUTHORITY=/h/.Xauthority", "-- xterm -title top -hold -e /bin/sh -c", "sh " + dir + " htop"} {
|
||||
if !strings.Contains(f.ran[0], want) {
|
||||
t.Errorf("lacks %q: %s", want, f.ran[0])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(asJSON(got), `"unit":"mesh-xterm-`) {
|
||||
t.Fatalf("%s", asJSON(got))
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).open(context.Background(), desktop.Args{"directory": "/no/such/dir"}); err == nil {
|
||||
t.Fatal("a directory that is not there")
|
||||
}
|
||||
if _, err := (xterm{d: (&fake{}).desk(false)}).open(context.Background(), desktop.Args{}); !desktop.IsNoSession(err) {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTerminalsScriptStartsInTheDirectoryAndRunsTheCommandThroughTheShell(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
args := OpenArgs(dir, "pwd > out", "", false)
|
||||
sh := args[len(args)-6:] // /bin/sh -c script sh dir command
|
||||
cmd := exec.Command(sh[0], sh[1:]...)
|
||||
cmd.Env = []string{"SHELL=/bin/sh", "PATH=/usr/bin:/bin", "HOME=" + dir}
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v %s", err, out)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "out"))
|
||||
if strings.TrimSpace(string(b)) != dir {
|
||||
t.Fatalf("%q", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFontAndColoursAreReadAsAnXTermSeesThem(t *testing.T) {
|
||||
q := "*faceName:\tHack Nerd Font\nXTerm*faceName:\tJetBrainsMono Nerd Font\nXTerm*faceSize:\t12\n" +
|
||||
"XTerm*background:\t#000000\n*foreground:\trgb:a8/a8/a8\nXTerm*color1:\trgb:ff/00/00\n"
|
||||
if Effective(q, "faceName") != "JetBrainsMono Nerd Font" || Effective(q, "foreground") != "rgb:a8/a8/a8" {
|
||||
t.Fatal("XTerm's own value outranks the global one; the global one stands alone")
|
||||
}
|
||||
for in, want := range map[string]string{"rgb:9e/18/28": "#9e1828", "rgb:f/0/8": "#ff0088", "rgb:ffff/0000/8080": "#ff0080", "#ABCDEF": "#abcdef", "red": "red"} {
|
||||
if got := Hex(in); got != want {
|
||||
t.Errorf("%s: %s", in, got)
|
||||
}
|
||||
}
|
||||
f := &fake{query: q}
|
||||
got, err := xterm{d: f.desk(true)}.colours(context.Background(), desktop.Args{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
j := asJSON(got)
|
||||
if !strings.Contains(j, `{"name":"color1","value":"#ff0000","default":"#9e1828","differs":true}`) || !strings.Contains(j, `{"name":"foreground","value":"#a8a8a8","default":"#a8a8a8"}`) {
|
||||
t.Fatalf("%s", j)
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"face": "Missing Font"}); err == nil {
|
||||
t.Fatal("a face that is not installed")
|
||||
}
|
||||
if _, err := (xterm{d: f.desk(true)}).font(context.Background(), desktop.Args{"size": 99.0}); err == nil {
|
||||
t.Fatal("size out of range")
|
||||
}
|
||||
f.ran, f.stdin = nil, nil
|
||||
got, err = xterm{d: f.desk(true)}.font(context.Background(), desktop.Args{"face": "Inter", "size": 13.0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.stdin[1] != "XTerm*faceName: Inter\nXTerm*faceSize: 13\n" || !strings.Contains(asJSON(got), `"lasts"`) {
|
||||
t.Fatalf("%q %s", f.stdin, asJSON(got))
|
||||
}
|
||||
}
|
||||
|
||||
func asJSON(v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module xterm
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -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=
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/<pid>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"module": "xterm",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-terminal-emulator",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"open"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"xterm_font",
|
||||
"xterm_colours"
|
||||
],
|
||||
"environment": {
|
||||
"variables": {
|
||||
"TERMINAL": "xterm"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"for": "xresources",
|
||||
"slot": "normal",
|
||||
"code": "! xterm: the face, the palette and the clipboard keys. Scoped to the XTerm class, so they\n! outrank any `*name` resource of yours and touch no other X program.\nXTerm*termName: xterm-256color\nXTerm*saveLines: 10000\nXTerm*renderFont: true\nXTerm*faceName: JetBrainsMono Nerd Font\nXTerm*faceSize: 12\nXTerm*boldMode: false\nXTerm*alwaysBoldMode: false\nXTerm*allowBoldFonts: true\nXTerm*background: #000000\nXTerm*foreground: #a8a8a8\nXTerm*color0: #000000\nXTerm*color8: #666666\nXTerm*color1: #9e1828\nXTerm*color9: #bc5766\nXTerm*color2: #008800\nXTerm*color10: #61a171\nXTerm*color3: #d2bb4b\nXTerm*color11: #e7db52\nXTerm*color4: #414171\nXTerm*color12: #5085af\nXTerm*color5: #963c59\nXTerm*color13: #a97a99\nXTerm*color6: #418179\nXTerm*color14: #6ba4a4\nXTerm*color7: #bebebe\nXTerm*color15: #ffffff\nXTerm*VT100.Translations: #override \\n\\\n Ctrl Shift ~Alt <Key> C: copy-selection(CLIPBOARD) \\n\\\n Ctrl Shift ~Alt <Key> V: insert-selection(CLIPBOARD)\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "xterm"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/xterm-tools",
|
||||
"binary": "xterm-tools",
|
||||
"loads": [
|
||||
"xterm-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user