mesh/merge-gate out of date: judged against trunk aee97bf8ad42, the trunk is now 7443938fef69; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk aee97bf8ad42, the trunk is now 7443938fef69; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
clipmenud is declared as the operator's user unit, enabled and running, with a launcher that finds the display. Copy reads the clipboard back and fails with the reason. History and paste answer counts, times and lengths, never content.
388 lines
11 KiB
Go
388 lines
11 KiB
Go
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: <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 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 "<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: 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
|
|
}
|