commit 7435a13e5f4d176db4421149b85e6d3319122c3a
parent 564feb393e606b410d5cc88746c10398976035b3
Author: Sean Enck <sean@ttypty.com>
Date: Sun, 29 Jun 2025 21:39:18 -0400
merge clip and platform into one
Diffstat:
11 files changed, 615 insertions(+), 616 deletions(-)
diff --git a/cmd/lb/main.go b/cmd/lb/main.go
@@ -11,7 +11,6 @@ import (
"git.sr.ht/~enckse/lockbox/internal/app/commands"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/platform"
- "git.sr.ht/~enckse/lockbox/internal/platform/clip"
)
var version string
@@ -45,7 +44,7 @@ func handleEarly(command string, args []string) (bool, error) {
fmt.Printf("version: %s\n", vers)
return true, nil
case commands.ClipManager, commands.ClipManagerDaemon:
- return true, clip.Manager(command == commands.ClipManagerDaemon, clip.DefaultDaemon{})
+ return true, platform.ClipboardManager(command == commands.ClipManagerDaemon, platform.DefaultClipboardDaemon{})
}
return false, nil
}
diff --git a/internal/app/showclip.go b/internal/app/showclip.go
@@ -6,7 +6,7 @@ import (
"fmt"
"git.sr.ht/~enckse/lockbox/internal/kdbx"
- "git.sr.ht/~enckse/lockbox/internal/platform/clip"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
)
// ShowClip will handle showing/clipping an entry
@@ -16,10 +16,10 @@ func ShowClip(cmd CommandOptions, isShow bool) error {
return errors.New("only one argument supported")
}
entry := args[0]
- clipboard := clip.Board{}
+ clipboard := platform.Clipboard{}
if !isShow {
var err error
- clipboard, err = clip.New(clip.DefaultLoader{Full: false})
+ clipboard, err = platform.NewClipboard(platform.DefaultClipboardLoader{Full: false})
if err != nil {
return fmt.Errorf("unable to get clipboard: %w", err)
}
diff --git a/internal/app/totp.go b/internal/app/totp.go
@@ -15,7 +15,7 @@ import (
"git.sr.ht/~enckse/lockbox/internal/app/commands"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/kdbx"
- "git.sr.ht/~enckse/lockbox/internal/platform/clip"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
)
var (
@@ -119,9 +119,9 @@ func (args *TOTPArguments) display(opts TOTPOptions) error {
opts.Clear()
}
}
- clipboard := clip.Board{}
+ clipboard := platform.Clipboard{}
if clipMode {
- clipboard, err = clip.New(clip.DefaultLoader{Full: false})
+ clipboard, err = platform.NewClipboard(platform.DefaultClipboardLoader{Full: false})
if err != nil {
return err
}
diff --git a/internal/platform/clip.go b/internal/platform/clip.go
@@ -0,0 +1,160 @@
+// Package platform handles platform-specific operations around clipboards.
+package platform
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "runtime"
+ "strings"
+
+ "git.sr.ht/~enckse/lockbox/internal/config"
+)
+
+type (
+ // Clipboard represent system clipboard operations.
+ Clipboard struct {
+ PIDFile string
+ copying []string
+ pasting []string
+ MaxTime int64
+ }
+ // ClipboardLoader handles how the system is detected
+ ClipboardLoader interface {
+ Name() (string, error)
+ Runtime() string
+ Complete() bool
+ }
+ // DefaultClipboardLoader is the default system detector
+ DefaultClipboardLoader struct {
+ Full bool
+ }
+)
+
+// Name will get the uname results
+func (l DefaultClipboardLoader) Name() (string, error) {
+ b, err := exec.Command("uname", "-a").Output()
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+// Runtime will return the GOOS runtime
+func (l DefaultClipboardLoader) Runtime() string {
+ return runtime.GOOS
+}
+
+// Complete indicates if the loader needs a full complete
+func (l DefaultClipboardLoader) Complete() bool {
+ return l.Full
+}
+
+func newBoard(copying, pasting []string) (Clipboard, error) {
+ maximum, err := config.EnvClipTimeout.Get()
+ if err != nil {
+ return Clipboard{}, err
+ }
+ pid := config.EnvClipProcessFile.Get()
+ return Clipboard{copying: copying, pasting: pasting, MaxTime: maximum, PIDFile: pid}, nil
+}
+
+// NewClipboard creates a new clipboard
+func NewClipboard(loader ClipboardLoader) (Clipboard, error) {
+ if !config.EnvFeatureClip.Get() {
+ return Clipboard{}, config.NewFeatureError("clip")
+ }
+ overridePaste := config.EnvClipPaste.Get()
+ overrideCopy := config.EnvClipCopy.Get()
+ setPaste := len(overridePaste) > 0
+ setCopy := len(overrideCopy) > 0
+ if setPaste && setCopy {
+ return newBoard(overrideCopy, overridePaste)
+ }
+ if setCopy && !loader.Complete() {
+ return newBoard(overrideCopy, []string{})
+ }
+
+ var copying []string
+ var pasting []string
+ switch loader.Runtime() {
+ case "darwin":
+ copying = []string{"pbcopy"}
+ pasting = []string{"pbpaste"}
+ case "linux":
+ name, err := loader.Name()
+ if err != nil {
+ return Clipboard{}, err
+ }
+ if strings.Contains(strings.ToLower(name), "microsoft") {
+ copying = []string{"clip.exe"}
+ pasting = []string{"powershell.exe", "-command", "Get-Clipboard"}
+ } else {
+ if strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) != "" {
+ copying = []string{"wl-copy"}
+ pasting = []string{"wl-paste"}
+ } else {
+ if strings.TrimSpace(os.Getenv("DISPLAY")) != "" {
+ copying = []string{"xclip"}
+ pasting = []string{"xclip", "-o"}
+ } else {
+ return Clipboard{}, errors.New("unable to detect linux clipboard")
+ }
+ }
+ }
+ default:
+ return Clipboard{}, errors.New("clipboard is unavailable")
+ }
+ if setPaste {
+ pasting = overridePaste
+ }
+ if setCopy {
+ copying = overrideCopy
+ }
+ return newBoard(copying, pasting)
+}
+
+// CopyTo will copy to clipboard, if non-empty will clear later.
+func (c Clipboard) CopyTo(value string) error {
+ cmd, args, err := c.Args(true)
+ if err != nil {
+ return err
+ }
+ pipeTo(cmd, value, args...)
+ return nil
+}
+
+// Args returns clipboard args for execution.
+func (c Clipboard) Args(copying bool) (string, []string, error) {
+ var using []string
+ if copying {
+ using = c.copying
+ } else {
+ using = c.pasting
+ }
+ if len(using) == 0 {
+ return "", nil, fmt.Errorf("command is not set (copying? %v)", copying)
+ }
+ var args []string
+ if len(using) > 1 {
+ args = using[1:]
+ }
+ return using[0], args, nil
+}
+
+func pipeTo(command, value string, args ...string) error {
+ cmd := exec.Command(command, args...)
+ stdin, err := cmd.StdinPipe()
+ if err != nil {
+ return err
+ }
+
+ go func() {
+ defer stdin.Close()
+ if _, err := stdin.Write([]byte(value)); err != nil {
+ fmt.Printf("failed writing to stdin: %v\n", err)
+ }
+ }()
+ return cmd.Run()
+}
diff --git a/internal/platform/clip/core.go b/internal/platform/clip/core.go
@@ -1,160 +0,0 @@
-// Package clip handles platform-specific operations around clipboards.
-package clip
-
-import (
- "errors"
- "fmt"
- "os"
- "os/exec"
- "runtime"
- "strings"
-
- "git.sr.ht/~enckse/lockbox/internal/config"
-)
-
-type (
- // Board represent system clipboard operations.
- Board struct {
- PIDFile string
- copying []string
- pasting []string
- MaxTime int64
- }
- // Loader handles how the system is detected
- Loader interface {
- Name() (string, error)
- Runtime() string
- Complete() bool
- }
- // DefaultLoader is the default system detector
- DefaultLoader struct {
- Full bool
- }
-)
-
-// Name will get the uname results
-func (l DefaultLoader) Name() (string, error) {
- b, err := exec.Command("uname", "-a").Output()
- if err != nil {
- return "", err
- }
- return string(b), nil
-}
-
-// Runtime will return the GOOS runtime
-func (l DefaultLoader) Runtime() string {
- return runtime.GOOS
-}
-
-// Complete indicates if the loader needs a full complete
-func (l DefaultLoader) Complete() bool {
- return l.Full
-}
-
-func newBoard(copying, pasting []string) (Board, error) {
- maximum, err := config.EnvClipTimeout.Get()
- if err != nil {
- return Board{}, err
- }
- pid := config.EnvClipProcessFile.Get()
- return Board{copying: copying, pasting: pasting, MaxTime: maximum, PIDFile: pid}, nil
-}
-
-// New creates a new clipboard
-func New(loader Loader) (Board, error) {
- if !config.EnvFeatureClip.Get() {
- return Board{}, config.NewFeatureError("clip")
- }
- overridePaste := config.EnvClipPaste.Get()
- overrideCopy := config.EnvClipCopy.Get()
- setPaste := len(overridePaste) > 0
- setCopy := len(overrideCopy) > 0
- if setPaste && setCopy {
- return newBoard(overrideCopy, overridePaste)
- }
- if setCopy && !loader.Complete() {
- return newBoard(overrideCopy, []string{})
- }
-
- var copying []string
- var pasting []string
- switch loader.Runtime() {
- case "darwin":
- copying = []string{"pbcopy"}
- pasting = []string{"pbpaste"}
- case "linux":
- name, err := loader.Name()
- if err != nil {
- return Board{}, err
- }
- if strings.Contains(strings.ToLower(name), "microsoft") {
- copying = []string{"clip.exe"}
- pasting = []string{"powershell.exe", "-command", "Get-Clipboard"}
- } else {
- if strings.TrimSpace(os.Getenv("WAYLAND_DISPLAY")) != "" {
- copying = []string{"wl-copy"}
- pasting = []string{"wl-paste"}
- } else {
- if strings.TrimSpace(os.Getenv("DISPLAY")) != "" {
- copying = []string{"xclip"}
- pasting = []string{"xclip", "-o"}
- } else {
- return Board{}, errors.New("unable to detect linux clipboard")
- }
- }
- }
- default:
- return Board{}, errors.New("clipboard is unavailable")
- }
- if setPaste {
- pasting = overridePaste
- }
- if setCopy {
- copying = overrideCopy
- }
- return newBoard(copying, pasting)
-}
-
-// CopyTo will copy to clipboard, if non-empty will clear later.
-func (c Board) CopyTo(value string) error {
- cmd, args, err := c.Args(true)
- if err != nil {
- return err
- }
- pipeTo(cmd, value, args...)
- return nil
-}
-
-// Args returns clipboard args for execution.
-func (c Board) Args(copying bool) (string, []string, error) {
- var using []string
- if copying {
- using = c.copying
- } else {
- using = c.pasting
- }
- if len(using) == 0 {
- return "", nil, fmt.Errorf("command is not set (copying? %v)", copying)
- }
- var args []string
- if len(using) > 1 {
- args = using[1:]
- }
- return using[0], args, nil
-}
-
-func pipeTo(command, value string, args ...string) error {
- cmd := exec.Command(command, args...)
- stdin, err := cmd.StdinPipe()
- if err != nil {
- return err
- }
-
- go func() {
- defer stdin.Close()
- if _, err := stdin.Write([]byte(value)); err != nil {
- fmt.Printf("failed writing to stdin: %v\n", err)
- }
- }()
- return cmd.Run()
-}
diff --git a/internal/platform/clip/core_test.go b/internal/platform/clip/core_test.go
@@ -1,160 +0,0 @@
-package clip_test
-
-import (
- "fmt"
- "testing"
-
- "git.sr.ht/~enckse/lockbox/internal/config/store"
- "git.sr.ht/~enckse/lockbox/internal/platform/clip"
-)
-
-type mockLoader struct {
- err error
- name string
- runtime string
- full bool
-}
-
-func (m mockLoader) Name() (string, error) {
- return m.name, m.err
-}
-
-func (m mockLoader) Runtime() string {
- return m.runtime
-}
-
-func (m mockLoader) Complete() bool {
- return m.full
-}
-
-func TestDisabled(t *testing.T) {
- defer store.Clear()
- store.SetBool("LOCKBOX_FEATURE_CLIP", false)
- if _, err := clip.New(mockLoader{}); err == nil || err.Error() != "clip feature is disabled" {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestMaxTime(t *testing.T) {
- store.Clear()
- defer store.Clear()
- t.Setenv("WAYLAND_DISPLAY", "1")
- loader := mockLoader{name: "linux", runtime: "linux"}
- c, err := clip.New(loader)
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if c.MaxTime != 120 {
- t.Error("invalid default")
- }
- store.SetInt64("LOCKBOX_CLIP_TIMEOUT", 1)
- c, err = clip.New(loader)
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if c.MaxTime != 1 {
- t.Error("invalid default")
- }
- store.SetInt64("LOCKBOX_CLIP_TIMEOUT", -1)
- c, err = clip.New(loader)
- if err == nil || err.Error() != "clipboard entry max time must be > 0" {
- t.Errorf("invalid max time error: %v", err)
- }
-}
-
-func TestInstance(t *testing.T) {
- store.Clear()
- defer store.Clear()
- fxn := func(runtime, name, c, p, e string) {
- l := mockLoader{runtime: runtime, name: name}
- b, err := clip.New(l)
- if err != nil {
- if err.Error() != e {
- t.Errorf("invalid error: %v", err)
- }
- return
- }
- cmd, args, _ := b.Args(true)
- copying := fmt.Sprintf("%s (%v)", cmd, args)
- cmd, args, _ = b.Args(false)
- pasting := fmt.Sprintf("%s (%v)", cmd, args)
- if copying != c {
- t.Errorf("invalid copy: %s != %s", c, copying)
- }
- if pasting != p {
- t.Errorf("invalid copy: %s != %s", p, pasting)
- }
- }
- fxn("darwin", "", "pbcopy ([])", "pbpaste ([])", "")
- fxn("linux", "microsoft", "clip.exe ([])", "powershell.exe ([-command Get-Clipboard])", "")
- fxn("linux", "linux", "", "", "unable to detect linux clipboard")
- t.Setenv("DISPLAY", "1")
- t.Setenv("WAYLAND_DISPLAY", "1")
- fxn("linux", "linux", "wl-copy ([])", "wl-paste ([])", "")
- t.Setenv("WAYLAND_DISPLAY", "")
- fxn("linux", "linux", "xclip ([])", "xclip ([-o])", "")
-}
-
-func TestFullPartial(t *testing.T) {
- store.Clear()
- defer store.Clear()
- store.SetArray("LOCKBOX_CLIP_COPY_COMMAND", []string{"abc", "xyz", "111"})
- if _, err := clip.New(mockLoader{}); err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if _, err := clip.New(mockLoader{full: true}); err == nil || err.Error() != "clipboard is unavailable" {
- t.Errorf("invalid error: %v", err)
- }
- store.SetArray("LOCKBOX_CLIP_PASTE_COMMAND", []string{"abc", "xyz", "111"})
- if _, err := clip.New(mockLoader{full: true}); err != nil {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestArgsOverride(t *testing.T) {
- store.Clear()
- defer store.Clear()
- store.SetArray("LOCKBOX_CLIP_PASTE_COMMAND", []string{"abc", "xyz", "111"})
- c, err := clip.New(mockLoader{name: "microsoft", runtime: "linux"})
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- cmd, args, err := c.Args(true)
- if cmd != "clip.exe" || len(args) != 0 || err != nil {
- t.Error("invalid parse")
- }
- cmd, args, err = c.Args(false)
- if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil {
- t.Error("invalid parse")
- }
- store.SetArray("LOCKBOX_CLIP_COPY_COMMAND", []string{"zzz", "lll", "123"})
- c, err = clip.New(mockLoader{})
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- cmd, args, err = c.Args(true)
- if cmd != "zzz" || len(args) != 2 || args[0] != "lll" || args[1] != "123" || err != nil {
- t.Error("invalid parse")
- }
- cmd, args, err = c.Args(false)
- if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil {
- t.Error("invalid parse")
- }
- store.Clear()
- c, err = clip.New(mockLoader{name: "microsoft", runtime: "linux"})
- if err != nil {
- t.Errorf("invalid error: %v", err)
- }
- cmd, args, err = c.Args(true)
- if cmd != "clip.exe" || len(args) != 0 || err != nil {
- t.Error("invalid parse")
- }
- cmd, args, err = c.Args(false)
- if cmd != "powershell.exe" || len(args) != 2 || args[0] != "-command" || args[1] != "Get-Clipboard" || err != nil {
- t.Errorf("invalid parse %s %v", cmd, args)
- }
- c = clip.Board{}
- if _, _, err := c.Args(true); err == nil || err.Error() != "command is not set (copying? true)" {
- t.Errorf("invalid error: %v", err)
- }
-}
diff --git a/internal/platform/clip/manager.go b/internal/platform/clip/manager.go
@@ -1,170 +0,0 @@
-package clip
-
-import (
- "crypto/sha256"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "strings"
- "time"
-
- "git.sr.ht/~enckse/lockbox/internal/app/commands"
-)
-
-type (
- // Daemon is the manager interface
- Daemon interface {
- WriteFile(string, string)
- ReadFile(string) ([]byte, error)
- Output(string, ...string) ([]byte, error)
- Start(string, ...string) error
- Getpid() int
- Copy(Board, string)
- Sleep()
- Loader() Loader
- }
- // DefaultDaemon is the default functioning daemon
- DefaultDaemon struct{}
-)
-
-// WriteFile will write the necessary file to backing filesystem
-func (d DefaultDaemon) WriteFile(file, data string) {
- os.WriteFile(file, []byte(data), 0o644)
-}
-
-// ReadFile will read a file from the filesystem
-func (d DefaultDaemon) ReadFile(file string) ([]byte, error) {
- return os.ReadFile(file)
-}
-
-// Output will run a command and get output
-func (d DefaultDaemon) Output(cmd string, args ...string) ([]byte, error) {
- return exec.Command(cmd, args...).Output()
-}
-
-// Start will start an disconnected execution
-func (d DefaultDaemon) Start(cmd string, args ...string) error {
- return exec.Command(cmd, args...).Start()
-}
-
-// Getpid will return the pid
-func (d DefaultDaemon) Getpid() int {
- return os.Getpid()
-}
-
-// Copy will copy data to the clipboard
-func (d DefaultDaemon) Copy(c Board, val string) {
- c.CopyTo(val)
-}
-
-// Sleep will cause a pause/delay/wait
-func (d DefaultDaemon) Sleep() {
- time.Sleep(1 * time.Second)
-}
-
-// Loader will get the backing loader to use
-func (d DefaultDaemon) Loader() Loader {
- return DefaultLoader{Full: true}
-}
-
-// Manager handles the daemon runner
-func Manager(daemon bool, manager Daemon) error {
- if manager == nil {
- return errors.New("manager is nil")
- }
- clipboard, err := New(manager.Loader())
- if err != nil {
- return err
- }
- if clipboard.PIDFile == "" {
- return errors.New("pidfile is unset")
- }
- if !daemon {
- return manager.Start(commands.Executable, commands.ClipManagerDaemon)
- }
- paste, pasteArgs, err := clipboard.Args(false)
- if err != nil {
- return err
- }
- pasteFxn := func() (string, error) {
- b, err := manager.Output(paste, pasteArgs...)
- if err != nil {
- return "", err
- }
- val := strings.TrimSpace(string(b))
- if val == "" {
- return "", nil
- }
- hash := sha256.New()
- if _, err := hash.Write([]byte(val)); err != nil {
- return "", err
- }
- return fmt.Sprintf("%x", hash.Sum(nil)), nil
- }
- pid := strings.TrimSpace(fmt.Sprintf("%d", manager.Getpid()))
- isCurrentProcess := func() (bool, error) {
- b, err := manager.ReadFile(clipboard.PIDFile)
- if err != nil {
- return false, err
- }
- val := strings.TrimSpace(string(b))
- return val == pid, nil
- }
- manager.WriteFile(clipboard.PIDFile, pid)
- var errs []error
- for {
- if len(errs) > 5 {
- return errors.Join(errs...)
- }
- manager.Sleep()
- ok, err := isCurrentProcess()
- if err != nil {
- errs = append(errs, err)
- continue
- }
- if !ok {
- return nil
- }
- current, err := pasteFxn()
- if err != nil {
- errs = append(errs, err)
- continue
- }
- if current != "" {
- ok, err := wait(current, clipboard, manager, isCurrentProcess, pasteFxn)
- if err != nil {
- errs = append(errs, err)
- continue
- }
- if !ok {
- return nil
- }
- }
- errs = []error{}
- }
-}
-
-func wait(val string, clip Board, mgr Daemon, isCurrent func() (bool, error), pasteFxn func() (string, error)) (bool, error) {
- var count int64
- for count < clip.MaxTime {
- ok, err := isCurrent()
- if err != nil {
- return false, err
- }
- if !ok {
- return false, nil
- }
- cur, err := pasteFxn()
- if err != nil {
- return false, err
- }
- if cur != val {
- return true, nil
- }
- mgr.Sleep()
- count++
- }
- mgr.Copy(clip, "")
- return true, nil
-}
diff --git a/internal/platform/clip/manager_test.go b/internal/platform/clip/manager_test.go
@@ -1,118 +0,0 @@
-package clip_test
-
-import (
- "errors"
- "fmt"
- "strings"
- "testing"
-
- "git.sr.ht/~enckse/lockbox/internal/config/store"
- "git.sr.ht/~enckse/lockbox/internal/platform/clip"
-)
-
-type mock struct {
- err error
- file string
- data string
- cmd string
- args []string
- pid int
- pasted int
-}
-
-func (d *mock) WriteFile(file, data string) {
- d.file = file
- d.data = data
-}
-
-func (d *mock) ReadFile(file string) ([]byte, error) {
- if file == "falsepid" {
- return []byte("1"), nil
- }
- d.file = file
- return []byte(d.data), d.err
-}
-
-func (d *mock) Output(cmd string, args ...string) ([]byte, error) {
- d.pasted++
- d.cmd = cmd
- d.args = args
- val := fmt.Sprintf("%d", min(d.pasted, 100))
- return []byte(val), d.err
-}
-
-func (d *mock) Start(cmd string, args ...string) error {
- d.cmd = cmd
- d.args = args
- return d.err
-}
-
-func (d *mock) Getpid() int {
- return d.pid
-}
-
-func (d *mock) Copy(_ clip.Board, val string) {
- d.err = fmt.Errorf("copied%s: %d", val, d.pasted)
-}
-
-func (d *mock) Sleep() {
-}
-
-func (d *mock) Loader() clip.Loader {
- return mockLoader{name: "linux", runtime: "linux"}
-}
-
-func TestErrors(t *testing.T) {
- store.Clear()
- defer store.Clear()
- t.Setenv("WAYLAND_DISPLAY", "1")
- if err := clip.Manager(false, nil); err == nil || err.Error() != "manager is nil" {
- t.Errorf("invalid error: %v", err)
- }
- if err := clip.Manager(false, &mock{}); err == nil || err.Error() != "pidfile is unset" {
- t.Errorf("invalid error: %v", err)
- }
- store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
- m := &mock{}
- m.err = errors.New("xyz")
- if err := clip.Manager(true, m); err == nil || strings.Count(err.Error(), "xyz") != 6 {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestStart(t *testing.T) {
- store.Clear()
- defer store.Clear()
- store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
- t.Setenv("WAYLAND_DISPLAY", "1")
- m := &mock{}
- if err := clip.Manager(false, m); err != nil {
- t.Errorf("invalid error: %v", err)
- }
- if m.cmd != "lb" || fmt.Sprintf("%v", m.args) != "[clipmgrd]" {
- t.Errorf("invalid calls: %s %v", m.cmd, m.args)
- }
-}
-
-func TestPIDMismatch(t *testing.T) {
- store.Clear()
- defer store.Clear()
- store.SetString("LOCKBOX_CLIP_PIDFILE", "falsepid")
- t.Setenv("WAYLAND_DISPLAY", "1")
- m := &mock{}
- if err := clip.Manager(true, m); err != nil {
- t.Errorf("invalid error: %v", err)
- }
-}
-
-func TestChange(t *testing.T) {
- store.Clear()
- defer store.Clear()
- store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
- t.Setenv("WAYLAND_DISPLAY", "1")
- m := &mock{}
- // NOTE: 100 (count before static) + 120 (default timeout) + 1 (caused break of loop)
- if err := clip.Manager(true, m); err == nil || strings.Count(err.Error(), "copied: 221") != 6 {
- t.Errorf("invalid error: %v", err)
- }
-}
diff --git a/internal/platform/clip_test.go b/internal/platform/clip_test.go
@@ -0,0 +1,160 @@
+package platform_test
+
+import (
+ "fmt"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+)
+
+type mockLoader struct {
+ err error
+ name string
+ runtime string
+ full bool
+}
+
+func (m mockLoader) Name() (string, error) {
+ return m.name, m.err
+}
+
+func (m mockLoader) Runtime() string {
+ return m.runtime
+}
+
+func (m mockLoader) Complete() bool {
+ return m.full
+}
+
+func TestDisabled(t *testing.T) {
+ defer store.Clear()
+ store.SetBool("LOCKBOX_FEATURE_CLIP", false)
+ if _, err := platform.NewClipboard(mockLoader{}); err == nil || err.Error() != "clip feature is disabled" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestMaxTime(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ loader := mockLoader{name: "linux", runtime: "linux"}
+ c, err := platform.NewClipboard(loader)
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if c.MaxTime != 120 {
+ t.Error("invalid default")
+ }
+ store.SetInt64("LOCKBOX_CLIP_TIMEOUT", 1)
+ c, err = platform.NewClipboard(loader)
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if c.MaxTime != 1 {
+ t.Error("invalid default")
+ }
+ store.SetInt64("LOCKBOX_CLIP_TIMEOUT", -1)
+ c, err = platform.NewClipboard(loader)
+ if err == nil || err.Error() != "clipboard entry max time must be > 0" {
+ t.Errorf("invalid max time error: %v", err)
+ }
+}
+
+func TestInstance(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ fxn := func(runtime, name, c, p, e string) {
+ l := mockLoader{runtime: runtime, name: name}
+ b, err := platform.NewClipboard(l)
+ if err != nil {
+ if err.Error() != e {
+ t.Errorf("invalid error: %v", err)
+ }
+ return
+ }
+ cmd, args, _ := b.Args(true)
+ copying := fmt.Sprintf("%s (%v)", cmd, args)
+ cmd, args, _ = b.Args(false)
+ pasting := fmt.Sprintf("%s (%v)", cmd, args)
+ if copying != c {
+ t.Errorf("invalid copy: %s != %s", c, copying)
+ }
+ if pasting != p {
+ t.Errorf("invalid copy: %s != %s", p, pasting)
+ }
+ }
+ fxn("darwin", "", "pbcopy ([])", "pbpaste ([])", "")
+ fxn("linux", "microsoft", "clip.exe ([])", "powershell.exe ([-command Get-Clipboard])", "")
+ fxn("linux", "linux", "", "", "unable to detect linux clipboard")
+ t.Setenv("DISPLAY", "1")
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ fxn("linux", "linux", "wl-copy ([])", "wl-paste ([])", "")
+ t.Setenv("WAYLAND_DISPLAY", "")
+ fxn("linux", "linux", "xclip ([])", "xclip ([-o])", "")
+}
+
+func TestFullPartial(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetArray("LOCKBOX_CLIP_COPY_COMMAND", []string{"abc", "xyz", "111"})
+ if _, err := platform.NewClipboard(mockLoader{}); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if _, err := platform.NewClipboard(mockLoader{full: true}); err == nil || err.Error() != "clipboard is unavailable" {
+ t.Errorf("invalid error: %v", err)
+ }
+ store.SetArray("LOCKBOX_CLIP_PASTE_COMMAND", []string{"abc", "xyz", "111"})
+ if _, err := platform.NewClipboard(mockLoader{full: true}); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestArgsOverride(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetArray("LOCKBOX_CLIP_PASTE_COMMAND", []string{"abc", "xyz", "111"})
+ c, err := platform.NewClipboard(mockLoader{name: "microsoft", runtime: "linux"})
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ cmd, args, err := c.Args(true)
+ if cmd != "clip.exe" || len(args) != 0 || err != nil {
+ t.Error("invalid parse")
+ }
+ cmd, args, err = c.Args(false)
+ if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil {
+ t.Error("invalid parse")
+ }
+ store.SetArray("LOCKBOX_CLIP_COPY_COMMAND", []string{"zzz", "lll", "123"})
+ c, err = platform.NewClipboard(mockLoader{})
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ cmd, args, err = c.Args(true)
+ if cmd != "zzz" || len(args) != 2 || args[0] != "lll" || args[1] != "123" || err != nil {
+ t.Error("invalid parse")
+ }
+ cmd, args, err = c.Args(false)
+ if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" || err != nil {
+ t.Error("invalid parse")
+ }
+ store.Clear()
+ c, err = platform.NewClipboard(mockLoader{name: "microsoft", runtime: "linux"})
+ if err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ cmd, args, err = c.Args(true)
+ if cmd != "clip.exe" || len(args) != 0 || err != nil {
+ t.Error("invalid parse")
+ }
+ cmd, args, err = c.Args(false)
+ if cmd != "powershell.exe" || len(args) != 2 || args[0] != "-command" || args[1] != "Get-Clipboard" || err != nil {
+ t.Errorf("invalid parse %s %v", cmd, args)
+ }
+ c = platform.Clipboard{}
+ if _, _, err := c.Args(true); err == nil || err.Error() != "command is not set (copying? true)" {
+ t.Errorf("invalid error: %v", err)
+ }
+}
diff --git a/internal/platform/clipmanager.go b/internal/platform/clipmanager.go
@@ -0,0 +1,170 @@
+package platform
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "git.sr.ht/~enckse/lockbox/internal/app/commands"
+)
+
+type (
+ // ClipboardDaemon is the manager interface
+ ClipboardDaemon interface {
+ WriteFile(string, string)
+ ReadFile(string) ([]byte, error)
+ Output(string, ...string) ([]byte, error)
+ Start(string, ...string) error
+ Getpid() int
+ Copy(Clipboard, string)
+ Sleep()
+ Loader() ClipboardLoader
+ }
+ // DefaultClipboardDaemon is the default functioning daemon
+ DefaultClipboardDaemon struct{}
+)
+
+// WriteFile will write the necessary file to backing filesystem
+func (d DefaultClipboardDaemon) WriteFile(file, data string) {
+ os.WriteFile(file, []byte(data), 0o644)
+}
+
+// ReadFile will read a file from the filesystem
+func (d DefaultClipboardDaemon) ReadFile(file string) ([]byte, error) {
+ return os.ReadFile(file)
+}
+
+// Output will run a command and get output
+func (d DefaultClipboardDaemon) Output(cmd string, args ...string) ([]byte, error) {
+ return exec.Command(cmd, args...).Output()
+}
+
+// Start will start an disconnected execution
+func (d DefaultClipboardDaemon) Start(cmd string, args ...string) error {
+ return exec.Command(cmd, args...).Start()
+}
+
+// Getpid will return the pid
+func (d DefaultClipboardDaemon) Getpid() int {
+ return os.Getpid()
+}
+
+// Copy will copy data to the clipboard
+func (d DefaultClipboardDaemon) Copy(c Clipboard, val string) {
+ c.CopyTo(val)
+}
+
+// Sleep will cause a pause/delay/wait
+func (d DefaultClipboardDaemon) Sleep() {
+ time.Sleep(1 * time.Second)
+}
+
+// Loader will get the backing loader to use
+func (d DefaultClipboardDaemon) Loader() ClipboardLoader {
+ return DefaultClipboardLoader{Full: true}
+}
+
+// ClipboardManager handles the daemon runner
+func ClipboardManager(daemon bool, manager ClipboardDaemon) error {
+ if manager == nil {
+ return errors.New("manager is nil")
+ }
+ clipboard, err := NewClipboard(manager.Loader())
+ if err != nil {
+ return err
+ }
+ if clipboard.PIDFile == "" {
+ return errors.New("pidfile is unset")
+ }
+ if !daemon {
+ return manager.Start(commands.Executable, commands.ClipManagerDaemon)
+ }
+ paste, pasteArgs, err := clipboard.Args(false)
+ if err != nil {
+ return err
+ }
+ pasteFxn := func() (string, error) {
+ b, err := manager.Output(paste, pasteArgs...)
+ if err != nil {
+ return "", err
+ }
+ val := strings.TrimSpace(string(b))
+ if val == "" {
+ return "", nil
+ }
+ hash := sha256.New()
+ if _, err := hash.Write([]byte(val)); err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%x", hash.Sum(nil)), nil
+ }
+ pid := strings.TrimSpace(fmt.Sprintf("%d", manager.Getpid()))
+ isCurrentProcess := func() (bool, error) {
+ b, err := manager.ReadFile(clipboard.PIDFile)
+ if err != nil {
+ return false, err
+ }
+ val := strings.TrimSpace(string(b))
+ return val == pid, nil
+ }
+ manager.WriteFile(clipboard.PIDFile, pid)
+ var errs []error
+ for {
+ if len(errs) > 5 {
+ return errors.Join(errs...)
+ }
+ manager.Sleep()
+ ok, err := isCurrentProcess()
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ if !ok {
+ return nil
+ }
+ current, err := pasteFxn()
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ if current != "" {
+ ok, err := wait(current, clipboard, manager, isCurrentProcess, pasteFxn)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ if !ok {
+ return nil
+ }
+ }
+ errs = []error{}
+ }
+}
+
+func wait(val string, clip Clipboard, mgr ClipboardDaemon, isCurrent func() (bool, error), pasteFxn func() (string, error)) (bool, error) {
+ var count int64
+ for count < clip.MaxTime {
+ ok, err := isCurrent()
+ if err != nil {
+ return false, err
+ }
+ if !ok {
+ return false, nil
+ }
+ cur, err := pasteFxn()
+ if err != nil {
+ return false, err
+ }
+ if cur != val {
+ return true, nil
+ }
+ mgr.Sleep()
+ count++
+ }
+ mgr.Copy(clip, "")
+ return true, nil
+}
diff --git a/internal/platform/clipmanager_test.go b/internal/platform/clipmanager_test.go
@@ -0,0 +1,118 @@
+package platform_test
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+)
+
+type mock struct {
+ err error
+ file string
+ data string
+ cmd string
+ args []string
+ pid int
+ pasted int
+}
+
+func (d *mock) WriteFile(file, data string) {
+ d.file = file
+ d.data = data
+}
+
+func (d *mock) ReadFile(file string) ([]byte, error) {
+ if file == "falsepid" {
+ return []byte("1"), nil
+ }
+ d.file = file
+ return []byte(d.data), d.err
+}
+
+func (d *mock) Output(cmd string, args ...string) ([]byte, error) {
+ d.pasted++
+ d.cmd = cmd
+ d.args = args
+ val := fmt.Sprintf("%d", min(d.pasted, 100))
+ return []byte(val), d.err
+}
+
+func (d *mock) Start(cmd string, args ...string) error {
+ d.cmd = cmd
+ d.args = args
+ return d.err
+}
+
+func (d *mock) Getpid() int {
+ return d.pid
+}
+
+func (d *mock) Copy(_ platform.Clipboard, val string) {
+ d.err = fmt.Errorf("copied%s: %d", val, d.pasted)
+}
+
+func (d *mock) Sleep() {
+}
+
+func (d *mock) Loader() platform.ClipboardLoader {
+ return mockLoader{name: "linux", runtime: "linux"}
+}
+
+func TestErrors(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ if err := platform.ClipboardManager(false, nil); err == nil || err.Error() != "manager is nil" {
+ t.Errorf("invalid error: %v", err)
+ }
+ if err := platform.ClipboardManager(false, &mock{}); err == nil || err.Error() != "pidfile is unset" {
+ t.Errorf("invalid error: %v", err)
+ }
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
+ m := &mock{}
+ m.err = errors.New("xyz")
+ if err := platform.ClipboardManager(true, m); err == nil || strings.Count(err.Error(), "xyz") != 6 {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestStart(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ m := &mock{}
+ if err := platform.ClipboardManager(false, m); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+ if m.cmd != "lb" || fmt.Sprintf("%v", m.args) != "[clipmgrd]" {
+ t.Errorf("invalid calls: %s %v", m.cmd, m.args)
+ }
+}
+
+func TestPIDMismatch(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "falsepid")
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ m := &mock{}
+ if err := platform.ClipboardManager(true, m); err != nil {
+ t.Errorf("invalid error: %v", err)
+ }
+}
+
+func TestChange(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
+ t.Setenv("WAYLAND_DISPLAY", "1")
+ m := &mock{}
+ // NOTE: 100 (count before static) + 120 (default timeout) + 1 (caused break of loop)
+ if err := platform.ClipboardManager(true, m); err == nil || strings.Count(err.Error(), "copied: 221") != 6 {
+ t.Errorf("invalid error: %v", err)
+ }
+}