From 1d4de00603fc84974250a9b1b53ab4822a54558c Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 5 Oct 2026 10:11:27 +0200 Subject: [PATCH] i3: other modules' lines are contributions to node-display-session (hq ADR 0212) rofi, clipmenu, feh, i3status-rust and the laptop's model module wrote files into i3's config.d, naming no dependency on the window manager. They now contribute their lines; i3 places them under a line naming each module, and config.d is the operator's alone. The catalogue-wide test composes the contributions as the controller does and checks the whole with i3 -C. --- modules/asus-zephyrus-g14/README.md | 7 ++ .../asus-zephyrus-g14/cmd/zephyrus/keys.go | 64 +++++++++++++++---- .../cmd/zephyrus/keys_test.go | 11 +++- modules/asus-zephyrus-g14/module.json | 26 +++----- modules/clipmenu/README.md | 7 ++ .../clipmenu-tools/manifest_helpers_test.go | 33 +++++++++- .../cmd/clipmenu-tools/manifest_test.go | 4 +- modules/clipmenu/module.json | 17 +++-- modules/feh/README.md | 7 ++ .../cmd/feh-tools/manifest_helpers_test.go | 33 +++++++++- modules/feh/cmd/feh-tools/manifest_test.go | 6 +- modules/feh/module.json | 17 +++-- modules/i3/README.md | 8 +++ modules/i3/cmd/i3-tools/manifest_test.go | 53 +++++++++++---- modules/i3/config/config | 21 ++++-- modules/i3/module.json | 2 +- modules/i3status-rust/README.md | 7 ++ .../manifest_helpers_test.go | 33 +++++++++- .../cmd/i3status-rust-tools/manifest_test.go | 11 ++-- modules/i3status-rust/module.json | 17 +++-- modules/rofi/README.md | 7 ++ .../cmd/rofi-tools/manifest_helpers_test.go | 33 +++++++++- modules/rofi/cmd/rofi-tools/manifest_test.go | 4 +- modules/rofi/module.json | 17 +++-- 24 files changed, 337 insertions(+), 108 deletions(-) diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md index bb819d4..2d6f69b 100644 --- a/modules/asus-zephyrus-g14/README.md +++ b/modules/asus-zephyrus-g14/README.md @@ -376,3 +376,10 @@ module's contribution to `node-power`'s `after-wake` moment, so the module depen module. `logind.conf.d/power.conf` is the power module's. This laptop's values (suspend on the power key and on the lid in every case) are that module's settings for this machine. The NVIDIA driver's sleep drop-ins stay here: they must run inside the sleep transaction, which a contribution cannot. + +## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212) + +The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still +under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places +them in its own configuration under a `# ` line, so this module depends on a window manager +being assigned beside it. diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/keys.go b/modules/asus-zephyrus-g14/cmd/zephyrus/keys.go index 017c46a..3c3f5fb 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/keys.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/keys.go @@ -28,8 +28,48 @@ type KeysReport struct { // TriggerDir is triggerhappy's directory of trigger files; the module owns one file in it. const TriggerDir = "/etc/triggerhappy/triggers.d" -// I3Fragments are the module's own files in i3's include directory, relative to the account's home. -var I3Fragments = []string{".config/i3/config.d/10-asus.conf", ".config/i3/config.d/20-g14.conf"} +// I3Config is the window manager's configuration, relative to the account's home. This module's lines +// are its contribution to node-display-session (novox/hq ADR 0212), placed there by the i3 module +// under a line that names this module. +const I3Config = ".config/i3/config" + +// ModuleName is the line the controller writes before this module's contributed lines. +const ModuleName = "asus-zephyrus-g14" + +// ownSection is this module's contributed lines in a placed file: from the line naming it to the next +// line naming another module, a section header, or the file's end. +func ownSection(text string) []string { + var out []string + in := false + for _, line := range strings.Split(text, "\n") { + t := strings.TrimSpace(line) + if t == "# "+ModuleName { + in = true + continue + } + if !in { + continue + } + if strings.HasPrefix(t, "####") || (strings.HasPrefix(t, "# ") && isModuleLine(strings.TrimPrefix(t, "# "))) { + break + } + out = append(out, line) + } + return out +} + +// isModuleLine is a comment that is one catalogue module's name: lower case, digits and dashes. +func isModuleLine(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-') { + return false + } + } + return true +} // physicalKeys names the key behind an evdev code, as far as it is known on this model. The media // codes are the M4 key and Fn+F4/F5 together; which code is which key was not recorded. @@ -91,26 +131,22 @@ func (m *Machine) Keys() KeysReport { if home == "" { r.NotRead = append(r.NotRead, "the module's i3 lines (MESH_OPERATOR_HOME is not set)") } - for _, rel := range I3Fragments { - if home == "" { - break + if home != "" { + lines := ownSection(m.read(filepath.Join(home, I3Config))) + if len(lines) == 0 { + r.Warnings = append(r.Warnings, "~/"+I3Config+" holds no lines of this module's: is the i3 module assigned and pushed?") } - p := filepath.Join(home, rel) - text := m.read(p) - if text == "" { - r.Warnings = append(r.Warnings, "~/"+rel+" is missing or empty") - continue - } - for _, line := range strings.Split(text, "\n") { + from := "~/" + I3Config + " (" + ModuleName + "'s contribution)" + for _, line := range lines { line = strings.TrimSpace(line) if g := i3Bind.FindStringSubmatch(line); g != nil { when := "pressed" if strings.Contains(g[1], "--release") { when = "released" } - r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: "~/" + rel}) + r.Keys = append(r.Keys, Key{Key: g[2], Physical: physicalKeys[g[2]], When: when, Runs: strings.TrimPrefix(strings.TrimPrefix(g[3], "exec "), "--no-startup-id "), From: from}) } else if g := i3Exec.FindStringSubmatch(line); g != nil { - r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: "~/" + rel}) + r.Keys = append(r.Keys, Key{Key: "(session start)", When: "at login", Runs: g[1], From: from}) } } } diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go index 68a71d9..d289c14 100644 --- a/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/keys_test.go @@ -33,8 +33,15 @@ func TestKeysListsTriggersI3LinesAndFirmwareKeys(t *testing.T) { put(t, f.root, TriggerDir+"/mesh.conf", "# asus-zephyrus-g14\n"+m.triggers()) home := "/home/operator" t.Setenv("MESH_OPERATOR_HOME", home) - put(t, f.root, home+"/"+I3Fragments[0], content["i3-vendor-keys"]) - put(t, f.root, home+"/"+I3Fragments[1], content["i3-model"]) + // The i3 module's file as the controller composes it: other modules' lines around this one's. + var own string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" { + own += c.Content + } + } + put(t, f.root, home+"/"+I3Config, "set $mod Mod4\n# rofi\nbindsym $mod+d exec rofi\n# "+ModuleName+"\n"+own+ + "# triggerhappy-not-here\nbindsym $mod+Shift+z exec other\n#########################################\ninclude x\n") r := f.machine().Keys() got := map[string]Key{} diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json index d9f3a06..13a2fbf 100644 --- a/modules/asus-zephyrus-g14/module.json +++ b/modules/asus-zephyrus-g14/module.json @@ -175,22 +175,6 @@ "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", "mode": "0644", "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n" - }, - { - "id": "i3-vendor-keys", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/10-asus.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n" - }, - { - "id": "i3-model", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/20-g14.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" } ], "build": { @@ -218,6 +202,16 @@ "seat": "node-hotkeys", "kind": "trigger", "content": "# The ROG Zephyrus G14's vendor keys, which reach no X client: media (the M-keys), panel brightness,\n# and the touchpad key. Each runs this module's own script, as the operator's account.\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-media next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" + }, + { + "seat": "node-display-session", + "kind": "config", + "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-kbd-notify\n" + }, + { + "seat": "node-display-session", + "kind": "config", + "content": "# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" } ], "shell": [ diff --git a/modules/clipmenu/README.md b/modules/clipmenu/README.md index f8ee99f..5b70912 100644 --- a/modules/clipmenu/README.md +++ b/modules/clipmenu/README.md @@ -80,3 +80,10 @@ So clipmenud runs with the module's own `xsel` first on its `PATH` (`/usr/local/lib/mesh-clipmenu/xsel`). Before a read it asks the selection for its `TARGETS` and goes ahead only when the selection offers text. It uses `xclip` for that question, which the `xclip` module installs on every workstation. + +## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212) + +The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still +under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places +them in its own configuration under a `# ` line, so this module depends on a window manager +being assigned beside it. diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go index d4fb76d..441415f 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go @@ -22,7 +22,9 @@ type manifest struct { Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` - Build struct { + // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. + Contributions []contribution `json:"contributions"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } } + +type contribution struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` +} + +// i3Lines is what the module contributes to the window manager. +func (m manifest) i3Lines() string { + var out string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" && c.Kind == "config" { + out += c.Content + } + } + return out +} + +// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +func (m manifest) i3LinesAreSource(t *testing.T, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + if m.i3Lines() != string(want) { + t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + } +} diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go index 0b29686..3ab8656 100644 --- a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go +++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go @@ -53,8 +53,8 @@ func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) { if _, set := m.Environment.Variables["CM_LAUNCHER"]; set { t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command") } - m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf") - if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") { + m.i3LinesAreSource(t, "files/i3/50-clipmenu.conf") + if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") { t.Fatalf("%s", c) } } diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json index 403415f..e157350 100644 --- a/modules/clipmenu/module.json +++ b/modules/clipmenu/module.json @@ -48,14 +48,6 @@ "package": "rofi-greenclip", "absent": true }, - { - "id": "i3-bindings", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n" - }, { "id": "text-only", "type": "file", @@ -78,5 +70,12 @@ ] } ] - } + }, + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n" + } + ] } diff --git a/modules/feh/README.md b/modules/feh/README.md index 028506d..ea3140e 100644 --- a/modules/feh/README.md +++ b/modules/feh/README.md @@ -45,3 +45,10 @@ setting once issue 168 closes, not a file the next push would overwrite. - `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads them as unknown. + +## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212) + +The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still +under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places +them in its own configuration under a `# ` line, so this module depends on a window manager +being assigned beside it. diff --git a/modules/feh/cmd/feh-tools/manifest_helpers_test.go b/modules/feh/cmd/feh-tools/manifest_helpers_test.go index d4fb76d..441415f 100644 --- a/modules/feh/cmd/feh-tools/manifest_helpers_test.go +++ b/modules/feh/cmd/feh-tools/manifest_helpers_test.go @@ -22,7 +22,9 @@ type manifest struct { Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` - Build struct { + // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. + Contributions []contribution `json:"contributions"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } } + +type contribution struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` +} + +// i3Lines is what the module contributes to the window manager. +func (m manifest) i3Lines() string { + var out string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" && c.Kind == "config" { + out += c.Content + } + } + return out +} + +// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +func (m manifest) i3LinesAreSource(t *testing.T, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + if m.i3Lines() != string(want) { + t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + } +} diff --git a/modules/feh/cmd/feh-tools/manifest_test.go b/modules/feh/cmd/feh-tools/manifest_test.go index dbdd9ab..b02c149 100644 --- a/modules/feh/cmd/feh-tools/manifest_test.go +++ b/modules/feh/cmd/feh-tools/manifest_test.go @@ -63,11 +63,11 @@ func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) { func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) { m := readManifest(t) - m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf") - if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" { + m.i3LinesAreSource(t, "files/i3/50-feh.conf") + if p := m.i3Lines(); p == "" { t.Fatalf("path: %v", p) } - if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") { + if c := m.i3Lines(); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") { t.Fatalf("%s", c) } } diff --git a/modules/feh/module.json b/modules/feh/module.json index 9853e54..70a2071 100644 --- a/modules/feh/module.json +++ b/modules/feh/module.json @@ -38,14 +38,6 @@ "owner": "${machine:account}", "mode": "0755", "content": "#!/bin/sh\n# The wallpaper (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push. The\n# session's start runs it, and so may anything that wants the declared wallpaper back. The image is\n# the module's own, in ~/.local/share/feh/wallpapers. feh_set changes the wallpaper for a session\n# without touching this file.\nfeh --no-fehbg --bg-fill \"$HOME/.local/share/feh/wallpapers/default.jpg\"\n" - }, - { - "id": "i3-bindings", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/50-feh.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n" } ], "build": { @@ -67,5 +59,12 @@ ] } ] - } + }, + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n" + } + ] } diff --git a/modules/i3/README.md b/modules/i3/README.md index 489abef..5c979c2 100644 --- a/modules/i3/README.md +++ b/modules/i3/README.md @@ -150,3 +150,11 @@ about to end, or accept it: a reload keeps every window and workspace. written the files. So at its start the watcher compares the files on disk with what the running i3 loaded (`GET_CONFIG`), and reloads when they differ. The push that assigns `i3`, or changes its configuration, is therefore reloaded although it also restarted the watcher. + +## Other modules' lines are contributions (changed 2026-10-05, novox/hq ADR 0212) + +`node-display-session` receives `config` contributions, and this module places them with +`${contribution:node-display-session:config}` near the end of its configuration, each module's +under a line naming it. `~/.config/i3/config.d/` is now the operator's alone: the include at the very +end reads it after the mesh's lines. The catalogue-wide test composes every module's contribution as +the controller does and checks the result with `i3 -C`. diff --git a/modules/i3/cmd/i3-tools/manifest_test.go b/modules/i3/cmd/i3-tools/manifest_test.go index 2df3ab2..94d2d3e 100644 --- a/modules/i3/cmd/i3-tools/manifest_test.go +++ b/modules/i3/cmd/i3-tools/manifest_test.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "testing" ) @@ -138,7 +139,12 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin t.Fatal("the lock key goes through logind, which the lock screen's module relies on") } if i3, err := exec.LookPath("i3"); err == nil { - out, err := exec.Command(i3, "-C", "-c", filepath.Join("..", "..", "config", "config")).CombinedOutput() + // As a node with no contribution and no file of the operator's would hold it. + alone := strings.Replace(strings.Replace(c, "${contribution:node-display-session:config}", "", 1), + "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1) + path := filepath.Join(t.TempDir(), "config") + os.WriteFile(path, []byte(alone), 0o644) + out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput() if err != nil || len(ParseCheck(string(out))) > 0 { t.Fatalf("i3 -C: %v %s", err, out) } @@ -169,37 +175,56 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) { } } -// Every module of the catalogue that drops a file into i3's config.d is loaded with the main file, as -// i3 would load them on a machine with all of them assigned: no two bind one key, and every line parses. -func TestTheMainFileAndEveryModulesDropInLoadTogether(t *testing.T) { +// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in +// the main file as the controller places them — in module order, each module's under a line naming it +// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and +// every line parses. +func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) { i3, err := exec.LookPath("i3") if err != nil { t.Skip("no i3 here to check with") } dir := t.TempDir() - drop := filepath.Join(dir, "config.d") - os.MkdirAll(drop, 0o755) manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json")) + sort.Strings(manifests) + var placed strings.Builder var found []string for _, p := range manifests { raw, _ := os.ReadFile(p) var m struct { - Resources []map[string]any `json:"resources"` + Module string `json:"module"` + Contributions []struct { + Seat, Kind, Content string + } `json:"contributions"` } json.Unmarshal(raw, &m) - for _, r := range m.Resources { - path, _ := r["path"].(string) - if r["type"] == "file" && strings.Contains(path, "/.config/i3/config.d/") { - os.WriteFile(filepath.Join(drop, filepath.Base(path)), []byte(r["content"].(string)), 0o644) - found = append(found, filepath.Base(path)) + named := false + for _, c := range m.Contributions { + if c.Seat != "node-display-session" || c.Kind != "config" { + continue + } + if !named { + placed.WriteString("# " + m.Module + "\n") + named = true + found = append(found, m.Module) + } + placed.WriteString(c.Content) + if !strings.HasSuffix(c.Content, "\n") { + placed.WriteString("\n") } } } + if len(found) < 5 { + t.Fatalf("expected the launcher, clipboard, wallpaper, bars and the laptop to contribute; found %v", found) + } main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) - text := strings.Replace(string(main), "include ~/.config/i3/config.d/*.conf", "include "+drop+"/*.conf", 1) + text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1) + // The operator's own files are not the catalogue's: the include line itself goes (a contribution may + // name it in a comment, so only the line that is exactly it). + text = strings.Replace(text, "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1) os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644) out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput() if err != nil || len(ParseCheck(string(out))) > 0 { - t.Fatalf("with the drop-ins %v: %v\n%s", found, err, out) + t.Fatalf("with the contributions of %v: %v\n%s", found, err, out) } } diff --git a/modules/i3/config/config b/modules/i3/config/config index 085dcea..7840f79 100644 --- a/modules/i3/config/config +++ b/modules/i3/config/config @@ -1,9 +1,10 @@ # i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push; # change the module instead. i3's user guide is the reference. # -# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named -# -.conf and read in name order by the include at the end, where every variable set here -# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours. +# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR +# 0212): the mesh places their lines near the end, each module's under a line naming it, where every +# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read +# after them, by the include at the very end, and is yours. # # The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the # result with i3 -C; it never reloads into a configuration with errors. @@ -172,9 +173,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000 ###### Until their modules carry them ## ######################################### # Each line below belongs to something other than i3, named on its line. When that module is written -# it contributes the line as its own drop-in in config.d, and the line goes from here in the same -# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs -# (rofi, clipmenu, feh, i3status-rust, gnome-keyring). +# it contributes the line to node-display-session, and the line goes from here in the same change. +# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute +# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14). # the peripherals' tray (the operator's application) exec --no-startup-id polychromatic-tray-applet @@ -190,6 +191,12 @@ bindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.s bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh ######################################### -###### Other modules' drop-ins #### +###### Other modules' lines #### +######################################### +# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the +# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it. +${contribution:node-display-session:config} +######################################### +###### Your own files #### ######################################### include ~/.config/i3/config.d/*.conf diff --git a/modules/i3/module.json b/modules/i3/module.json index b44fa3f..7257bc0 100644 --- a/modules/i3/module.json +++ b/modules/i3/module.json @@ -74,7 +74,7 @@ "path": "${machine:account-home}/.config/i3/config", "owner": "${machine:account}", "mode": "0644", - "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration with files of their own in ~/.config/i3/config.d/, named\n# -.conf and read in name order by the include at the end, where every variable set here\n# ($mod, $ws1 … $ws10) is in scope. A file of yours there is read the same way and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line as its own drop-in in config.d, and the line goes from here in the same\n# change. The launcher, the clipboard, the wallpaper, the bars and the keyring already have theirs\n# (rofi, clipmenu, feh, i3status-rust, gnome-keyring).\n\n# the peripherals' tray (the operator's application)\nexec --no-startup-id polychromatic-tray-applet\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' drop-ins ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n" + "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR\n# 0212): the mesh places their lines near the end, each module's under a line naming it, where every\n# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read\n# after them, by the include at the very end, and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line to node-display-session, and the line goes from here in the same change.\n# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute\n# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).\n\n# the peripherals' tray (the operator's application)\nexec --no-startup-id polychromatic-tray-applet\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' lines ####\n#########################################\n# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the\n# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.\n${contribution:node-display-session:config}\n#########################################\n###### Your own files ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n" }, { "id": "session", diff --git a/modules/i3status-rust/README.md b/modules/i3status-rust/README.md index b0cb60c..70a19c7 100644 --- a/modules/i3status-rust/README.md +++ b/modules/i3status-rust/README.md @@ -84,3 +84,10 @@ Until one of them is chosen, **the laptop's bar shows no battery** once this mod runs per session and ends with the session, as a session-start line already does. - `pacman-contrib` is the `pacman` module's, which took it over as foreseen: a package is declared once per node. + +## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212) + +The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still +under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places +them in its own configuration under a `# ` line, so this module depends on a window manager +being assigned beside it. diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go index d4fb76d..441415f 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_helpers_test.go @@ -22,7 +22,9 @@ type manifest struct { Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` - Build struct { + // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. + Contributions []contribution `json:"contributions"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } } + +type contribution struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` +} + +// i3Lines is what the module contributes to the window manager. +func (m manifest) i3Lines() string { + var out string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" && c.Kind == "config" { + out += c.Content + } + } + return out +} + +// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +func (m manifest) i3LinesAreSource(t *testing.T, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + if m.i3Lines() != string(want) { + t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + } +} diff --git a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go index 1487c2b..0acfefa 100644 --- a/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go +++ b/modules/i3status-rust/cmd/i3status-rust-tools/manifest_test.go @@ -31,7 +31,7 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { m := readManifest(t) for id, src := range map[string]string{ "top-bar": "files/top-bar.toml", "bottom-bar": "files/bottom-bar.toml", "icons": "files/icons/custom-icons.toml", - "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", "i3-bars": "files/i3/60-i3status-rust.conf", + "updates": "files/bin/i3status-updates", "watchdog": "files/bin/i3bar-watchdog", } { m.sameAsSource(t, id, src) } @@ -47,13 +47,10 @@ func TestNoBlockFollowsAMachinesHardwareOrAPersonsDevicesOrCarriesAKey(t *testin } } -func TestTheBarsAreAnI3DropInAndTheWatchdogRunsOncePerSession(t *testing.T) { +func TestTheBarsAreAContributionToTheWindowManagerAndTheWatchdogRunsOncePerSession(t *testing.T) { m := readManifest(t) - bars := m.resource(t, "i3-bars") - if bars["path"] != "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf" { - t.Fatalf("%v", bars["path"]) - } - c := bars["content"].(string) + m.i3LinesAreSource(t, "files/i3/60-i3status-rust.conf") + c := m.i3Lines() if strings.Count(c, "bar {") != 2 || !strings.Contains(c, "status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml") || !strings.Contains(c, "font pango:JetBrainsMono Nerd Font 11") { t.Fatalf("%s", c) diff --git a/modules/i3status-rust/module.json b/modules/i3status-rust/module.json index febb652..ad64788 100644 --- a/modules/i3status-rust/module.json +++ b/modules/i3status-rust/module.json @@ -85,14 +85,6 @@ "owner": "${machine:account}", "mode": "0755", "content": "#!/usr/bin/env bash\n# i3bar-watchdog [session-pid] (module i3status-rust, novox/hq ADR 0208): respawns a bar that died.\n#\n# i3 starts one i3bar per `bar { }` block and never restarts one that exits, and a reload does not\n# either. Changing the monitor setup reliably kills the bar that owns the tray. This brings back the\n# one missing i3bar, without restarting i3, and logs the outputs at that moment: the evidence for the\n# cause, which is i3bar's.\n#\n# Started once per session from the session's start, with the session's own pid; it ends when that\n# process does. Adopted from the predecessor's i3-bar-watchdog user unit of 2026-10-04.\nset -uo pipefail\n\nsession_pid=\"${1:-}\"\ninterval=\"${I3_BAR_WATCHDOG_INTERVAL:-5}\"\n# Two misses in a row before acting: an i3 restart tears every bar down and starts them again.\nconfirm=\"${I3_BAR_WATCHDOG_CONFIRM:-2}\"\n# Never respawn the same bar more often than this, so a bar that dies at once is not a tight loop.\ncooldown=\"${I3_BAR_WATCHDOG_COOLDOWN:-30}\"\n\nlog() { printf 'i3bar-watchdog: %s\\n' \"$*\" >&2; }\n\nfor tool in i3-msg i3bar pgrep; do\n\tcommand -v \"$tool\" >/dev/null 2>&1 || {\n\t\tlog \"$tool is missing; not watching\"\n\t\texit 1\n\t}\ndone\n\ndeclare -A missed=() fixed=()\n\nbar_ids() { i3-msg -t get_bar_config 2>/dev/null | grep -oE '\"[^\"]+\"' | tr -d '\"'; }\noutputs() { xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $2\" \"$3}' | tr '\\n' ' '; }\nsession_alive() { [ -z \"$session_pid\" ] || kill -0 \"$session_pid\" 2>/dev/null; }\n\nwhile session_alive; do\n\tsleep \"$interval\"\n\tsocket=\"$(i3 --get-socketpath 2>/dev/null)\"\n\t[ -n \"$socket\" ] && [ -S \"$socket\" ] || continue\n\tids=\"$(bar_ids)\"\n\t[ -n \"$ids\" ] || continue\n\twhile read -r id; do\n\t\t[ -n \"$id\" ] || continue\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\t\tcontinue\n\t\tfi\n\t\tmissed[$id]=$((${missed[$id]:-0} + 1))\n\t\t[ \"${missed[$id]}\" -ge \"$confirm\" ] || continue\n\t\tnow=\"$(date +%s)\"\n\t\tif [ $((now - ${fixed[$id]:-0})) -lt \"$cooldown\" ]; then\n\t\t\tcontinue\n\t\tfi\n\t\tlog \"$id is gone; respawning it. Outputs now: $(outputs)\"\n\t\tnohup i3bar --bar_id=\"$id\" --socket=\"$socket\" >/dev/null 2>&1 &\n\t\tdisown 2>/dev/null || true\n\t\tfixed[$id]=\"$now\"\n\t\tsleep 2\n\t\tif pgrep -u \"$EUID\" -f -- \"i3bar --bar_id=$id\" >/dev/null 2>&1; then\n\t\t\tmissed[$id]=0\n\t\telse\n\t\t\tlog \"$id exited within 2s of respawning; check its status_command\"\n\t\tfi\n\tdone <<<\"$ids\"\ndone\n" - }, - { - "id": "i3-bars", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/60-i3status-rust.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" } ], "build": { @@ -109,5 +101,12 @@ ] } ] - } + }, + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n" + } + ] } diff --git a/modules/rofi/README.md b/modules/rofi/README.md index 827c8e1..59fba22 100644 --- a/modules/rofi/README.md +++ b/modules/rofi/README.md @@ -86,3 +86,10 @@ Once the `i3` module carries the main i3 configuration: module's. Until the controller knows the seat, `mctl` reads the claim as unknown. - `~/.local/bin` is on `PATH` through the `zsh` module's environment contribution (ADR 0203). The key bindings name `~/.local/bin/…` in full, so they do not depend on it. Callers of `dmenu` do. + +## Its i3 lines are a contribution (changed 2026-10-05, novox/hq ADR 0212) + +The module no longer writes a file into i3's `config.d`. Its window-manager lines (the source is still +under `files/i3/` where it had one) are a contribution to `node-display-session`. The i3 module places +them in its own configuration under a `# ` line, so this module depends on a window manager +being assigned beside it. diff --git a/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go index d4fb76d..441415f 100644 --- a/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go +++ b/modules/rofi/cmd/rofi-tools/manifest_helpers_test.go @@ -22,7 +22,9 @@ type manifest struct { Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` - Build struct { + // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. + Contributions []contribution `json:"contributions"` + Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } @@ -173,3 +175,32 @@ func checkNoSecretsOrInstallationNames(t *testing.T) { } } } + +type contribution struct { + Seat string `json:"seat"` + Kind string `json:"kind"` + Content string `json:"content"` +} + +// i3Lines is what the module contributes to the window manager. +func (m manifest) i3Lines() string { + var out string + for _, c := range m.Contributions { + if c.Seat == "node-display-session" && c.Kind == "config" { + out += c.Content + } + } + return out +} + +// i3LinesAreSource checks the window-manager contribution is the source file it is written from. +func (m manifest) i3LinesAreSource(t *testing.T, source string) { + t.Helper() + want, err := os.ReadFile(filepath.Join("..", "..", source)) + if err != nil { + t.Fatal(err) + } + if m.i3Lines() != string(want) { + t.Fatalf("the contribution to node-display-session is not %s: edit the source and copy it into module.json", source) + } +} diff --git a/modules/rofi/cmd/rofi-tools/manifest_test.go b/modules/rofi/cmd/rofi-tools/manifest_test.go index a9c8265..45fd21a 100644 --- a/modules/rofi/cmd/rofi-tools/manifest_test.go +++ b/modules/rofi/cmd/rofi-tools/manifest_test.go @@ -36,7 +36,6 @@ func TestItOwnsItsFilesAsWrittenInTheModule(t *testing.T) { "dmenu": "files/bin/dmenu", "launch": "files/bin/rofi-launch", "powermenu": "files/bin/rofi-powermenu", - "i3-bindings": "files/i3/50-rofi.conf", } { m.sameAsSource(t, id, source) } @@ -85,7 +84,8 @@ func TestThePowerMenuNamesNoWindowManagerAndNoLocker(t *testing.T) { func TestTheKeyBindingsAreAnI3DropInRunningTheModulesOwnCommands(t *testing.T) { m := readManifest(t) - bindings := m.resource(t, "i3-bindings")["content"].(string) + m.i3LinesAreSource(t, "files/i3/50-rofi.conf") + bindings := m.i3Lines() for _, line := range strings.Split(bindings, "\n") { if line == "" || strings.HasPrefix(line, "#") { continue diff --git a/modules/rofi/module.json b/modules/rofi/module.json index 85eafab..f58f570 100644 --- a/modules/rofi/module.json +++ b/modules/rofi/module.json @@ -88,14 +88,6 @@ "owner": "${machine:account}", "mode": "0755", "content": "#!/usr/bin/env bash\n# rofi-powermenu (module rofi, novox/hq ADR 0208): lock, suspend, log out, reboot, shut down.\n#\n# It belongs to the session, and it is the launcher's: its whole face is rofi's. Every action is a\n# verb of logind or the service manager, so nothing here names a window manager or a locker:\n# - lock asks logind to lock the session, which the holder of node-lock-screen answers;\n# - log out ends this login session, whichever window manager runs in it.\n# Adopted from the workstations' power menu of 2026-10-04 (after adi1090x's powermenu type-2).\nset -u\n\ntheme=mesh-powermenu\nuptime=\"$(uptime -p | sed -e 's/^up //')\"\n\nshutdown='󰤂'\nreboot='󰜉'\nlock='󰌾'\nsuspend='󰤄'\nlogout='󰍃'\nyes='󰄲'\nno='󰅖'\n\nmenu() {\n\trofi -dmenu -p \"Uptime: $uptime\" -mesg \"Uptime: $uptime\" -theme \"$theme\"\n}\n\nconfirm() {\n\tprintf '%s\\n%s\\n' \"$yes\" \"$no\" | rofi -dmenu -p 'Confirmation' -mesg 'Are you sure?' -theme \"$theme\" \\\n\t\t-theme-str 'window {location: center; anchor: center; fullscreen: false; width: 350px;}' \\\n\t\t-theme-str 'mainbox {children: [ \"message\", \"listview\" ];}' \\\n\t\t-theme-str 'listview {columns: 2; lines: 1;}' \\\n\t\t-theme-str 'element-text {horizontal-align: 0.5;}' \\\n\t\t-theme-str 'textbox {horizontal-align: 0.5;}'\n}\n\nsession() {\n\t# The login session this menu runs in: the session's own id, else logind's answer for this process.\n\tif [ -n \"${XDG_SESSION_ID:-}\" ]; then\n\t\techo \"$XDG_SESSION_ID\"\n\telse\n\t\tloginctl show-session auto -p Id --value 2>/dev/null\n\tfi\n}\n\nconfirmed() { [ \"$(confirm)\" = \"$yes\" ]; }\n\nchosen=\"$(printf '%s\\n' \"$lock\" \"$suspend\" \"$logout\" \"$reboot\" \"$shutdown\" | menu)\" || exit 0\ncase $chosen in\n\"$lock\") loginctl lock-session \"$(session)\" ;;\n\"$suspend\") confirmed && systemctl suspend ;;\n\"$logout\") confirmed && loginctl terminate-session \"$(session)\" ;;\n\"$reboot\") confirmed && systemctl reboot ;;\n\"$shutdown\") confirmed && systemctl poweroff ;;\nesac\nexit 0\n" - }, - { - "id": "i3-bindings", - "type": "file", - "path": "${machine:account-home}/.config/i3/config.d/50-rofi.conf", - "owner": "${machine:account}", - "mode": "0644", - "content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n" } ], "build": { @@ -112,5 +104,12 @@ ] } ] - } + }, + "contributions": [ + { + "seat": "node-display-session", + "kind": "config", + "content": "# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n" + } + ] }