clipman/main.go

244 lines
6.7 KiB
Go
Raw Permalink Normal View History

2020-02-18 19:36:01 +01:00
// GPL v3.0
// 2019- (C) yory8 <yory8@users.noreply.github.com>
2019-03-22 15:13:41 +01:00
package main
import (
"bufio"
2020-05-07 17:35:06 +02:00
"bytes"
2019-03-22 15:13:41 +01:00
"encoding/json"
2019-08-02 13:51:13 +02:00
"fmt"
2019-03-22 15:13:41 +01:00
"io/ioutil"
"os"
"os/exec"
2019-09-07 19:41:41 +02:00
"strings"
2020-05-12 15:49:29 +02:00
"syscall"
"gopkg.in/alecthomas/kingpin.v2"
2019-03-22 15:13:41 +01:00
)
2020-05-12 16:01:01 +02:00
const version = "1.5.2"
2019-10-02 16:20:04 +02:00
var (
2019-09-17 09:46:30 +02:00
app = kingpin.New("clipman", "A clipboard manager for Wayland")
histpath = app.Flag("histpath", "Path of history file").Default("~/.local/share/clipman.json").String()
2020-02-25 22:24:56 +01:00
alert = app.Flag("notify", "Send desktop notifications on errors").Bool()
2019-09-17 09:46:30 +02:00
storer = app.Command("store", "Record clipboard events (run as argument to `wl-paste --watch`)")
maxDemon = storer.Flag("max-items", "history size").Default("15").Int()
noPersist = storer.Flag("no-persist", "Don't persist a copy buffer after a program exits").Short('P').Default("false").Bool()
unix = storer.Flag("unix", "Normalize line endings to LF").Bool()
2019-09-17 09:46:30 +02:00
picker = app.Command("pick", "Pick an item from clipboard history")
maxPicker = picker.Flag("max-items", "scrollview length").Default("15").Int()
pickTool = picker.Flag("tool", "Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT").Short('t').Required().String()
pickToolArgs = picker.Flag("tool-args", "Extra arguments to pass to the --tool").Short('T').Default("").String()
pickEsc = picker.Flag("print0", "Separate items using NULL; recommended if your tool supports --read0 or similar").Default("false").Bool()
2020-02-19 14:27:47 +01:00
clearer = app.Command("clear", "Remove item/s from history")
maxClearer = clearer.Flag("max-items", "scrollview length").Default("15").Int()
clearTool = clearer.Flag("tool", "Which selector to use: wofi/bemenu/CUSTOM/dmenu/rofi/STDOUT").Short('t').String()
clearToolArgs = clearer.Flag("tool-args", "Extra arguments to pass to the --tool").Short('T').Default("").String()
clearAll = clearer.Flag("all", "Remove all items").Short('a').Default("false").Bool()
clearEsc = clearer.Flag("print0", "Separate items using NULL; recommended if your tool supports --read0 or similar").Default("false").Bool()
2019-11-08 13:22:36 +01:00
_ = app.Command("restore", "Serve the last recorded item from history")
)
2019-03-22 15:13:41 +01:00
func main() {
2019-10-02 16:20:04 +02:00
app.Version(version)
app.HelpFlag.Short('h')
2019-10-02 16:20:04 +02:00
app.VersionFlag.Short('v')
2019-10-27 16:00:51 +01:00
action := kingpin.MustParse(app.Parse(os.Args[1:]))
histfile, history, err := getHistory(*histpath)
if err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "critical", *alert)
2019-10-27 16:00:51 +01:00
}
2019-10-27 16:00:51 +01:00
switch action {
case "store":
// read copy from stdin
var stdin []string
scanner := bufio.NewScanner(os.Stdin)
2020-05-07 17:35:06 +02:00
scanner.Split(scanLines)
for scanner.Scan() {
stdin = append(stdin, scanner.Text())
}
if err := scanner.Err(); err != nil {
2020-02-25 22:24:56 +01:00
smartLog("Couldn't get input from stdin.", "critical", *alert)
}
2020-05-07 17:35:06 +02:00
text := strings.Join(stdin, "")
2019-09-17 09:46:30 +02:00
persist := !*noPersist
if err := store(text, history, histfile, *maxDemon, persist); err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "critical", *alert)
2019-09-17 09:46:30 +02:00
}
case "pick":
selection, err := selector(history, *maxPicker, *pickTool, "pick", *pickToolArgs, *pickEsc)
if err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "normal", *alert)
}
if selection != "" {
// serve selection to the OS
2019-10-27 23:18:46 +01:00
serveTxt(selection)
2019-09-17 11:03:37 +02:00
}
case "restore":
if len(history) == 0 {
2020-02-25 22:24:56 +01:00
fmt.Println("Nothing to restore")
return
}
2019-10-27 23:18:46 +01:00
serveTxt(history[len(history)-1])
case "clear":
2019-09-17 09:46:30 +02:00
// remove all history
if *clearAll {
if err := wipeAll(histfile); err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "normal", *alert)
}
return
}
2020-02-19 14:27:47 +01:00
if *clearTool == "" {
fmt.Println("clipman: error: required flag --tool or --all not provided, try --help")
os.Exit(1)
}
selection, err := selector(history, *maxClearer, *clearTool, "clear", *clearToolArgs, *clearEsc)
if err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "normal", *alert)
}
if selection == "" {
return
}
if len(history) < 2 {
// there was only one possible item we could select, and we selected it,
// so wipe everything
if err := wipeAll(histfile); err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "normal", *alert)
}
return
}
if selection == history[len(history)-1] {
// wl-copy is still serving the copy, so replace with next latest
// note: we alread exited if less than 2 items
2019-10-28 07:40:33 +01:00
serveTxt(history[len(history)-2])
}
if err := write(filter(history, selection), histfile); err != nil {
2020-02-25 22:24:56 +01:00
smartLog(err.Error(), "critical", *alert)
}
}
}
func wipeAll(histfile string) error {
// clear WM's clipboard
if err := exec.Command("wl-copy", "-c").Run(); err != nil {
return err
2019-03-22 15:13:41 +01:00
}
if err := os.Remove(histfile); err != nil {
return err
}
return nil
}
2019-03-22 15:13:41 +01:00
2019-09-17 09:46:30 +02:00
func getHistory(rawPath string) (string, []string, error) {
// set histfile; expand user home
histfile := rawPath
2019-09-07 19:41:41 +02:00
if strings.HasPrefix(histfile, "~") {
2019-08-24 21:08:38 +02:00
home, err := os.UserHomeDir()
if err != nil {
return "", nil, err
2019-08-24 21:08:38 +02:00
}
2019-09-07 19:41:41 +02:00
histfile = strings.Replace(histfile, "~", home, 1)
2019-03-22 15:13:41 +01:00
}
2019-09-17 09:46:30 +02:00
// read history if it exists
2019-03-25 19:13:19 +01:00
var history []string
b, err := ioutil.ReadFile(histfile)
2019-09-07 19:41:41 +02:00
if err != nil {
2019-09-16 12:51:33 +02:00
if !os.IsNotExist(err) {
2019-09-17 09:46:30 +02:00
return "", nil, fmt.Errorf("failure reading history file: %s", err)
2019-09-16 12:51:33 +02:00
}
} else {
if err := json.Unmarshal(b, &history); err != nil {
2019-09-17 09:46:30 +02:00
return "", nil, fmt.Errorf("failure parsing history: %s", err)
2019-09-16 12:51:33 +02:00
}
2019-03-22 15:13:41 +01:00
}
return histfile, history, nil
2019-03-22 15:13:41 +01:00
}
2019-10-27 21:53:27 +01:00
2019-10-27 23:18:46 +01:00
func serveTxt(s string) {
2019-10-27 21:53:27 +01:00
bin, err := exec.LookPath("wl-copy")
if err != nil {
2020-02-25 22:24:56 +01:00
smartLog(fmt.Sprintf("couldn't find wl-copy: %v\n", err), "low", *alert)
2019-10-27 21:53:27 +01:00
}
2020-05-12 15:49:29 +02:00
// daemonize wl-copy into a truly independent process
// necessary for running stuff like `alacritty -e sh -c clipman pick`
attr := &syscall.SysProcAttr{
Setpgid: true,
}
2020-02-19 13:54:35 +01:00
// we mandate the mime type because we know we can only serve text; not doing this leads to weird bugs like #35
2020-05-12 15:49:29 +02:00
cmd := exec.Cmd{Path: bin, Args: []string{bin, "-t", "TEXT"}, Stdin: strings.NewReader(s), SysProcAttr: attr}
2019-10-27 21:53:27 +01:00
if err := cmd.Run(); err != nil {
2020-02-25 22:24:56 +01:00
smartLog(fmt.Sprintf("error running wl-copy: %s\n", err), "low", *alert)
2019-10-27 21:53:27 +01:00
}
}
2020-05-07 17:35:06 +02:00
// modified from standard lib to not drop \r and \n
func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexByte(data, '\n'); i >= 0 {
// We have a full newline-terminated line.
b := data[0 : i+1]
if *unix {
b = dropCR(b)
}
return i + 1, b, nil
2020-05-07 17:35:06 +02:00
}
// If we're at EOF, we have a final, non-terminated line. Return it.
if atEOF {
b := data
if *unix {
b = dropCR(b)
}
return len(data), b, nil
2020-05-07 17:35:06 +02:00
}
// Request more data.
return 0, nil, nil
}
// dropCR drops a terminal \r from the data. Modified from Go's Stdlib
func dropCR(data []byte) []byte {
orig := data
var lf bool
if len(data) > 0 && data[len(data)-1] == '\n' {
lf = true
data = data[0 : len(data)-1]
}
if len(data) > 0 && data[len(data)-1] == '\r' {
b := data[0 : len(data)-1]
if lf {
b = append(b, '\n')
}
return b
}
return orig
}