diff --git a/modules/clipmenu/README.md b/modules/clipmenu/README.md
new file mode 100644
index 0000000..6243582
--- /dev/null
+++ b/modules/clipmenu/README.md
@@ -0,0 +1,69 @@
+# clipmenu
+
+The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
+
+- Installs `clipmenu` from the official repositories. It brings `clipnotify`, `xsel`, `xdotool` and
+ `dmenu` as its own dependencies.
+- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
+ `x11-display` on its own machine.
+- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
+- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
+ It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
+ merged), and the session start alone is one starter.
+- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
+ history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
+- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
+ alike:
+ - `CM_SELECTIONS=clipboard`: what was copied, not every highlighted word. The found greenclip did
+ the same.
+ - `CM_MAX_CLIPS=500`: how many clips are kept.
+ - `CM_HISTLENGTH=15`: how many lines the menu shows.
+
+## Tools
+
+| tool | does |
+|---|---|
+| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
+| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
+| `clipmenu_paste` | what the clipboard holds now |
+| `clipmenu_clear` | forget the history; the daemon's locks stay |
+| `clipmenu_delete` | forget one entry, by id or first line |
+
+The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
+own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
+owner (`xsel`) under the account's service manager. As a child of the tools runtime, the clipboard
+would empty whenever the runtime restarted.
+
+## What it improves on what was found
+
+- **No AUR package.** greenclip came from the user repository. clipmenu is in the official one.
+- **Started once.** greenclip was started by the window manager on both workstations, and on the
+ desktop by an enabled user unit as well.
+- **No absolute home path** in any configuration. greenclip's named one.
+- **The history does not outlive a reboot.** greenclip kept it in `~/.cache`, so every password ever
+ copied stayed on disk. clipmenu keeps it in the runtime directory, which is memory.
+- **One menu.** The history appears in the launcher's own menu, through the seat's `dmenu` command,
+ instead of a theme from a cloned theme repository.
+
+## What it leaves as found
+
+- `~/.config/greenclip.toml` and greenclip's history, `~/.cache/greenclip.history`.
+- On the desktop: greenclip's enabled user unit link
+ (`~/.config/systemd/user/default.target.wants/greenclip.service`). It dangles once the package is
+ gone.
+
+## Migration (ADR 0182)
+
+1. After the first push, delete `~/.config/greenclip.toml` and `~/.cache/greenclip.history`.
+2. On the desktop: `systemctl --user disable greenclip.service`, before the push if you can. The
+ package's removal takes the unit file with it.
+3. Until the `i3` module carries the main configuration, the found `exec --no-startup-id greenclip
+ daemon` and `$mod+period` lines stay in `~/.config/i3/config`. i3 reports `$mod+period` as bound
+ twice. The `i3` module's configuration carries neither.
+
+## Blockers
+
+- `node-clipboard`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows
+ them, `mctl` reads them as unknown.
+- `CM_*` reach the session through `node-env` (ADR 0203), so the account's environment module must
+ be assigned too. Without it clipmenu runs on its defaults: both selections, 1000 clips, 8 lines.
diff --git a/modules/clipmenu/cmd/clipmenu-tools/args.go b/modules/clipmenu/cmd/clipmenu-tools/args.go
new file mode 100644
index 0000000..9b5dfcf
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/args.go
@@ -0,0 +1,97 @@
+// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
+// The same in every desktop module that carries it.
+package main
+
+import (
+ "fmt"
+ "math"
+ "strings"
+ "time"
+)
+
+// text is a string argument, trimmed; required says an empty one is refused.
+func text(args map[string]any, key string, required bool) (string, error) {
+ v, present := args[key]
+ if !present || v == nil {
+ if required {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ return "", nil
+ }
+ s, ok := v.(string)
+ if !ok {
+ return "", fmt.Errorf("%s is a string, not %T", key, v)
+ }
+ s = strings.TrimSpace(s)
+ if s == "" && required {
+ return "", fmt.Errorf("%s is required", key)
+ }
+ return s, nil
+}
+
+// whole is a whole-number argument within [least, most], or def when absent.
+func whole(args map[string]any, key string, def, least, most int) (int, error) {
+ v, present := args[key]
+ if !present || v == nil {
+ return def, nil
+ }
+ f, ok := v.(float64)
+ if !ok {
+ if i, isInt := v.(int); isInt {
+ f = float64(i)
+ } else {
+ return 0, fmt.Errorf("%s is a number, not %T", key, v)
+ }
+ }
+ if f != math.Trunc(f) {
+ return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
+ }
+ n := int(f)
+ if n < least || n > most {
+ return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
+ }
+ return n, nil
+}
+
+// flag is a boolean argument, or def when absent.
+func flag(args map[string]any, key string, def bool) (bool, error) {
+ v, present := args[key]
+ if !present || v == nil {
+ return def, nil
+ }
+ b, ok := v.(bool)
+ if !ok {
+ return false, fmt.Errorf("%s is true or false, not %T", key, v)
+ }
+ return b, nil
+}
+
+// texts is a list-of-strings argument.
+func texts(args map[string]any, key string) ([]string, error) {
+ v, present := args[key]
+ if !present || v == nil {
+ return nil, nil
+ }
+ list, ok := v.([]any)
+ if !ok {
+ if ss, isStrings := v.([]string); isStrings {
+ return ss, nil
+ }
+ return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
+ }
+ out := make([]string, 0, len(list))
+ for i, item := range list {
+ s, ok := item.(string)
+ if !ok {
+ return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
+ }
+ out = append(out, s)
+ }
+ return out, nil
+}
+
+// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
+func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
+ n, err := whole(args, key, def, 1, most)
+ return time.Duration(n) * time.Second, err
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard.go
new file mode 100644
index 0000000..b025030
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard.go
@@ -0,0 +1,348 @@
+package main
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+const (
+ mostCopy = 1 << 20
+ mostPaste = 64 << 10
+ // majorVersion is clipmenu's store layout:
/clipmenu../.
+ majorVersion = 6
+)
+
+// account is the user clipmenu's store is named for.
+func account() string {
+ for _, k := range []string{"USER", "MESH_OPERATOR_ACCOUNT", "LOGNAME"} {
+ if v := strings.TrimSpace(os.Getenv(k)); v != "" {
+ return v
+ }
+ }
+ if u, err := user.Current(); err == nil {
+ return u.Username
+ }
+ return ""
+}
+
+// storeDir is where clipmenud keeps the history: CM_DIR, else the account's runtime directory.
+func storeDir(s Session) (string, error) {
+ base := os.Getenv("CM_DIR")
+ if base == "" {
+ base = s.RuntimeDir
+ }
+ if base == "" {
+ return "", fmt.Errorf("%w: the account's runtime directory, where the clipboard history lives, is missing (the account is not logged in)", ErrNoBus)
+ }
+ return filepath.Join(base, fmt.Sprintf("clipmenu.%d.%s", majorVersion, account())), nil
+}
+
+// cksum is POSIX cksum(1) of data: clipmenu names each entry's file by the cksum of its first line
+// followed by a newline, as " ".
+func cksum(data []byte) string {
+ var crc uint32
+ step := func(b byte) {
+ crc ^= uint32(b) << 24
+ for i := 0; i < 8; i++ {
+ if crc&0x80000000 != 0 {
+ crc = crc<<1 ^ 0x04C11DB7
+ } else {
+ crc <<= 1
+ }
+ }
+ }
+ for _, b := range data {
+ step(b)
+ }
+ for n := len(data); n != 0; n >>= 8 {
+ step(byte(n))
+ }
+ return fmt.Sprintf("%d %d", ^crc, len(data))
+}
+
+func entryID(line string) string { return cksum([]byte(line + "\n")) }
+
+// Entry is one clip in the history.
+type Entry struct {
+ ID string `json:"id"`
+ Line string `json:"line"`
+ At string `json:"at"`
+ Bytes int `json:"bytes"`
+ Text string `json:"text,omitempty"`
+ Truncated bool `json:"truncated,omitempty"`
+ at int64
+}
+
+// HistoryResult is what node-clipboard.history answers.
+type HistoryResult struct {
+ Collecting bool `json:"collecting"`
+ Store string `json:"store"`
+ Total int `json:"total"`
+ Entries []Entry `json:"entries"`
+}
+
+// readStore reads clipmenu's line cache: one " " per copy, oldest first, a
+// line repeated when the same thing was copied again. The newest copy of each line wins.
+func readStore(dir string) ([]Entry, error) {
+ f, err := os.Open(filepath.Join(dir, "line_cache"))
+ if errors.Is(err, os.ErrNotExist) {
+ return []Entry{}, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ latest := map[string]int64{}
+ scan := bufio.NewScanner(f)
+ scan.Buffer(make([]byte, 64<<10), 1<<20)
+ for scan.Scan() {
+ stamp, line, ok := strings.Cut(scan.Text(), " ")
+ if !ok {
+ continue
+ }
+ ns, err := strconv.ParseInt(stamp, 10, 64)
+ if err != nil {
+ continue
+ }
+ if ns >= latest[line] {
+ latest[line] = ns
+ }
+ }
+ out := make([]Entry, 0, len(latest))
+ for line, ns := range latest {
+ out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
+ return out, scan.Err()
+}
+
+// History is the clipboard's history, newest first.
+func History(limit, maxBytes int) (HistoryResult, error) {
+ dir, err := storeDir(findEnvironment())
+ if err != nil {
+ return HistoryResult{}, err
+ }
+ entries, err := readStore(dir)
+ if err != nil {
+ return HistoryResult{}, err
+ }
+ out := HistoryResult{Collecting: len(processesOf("clipmenud")) > 0, Store: dir, Total: len(entries), Entries: []Entry{}}
+ for i, e := range entries {
+ if i == limit {
+ break
+ }
+ if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
+ e.Bytes = int(info.Size())
+ if maxBytes > 0 {
+ raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
+ if len(raw) > maxBytes {
+ raw, e.Truncated = raw[:maxBytes], true
+ }
+ e.Text = string(raw)
+ }
+ }
+ out.Entries = append(out.Entries, e)
+ }
+ return out, nil
+}
+
+// CopyResult is what node-clipboard.copy answers.
+type CopyResult struct {
+ Bytes int `json:"bytes"`
+ Unit string `json:"unit"`
+}
+
+// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
+// owner (xsel) runs under the account's service manager, not as a child of this tool.
+func Copy(text string) (CopyResult, error) {
+ if len(text) == 0 {
+ return CopyResult{}, errors.New("text is empty; to empty the clipboard's history, clipmenu_clear")
+ }
+ if len(text) > mostCopy {
+ return CopyResult{}, fmt.Errorf("%d bytes; the clipboard takes at most %d here", len(text), mostCopy)
+ }
+ s, err := findSession()
+ if err != nil {
+ return CopyResult{}, err
+ }
+ if s.RuntimeDir == "" {
+ return CopyResult{}, fmt.Errorf("%w: no runtime directory to hand the text over in", ErrNoBus)
+ }
+ // Handed over in a file only the account can read, which the owner reads and removes.
+ f, err := os.CreateTemp(s.RuntimeDir, "clipmenu-copy-")
+ if err != nil {
+ return CopyResult{}, err
+ }
+ if _, err := f.WriteString(text); err != nil {
+ f.Close()
+ os.Remove(f.Name())
+ return CopyResult{}, err
+ }
+ f.Close()
+ unit := uniqueUnit("clipmenu-copy")
+ script := `xsel --nodetach --input --clipboard < "$0" & sleep 1; rm -f "$0"; wait`
+ if err := s.detach(unit, "/bin/sh", "-c", script, f.Name()); err != nil {
+ os.Remove(f.Name())
+ return CopyResult{}, err
+ }
+ return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
+}
+
+// PasteResult is what clipmenu_paste answers.
+type PasteResult struct {
+ Text string `json:"text"`
+ Bytes int `json:"bytes"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// Paste reads the clipboard now.
+func Paste() (PasteResult, error) {
+ s, err := findSession()
+ if err != nil {
+ return PasteResult{}, err
+ }
+ r, err := s.run(5*time.Second, "", "xsel", "--output", "--clipboard")
+ if err != nil {
+ return PasteResult{}, err
+ }
+ if r.Code != 0 {
+ return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
+ }
+ out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
+ if len(out.Text) > mostPaste {
+ out.Text, out.Truncated = out.Text[:mostPaste], true
+ }
+ return out, nil
+}
+
+// ChangeResult is what clear and delete answer.
+type ChangeResult struct {
+ Removed int `json:"removed"`
+ Remaining int `json:"remaining"`
+}
+
+// withStoreLock holds clipmenu's own lock on its store, the one clipmenud and clipdel take, while
+// change runs, so a copy arriving meanwhile is neither lost nor half-written.
+func withStoreLock(dir string, change func() error) error {
+ lock, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_CREATE|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ deadline := time.Now().Add(2 * time.Second)
+ for {
+ err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
+ if err == nil {
+ break
+ }
+ if time.Now().After(deadline) {
+ return fmt.Errorf("the clipboard store is locked by clipmenud and did not come free within 2s")
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
+ return change()
+}
+
+func storeOf() (string, error) {
+ dir, err := storeDir(findEnvironment())
+ if err != nil {
+ return "", err
+ }
+ if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("there is no clipboard history at %s: clipmenud has not run in this login", dir)
+ }
+ return dir, nil
+}
+
+// Clear forgets every entry and its text, keeping the store and its locks (clipdel's own clear
+// removes the directory, the daemon's lock with it).
+func Clear() (ChangeResult, error) {
+ dir, err := storeOf()
+ if err != nil {
+ return ChangeResult{}, err
+ }
+ var out ChangeResult
+ err = withStoreLock(dir, func() error {
+ entries, err := readStore(dir)
+ if err != nil {
+ return err
+ }
+ out.Removed = len(entries)
+ files, err := os.ReadDir(dir)
+ if err != nil {
+ return err
+ }
+ for _, f := range files {
+ switch f.Name() {
+ case "lock", "session_lock", "line_cache":
+ continue
+ }
+ if f.Type().IsRegular() {
+ if err := os.Remove(filepath.Join(dir, f.Name())); err != nil {
+ return err
+ }
+ }
+ }
+ return os.WriteFile(filepath.Join(dir, "line_cache"), nil, 0o600)
+ })
+ return out, err
+}
+
+// Delete forgets one entry, by id or by its first line.
+func Delete(id, line string) (ChangeResult, error) {
+ if (id == "") == (line == "") {
+ return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
+ }
+ dir, err := storeOf()
+ if err != nil {
+ return ChangeResult{}, err
+ }
+ var out ChangeResult
+ err = withStoreLock(dir, func() error {
+ raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ var kept []string
+ for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
+ if l == "" {
+ continue
+ }
+ _, text, _ := strings.Cut(l, " ")
+ if text == line || (id != "" && entryID(text) == id) {
+ out.Removed++
+ _ = os.Remove(filepath.Join(dir, entryID(text)))
+ continue
+ }
+ kept = append(kept, l)
+ }
+ if out.Removed == 0 {
+ return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
+ }
+ content := strings.Join(kept, "\n")
+ if content != "" {
+ content += "\n"
+ }
+ tmp := filepath.Join(dir, ".line_cache.mesh")
+ if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmp, filepath.Join(dir, "line_cache"))
+ })
+ if err != nil {
+ return ChangeResult{}, err
+ }
+ entries, _ := readStore(dir)
+ out.Remaining = len(entries)
+ return out, nil
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go
new file mode 100644
index 0000000..058b8ae
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/clipboard_test.go
@@ -0,0 +1,163 @@
+package main
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+const nobody = 4194400
+
+func TestEntryIdsAreWhatClipmenuNamesItsFiles(t *testing.T) {
+ for _, line := range []string{"hello", "", "two words (3 lines)", "ünïcode ✓", strings.Repeat("x", 300)} {
+ out, err := exec.Command("bash", "-c", `cksum <<< "$1"`, "_", line).Output()
+ if err != nil {
+ t.Skip("bash or cksum is missing here")
+ }
+ if got, want := entryID(line), strings.TrimSpace(string(out)); got != want {
+ t.Errorf("%q: %s, cksum says %s", line, got, want)
+ }
+ }
+}
+
+// store makes clipmenu's store as clipmenud leaves it, for the account the tools run as.
+func store(t *testing.T, clips map[string]string, order ...string) string {
+ t.Helper()
+ fakeMachine(t)
+ runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
+ t.Setenv("USER", "op")
+ t.Setenv("CM_DIR", "")
+ dir := filepath.Join(runtime, "clipmenu.6.op")
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ var cache strings.Builder
+ for i, line := range order {
+ cache.WriteString(strconv.FormatInt(1_700_000_000_000_000_000+int64(i)*1_000_000_000, 10) + " " + line + "\n")
+ if err := os.WriteFile(filepath.Join(dir, entryID(line)), []byte(clips[line]), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "line_cache"), []byte(cache.String()), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+}
+
+func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
+ store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
+ fakeProcess(t, nobody, "clipmenud")
+ h, err := History(10, 4096)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
+ t.Fatalf("%+v", h)
+ }
+ if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
+ t.Fatalf("limited and cut: %+v", h)
+ }
+ if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
+ t.Fatalf("without text: %+v", h)
+ }
+}
+
+func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
+ dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
+ r, err := Delete(entryID("a"), "")
+ if err != nil || r.Removed != 2 || r.Remaining != 2 {
+ t.Fatalf("by id, both copies: %+v, %v", r, err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("the text stayed")
+ }
+ if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
+ t.Fatalf("by line: %+v, %v", r, err)
+ }
+ if _, err := Delete("", "zzz"); err == nil {
+ t.Fatal("a missing entry was reported deleted")
+ }
+ if _, err := Delete("x", "y"); err == nil {
+ t.Fatal("both an id and a line were accepted")
+ }
+ cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
+ if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
+ t.Fatalf("line cache: %q", cache)
+ }
+}
+
+func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
+ dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
+ if err := os.WriteFile(filepath.Join(dir, "session_lock"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ r, err := Clear()
+ if err != nil || r.Removed != 2 {
+ t.Fatalf("%+v, %v", r, err)
+ }
+ left, _ := os.ReadDir(dir)
+ var names []string
+ for _, f := range left {
+ names = append(names, f.Name())
+ }
+ if strings.Join(names, ",") != "line_cache,lock,session_lock" {
+ t.Fatalf("left: %v", names)
+ }
+}
+
+func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
+ store(t, nil)
+ fakeProcess(t, nobody, "i3", "DISPLAY=:1")
+ bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
+ t.Setenv("LOG", filepath.Join(bin, "log"))
+ r, err := Copy("secret-free text")
+ if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
+ t.Fatalf("%+v, %v", r, err)
+ }
+ asked, _ := os.ReadFile(filepath.Join(bin, "log"))
+ if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 -- /bin/sh -c xsel --nodetach --input --clipboard") {
+ t.Fatalf("asked: %s", asked)
+ }
+ handed, _ := os.ReadFile(filepath.Join(bin, "log.text"))
+ if string(handed) != "secret-free text" {
+ t.Fatalf("handed over: %q", handed)
+ }
+ if _, err := Copy(""); err == nil {
+ t.Fatal("empty text was accepted")
+ }
+}
+
+func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
+ fakeMachine(t)
+ if _, err := Paste(); !errors.Is(err, ErrNoSession) {
+ t.Fatal(err)
+ }
+ fakeProcess(t, nobody, "i3", "DISPLAY=:1")
+ fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
+ p, err := Paste()
+ if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
+ t.Fatalf("%+v, %v", p, err)
+ }
+}
+
+func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
+ fakeMachine(t)
+ runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
+ if err := os.MkdirAll(runtime, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("USER", "op")
+ if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
+ t.Fatal(err)
+ }
+ if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
+ t.Fatalf("an empty history: %+v, %v", h, err)
+ }
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/main.go b/modules/clipmenu/cmd/clipmenu-tools/main.go
new file mode 100644
index 0000000..e8e9212
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/main.go
@@ -0,0 +1,90 @@
+// clipmenu's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
+// node-clipboard's verbs `history` and `copy`, and its own tools, served by the node's runtime as the
+// operator account. The history is read from clipmenu's own store in the account's runtime directory;
+// the clipboard itself is the X session's.
+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: "node-clipboard.history",
+ Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
+ "and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
+ Input: map[string]any{
+ "limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
+ "max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ limit, err := whole(args, "limit", 20, 1, 500)
+ if err != nil {
+ return nil, err
+ }
+ most, err := whole(args, "max_bytes", 4096, 0, 65536)
+ if err != nil {
+ return nil, err
+ }
+ return History(limit, most)
+ },
+ },
+ {
+ Name: "node-clipboard.copy",
+ Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
+ "like any copy. Answers how many bytes.",
+ Input: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "text": map[string]any{"type": "string", "description": fmt.Sprintf("the text (at most %d bytes)", mostCopy)},
+ },
+ "required": []string{"text"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ t, ok := args["text"].(string)
+ if !ok {
+ return nil, fmt.Errorf("text is required, as a string")
+ }
+ return Copy(t)
+ },
+ },
+ {
+ Name: "clipmenu_paste",
+ Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
+ Run: func(map[string]any) (any, error) { return Paste() },
+ },
+ {
+ Name: "clipmenu_clear",
+ Description: "Forget the whole clipboard history. What is on the clipboard now stays there.",
+ Run: func(map[string]any) (any, error) { return Clear() },
+ },
+ {
+ Name: "clipmenu_delete",
+ Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
+ "its first line exactly.",
+ Input: map[string]any{
+ "id": map[string]any{"type": "string", "description": "the entry's id"},
+ "line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
+ },
+ Run: func(args map[string]any) (any, error) {
+ id, err := text(args, "id", false)
+ if err != nil {
+ return nil, err
+ }
+ line, _ := args["line"].(string)
+ return Delete(id, line)
+ },
+ },
+ }
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go
new file mode 100644
index 0000000..d4fb76d
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_helpers_test.go
@@ -0,0 +1,175 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
+// every desktop module that carries it.
+
+type manifest struct {
+ Module string `json:"module"`
+ Version string `json:"version"`
+ Capabilities []string `json:"capabilities"`
+ Requires []string `json:"requires"`
+ Claims []claim `json:"claims"`
+ Seats []any `json:"seats"`
+ Tools []string `json:"tools"`
+ Environment *environment `json:"environment"`
+ Shell []shellCode `json:"shell"`
+ Resources []map[string]any `json:"resources"`
+ Build struct {
+ Artifacts []map[string]any `json:"artifacts"`
+ } `json:"build"`
+}
+
+type claim struct {
+ Name string `json:"name"`
+ Scope string `json:"scope"`
+ Serves []string `json:"serves"`
+}
+
+type environment struct {
+ Variables map[string]string `json:"variables"`
+ Path []map[string]any `json:"path"`
+}
+
+type shellCode struct {
+ For string `json:"for"`
+ Slot string `json:"slot"`
+ Code string `json:"code"`
+}
+
+func readManifest(t *testing.T) manifest {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ dec := json.NewDecoder(strings.NewReader(string(raw)))
+ dec.DisallowUnknownFields()
+ var m manifest
+ if err := dec.Decode(&m); err != nil {
+ t.Fatalf("module.json: %v", err)
+ }
+ return m
+}
+
+func (m manifest) resource(t *testing.T, id string) map[string]any {
+ t.Helper()
+ for _, r := range m.Resources {
+ if r["id"] == id {
+ return r
+ }
+ }
+ t.Fatalf("no resource %q", id)
+ return nil
+}
+
+func (m manifest) packages() (present, absent []string) {
+ for _, r := range m.Resources {
+ if r["type"] == "package" {
+ if r["absent"] == true {
+ absent = append(absent, r["package"].(string))
+ } else {
+ present = append(present, r["package"].(string))
+ }
+ }
+ }
+ return present, absent
+}
+
+// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
+// the readable file in the repository is what the machine gets.
+func (m manifest) sameAsSource(t *testing.T, id, source string) {
+ t.Helper()
+ want, err := os.ReadFile(filepath.Join("..", "..", source))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := m.resource(t, id)
+ if r["type"] != "file" {
+ t.Fatalf("%s is a %v, not a file", id, r["type"])
+ }
+ if got, _ := r["content"].(string); got != string(want) {
+ t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
+ }
+ if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
+ t.Fatalf("%s under the home is the account's", id)
+ }
+}
+
+// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
+// serves each seat verb the claims promise as ., and that the Go bundle is declared.
+func checkTheToolsAgree(t *testing.T, m manifest) {
+ t.Helper()
+ own, seat := map[string]bool{}, map[string]bool{}
+ for _, tool := range tools() {
+ if strings.Contains(tool.Name, ".") {
+ seat[tool.Name] = true
+ } else {
+ own[tool.Name] = true
+ }
+ if strings.TrimSpace(tool.Description) == "" {
+ t.Errorf("%s has no description", tool.Name)
+ }
+ }
+ listed := map[string]bool{}
+ for _, name := range m.Tools {
+ listed[name] = true
+ if !own[name] {
+ t.Errorf("module.json lists %s, which the bundle does not serve", name)
+ }
+ }
+ for name := range own {
+ if !listed[name] {
+ t.Errorf("the bundle serves %s, which module.json does not list", name)
+ }
+ if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
+ t.Errorf("%s is not prefixed with the module's name", name)
+ }
+ }
+ promised := map[string]bool{}
+ for _, c := range m.Claims {
+ for _, verb := range c.Serves {
+ promised[c.Name+"."+verb] = true
+ if !seat[c.Name+"."+verb] {
+ t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
+ }
+ }
+ }
+ for name := range seat {
+ if !promised[name] {
+ t.Errorf("the bundle serves %s, which no claim promises", name)
+ }
+ }
+ var bundle map[string]any
+ for _, a := range m.Build.Artifacts {
+ if a["kind"] == "bundle" {
+ bundle = a
+ }
+ }
+ if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
+ bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
+ t.Errorf("the Go tools bundle: %v", bundle)
+ }
+}
+
+// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
+func checkNoSecretsOrInstallationNames(t *testing.T) {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := strings.ToLower(string(raw))
+ for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
+ if strings.Contains(s, never) {
+ t.Errorf("module.json names %q", never)
+ }
+ }
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go
new file mode 100644
index 0000000..e0adb4c
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/manifest_test.go
@@ -0,0 +1,64 @@
+package main
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
+// and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once
+// from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command.
+
+func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
+ m := readManifest(t)
+ if m.Module != "clipmenu" || m.Seats != nil {
+ t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
+ }
+ if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) {
+ t.Fatalf("claims: %+v", m.Claims)
+ }
+ if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
+ t.Fatalf("requires: %v", m.Requires)
+ }
+ present, absent := m.packages()
+ if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) {
+ t.Fatalf("packages: %v, absent %v", present, absent)
+ }
+}
+
+func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
+ m := readManifest(t)
+ if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
+ t.Fatalf("%+v", m.Shell)
+ }
+ code := m.Shell[0].Code
+ if strings.Count(code, "\nclipmenud &\n") != 1 || strings.Contains(code, "greenclip") {
+ t.Fatalf("%q", code)
+ }
+ for _, r := range m.Resources {
+ if r["type"] == "service" || r["type"] == "process" {
+ t.Fatalf("a second start: %v", r)
+ }
+ }
+}
+
+func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
+ m := readManifest(t)
+ if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) {
+ t.Fatalf("%v", m.Environment.Variables)
+ }
+ 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") {
+ t.Fatalf("%s", c)
+ }
+}
+
+func TestTheToolsAgreeWithTheManifest(t *testing.T) {
+ m := readManifest(t)
+ checkTheToolsAgree(t, m)
+ checkNoSecretsOrInstallationNames(t)
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/session.go b/modules/clipmenu/cmd/clipmenu-tools/session.go
new file mode 100644
index 0000000..dc21774
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/session.go
@@ -0,0 +1,423 @@
+// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
+//
+// The runtime is a system service running as the operator account (ADR 0175): it has the account's
+// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
+// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
+// the account that is part of the session (the window manager first), the same thing `loginctl` and
+// a person's own shell would point at, and says where it found them.
+//
+// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
+// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
+// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
+// die with it.
+//
+// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
+// second consumer outside the desktop wants it.
+package main
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// Where the session is looked for. Variables so a test can point them at a fake tree.
+var (
+ procRoot = "/proc"
+ runUserDir = "/run/user"
+ x11Sockets = "/tmp/.X11-unix"
+)
+
+// sessionHolders are the processes whose environment is the session's, best first: the window
+// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
+var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
+
+// sessionKeys are the variables a session carries that a tool hands on to what it runs.
+var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
+ "XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
+
+// Session is what a tool needs to reach the operator's desktop.
+type Session struct {
+ UID int `json:"uid"`
+ Display string `json:"display,omitempty"`
+ XAuthority string `json:"xauthority,omitempty"`
+ Wayland string `json:"wayland_display,omitempty"`
+ Bus string `json:"bus,omitempty"`
+ RuntimeDir string `json:"runtime_dir,omitempty"`
+ SessionID string `json:"session_id,omitempty"`
+ I3Sock string `json:"i3sock,omitempty"`
+ // From says where the values were found: the tool's own environment, a process, or the socket.
+ From string `json:"from"`
+}
+
+// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
+var ErrNoSession = errors.New("no graphical session")
+
+// ErrTimedOut is what run answers for a command ended because it ran past its time.
+var ErrTimedOut = errors.New("timed out")
+
+// ErrNoBus is answered by a tool that needs the session bus when the account has none.
+var ErrNoBus = errors.New("no session bus")
+
+// operatorHome is the account's home: what the runtime was told, else the process's own.
+func operatorHome() string {
+ if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
+ return h
+ }
+ h, _ := os.UserHomeDir()
+ return h
+}
+
+// findSession finds the graphical session of the account this tool runs as, or answers
+// ErrNoSession with what it looked at.
+func findSession() (Session, error) {
+ s := findEnvironment()
+ if s.Display == "" && s.Wayland == "" {
+ return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
+ "or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
+ "Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
+ }
+ return s, nil
+}
+
+// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
+// is running.
+func findBus() (Session, error) {
+ s := findEnvironment()
+ if s.Bus == "" {
+ return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
+ "(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
+ }
+ return s, nil
+}
+
+func findEnvironment() Session {
+ uid := os.Getuid()
+ s := Session{UID: uid}
+ own := map[string]string{}
+ for _, k := range sessionKeys {
+ own[k] = os.Getenv(k)
+ }
+ if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
+ s.fill(own)
+ s.From = "the tool's own environment"
+ } else if pid, comm, env, ok := sessionProcess(uid); ok {
+ s.fill(env)
+ s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
+ } else if display, ok := lonelyX11Socket(); ok {
+ if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
+ s.Display, s.XAuthority = display, a
+ s.From = "the X server socket and the account's ~/.Xauthority"
+ }
+ s.fill(own)
+ } else {
+ s.fill(own)
+ s.From = "nothing: no session found"
+ }
+ // The bus and the runtime directory are the account's, whether or not the process named them.
+ runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
+ if s.RuntimeDir == "" && exists(runtime) {
+ s.RuntimeDir = runtime
+ }
+ if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
+ s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
+ }
+ return s
+}
+
+func (s *Session) fill(env map[string]string) {
+ set := func(dst *string, key string) {
+ if *dst == "" {
+ *dst = env[key]
+ }
+ }
+ set(&s.Display, "DISPLAY")
+ set(&s.XAuthority, "XAUTHORITY")
+ set(&s.Wayland, "WAYLAND_DISPLAY")
+ set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
+ set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
+ set(&s.SessionID, "XDG_SESSION_ID")
+ set(&s.I3Sock, "I3SOCK")
+}
+
+// sessionProcess is the best process of this uid whose environment names a display.
+func sessionProcess(uid int) (int, string, map[string]string, bool) {
+ entries, err := os.ReadDir(procRoot)
+ if err != nil {
+ return 0, "", nil, false
+ }
+ type candidate struct {
+ pid int
+ comm string
+ env map[string]string
+ rank int
+ }
+ var found []candidate
+ for _, e := range entries {
+ pid, err := strconv.Atoi(e.Name())
+ if err != nil {
+ continue
+ }
+ dir := filepath.Join(procRoot, e.Name())
+ if owner, ok := ownerOf(dir); !ok || owner != uid {
+ continue
+ }
+ raw, err := os.ReadFile(filepath.Join(dir, "environ"))
+ if err != nil {
+ continue
+ }
+ env := parseEnviron(raw)
+ if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
+ continue
+ }
+ comm := readTrimmed(filepath.Join(dir, "comm"))
+ rank := len(sessionHolders)
+ for i, h := range sessionHolders {
+ if h == comm {
+ rank = i
+ break
+ }
+ }
+ found = append(found, candidate{pid, comm, env, rank})
+ }
+ if len(found) == 0 {
+ return 0, "", nil, false
+ }
+ sort.Slice(found, func(i, j int) bool {
+ if found[i].rank != found[j].rank {
+ return found[i].rank < found[j].rank
+ }
+ return found[i].pid > found[j].pid // the newer of two equals
+ })
+ best := found[0]
+ return best.pid, best.comm, best.env, true
+}
+
+func parseEnviron(raw []byte) map[string]string {
+ env := map[string]string{}
+ for _, kv := range bytes.Split(raw, []byte{0}) {
+ if i := bytes.IndexByte(kv, '='); i > 0 {
+ env[string(kv[:i])] = string(kv[i+1:])
+ }
+ }
+ return env
+}
+
+func ownerOf(path string) (int, bool) {
+ info, err := os.Stat(path)
+ if err != nil {
+ return 0, false
+ }
+ st, ok := info.Sys().(*syscall.Stat_t)
+ if !ok {
+ return 0, false
+ }
+ return int(st.Uid), true
+}
+
+// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
+func lonelyX11Socket() (string, bool) {
+ entries, err := os.ReadDir(x11Sockets)
+ if err != nil {
+ return "", false
+ }
+ var displays []string
+ for _, e := range entries {
+ if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
+ if _, err := strconv.Atoi(n); err == nil {
+ displays = append(displays, ":"+n)
+ }
+ }
+ }
+ if len(displays) != 1 {
+ return "", false
+ }
+ return displays[0], true
+}
+
+func readTrimmed(path string) string {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(b))
+}
+
+func exists(path string) bool {
+ _, err := os.Stat(path)
+ return err == nil
+}
+
+// Env is this process's environment with the session's variables in place of its own.
+func (s Session) Env() []string {
+ drop := map[string]bool{}
+ for _, k := range sessionKeys {
+ drop[k] = true
+ }
+ var env []string
+ for _, kv := range os.Environ() {
+ if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
+ continue
+ }
+ env = append(env, kv)
+ }
+ add := func(k, v string) {
+ if v != "" {
+ env = append(env, k+"="+v)
+ }
+ }
+ add("DISPLAY", s.Display)
+ add("XAUTHORITY", s.XAuthority)
+ add("WAYLAND_DISPLAY", s.Wayland)
+ add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
+ add("XDG_RUNTIME_DIR", s.RuntimeDir)
+ add("XDG_SESSION_ID", s.SessionID)
+ add("I3SOCK", s.I3Sock)
+ return env
+}
+
+// mostOutput bounds what a command may answer with, per stream.
+const mostOutput = 256 << 10
+
+// Result is what a command did.
+type Result struct {
+ Stdout string `json:"stdout"`
+ Stderr string `json:"stderr,omitempty"`
+ Code int `json:"code"`
+ Truncated bool `json:"truncated,omitempty"`
+}
+
+// run runs a command in the session's environment, its input given, ended with everything it
+// started after timeout. A command that is not installed is an error naming it; one that exits
+// non-zero is a Result with its code, for the caller to judge.
+func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
+ path, err := exec.LookPath(name)
+ if err != nil {
+ return Result{}, fmt.Errorf("%s is not installed on this machine", name)
+ }
+ cmd := exec.Command(path, args...)
+ cmd.Env = s.Env()
+ if home := operatorHome(); exists(home) {
+ cmd.Dir = home
+ }
+ if stdin != "" {
+ cmd.Stdin = strings.NewReader(stdin)
+ }
+ var out, errOut capped
+ cmd.Stdout, cmd.Stderr = &out, &errOut
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
+ if err := cmd.Start(); err != nil {
+ return Result{}, fmt.Errorf("%s: %w", name, err)
+ }
+ done := make(chan error, 1)
+ go func() { done <- cmd.Wait() }()
+ select {
+ case err = <-done:
+ case <-time.After(timeout):
+ _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
+ <-done
+ return Result{Stdout: out.String(), Stderr: errOut.String()},
+ fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
+ }
+ r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
+ var exit *exec.ExitError
+ if errors.As(err, &exit) {
+ r.Code = exit.ExitCode()
+ } else if err != nil {
+ return r, fmt.Errorf("%s: %w", name, err)
+ }
+ return r, nil
+}
+
+// detach starts a long-lived program under the account's own service manager, as a transient unit
+// that carries the session's display, so it outlives the runtime that asked for it. A unit already
+// running under the same name is stopped first, so a fixed name means "at most one".
+func (s Session) detach(unit string, args ...string) error {
+ if s.RuntimeDir == "" {
+ return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
+ "cannot be reached", ErrNoBus)
+ }
+ _, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
+ call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
+ for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
+ {"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
+ if kv[1] != "" {
+ call = append(call, "--setenv="+kv[0]+"="+kv[1])
+ }
+ }
+ call = append(call, "--")
+ call = append(call, args...)
+ r, err := s.run(10*time.Second, "", "systemd-run", call...)
+ if err != nil {
+ return err
+ }
+ if r.Code != 0 {
+ return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
+ }
+ return nil
+}
+
+// uniqueUnit is a transient unit name that will not collide with an earlier one.
+func uniqueUnit(prefix string) string {
+ return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
+}
+
+type capped struct {
+ bytes.Buffer
+ cut bool
+}
+
+func (c *capped) Write(p []byte) (int, error) {
+ if room := mostOutput - c.Len(); room < len(p) {
+ if room > 0 {
+ c.Buffer.Write(p[:room])
+ }
+ c.cut = true
+ return len(p), nil
+ }
+ return c.Buffer.Write(p)
+}
+
+// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
+func processesOf(comm string) []int {
+ entries, err := os.ReadDir(procRoot)
+ if err != nil {
+ return nil
+ }
+ uid := os.Getuid()
+ var pids []int
+ for _, e := range entries {
+ pid, err := strconv.Atoi(e.Name())
+ if err != nil {
+ continue
+ }
+ dir := filepath.Join(procRoot, e.Name())
+ if owner, ok := ownerOf(dir); !ok || owner != uid {
+ continue
+ }
+ if readTrimmed(filepath.Join(dir, "comm")) == comm {
+ pids = append(pids, pid)
+ }
+ }
+ sort.Ints(pids)
+ return pids
+}
+
+// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
+func signalAll(comm string, sig syscall.Signal) []int {
+ var reached []int
+ for _, pid := range processesOf(comm) {
+ if syscall.Kill(pid, sig) == nil {
+ reached = append(reached, pid)
+ }
+ }
+ return reached
+}
diff --git a/modules/clipmenu/cmd/clipmenu-tools/session_test.go b/modules/clipmenu/cmd/clipmenu-tools/session_test.go
new file mode 100644
index 0000000..800cc6d
--- /dev/null
+++ b/modules/clipmenu/cmd/clipmenu-tools/session_test.go
@@ -0,0 +1,174 @@
+package main
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+)
+
+// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
+// none of the test process's own session variables, and gives back the root.
+func fakeMachine(t *testing.T) string {
+ t.Helper()
+ root := t.TempDir()
+ procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
+ for _, d := range []string{procRoot, runUserDir, x11Sockets} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ for _, k := range sessionKeys {
+ t.Setenv(k, "")
+ }
+ t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
+ t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
+ return root
+}
+
+func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
+ t.Helper()
+ dir := filepath.Join(procRoot, strconv.Itoa(pid))
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
+ fakeMachine(t)
+ fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
+ fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
+ "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
+ fakeProcess(t, 50, "bash", "PATH=/usr/bin")
+ s, err := findSession()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
+ t.Fatalf("the window manager's environment: %+v", s)
+ }
+ for _, kv := range s.Env() {
+ if strings.HasPrefix(kv, "SECRET_TOKEN=") {
+ t.Fatal("a variable of the session process that is not a session variable was handed on")
+ }
+ }
+}
+
+func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
+ fakeMachine(t)
+ fakeProcess(t, 10, "firefox", "DISPLAY=:0")
+ fakeProcess(t, 20, "firefox", "DISPLAY=:2")
+ s, err := findSession()
+ if err != nil || s.Display != ":2" {
+ t.Fatalf("the newest of two equals: %+v, %v", s, err)
+ }
+}
+
+func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
+ fakeMachine(t)
+ fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
+ _, err := findSession()
+ if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
+ t.Fatalf("no session: %v", err)
+ }
+}
+
+func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
+ root := fakeMachine(t)
+ if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s, err := findSession()
+ if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
+ t.Fatalf("socket and authority: %+v, %v", s, err)
+ }
+}
+
+func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
+ fakeMachine(t)
+ runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
+ if _, err := findBus(); !errors.Is(err, ErrNoBus) {
+ t.Fatalf("no runtime directory is no bus: %v", err)
+ }
+ if err := os.MkdirAll(runtime, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ s, err := findBus()
+ if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
+ t.Fatalf("bus: %+v, %v", s, err)
+ }
+ env := strings.Join(s.Env(), "\n")
+ if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
+ t.Fatalf("the bus is handed on: %s", env)
+ }
+}
+
+func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
+ fakeMachine(t)
+ s := Session{}
+ r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
+ if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
+ t.Fatalf("result: %+v, %v", r, err)
+ }
+ start := time.Now()
+ if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
+ t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
+ }
+ if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
+ t.Fatalf("a missing program: %v", err)
+ }
+}
+
+func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
+ fakeMachine(t)
+ bin := fakeBinaries(t, map[string]string{
+ "systemctl": `echo "systemctl $*" >> "$LOG"`,
+ "systemd-run": `echo "systemd-run $*" >> "$LOG"`,
+ })
+ log := filepath.Join(bin, "log")
+ t.Setenv("LOG", log)
+ s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
+ if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(log)
+ want := "systemctl --user stop picom-session.service\n" +
+ "systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
+ if string(got) != want {
+ t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
+ }
+ if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
+ t.Fatalf("no runtime directory: %v", err)
+ }
+}
+
+// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
+func fakeBinaries(t *testing.T, scripts map[string]string) string {
+ t.Helper()
+ dir := t.TempDir()
+ for name, body := range scripts {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
+ return dir
+}
diff --git a/modules/clipmenu/files/i3/50-clipmenu.conf b/modules/clipmenu/files/i3/50-clipmenu.conf
new file mode 100644
index 0000000..41835e6
--- /dev/null
+++ b/modules/clipmenu/files/i3/50-clipmenu.conf
@@ -0,0 +1,5 @@
+# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at
+# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which
+# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the
+# clipboard.
+bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard
diff --git a/modules/clipmenu/go.mod b/modules/clipmenu/go.mod
new file mode 100644
index 0000000..f6c083d
--- /dev/null
+++ b/modules/clipmenu/go.mod
@@ -0,0 +1,5 @@
+module clipmenu
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.7
diff --git a/modules/clipmenu/go.sum b/modules/clipmenu/go.sum
new file mode 100644
index 0000000..b474419
--- /dev/null
+++ b/modules/clipmenu/go.sum
@@ -0,0 +1,2 @@
+git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
+git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
diff --git a/modules/clipmenu/module.json b/modules/clipmenu/module.json
new file mode 100644
index 0000000..1cb1991
--- /dev/null
+++ b/modules/clipmenu/module.json
@@ -0,0 +1,75 @@
+{
+ "module": "clipmenu",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "requires": [
+ "x11-display"
+ ],
+ "claims": [
+ {
+ "name": "node-clipboard",
+ "scope": "node",
+ "serves": [
+ "history",
+ "copy"
+ ]
+ }
+ ],
+ "tools": [
+ "clipmenu_paste",
+ "clipmenu_clear",
+ "clipmenu_delete"
+ ],
+ "environment": {
+ "variables": {
+ "CM_SELECTIONS": "clipboard",
+ "CM_MAX_CLIPS": "500",
+ "CM_HISTLENGTH": "15"
+ }
+ },
+ "shell": [
+ {
+ "for": "xinitrc",
+ "slot": "normal",
+ "code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\nclipmenud &\n"
+ }
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "clipmenu"
+ },
+ {
+ "id": "greenclip",
+ "type": "package",
+ "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"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/clipmenu-tools",
+ "binary": "clipmenu-tools",
+ "loads": [
+ "clipmenu-tools"
+ ]
+ }
+ ]
+ }
+}