From 04894e6618a24af62b921cf81ee34673f32fd545 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:46:44 +0200 Subject: [PATCH] picom: the compositor as a module, claiming node-compositor (hq ADR 0208) Owns its configuration, moved to picom's window rules; started once from the session's xinitrc slot; Go tools restart, rules, window-opacity and toggle, which find the operator's X session from the window manager's environment. --- modules/picom/README.md | 62 +++ modules/picom/cmd/picom-tools/args.go | 97 ++++ modules/picom/cmd/picom-tools/main.go | 89 ++++ .../cmd/picom-tools/manifest_helpers_test.go | 175 ++++++++ .../picom/cmd/picom-tools/manifest_test.go | 61 +++ modules/picom/cmd/picom-tools/picom.go | 293 ++++++++++++ modules/picom/cmd/picom-tools/picom_test.go | 131 ++++++ modules/picom/cmd/picom-tools/session.go | 423 ++++++++++++++++++ modules/picom/cmd/picom-tools/session_test.go | 174 +++++++ modules/picom/files/picom.conf | 32 ++ modules/picom/go.mod | 5 + modules/picom/go.sum | 2 + modules/picom/module.json | 64 +++ 13 files changed, 1608 insertions(+) create mode 100644 modules/picom/README.md create mode 100644 modules/picom/cmd/picom-tools/args.go create mode 100644 modules/picom/cmd/picom-tools/main.go create mode 100644 modules/picom/cmd/picom-tools/manifest_helpers_test.go create mode 100644 modules/picom/cmd/picom-tools/manifest_test.go create mode 100644 modules/picom/cmd/picom-tools/picom.go create mode 100644 modules/picom/cmd/picom-tools/picom_test.go create mode 100644 modules/picom/cmd/picom-tools/session.go create mode 100644 modules/picom/cmd/picom-tools/session_test.go create mode 100644 modules/picom/files/picom.conf create mode 100644 modules/picom/go.mod create mode 100644 modules/picom/go.sum create mode 100644 modules/picom/module.json diff --git a/modules/picom/README.md b/modules/picom/README.md new file mode 100644 index 0000000..827215f --- /dev/null +++ b/modules/picom/README.md @@ -0,0 +1,62 @@ +# picom + +The X compositor as a module (novox/hq ADR 0208, research 026). + +- Installs `picom`, and `xorg-xprop` for setting a window's own opacity. +- Claims the mesh's `node-compositor` seat (no verbs yet, ADR 0208 §2) and requires `x11-display`. + That requirement has the machine's reach: the display server must be held on this module's own + machine, or assignment is refused, naming the seat's holders. +- Owns `~/.config/picom/` and `~/.config/picom/picom.conf`, which it writes whole at every push. +- **Adds no start of its own.** picom's package ships an XDG autostart entry + (`/etc/xdg/autostart/picom.desktop`), which the session runs: the `i3` module's `dex --autostart`. + That is the tool's own grain (ADR 0208 §4) and its one start. The desktop modules follow one rule: + a process has one starter. Its package's autostart entry is that starter where there is one, and + the `xinitrc` slot where there is none. + +## Tools + +Served by the node's runtime as the operator account (ADR 0175). The tools find the operator's X +session from the window manager's own environment, and say so plainly when nobody is logged in. + +| tool | does | +|---|---| +| `picom_restart` | a running picom re-reads its file (`SIGUSR1`); `hard`, or none running, starts a fresh one | +| `picom_rules` | the global options and the window rules in force, in picom's order, and whether it runs | +| `picom_window_opacity` | read or set one window's own opacity, the focused window by default | +| `picom_toggle` | compositing off or on, for a game or a test; the next login starts it again | + +A picom a tool starts runs under the account's own service manager (`systemd-run --user`, unit +`picom`). As a child of the runtime it would die whenever the runtime restarts. + +## What it improves on what was found + +- **Window rules** replace `opacity-rule`, `inactive-opacity` and `inactive-dim`, which picom 12 and + later supersede. The behaviour is the same: only terminals are translucent (95 % focused, 75 % + unfocused). The browser and video "pin to 100 %" rule went, because nothing else is made + translucent any more. +- **Full-screen windows are opaque,** a terminal included. +- **One start.** Today both the window manager's configuration and the autostart entry start picom, + and the second one exits. +- On the desktop, picom was not running at all on the day it was measured, although both starts were + in place. It probably fails to start on that machine's GPU. `picom_restart` with `hard` after + assignment answers with what happened. The next step is the `picom` user unit's journal. + +## What it leaves as found + +The window manager's own `exec --no-startup-id picom` line belongs to the `i3` module's configuration, +which no longer carries it. Nothing else of picom's lies outside the directory this module owns. + +## Migration (ADR 0182) + +- The first push keeps the found `picom.conf` once, then writes the module's. +- Until the `i3` module replaces the hand-kept `~/.config/i3/config`, its `exec picom` line starts a + second picom. The second one exits at once, because a compositor is already running. Nothing to + do, unless the line is still there after `i3` is assigned. + +## Blockers + +- The seat `node-compositor` and the provision `x11-display` are ADR 0208's. + Until the controller knows them, `mctl` reads the claim and the requirement as unknown, and refuses + the module. +- `xorg-xprop` is declared here because the `xorg` module does not install it. If `xorg` comes to + declare it, it leaves this module, since a package is declared once per node. diff --git a/modules/picom/cmd/picom-tools/args.go b/modules/picom/cmd/picom-tools/args.go new file mode 100644 index 0000000..9b5dfcf --- /dev/null +++ b/modules/picom/cmd/picom-tools/args.go @@ -0,0 +1,97 @@ +// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default. +// The same in every desktop module that carries it. +package main + +import ( + "fmt" + "math" + "strings" + "time" +) + +// text is a string argument, trimmed; required says an empty one is refused. +func text(args map[string]any, key string, required bool) (string, error) { + v, present := args[key] + if !present || v == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := v.(string) + if !ok { + return "", fmt.Errorf("%s is a string, not %T", key, v) + } + s = strings.TrimSpace(s) + if s == "" && required { + return "", fmt.Errorf("%s is required", key) + } + return s, nil +} + +// whole is a whole-number argument within [least, most], or def when absent. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + f, ok := v.(float64) + if !ok { + if i, isInt := v.(int); isInt { + f = float64(i) + } else { + return 0, fmt.Errorf("%s is a number, not %T", key, v) + } + } + if f != math.Trunc(f) { + return 0, fmt.Errorf("%s is a whole number, not %v", key, f) + } + n := int(f) + if n < least || n > most { + return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most) + } + return n, nil +} + +// flag is a boolean argument, or def when absent. +func flag(args map[string]any, key string, def bool) (bool, error) { + v, present := args[key] + if !present || v == nil { + return def, nil + } + b, ok := v.(bool) + if !ok { + return false, fmt.Errorf("%s is true or false, not %T", key, v) + } + return b, nil +} + +// texts is a list-of-strings argument. +func texts(args map[string]any, key string) ([]string, error) { + v, present := args[key] + if !present || v == nil { + return nil, nil + } + list, ok := v.([]any) + if !ok { + if ss, isStrings := v.([]string); isStrings { + return ss, nil + } + return nil, fmt.Errorf("%s is a list of strings, not %T", key, v) + } + out := make([]string, 0, len(list)) + for i, item := range list { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item) + } + out = append(out, s) + } + return out, nil +} + +// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit. +func seconds(args map[string]any, key string, def, most int) (time.Duration, error) { + n, err := whole(args, key, def, 1, most) + return time.Duration(n) * time.Second, err +} diff --git a/modules/picom/cmd/picom-tools/main.go b/modules/picom/cmd/picom-tools/main.go new file mode 100644 index 0000000..379cd56 --- /dev/null +++ b/modules/picom/cmd/picom-tools/main.go @@ -0,0 +1,89 @@ +// picom's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the compositor's own tools, served +// by the node's runtime as the operator account. node-compositor has no verbs yet (ADR 0208 §2), so +// every tool here is the module's own. +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "picom_restart", + Description: "Make the compositor re-read its configuration: a running picom is told to reinitialise " + + "(SIGUSR1); with hard, or when none runs, a fresh one is started in the operator's session under " + + "their service manager. Answers what was done and the pids.", + Input: map[string]any{ + "hard": map[string]any{"type": "boolean", "description": "end the running picom and start a new one (default false)"}, + }, + Run: func(args map[string]any) (any, error) { + hard, err := flag(args, "hard", false) + if err != nil { + return nil, err + } + return Restart(hard) + }, + }, + { + Name: "picom_rules", + Description: "The compositor's configuration in force: its global options and each window rule " + + "(match, opacity, shadow, fade…) in the order picom applies them, from the file the mesh placed, " + + "and whether picom is running.", + Run: func(map[string]any) (any, error) { return Rules(configPath()) }, + }, + { + Name: "picom_window_opacity", + Description: "Read or set one window's own opacity (_NET_WM_WINDOW_OPACITY), the focused window " + + "unless one is named. A window rule that sets opacity (terminals) outranks it. Lasts as long as " + + "the window; the declared rules are untouched.", + Input: map[string]any{ + "window": map[string]any{"type": "string", "description": "X window id, 0x… or decimal (default: the focused window)"}, + "opacity": map[string]any{"type": "integer", "description": "0-100 to set; 100 removes the window's own value; omit to read"}, + }, + Run: func(args map[string]any) (any, error) { + window, err := text(args, "window", false) + if err != nil { + return nil, err + } + if _, set := args["opacity"]; !set { + return WindowOpacity(window, -1) + } + opacity, err := whole(args, "opacity", 100, 0, 100) + if err != nil { + return nil, err + } + return WindowOpacity(window, opacity) + }, + }, + { + Name: "picom_toggle", + Description: "Turn compositing off or on in the operator's session, for a game or a test: off ends " + + "picom, on starts it. Without `on`, flips it. The session start brings it back at the next login.", + Input: map[string]any{ + "on": map[string]any{"type": "boolean", "description": "true to start, false to stop (default: the opposite of now)"}, + }, + Run: func(args map[string]any) (any, error) { + var want *bool + if _, given := args["on"]; given { + on, err := flag(args, "on", false) + if err != nil { + return nil, err + } + want = &on + } + return Toggle(want) + }, + }, + } +} diff --git a/modules/picom/cmd/picom-tools/manifest_helpers_test.go b/modules/picom/cmd/picom-tools/manifest_helpers_test.go new file mode 100644 index 0000000..d4fb76d --- /dev/null +++ b/modules/picom/cmd/picom-tools/manifest_helpers_test.go @@ -0,0 +1,175 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in +// every desktop module that carries it. + +type manifest struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Requires []string `json:"requires"` + Claims []claim `json:"claims"` + Seats []any `json:"seats"` + Tools []string `json:"tools"` + Environment *environment `json:"environment"` + Shell []shellCode `json:"shell"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +type claim struct { + Name string `json:"name"` + Scope string `json:"scope"` + Serves []string `json:"serves"` +} + +type environment struct { + Variables map[string]string `json:"variables"` + Path []map[string]any `json:"path"` +} + +type shellCode struct { + For string `json:"for"` + Slot string `json:"slot"` + Code string `json:"code"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + dec := json.NewDecoder(strings.NewReader(string(raw))) + dec.DisallowUnknownFields() + var m manifest + if err := dec.Decode(&m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +func (m manifest) resource(t *testing.T, id string) map[string]any { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %q", id) + return nil +} + +func (m manifest) packages() (present, absent []string) { + for _, r := range m.Resources { + if r["type"] == "package" { + if r["absent"] == true { + absent = append(absent, r["package"].(string)) + } else { + present = append(present, r["package"].(string)) + } + } + } + return present, absent +} + +// sameAsSource checks that a file resource's content is byte for byte the module's source file, so +// the readable file in the repository is what the machine gets. +func (m manifest) sameAsSource(t *testing.T, id, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + r := m.resource(t, id) + if r["type"] != "file" { + t.Fatalf("%s is a %v, not a file", id, r["type"]) + } + if got, _ := r["content"].(string); got != string(want) { + t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source) + } + if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { + t.Fatalf("%s under the home is the account's", id) + } +} + +// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle +// serves each seat verb the claims promise as ., and that the Go bundle is declared. +func checkTheToolsAgree(t *testing.T, m manifest) { + t.Helper() + own, seat := map[string]bool{}, map[string]bool{} + for _, tool := range tools() { + if strings.Contains(tool.Name, ".") { + seat[tool.Name] = true + } else { + own[tool.Name] = true + } + if strings.TrimSpace(tool.Description) == "" { + t.Errorf("%s has no description", tool.Name) + } + } + listed := map[string]bool{} + for _, name := range m.Tools { + listed[name] = true + if !own[name] { + t.Errorf("module.json lists %s, which the bundle does not serve", name) + } + } + for name := range own { + if !listed[name] { + t.Errorf("the bundle serves %s, which module.json does not list", name) + } + if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") { + t.Errorf("%s is not prefixed with the module's name", name) + } + } + promised := map[string]bool{} + for _, c := range m.Claims { + for _, verb := range c.Serves { + promised[c.Name+"."+verb] = true + if !seat[c.Name+"."+verb] { + t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb) + } + } + } + for name := range seat { + if !promised[name] { + t.Errorf("the bundle serves %s, which no claim promises", name) + } + } + var bundle map[string]any + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + bundle = a + } + } + if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" || + bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" { + t.Errorf("the Go tools bundle: %v", bundle) + } +} + +// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry. +func checkNoSecretsOrInstallationNames(t *testing.T) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + s := strings.ToLower(string(raw)) + for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} { + if strings.Contains(s, never) { + t.Errorf("module.json names %q", never) + } + } +} diff --git a/modules/picom/cmd/picom-tools/manifest_test.go b/modules/picom/cmd/picom-tools/manifest_test.go new file mode 100644 index 0000000..6f67e50 --- /dev/null +++ b/modules/picom/cmd/picom-tools/manifest_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +// picom's shape (novox/hq ADR 0208): it claims node-compositor and serves no verb of it, requires the +// X display on its own machine, owns its configuration file, and is started once, by its package's +// XDG autostart entry, never by the window manager or the session's start as well. + +func TestItClaimsTheCompositorSeatAndRequiresTheXDisplay(t *testing.T) { + m := readManifest(t) + if m.Module != "picom" || m.Seats != nil { + t.Fatalf("module %q declares seats %v", m.Module, m.Seats) + } + if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-compositor", Scope: "node"}}) { + t.Fatalf("claims: %+v", m.Claims) + } + if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) { + t.Fatalf("requires: %v", m.Requires) + } + if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"picom", "xorg-xprop"}) || absent != nil { + t.Fatalf("packages: %v, absent %v", present, absent) + } +} + +func TestItOwnsItsConfigurationAsWrittenInTheModule(t *testing.T) { + m := readManifest(t) + m.sameAsSource(t, "configuration", "files/picom.conf") + if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/picom/picom.conf" { + t.Fatalf("path: %v", p) + } + if d := m.resource(t, "configuration-dir"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/picom" { + t.Fatalf("the directory: %v", d) + } +} + +func TestThePackagesAutostartEntryIsItsOnlyStart(t *testing.T) { + m := readManifest(t) + // picom's package ships /etc/xdg/autostart/picom.desktop, which the session's dex runs. A second + // start from here would be a second compositor, refused by the first. + if m.Shell != nil { + t.Fatalf("a session-start line as well as the package's autostart entry: %+v", m.Shell) + } + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") || strings.Contains(p, "autostart") { + t.Fatalf("a second start: %v", r) + } + if r["type"] == "service" || r["type"] == "process" { + t.Fatalf("a unit: %v", r) + } + } +} + +func TestTheToolsAgreeWithTheManifest(t *testing.T) { + m := readManifest(t) + checkTheToolsAgree(t, m) + checkNoSecretsOrInstallationNames(t) +} diff --git a/modules/picom/cmd/picom-tools/picom.go b/modules/picom/cmd/picom-tools/picom.go new file mode 100644 index 0000000..c719df2 --- /dev/null +++ b/modules/picom/cmd/picom-tools/picom.go @@ -0,0 +1,293 @@ +package main + +import ( + "errors" + "fmt" + "math" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +// unit is the name picom runs under in the account's service manager when a tool starts it. The +// session start runs its own, and a tool that starts one first ends any other (Toggle, Restart). +const unit = "picom" + +func configPath() string { return filepath.Join(operatorHome(), ".config", "picom", "picom.conf") } + +// Running is picom's processes of this account. +func running() []int { return processesOf("picom") } + +// RestartResult is what picom_restart answers. +type RestartResult struct { + Action string `json:"action"` + PIDs []int `json:"pids"` + Note string `json:"note,omitempty"` +} + +// Restart tells a running picom to reinitialise, or starts one. +func Restart(hard bool) (RestartResult, error) { + if pids := running(); len(pids) > 0 && !hard { + return RestartResult{Action: "reinitialised", PIDs: signalAll("picom", syscall.SIGUSR1), + Note: "picom re-read " + configPath()}, nil + } + s, err := findSession() + if err != nil { + return RestartResult{}, err + } + if err := stopAll(); err != nil { + return RestartResult{}, err + } + if err := s.detach(unit, "picom", "--config", configPath()); err != nil { + return RestartResult{}, err + } + pids := waitFor(func() []int { return running() }, 3*time.Second) + return RestartResult{Action: "started", PIDs: pids, + Note: "started under the account's service manager as " + unit + ".service"}, nil +} + +// stopAll ends every picom of this account and waits for them to go. +func stopAll() error { + signalAll("picom", syscall.SIGTERM) + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + if len(running()) == 0 { + return nil + } + time.Sleep(100 * time.Millisecond) + } + if left := running(); len(left) > 0 { + return fmt.Errorf("picom %v did not end within 3s", left) + } + return nil +} + +func waitFor(get func() []int, within time.Duration) []int { + deadline := time.Now().Add(within) + for { + if got := get(); len(got) > 0 || time.Now().After(deadline) { + return got + } + time.Sleep(100 * time.Millisecond) + } +} + +// ToggleResult is what picom_toggle answers. +type ToggleResult struct { + Compositing bool `json:"compositing"` + PIDs []int `json:"pids"` + Note string `json:"note"` +} + +// Toggle stops or starts compositing; want nil flips it. +func Toggle(want *bool) (ToggleResult, error) { + on := len(running()) == 0 + if want != nil { + on = *want + } + if !on { + if err := stopAll(); err != nil { + return ToggleResult{}, err + } + return ToggleResult{Compositing: false, PIDs: []int{}, + Note: "picom ended; the session start runs it again at the next login, or call picom_toggle with on"}, nil + } + if pids := running(); len(pids) > 0 { + return ToggleResult{Compositing: true, PIDs: pids, Note: "picom was already running"}, nil + } + r, err := Restart(true) + if err != nil { + return ToggleResult{}, err + } + return ToggleResult{Compositing: len(r.PIDs) > 0, PIDs: r.PIDs, Note: r.Note}, nil +} + +// Rule is one window rule, its options as written. +type Rule struct { + Match string `json:"match"` + Options map[string]string `json:"options"` +} + +// RulesResult is what picom_rules answers. +type RulesResult struct { + File string `json:"file"` + Running []int `json:"running"` + Global map[string]string `json:"global"` + Rules []Rule `json:"rules"` + // Legacy names options that window rules supersede, which picom ignores when rules are set. + Legacy []string `json:"legacy,omitempty"` +} + +var ( + commentLine = regexp.MustCompile(`(?m)^\s*#.*$`) + rulesBlock = regexp.MustCompile(`(?s)\brules\s*[=:]\s*\((.*?)\)\s*;`) + ruleGroup = regexp.MustCompile(`(?s)\{(.*?)\}`) + assignment = regexp.MustCompile(`(?m)^\s*([A-Za-z][A-Za-z0-9-]*)\s*[=:]\s*(.+?)\s*;?\s*$`) +) + +// superseded are the options picom's window rules replace (picom(1), WINDOW RULES). +var superseded = []string{"opacity-rule", "inactive-opacity", "active-opacity", "inactive-dim", + "shadow-exclude", "fade-exclude", "focus-exclude", "rounded-corners-exclude", "blur-background-exclude", + "corner-radius-rules", "wintypes", "inactive-opacity-override", "mark-wmwin-focused"} + +// Rules reads picom's configuration file into its global options and its window rules. +func Rules(path string) (RulesResult, error) { + raw, err := os.ReadFile(path) + if err != nil { + return RulesResult{}, fmt.Errorf("picom's configuration: %w", err) + } + return parseRules(path, string(raw), running()), nil +} + +func parseRules(path, conf string, pids []int) RulesResult { + out := RulesResult{File: path, Running: pids, Global: map[string]string{}, Rules: []Rule{}} + conf = commentLine.ReplaceAllString(conf, "") + rest := conf + if m := rulesBlock.FindStringSubmatchIndex(conf); m != nil { + body := conf[m[2]:m[3]] + rest = conf[:m[0]] + conf[m[1]:] + for _, g := range ruleGroup.FindAllStringSubmatch(body, -1) { + r := Rule{Options: map[string]string{}} + for _, part := range strings.Split(g[1], ";") { + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if k == "match" { + r.Match = unquote(v) + } else { + r.Options[k] = unquote(v) + } + } + out.Rules = append(out.Rules, r) + } + } + for _, m := range assignment.FindAllStringSubmatch(rest, -1) { + out.Global[m[1]] = unquote(strings.TrimSuffix(m[2], ";")) + } + if len(out.Rules) > 0 { + for _, name := range superseded { + if _, set := out.Global[name]; set { + out.Legacy = append(out.Legacy, name) + } + } + } + return out +} + +func unquote(v string) string { + v = strings.TrimSpace(v) + if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' { + return v[1 : len(v)-1] + } + return v +} + +// OpacityResult is what picom_window_opacity answers. +type OpacityResult struct { + Window string `json:"window"` + Class string `json:"class,omitempty"` + Title string `json:"title,omitempty"` + // Opacity is the window's own value in percent; nil when it has none. + Opacity *int `json:"opacity"` + Note string `json:"note,omitempty"` +} + +var ( + activeWindow = regexp.MustCompile(`window id # (0x[0-9a-fA-F]+)`) + cardinal = regexp.MustCompile(`_NET_WM_WINDOW_OPACITY\(CARDINAL\) = (\d+)`) + wmClass = regexp.MustCompile(`WM_CLASS\(STRING\) = "[^"]*", "([^"]*)"`) + wmName = regexp.MustCompile(`_NET_WM_NAME\(UTF8_STRING\) = "(.*)"`) +) + +// WindowOpacity reads a window's own opacity, and sets it when percent is 0-100. +func WindowOpacity(window string, percent int) (OpacityResult, error) { + s, err := findSession() + if err != nil { + return OpacityResult{}, err + } + if window == "" { + r, err := s.run(5*time.Second, "", "xprop", "-root", "_NET_ACTIVE_WINDOW") + if err != nil { + return OpacityResult{}, err + } + m := activeWindow.FindStringSubmatch(r.Stdout) + if m == nil || m[1] == "0x0" { + return OpacityResult{}, errors.New("no window is focused") + } + window = m[1] + } + id, err := windowID(window) + if err != nil { + return OpacityResult{}, err + } + if percent >= 0 { + var r Result + if percent == 100 { + r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-remove", "_NET_WM_WINDOW_OPACITY") + } else { + r, err = s.run(5*time.Second, "", "xprop", "-id", id, "-f", "_NET_WM_WINDOW_OPACITY", "32c", + "-set", "_NET_WM_WINDOW_OPACITY", strconv.FormatUint(opacityCardinal(percent), 10)) + } + if err != nil { + return OpacityResult{}, err + } + if r.Code != 0 { + return OpacityResult{}, fmt.Errorf("xprop: %s", strings.TrimSpace(r.Stderr)) + } + } + r, err := s.run(5*time.Second, "", "xprop", "-id", id, "_NET_WM_WINDOW_OPACITY", "WM_CLASS", "_NET_WM_NAME") + if err != nil { + return OpacityResult{}, err + } + if r.Code != 0 { + return OpacityResult{}, fmt.Errorf("window %s: %s", id, strings.TrimSpace(r.Stderr)) + } + out := parseWindow(id, r.Stdout) + if rules, err := Rules(configPath()); err == nil && out.Class != "" { + for _, rule := range rules.Rules { + if _, sets := rule.Options["opacity"]; sets && strings.Contains(rule.Match, "class_g = '"+out.Class+"'") { + out.Note = "a window rule sets the opacity of class " + out.Class + " and outranks the window's own value" + } + } + } + return out, nil +} + +func windowID(w string) (string, error) { + w = strings.TrimSpace(strings.ToLower(w)) + base := 10 + if strings.HasPrefix(w, "0x") { + w, base = w[2:], 16 + } + n, err := strconv.ParseUint(w, base, 32) + if err != nil || n == 0 { + return "", fmt.Errorf("window %q is not an X window id", w) + } + return fmt.Sprintf("0x%x", n), nil +} + +func opacityCardinal(percent int) uint64 { + return uint64(math.Round(float64(percent) / 100 * float64(math.MaxUint32))) +} + +func parseWindow(id, out string) OpacityResult { + r := OpacityResult{Window: id} + if m := cardinal.FindStringSubmatch(out); m != nil { + n, _ := strconv.ParseUint(m[1], 10, 64) + p := int(math.Round(float64(n) / float64(math.MaxUint32) * 100)) + r.Opacity = &p + } + if m := wmClass.FindStringSubmatch(out); m != nil { + r.Class = m[1] + } + if m := wmName.FindStringSubmatch(out); m != nil { + r.Title = m[1] + } + return r +} diff --git a/modules/picom/cmd/picom-tools/picom_test.go b/modules/picom/cmd/picom-tools/picom_test.go new file mode 100644 index 0000000..102ad3e --- /dev/null +++ b/modules/picom/cmd/picom-tools/picom_test.go @@ -0,0 +1,131 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// A pid above the kernel's largest, so a signal a test sends reaches nothing. +const nobody = 4194400 + +func TestTheModulesOwnConfigurationReadsAsRulesInOrder(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf")) + if err != nil { + t.Fatal(err) + } + r := parseRules("picom.conf", string(raw), nil) + if r.Global["backend"] != "glx" || r.Global["vsync"] != "true" || r.Global["shadow"] != "false" { + t.Fatalf("global options: %v", r.Global) + } + if len(r.Rules) != 5 { + t.Fatalf("five rules: %+v", r.Rules) + } + focused, unfocused := r.Rules[2], r.Rules[3] + if focused.Match != "class_g = 'XTerm' && focused" || focused.Options["opacity"] != "0.95" || + unfocused.Match != "class_g = 'XTerm' && !focused" || unfocused.Options["opacity"] != "0.75" { + t.Fatalf("the terminal rules: %+v %+v", focused, unfocused) + } + if last := r.Rules[4]; last.Match != "fullscreen" || last.Options["opacity"] != "1" { + t.Fatalf("full screen is opaque, and last so it wins: %+v", last) + } + if len(r.Legacy) != 0 { + t.Fatalf("the module's file sets no option the rules supersede: %v", r.Legacy) + } + if _, inGlobal := r.Global["match"]; inGlobal { + t.Fatal("a rule's key leaked into the global options") + } +} + +func TestTodaysFileIsReportedForTheOptionsItsRulesWouldIgnore(t *testing.T) { + conf := "inactive-opacity = 1.0;\nopacity-rule = [ \"95:class_g = 'XTerm'\" ];\nrules = (\n { match = \"focused\"; opacity = 1; }\n);\n" + r := parseRules("x", conf, []int{7}) + if len(r.Rules) != 1 || r.Rules[0].Match != "focused" || len(r.Running) != 1 { + t.Fatalf("%+v", r) + } + if strings.Join(r.Legacy, ",") != "opacity-rule,inactive-opacity" { + t.Fatalf("legacy: %v", r.Legacy) + } +} + +func TestAWindowIdIsHexOrDecimalAndNeverZero(t *testing.T) { + for in, want := range map[string]string{"0x3C00012": "0x3c00012", "62914578": "0x3c00012"} { + if got, err := windowID(in); err != nil || got != want { + t.Errorf("%s: %s, %v", in, got, err) + } + } + for _, bad := range []string{"0", "0x0", "window", "-1", "0x1ffffffff"} { + if _, err := windowID(bad); err == nil { + t.Errorf("%s was accepted", bad) + } + } +} + +func TestOpacityIsAPercentOfTheFullCardinal(t *testing.T) { + if opacityCardinal(0) != 0 || opacityCardinal(100) != 0xffffffff || opacityCardinal(75) != 3221225471 { + t.Fatalf("%d %d %d", opacityCardinal(0), opacityCardinal(100), opacityCardinal(75)) + } + r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY(CARDINAL) = 3221225471\nWM_CLASS(STRING) = \"xterm\", \"XTerm\"\n_NET_WM_NAME(UTF8_STRING) = \"op@host: ~\"\n") + if r.Opacity == nil || *r.Opacity != 75 || r.Class != "XTerm" || r.Title != "op@host: ~" { + t.Fatalf("%+v", r) + } + if r := parseWindow("0x1", "_NET_WM_WINDOW_OPACITY: not found.\n"); r.Opacity != nil { + t.Fatalf("no value of its own: %+v", r) + } +} + +func TestSettingTheFocusedWindowsOpacityAsksXpropAndNamesTheRuleThatOutranksIt(t *testing.T) { + root := fakeMachine(t) + fakeProcess(t, nobody, "i3", "DISPLAY=:1") + conf := filepath.Join(root, "home", ".config", "picom") + if err := os.MkdirAll(conf, 0o755); err != nil { + t.Fatal(err) + } + src, _ := os.ReadFile(filepath.Join("..", "..", "files", "picom.conf")) + if err := os.WriteFile(filepath.Join(conf, "picom.conf"), src, 0o644); err != nil { + t.Fatal(err) + } + bin := fakeBinaries(t, map[string]string{"xprop": `echo "xprop $* DISPLAY=$DISPLAY" >> "$LOG" +case "$*" in + "-root _NET_ACTIVE_WINDOW") echo "_NET_ACTIVE_WINDOW(WINDOW): window id # 0x3c00012" ;; + *WM_CLASS*) printf '_NET_WM_WINDOW_OPACITY(CARDINAL) = 2147483648\nWM_CLASS(STRING) = "xterm", "XTerm"\n' ;; +esac`}) + t.Setenv("LOG", filepath.Join(bin, "log")) + r, err := WindowOpacity("", 50) + if err != nil { + t.Fatal(err) + } + if r.Window != "0x3c00012" || r.Opacity == nil || *r.Opacity != 50 || !strings.Contains(r.Note, "XTerm") { + t.Fatalf("%+v", r) + } + log, _ := os.ReadFile(filepath.Join(bin, "log")) + if !strings.Contains(string(log), "xprop -id 0x3c00012 -f _NET_WM_WINDOW_OPACITY 32c -set _NET_WM_WINDOW_OPACITY 2147483648 DISPLAY=:1") { + t.Fatalf("xprop was asked:\n%s", log) + } +} + +func TestWithoutASessionTheToolsThatDrawSaySo(t *testing.T) { + fakeMachine(t) + if _, err := WindowOpacity("", -1); !errors.Is(err, ErrNoSession) { + t.Fatalf("window opacity: %v", err) + } + on := true + if _, err := Toggle(&on); !errors.Is(err, ErrNoSession) { + t.Fatalf("toggle on: %v", err) + } +} + +func TestARunningPicomIsToldToReinitialiseNotRestarted(t *testing.T) { + fakeMachine(t) + fakeProcess(t, nobody, "picom", "DISPLAY=:1") + r, err := Restart(false) + if err != nil || r.Action != "reinitialised" { + t.Fatalf("%+v, %v", r, err) + } + on := true + if r, err := Toggle(&on); err != nil || !r.Compositing || r.PIDs[0] != nobody { + t.Fatalf("on while running: %+v, %v", r, err) + } +} diff --git a/modules/picom/cmd/picom-tools/session.go b/modules/picom/cmd/picom-tools/session.go new file mode 100644 index 0000000..dc21774 --- /dev/null +++ b/modules/picom/cmd/picom-tools/session.go @@ -0,0 +1,423 @@ +// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208). +// +// The runtime is a system service running as the operator account (ADR 0175): it has the account's +// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that +// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of +// the account that is part of the session (the window manager first), the same thing `loginctl` and +// a person's own shell would point at, and says where it found them. +// +// Long-lived programs a tool starts go to the account's own service manager through `systemd-run +// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties +// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would +// die with it. +// +// This file is the same in every desktop module that carries it; it moves into the Go SDK once a +// second consumer outside the desktop wants it. +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +// Where the session is looked for. Variables so a test can point them at a fake tree. +var ( + procRoot = "/proc" + runUserDir = "/run/user" + x11Sockets = "/tmp/.X11-unix" +) + +// sessionHolders are the processes whose environment is the session's, best first: the window +// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them. +var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"} + +// sessionKeys are the variables a session carries that a tool hands on to what it runs. +var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS", + "XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"} + +// Session is what a tool needs to reach the operator's desktop. +type Session struct { + UID int `json:"uid"` + Display string `json:"display,omitempty"` + XAuthority string `json:"xauthority,omitempty"` + Wayland string `json:"wayland_display,omitempty"` + Bus string `json:"bus,omitempty"` + RuntimeDir string `json:"runtime_dir,omitempty"` + SessionID string `json:"session_id,omitempty"` + I3Sock string `json:"i3sock,omitempty"` + // From says where the values were found: the tool's own environment, a process, or the socket. + From string `json:"from"` +} + +// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it. +var ErrNoSession = errors.New("no graphical session") + +// ErrTimedOut is what run answers for a command ended because it ran past its time. +var ErrTimedOut = errors.New("timed out") + +// ErrNoBus is answered by a tool that needs the session bus when the account has none. +var ErrNoBus = errors.New("no session bus") + +// operatorHome is the account's home: what the runtime was told, else the process's own. +func operatorHome() string { + if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" { + return h + } + h, _ := os.UserHomeDir() + return h +} + +// findSession finds the graphical session of the account this tool runs as, or answers +// ErrNoSession with what it looked at. +func findSession() (Session, error) { + s := findEnvironment() + if s.Display == "" && s.Wayland == "" { + return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+ + "or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+ + "Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets) + } + return s, nil +} + +// findBus finds the account's session bus, which a logged-in account has whether or not a desktop +// is running. +func findBus() (Session, error) { + s := findEnvironment() + if s.Bus == "" { + return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+ + "(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus")) + } + return s, nil +} + +func findEnvironment() Session { + uid := os.Getuid() + s := Session{UID: uid} + own := map[string]string{} + for _, k := range sessionKeys { + own[k] = os.Getenv(k) + } + if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" { + s.fill(own) + s.From = "the tool's own environment" + } else if pid, comm, env, ok := sessionProcess(uid); ok { + s.fill(env) + s.From = fmt.Sprintf("process %s (pid %d)", comm, pid) + } else if display, ok := lonelyX11Socket(); ok { + if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) { + s.Display, s.XAuthority = display, a + s.From = "the X server socket and the account's ~/.Xauthority" + } + s.fill(own) + } else { + s.fill(own) + s.From = "nothing: no session found" + } + // The bus and the runtime directory are the account's, whether or not the process named them. + runtime := filepath.Join(runUserDir, strconv.Itoa(uid)) + if s.RuntimeDir == "" && exists(runtime) { + s.RuntimeDir = runtime + } + if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) { + s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus") + } + return s +} + +func (s *Session) fill(env map[string]string) { + set := func(dst *string, key string) { + if *dst == "" { + *dst = env[key] + } + } + set(&s.Display, "DISPLAY") + set(&s.XAuthority, "XAUTHORITY") + set(&s.Wayland, "WAYLAND_DISPLAY") + set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS") + set(&s.RuntimeDir, "XDG_RUNTIME_DIR") + set(&s.SessionID, "XDG_SESSION_ID") + set(&s.I3Sock, "I3SOCK") +} + +// sessionProcess is the best process of this uid whose environment names a display. +func sessionProcess(uid int) (int, string, map[string]string, bool) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0, "", nil, false + } + type candidate struct { + pid int + comm string + env map[string]string + rank int + } + var found []candidate + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + raw, err := os.ReadFile(filepath.Join(dir, "environ")) + if err != nil { + continue + } + env := parseEnviron(raw) + if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" { + continue + } + comm := readTrimmed(filepath.Join(dir, "comm")) + rank := len(sessionHolders) + for i, h := range sessionHolders { + if h == comm { + rank = i + break + } + } + found = append(found, candidate{pid, comm, env, rank}) + } + if len(found) == 0 { + return 0, "", nil, false + } + sort.Slice(found, func(i, j int) bool { + if found[i].rank != found[j].rank { + return found[i].rank < found[j].rank + } + return found[i].pid > found[j].pid // the newer of two equals + }) + best := found[0] + return best.pid, best.comm, best.env, true +} + +func parseEnviron(raw []byte) map[string]string { + env := map[string]string{} + for _, kv := range bytes.Split(raw, []byte{0}) { + if i := bytes.IndexByte(kv, '='); i > 0 { + env[string(kv[:i])] = string(kv[i+1:]) + } + } + return env +} + +func ownerOf(path string) (int, bool) { + info, err := os.Stat(path) + if err != nil { + return 0, false + } + st, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, false + } + return int(st.Uid), true +} + +// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one. +func lonelyX11Socket() (string, bool) { + entries, err := os.ReadDir(x11Sockets) + if err != nil { + return "", false + } + var displays []string + for _, e := range entries { + if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() { + if _, err := strconv.Atoi(n); err == nil { + displays = append(displays, ":"+n) + } + } + } + if len(displays) != 1 { + return "", false + } + return displays[0], true +} + +func readTrimmed(path string) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func exists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// Env is this process's environment with the session's variables in place of its own. +func (s Session) Env() []string { + drop := map[string]bool{} + for _, k := range sessionKeys { + drop[k] = true + } + var env []string + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] { + continue + } + env = append(env, kv) + } + add := func(k, v string) { + if v != "" { + env = append(env, k+"="+v) + } + } + add("DISPLAY", s.Display) + add("XAUTHORITY", s.XAuthority) + add("WAYLAND_DISPLAY", s.Wayland) + add("DBUS_SESSION_BUS_ADDRESS", s.Bus) + add("XDG_RUNTIME_DIR", s.RuntimeDir) + add("XDG_SESSION_ID", s.SessionID) + add("I3SOCK", s.I3Sock) + return env +} + +// mostOutput bounds what a command may answer with, per stream. +const mostOutput = 256 << 10 + +// Result is what a command did. +type Result struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr,omitempty"` + Code int `json:"code"` + Truncated bool `json:"truncated,omitempty"` +} + +// run runs a command in the session's environment, its input given, ended with everything it +// started after timeout. A command that is not installed is an error naming it; one that exits +// non-zero is a Result with its code, for the caller to judge. +func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) { + path, err := exec.LookPath(name) + if err != nil { + return Result{}, fmt.Errorf("%s is not installed on this machine", name) + } + cmd := exec.Command(path, args...) + cmd.Env = s.Env() + if home := operatorHome(); exists(home) { + cmd.Dir = home + } + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + var out, errOut capped + cmd.Stdout, cmd.Stderr = &out, &errOut + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + return Result{}, fmt.Errorf("%s: %w", name, err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err = <-done: + case <-time.After(timeout): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + <-done + return Result{Stdout: out.String(), Stderr: errOut.String()}, + fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut) + } + r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut} + var exit *exec.ExitError + if errors.As(err, &exit) { + r.Code = exit.ExitCode() + } else if err != nil { + return r, fmt.Errorf("%s: %w", name, err) + } + return r, nil +} + +// detach starts a long-lived program under the account's own service manager, as a transient unit +// that carries the session's display, so it outlives the runtime that asked for it. A unit already +// running under the same name is stopped first, so a fixed name means "at most one". +func (s Session) detach(unit string, args ...string) error { + if s.RuntimeDir == "" { + return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+ + "cannot be reached", ErrNoBus) + } + _, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service") + call := []string{"--user", "--collect", "--quiet", "--unit=" + unit} + for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}, + {"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} { + if kv[1] != "" { + call = append(call, "--setenv="+kv[0]+"="+kv[1]) + } + } + call = append(call, "--") + call = append(call, args...) + r, err := s.run(10*time.Second, "", "systemd-run", call...) + if err != nil { + return err + } + if r.Code != 0 { + return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr)) + } + return nil +} + +// uniqueUnit is a transient unit name that will not collide with an earlier one. +func uniqueUnit(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +type capped struct { + bytes.Buffer + cut bool +} + +func (c *capped) Write(p []byte) (int, error) { + if room := mostOutput - c.Len(); room < len(p) { + if room > 0 { + c.Buffer.Write(p[:room]) + } + c.cut = true + return len(p), nil + } + return c.Buffer.Write(p) +} + +// processesOf are the pids of this uid's processes whose command name is comm, oldest first. +func processesOf(comm string) []int { + entries, err := os.ReadDir(procRoot) + if err != nil { + return nil + } + uid := os.Getuid() + var pids []int + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + dir := filepath.Join(procRoot, e.Name()) + if owner, ok := ownerOf(dir); !ok || owner != uid { + continue + } + if readTrimmed(filepath.Join(dir, "comm")) == comm { + pids = append(pids, pid) + } + } + sort.Ints(pids) + return pids +} + +// signalAll sends sig to every process of this uid named comm, and answers the pids it reached. +func signalAll(comm string, sig syscall.Signal) []int { + var reached []int + for _, pid := range processesOf(comm) { + if syscall.Kill(pid, sig) == nil { + reached = append(reached, pid) + } + } + return reached +} diff --git a/modules/picom/cmd/picom-tools/session_test.go b/modules/picom/cmd/picom-tools/session_test.go new file mode 100644 index 0000000..800cc6d --- /dev/null +++ b/modules/picom/cmd/picom-tools/session_test.go @@ -0,0 +1,174 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with +// none of the test process's own session variables, and gives back the root. +func fakeMachine(t *testing.T) string { + t.Helper() + root := t.TempDir() + procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11") + for _, d := range []string{procRoot, runUserDir, x11Sockets} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + for _, k := range sessionKeys { + t.Setenv(k, "") + } + t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home")) + t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" }) + return root +} + +func fakeProcess(t *testing.T, pid int, comm string, env ...string) { + t.Helper() + dir := filepath.Join(procRoot, strconv.Itoa(pid)) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere") + fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority", + "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied") + fakeProcess(t, 50, "bash", "PATH=/usr/bin") + s, err := findSession() + if err != nil { + t.Fatal(err) + } + if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") { + t.Fatalf("the window manager's environment: %+v", s) + } + for _, kv := range s.Env() { + if strings.HasPrefix(kv, "SECRET_TOKEN=") { + t.Fatal("a variable of the session process that is not a session variable was handed on") + } + } +} + +func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "firefox", "DISPLAY=:0") + fakeProcess(t, 20, "firefox", "DISPLAY=:2") + s, err := findSession() + if err != nil || s.Display != ":2" { + t.Fatalf("the newest of two equals: %+v, %v", s, err) + } +} + +func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) { + fakeMachine(t) + fakeProcess(t, 10, "sshd", "PATH=/usr/bin") + _, err := findSession() + if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") { + t.Fatalf("no session: %v", err) + } +} + +func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) { + root := fakeMachine(t) + if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findSession() + if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") { + t.Fatalf("socket and authority: %+v, %v", s, err) + } +} + +func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) { + fakeMachine(t) + runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid())) + if _, err := findBus(); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory is no bus: %v", err) + } + if err := os.MkdirAll(runtime, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil { + t.Fatal(err) + } + s, err := findBus() + if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime { + t.Fatalf("bus: %+v, %v", s, err) + } + env := strings.Join(s.Env(), "\n") + if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") { + t.Fatalf("the bus is handed on: %s", env) + } +} + +func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) { + fakeMachine(t) + s := Session{} + r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3") + if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" { + t.Fatalf("result: %+v, %v", r, err) + } + start := time.Now() + if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second { + t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start)) + } + if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") { + t.Fatalf("a missing program: %v", err) + } +} + +func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) { + fakeMachine(t) + bin := fakeBinaries(t, map[string]string{ + "systemctl": `echo "systemctl $*" >> "$LOG"`, + "systemd-run": `echo "systemd-run $*" >> "$LOG"`, + }) + log := filepath.Join(bin, "log") + t.Setenv("LOG", log) + s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"} + if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(log) + want := "systemctl --user stop picom-session.service\n" + + "systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n" + if string(got) != want { + t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want) + } + if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) { + t.Fatalf("no runtime directory: %v", err) + } +} + +// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory. +func fakeBinaries(t *testing.T, scripts map[string]string) string { + t.Helper() + dir := t.TempDir() + for name, body := range scripts { + if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil { + t.Fatal(err) + } + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return dir +} diff --git a/modules/picom/files/picom.conf b/modules/picom/files/picom.conf new file mode 100644 index 0000000..efe5ff2 --- /dev/null +++ b/modules/picom/files/picom.conf @@ -0,0 +1,32 @@ +# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is +# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to +# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim. + +backend = "glx"; +vsync = true; + +fading = true; +fade-in-step = 0.05; +fade-out-step = 0.05; + +# Tiled windows hide their shadows and corners, so neither is drawn. +shadow = false; +corner-radius = 0; +blur-method = "none"; + +# "Focused" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach +# the toplevel picom tracks, and a terminal then never lifts to its focused opacity. +use-ewmh-active-win = true; + +# Window rules are applied in order, and a later match wins. Only terminals are translucent: +# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class; +# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is +# used wherever no rule sets one, which is what the window-opacity tool sets. +rules = ( + { match = "window_type = 'tooltip'"; fade = false; opacity = 0.95; }, + { match = "window_type = 'dock' || window_type = 'desktop'"; fade = false; }, + { match = "class_g = 'XTerm' && focused"; opacity = 0.95; }, + { match = "class_g = 'XTerm' && !focused"; opacity = 0.75; }, + # A full-screen window is opaque, a terminal included: a video or a game is never see-through. + { match = "fullscreen"; opacity = 1; } +); diff --git a/modules/picom/go.mod b/modules/picom/go.mod new file mode 100644 index 0000000..c30ad61 --- /dev/null +++ b/modules/picom/go.mod @@ -0,0 +1,5 @@ +module picom + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.7 diff --git a/modules/picom/go.sum b/modules/picom/go.sum new file mode 100644 index 0000000..b474419 --- /dev/null +++ b/modules/picom/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w= +git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/picom/module.json b/modules/picom/module.json new file mode 100644 index 0000000..e7dc836 --- /dev/null +++ b/modules/picom/module.json @@ -0,0 +1,64 @@ +{ + "module": "picom", + "version": "1", + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-compositor", + "scope": "node" + } + ], + "tools": [ + "picom_restart", + "picom_rules", + "picom_window_opacity", + "picom_toggle" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "picom" + }, + { + "id": "window-properties", + "type": "package", + "package": "xorg-xprop" + }, + { + "id": "configuration-dir", + "type": "directory", + "path": "${machine:account-home}/.config/picom", + "owner": "${machine:account}", + "mode": "0755" + }, + { + "id": "configuration", + "type": "file", + "path": "${machine:account-home}/.config/picom/picom.conf", + "owner": "${machine:account}", + "mode": "0644", + "content": "# picom, the X compositor (module picom, novox/hq ADR 0208). Owned by the mesh: this file is\n# replaced at every push. Adopted from the two workstations' file of 2026-10-04 and moved to\n# picom's window rules, which supersede opacity-rule, inactive-opacity and inactive-dim.\n\nbackend = \"glx\";\nvsync = true;\n\nfading = true;\nfade-in-step = 0.05;\nfade-out-step = 0.05;\n\n# Tiled windows hide their shadows and corners, so neither is drawn.\nshadow = false;\ncorner-radius = 0;\nblur-method = \"none\";\n\n# \"Focused\" is i3's _NET_ACTIVE_WINDOW, not X focus events: under i3 those do not reliably reach\n# the toplevel picom tracks, and a terminal then never lifts to its focused opacity.\nuse-ewmh-active-win = true;\n\n# Window rules are applied in order, and a later match wins. Only terminals are translucent:\n# nothing else on screen (browsers, video, games) is touched. A terminal is matched by its class;\n# the node's terminal emulator today is xterm (class XTerm). A window's own opacity property is\n# used wherever no rule sets one, which is what the window-opacity tool sets.\nrules = (\n { match = \"window_type = 'tooltip'\"; fade = false; opacity = 0.95; },\n { match = \"window_type = 'dock' || window_type = 'desktop'\"; fade = false; },\n { match = \"class_g = 'XTerm' && focused\"; opacity = 0.95; },\n { match = \"class_g = 'XTerm' && !focused\"; opacity = 0.75; },\n # A full-screen window is opaque, a terminal included: a video or a game is never see-through.\n { match = \"fullscreen\"; opacity = 1; }\n);\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/picom-tools", + "binary": "picom-tools", + "loads": [ + "picom-tools" + ] + } + ] + } +}