package main import ( "bufio" "errors" "fmt" "os" "os/user" "path/filepath" "sort" "strconv" "strings" "syscall" "time" ) const ( mostCopy = 1 << 20 // 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 as a caller sees it: when and how long, never what. The text of a // clip, its first line and its id (a checksum of that line) stay in clipmenu's store, readable on the // operator's own screen through clipmenu (issue 750). type Entry struct { At string `json:"at"` Bytes int `json:"bytes"` id string line string 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: how many entries, when each was copied and how // long each is, and whether the daemon is collecting. It never returns a copied text. func History(limit 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()) } 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"` // Verified says the clipboard was read back and held exactly the text. Verified bool `json:"verified"` } // readBackWait is the pause between reads of the clipboard while the owner starts; read-back tries 15 times. var readBackWait = 200 * time.Millisecond // readBack waits for the clipboard to hold text and answers why when it does not. func readBack(s Session, unit, text string) error { var last string for i := 0; i < 15; i++ { time.Sleep(readBackWait) r, err := s.run(3*time.Second, "", "xsel", "--output", "--clipboard") switch { case err != nil: last = err.Error() case r.Code != 0: last = "xsel: " + strings.TrimSpace(r.Stderr) case r.Stdout == text: return nil default: last = fmt.Sprintf("it holds %d bytes of something else", len(r.Stdout)) } } why := "" if s.Display == "" { why = "; the session has no DISPLAY, so the owner could not reach the screen" } if j, err := s.run(5*time.Second, "", "journalctl", "--user", "-u", unit+".service", "-n", "5", "--no-pager", "-o", "cat"); err == nil { if tail := strings.TrimSpace(j.Stdout); tail != "" { why += "; the owner said: " + tail } } return fmt.Errorf("the clipboard does not hold the text (%d bytes): %s%s", len(text), last, why) } // 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 } if err := readBack(s, unit, text); err != nil { return CopyResult{}, err } return CopyResult{Bytes: len(text), Unit: unit + ".service", Verified: true}, nil } // PasteResult is what clipmenu_paste answers: how much text the clipboard holds, never the text. The // clipboard is the newest history entry, and a copied secret must not reach a caller (issue 750). type PasteResult struct { Bytes int `json:"bytes"` Text bool `json:"holds_text"` } // Paste reads the clipboard now and says how long it is. 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)) } return PasteResult{Bytes: len(r.Stdout), Text: len(r.Stdout) > 0}, 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 its place in the history as node-clipboard.history lists it (1 is the // newest) together with the time it reports, so a copy arriving since the listing cannot shift the // place onto another entry. Not by its text or id: either would let a caller confirm a guess of a copied line. func Delete(place int, at string) (ChangeResult, error) { if place < 1 { return ChangeResult{}, errors.New("give the entry's place in the history, 1 for the newest") } 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 } if place > len(entries) { return fmt.Errorf("no entry %d: the clipboard history holds %d", place, len(entries)) } if entries[place-1].At != at { return fmt.Errorf("entry %d was copied at %s, not at %s: the history changed since it was listed; list it again", place, entries[place-1].At, at) } line := entries[place-1].line 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 { out.Removed++ _ = os.Remove(filepath.Join(dir, entryID(text))) continue } kept = append(kept, l) } if out.Removed == 0 { return fmt.Errorf("no entry %d in the clipboard history", place) } 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 }