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.
This commit is contained in:
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <seat>.<verb>, 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module picom
|
||||||
|
|
||||||
|
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,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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user