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 }