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
}