commit 3daf15afb1cd6fdb99b08b8a46d9f46821070c1b
parent bfd89a6dacb629f0ac58de27e46b0904b1e2b253
Author: Sean Enck <sean@ttypty.com>
Date: Sun, 29 Jun 2025 20:38:38 -0400
add a clipmgr to handle clipboard manager outside of lb itself
Diffstat:
11 files changed, 335 insertions(+), 74 deletions(-)
diff --git a/cmd/lb/main.go b/cmd/lb/main.go
@@ -5,10 +5,7 @@ import (
"errors"
"fmt"
"os"
- "os/exec"
"runtime/debug"
- "strings"
- "time"
"git.sr.ht/~enckse/lockbox/internal/app"
"git.sr.ht/~enckse/lockbox/internal/app/commands"
@@ -47,8 +44,8 @@ func handleEarly(command string, args []string) (bool, error) {
}
fmt.Printf("version: %s\n", vers)
return true, nil
- case commands.ClipManager:
- return true, clearClipboard()
+ case commands.ClipManager, commands.ClipManagerDaemon:
+ return true, clip.Manager(command == commands.ClipManagerDaemon, clip.DefaultDaemon{})
}
return false, nil
}
@@ -112,29 +109,3 @@ func run() error {
return fmt.Errorf("unknown command: %s", command)
}
}
-
-func clearClipboard() error {
- var idx int64
- val, err := platform.Stdin(false)
- if err != nil {
- return err
- }
- clipboard, err := clip.New()
- if err != nil {
- return err
- }
- pCmd, pArgs := clipboard.Args(false)
- val = strings.TrimSpace(val)
- for idx < clipboard.MaxTime {
- idx++
- time.Sleep(1 * time.Second)
- out, err := exec.Command(pCmd, pArgs...).Output()
- if err != nil {
- continue
- }
- if strings.TrimSpace(string(out)) != val {
- return nil
- }
- }
- return clipboard.CopyTo("")
-}
diff --git a/cmd/lb/main_test.go b/cmd/lb/main_test.go
@@ -315,7 +315,7 @@ func test(profile string) error {
clipPassed := false
tries := 0
for tries < clipTries {
- if platform.PathExists(copyFile) && platform.PathExists(pasteFile) {
+ if platform.PathExists(copyFile) {
clipPassed = true
break
}
diff --git a/cmd/lb/tests/expected.log b/cmd/lb/tests/expected.log
@@ -313,7 +313,6 @@ json
}
}
clipboard
-clipboard will clear in 3 seconds
invalids
Wrong password? HMAC-SHA256 of header mismatching
no store set
diff --git a/internal/app/commands/core.go b/internal/app/commands/core.go
@@ -12,8 +12,10 @@ const (
TOTP = "totp"
// Conv handles text conversion of the data store
Conv = "conv"
- // ClipManager is a callback to manage clipboard clearing
- ClipManager = "clipmanager"
+ // ClipManager can handle simple clipboard management
+ ClipManager = "clipmgr"
+ // ClipManagerDaemon is the backing call to perform clipboard management
+ ClipManagerDaemon = "clipmgrd"
// Clip will copy values to the clipboard
Clip = "clip"
// Insert adds a value
diff --git a/internal/config/toml_test.go b/internal/config/toml_test.go
@@ -251,7 +251,7 @@ func TestDefaultTOMLToLoadFile(t *testing.T) {
if err := config.LoadConfigFile(file); err != nil {
t.Errorf("invalid error: %v", err)
}
- if len(store.List()) != 18 {
+ if len(store.List()) != 19 {
t.Errorf("invalid environment after load: %d", len(store.List()))
}
}
diff --git a/internal/config/vars.go b/internal/config/vars.go
@@ -36,12 +36,23 @@ var (
})
// EnvClipTimeout gets the maximum clipboard time
EnvClipTimeout = environmentRegister(EnvironmentInt{
- environmentDefault: newDefaultedEnvironment(45,
+ environmentDefault: newDefaultedEnvironment(120,
environmentBase{
key: clipCategory + "TIMEOUT",
- description: "Override the amount of time before totp clears the clipboard (seconds).",
+ description: "Override the amount of time before clearing the clipboard (seconds).",
}),
- short: "clipboard max time",
+ short: "clipboard entry max time",
+ })
+ // EnvClipProcessFile configures the clip manager daemon pidfile
+ EnvClipProcessFile = environmentRegister(EnvironmentString{
+ environmentStrings: environmentStrings{
+ environmentDefault: newDefaultedEnvironment("",
+ environmentBase{
+ key: clipCategory + "PIDFILE",
+ description: "Set the pidfile for clipboard management",
+ }),
+ allowed: []string{fileExample},
+ },
})
// EnvJSONHashLength handles the hashing output length
EnvJSONHashLength = environmentRegister(EnvironmentInt{
diff --git a/internal/config/vars_test.go b/internal/config/vars_test.go
@@ -62,7 +62,7 @@ func TestFormatTOTP(t *testing.T) {
}
func TestClipboardMax(t *testing.T) {
- checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard max time", 45, false, t)
+ checkInt(config.EnvClipTimeout, "LOCKBOX_CLIP_TIMEOUT", "clipboard entry max time", 120, false, t)
}
func TestHashLength(t *testing.T) {
@@ -161,6 +161,7 @@ func TestEmptyStrings(t *testing.T) {
config.EnvStore,
config.EnvKeyFile,
config.EnvDefaultModTime,
+ config.EnvClipProcessFile,
} {
val := v.Get()
if val != "" {
diff --git a/internal/platform/clip/core.go b/internal/platform/clip/core.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os/exec"
- "git.sr.ht/~enckse/lockbox/internal/app/commands"
"git.sr.ht/~enckse/lockbox/internal/config"
"git.sr.ht/~enckse/lockbox/internal/platform"
)
@@ -14,6 +13,7 @@ import (
type (
// Board represent system clipboard operations.
Board struct {
+ PIDFile string
copying []string
pasting []string
MaxTime int64
@@ -25,7 +25,8 @@ func newBoard(copying, pasting []string) (Board, error) {
if err != nil {
return Board{}, err
}
- return Board{copying: copying, pasting: pasting, MaxTime: maximum}, nil
+ pid := config.EnvClipProcessFile.Get()
+ return Board{copying: copying, pasting: pasting, MaxTime: maximum, PIDFile: pid}, nil
}
// New creates a new clipboard
@@ -74,31 +75,33 @@ func New() (Board, error) {
// CopyTo will copy to clipboard, if non-empty will clear later.
func (c Board) CopyTo(value string) error {
- cmd, args := c.Args(true)
- pipeTo(cmd, value, true, args...)
- if value != "" {
- fmt.Printf("clipboard will clear in %d seconds\n", c.MaxTime)
- pipeTo(commands.Executable, value, false, commands.ClipManager)
+ 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) {
+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
+ return using[0], args, nil
}
-func pipeTo(command, value string, wait bool, args ...string) error {
+func pipeTo(command, value string, args ...string) error {
cmd := exec.Command(command, args...)
stdin, err := cmd.StdinPipe()
if err != nil {
@@ -111,14 +114,5 @@ func pipeTo(command, value string, wait bool, args ...string) error {
fmt.Printf("failed writing to stdin: %v\n", err)
}
}()
- var ran error
- if wait {
- ran = cmd.Run()
- } else {
- ran = cmd.Start()
- }
- if ran != nil {
- return errors.New("failed to run command")
- }
- return nil
+ return cmd.Run()
}
diff --git a/internal/platform/clip/core_test.go b/internal/platform/clip/core_test.go
@@ -24,7 +24,7 @@ func TestMaxTime(t *testing.T) {
if err != nil {
t.Errorf("invalid clipboard: %v", err)
}
- if c.MaxTime != 45 {
+ if c.MaxTime != 120 {
t.Error("invalid default")
}
store.SetInt64("LOCKBOX_CLIP_TIMEOUT", 1)
@@ -37,7 +37,7 @@ func TestMaxTime(t *testing.T) {
}
store.SetInt64("LOCKBOX_CLIP_TIMEOUT", -1)
_, err = clip.New()
- if err == nil || err.Error() != "clipboard max time must be > 0" {
+ if err == nil || err.Error() != "clipboard entry max time must be > 0" {
t.Errorf("invalid max time error: %v", err)
}
}
@@ -63,12 +63,12 @@ func TestArgsOverride(t *testing.T) {
if err != nil {
t.Errorf("invalid error: %v", err)
}
- cmd, args := c.Args(true)
- if cmd != "clip.exe" || len(args) != 0 {
+ cmd, args, err := c.Args(true)
+ if cmd != "clip.exe" || len(args) != 0 || err != nil {
t.Error("invalid parse")
}
- cmd, args = c.Args(false)
- if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" {
+ 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"})
@@ -76,12 +76,12 @@ func TestArgsOverride(t *testing.T) {
if err != nil {
t.Errorf("invalid error: %v", err)
}
- cmd, args = c.Args(true)
- if cmd != "zzz" || len(args) != 2 || args[0] != "lll" || args[1] != "123" {
+ 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 = c.Args(false)
- if cmd != "abc" || len(args) != 2 || args[0] != "xyz" || args[1] != "111" {
+ 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()
@@ -90,12 +90,16 @@ func TestArgsOverride(t *testing.T) {
if err != nil {
t.Errorf("invalid error: %v", err)
}
- cmd, args = c.Args(true)
- if cmd != "clip.exe" || len(args) != 0 {
+ cmd, args, err = c.Args(true)
+ if cmd != "clip.exe" || len(args) != 0 || err != nil {
t.Error("invalid parse")
}
- cmd, args = c.Args(false)
- if cmd != "powershell.exe" || len(args) != 2 || args[0] != "-command" || args[1] != "Get-Clipboard" {
+ 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
@@ -0,0 +1,164 @@
+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()
+ }
+ // 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)
+}
+
+// Manager handles the daemon runner
+func Manager(daemon bool, manager Daemon) error {
+ if manager == nil {
+ return errors.New("manager is nil")
+ }
+ clipboard, err := New()
+ 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
@@ -0,0 +1,115 @@
+package clip_test
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.sr.ht/~enckse/lockbox/internal/config/store"
+ "git.sr.ht/~enckse/lockbox/internal/platform"
+ "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 TestErrors(t *testing.T) {
+ store.Clear()
+ defer store.Clear()
+ store.SetString("LOCKBOX_PLATFORM", string(platform.Systems.LinuxWaylandSystem))
+ 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_PLATFORM", string(platform.Systems.LinuxWaylandSystem))
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
+ 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_PLATFORM", string(platform.Systems.LinuxWaylandSystem))
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "falsepid")
+ 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_PLATFORM", string(platform.Systems.LinuxWaylandSystem))
+ store.SetString("LOCKBOX_CLIP_PIDFILE", "a")
+ 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)
+ }
+}