Replaces the AUR greenclip (declared absent) with the official clipmenu, started once from the session's xinitrc slot, its menu the launcher's dmenu command bound as an i3 drop-in, its history in the runtime directory. Go tools read and change clipmenu's own store under its lock.
349 lines
9.4 KiB
Go
349 lines
9.4 KiB
Go
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: <dir>/clipmenu.<major>.<user>/.
|
|
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 "<crc> <length>".
|
|
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 "<nanoseconds> <first line>" 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
|
|
}
|