lockbox

password manager
Log | Files | Refs | README | LICENSE

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:
Mcmd/lb/main.go | 3+--
Minternal/app/showclip.go | 6+++---
Minternal/app/totp.go | 6+++---
Ainternal/platform/clip.go | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dinternal/platform/clip/core.go | 160-------------------------------------------------------------------------------
Dinternal/platform/clip/core_test.go | 160-------------------------------------------------------------------------------
Dinternal/platform/clip/manager.go | 170-------------------------------------------------------------------------------
Dinternal/platform/clip/manager_test.go | 118-------------------------------------------------------------------------------
Ainternal/platform/clip_test.go | 160+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/platform/clipmanager.go | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ainternal/platform/clipmanager_test.go | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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) + } +}