Repository: gopasspw/gopass (go)

Issue:
[Feature Request] Support for x-kde-passwordManagerHint mime type
### Summary
If this mime type is set to secret it will prevent the clipboard entry to be added to Klipper. 

See https://phabricator.kde.org/D12539

This is the link to the PR in kepassxc: https://github.com/keepassxreboot/keepassxc/pull/1969

### Environment
<!--
Please complete the following information (see note below)
-->

- OS: Linux / KDE Plasma 5+


Pull request description:
[feat] Replace clipboard library to support wl-copy args
This change should allow us to protect sensitive content from being captured in some clipboard managers on KDE.

Fixes #2611

Patch:
diff --git a/go.mod b/go.mod
index 94fed207..2e3263d3 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,10 @@
 module github.com/gopasspw/gopass
 
-go 1.24.0
+go 1.24.1
 
 require (
 	filippo.io/age v1.2.1-0.20240618131852-7eedd929a6cf
 	github.com/ProtonMail/go-crypto v1.2.0
-	github.com/atotto/clipboard v0.1.4
 	github.com/blang/semver/v4 v4.0.0
 	github.com/caspr-io/yamlpath v0.0.0-20200722075116-502e8d113a9b
 	github.com/cenkalti/backoff/v4 v4.3.0
@@ -16,6 +15,7 @@ require (
 	github.com/gokyle/twofactor v1.0.1
 	github.com/google/go-cmp v0.7.0
 	github.com/google/go-github/v61 v61.0.0
+	github.com/gopasspw/clipboard v0.0.0-20250418184741-a9895c5a47ee
 	github.com/gopasspw/gopass-hibp v1.15.15
 	github.com/hashicorp/golang-lru/v2 v2.0.7
 	github.com/jsimonetti/pwscheme v0.0.0-20220922140336-67a4d090f150
@@ -52,6 +52,7 @@ require (
 	al.essio.dev/pkg/shellescape v1.6.0 // indirect
 	codeberg.org/tslocum/cbind v0.1.6 // indirect
 	filippo.io/edwards25519 v1.1.0 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/boombuler/barcode v1.0.2 // indirect
 	github.com/cloudflare/circl v1.6.1 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
diff --git a/go.sum b/go.sum
index 4ab68c19..d0c756f9 100644
--- a/go.sum
+++ b/go.sum
@@ -71,6 +71,14 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
+github.com/gopasspw/clipboard v0.0.0-20250417190150-d64f7d604636 h1:IqhPR3G2C1cdMgKKHYwaASJuh9meJDy6Abes44nYxTQ=
+github.com/gopasspw/clipboard v0.0.0-20250417190150-d64f7d604636/go.mod h1:b2ka158TfIu6UEYbnuYGjztg9dNwAIwCBL3qNoKH8qE=
+github.com/gopasspw/clipboard v0.0.0-20250418164705-c6bd724b45e0 h1:xCvVD0/DfFzYybu1t/dkLwXWwGPQdrfHvQSRtFJKhC8=
+github.com/gopasspw/clipboard v0.0.0-20250418164705-c6bd724b45e0/go.mod h1:i0cShr7JEbOXZ/iKM5RyfBLbu1FPzouO8BTCJy0uHy8=
+github.com/gopasspw/clipboard v0.0.0-20250418174641-ec5f8eb4a01b h1:wMbrBbxjh/cZeqXGTGhYs4L65tP6YBKvIgDTo9RIv94=
+github.com/gopasspw/clipboard v0.0.0-20250418174641-ec5f8eb4a01b/go.mod h1:i0cShr7JEbOXZ/iKM5RyfBLbu1FPzouO8BTCJy0uHy8=
+github.com/gopasspw/clipboard v0.0.0-20250418184741-a9895c5a47ee h1:I1bYhCNc3Qbd/sbjFPFZfeKmNvC+wIuFhwzCDmnBaX8=
+github.com/gopasspw/clipboard v0.0.0-20250418184741-a9895c5a47ee/go.mod h1:i0cShr7JEbOXZ/iKM5RyfBLbu1FPzouO8BTCJy0uHy8=
 github.com/gopasspw/gopass-hibp v1.15.15 h1:zmLhxXKu3fLa7qAexKw4EWVfKGgFzJ1j5ra9nzNnd1Y=
 github.com/gopasspw/gopass-hibp v1.15.15/go.mod h1:7xE84pJnO6x3i+u38NeUAvYlJVYPTUNL3Y5XJHSAwaY=
 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
diff --git a/helpers/release/main.go b/helpers/release/main.go
index f11f7c33..3aaa0721 100644
--- a/helpers/release/main.go
+++ b/helpers/release/main.go
@@ -50,7 +50,7 @@ func getVersion() semver.Version {
 		Pre: []semver.PRVersion{
 			{VersionStr: "git"},
 		},
-		Build: []string{"HEAD"},
+		Build: []string{"{{ .Build }}"},
 	}
 }
 `
@@ -250,7 +250,7 @@ func updateDeps() error {
 	}
 
 	if sv := os.Getenv("GOPASS_NOTEST"); sv != "" {
-		fmt.Printf("⚠ GOPASS_NOTEST=%v, skipping 'make travis'", sv)
+		fmt.Printf("⚠ GOPASS_NOTEST=%v, skipping 'make gha-linux'", sv)
 
 		return nil
 	}
@@ -262,7 +262,7 @@ func updateDeps() error {
 		return err
 	}
 
-	cmd = exec.Command("make", "travis")
+	cmd = exec.Command("make", "gha-linux")
 	cmd.Stderr = os.Stderr
 	cmd.Stdout = fh
 	cmd.Env = []string{
@@ -281,7 +281,7 @@ func updateDeps() error {
 
 	if err := cmd.Run(); err != nil {
 		_ = fh.Close()
-		fmt.Printf("⚠ 'make travis' failed. Please see the log at %s!", fn)
+		fmt.Printf("⚠ 'make gha-linux' failed. Please see the log at %s!", fn)
 
 		return err
 	}
@@ -387,6 +387,7 @@ type tplPayload struct {
 	Major uint64
 	Minor uint64
 	Patch uint64
+	Build string
 }
 
 func writeVersionGo(v semver.Version) error {
@@ -400,10 +401,16 @@ func writeVersionGo(v semver.Version) error {
 	}
 	defer fh.Close()
 
+	build := "HEAD"
+	if sv, err := gitCommitHash(); err == nil {
+		build = sv
+	}
+
 	return tmpl.Execute(fh, tplPayload{
 		Major: v.Major,
 		Minor: v.Minor,
 		Patch: v.Patch,
+		Build: build,
 	})
 }
 
@@ -420,6 +427,15 @@ func versionFile() (semver.Version, error) {
 	return semver.Parse(strings.TrimSpace(string(buf)))
 }
 
+func gitCommitHash() (string, error) {
+	buf, err := exec.Command("git", "rev-parse", "--short", "HEAD").CombinedOutput()
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(string(buf)), nil
+}
+
 func gitVersion() (semver.Version, error) {
 	buf, err := exec.Command("git", "tag", "--sort=version:refname").CombinedOutput()
 	if err != nil {
diff --git a/main.go b/main.go
index 4ff389bf..7472bd61 100644
--- a/main.go
+++ b/main.go
@@ -233,13 +233,13 @@ func parseBuildInfo() (string, string, string) {
 
 	for _, v := range bi.Settings {
 		switch v.Key {
-		case "gitrevision":
+		case "vcs.revision":
 			commit = v.Value[len(v.Value)-8:]
-		case "gitcommittime":
+		case "vcs.time":
 			if bt, err := time.Parse("2006-01-02T15:04:05Z", date); err == nil {
 				date = bt.Format("2006-01-02 15:04:05")
 			}
-		case "gituncommitted":
+		case "vcs.modified":
 			if v.Value == "true" {
 				dirty = " (dirty)"
 			}
diff --git a/pkg/clipboard/clipboard.go b/pkg/clipboard/clipboard.go
index 9842c129..74644bdc 100644
--- a/pkg/clipboard/clipboard.go
+++ b/pkg/clipboard/clipboard.go
@@ -7,8 +7,8 @@ import (
 	"os"
 	"os/exec"
 
-	"github.com/atotto/clipboard"
 	"github.com/fatih/color"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/notify"
 	"github.com/gopasspw/gopass/internal/out"
 	"github.com/gopasspw/gopass/pkg/debug"
@@ -17,7 +17,7 @@ import (
 var (
 	// Helpers can be overridden at compile time, e.g. go build \
 	// -ldflags=='-X github.com/gopasspw/gopass/pkg/clipboard.Helpers=termux-api'.
-	Helpers = "xsel or xclip"
+	Helpers = "xsel, xclip or wl-clipboard"
 	// ErrNotSupported is returned when the clipboard is not accessible.
 	ErrNotSupported = fmt.Errorf("WARNING: No clipboard available. "+
 		"Install %s, provide $GOPASS_CLIPBOARD_COPY_CMD and $GOPASS_CLIPBOARD_CLEAR_CMD or use -f to print to console", Helpers)
@@ -35,7 +35,7 @@ func CopyTo(ctx context.Context, name string, content []byte, timeout int) error
 
 			return fmt.Errorf("failed to call clipboard copy command: %w", err)
 		}
-	} else if clipboard.Unsupported {
+	} else if clipboard.IsUnsupported() {
 		out.Errorf(ctx, "%s", ErrNotSupported)
 		_ = notify.Notify(ctx, "gopass - clipboard", ErrNotSupported.Error())
 
@@ -67,7 +67,15 @@ func CopyTo(ctx context.Context, name string, content []byte, timeout int) error
 	return nil
 }
 
-func callCommand(ctx context.Context, cmd string, parameter string, stdinValue []byte) error {
+func copyToClipboard(ctx context.Context, content []byte) error {
+	if err := clipboard.WriteAll(ctx, content); err != nil {
+		return fmt.Errorf("failed to write to clipboard: %w", err)
+	}
+
+	return nil
+}
+
+func callCommand(_ context.Context, cmd string, parameter string, stdinValue []byte) error {
 	clipboardProcess := exec.Command(cmd, parameter)
 	stdin, err := clipboardProcess.StdinPipe()
 
diff --git a/pkg/clipboard/copy_darwin.go b/pkg/clipboard/copy_darwin.go
deleted file mode 100644
index 5340abee..00000000
--- a/pkg/clipboard/copy_darwin.go
+++ /dev/null
@@ -1,80 +0,0 @@
-//go:build darwin
-// +build darwin
-
-package clipboard
-
-import (
-	"context"
-	"fmt"
-	"io"
-	"os"
-	"os/exec"
-
-	"github.com/atotto/clipboard"
-	"github.com/gopasspw/gopass/pkg/debug"
-)
-
-func copyToClipboard(ctx context.Context, content []byte) error {
-	password := string(content)
-
-	// first try to copy via osascript, if that fails fallback to pbcopy
-	if err := copyViaOsascript(ctx, password); err != nil {
-		debug.Log("failed to copy via osascript: %s", err)
-
-		return clipboard.WriteAll(password)
-	}
-
-	return nil
-}
-
-func copyViaOsascript(ctx context.Context, password string) error {
-	args := []string{
-		// The Foundation library has the Objective C symbols for pasteboard
-		"-e", `use framework "Foundation"`,
-		// Need to use scripting additions for access to "do shell script"
-		"-e", "use scripting additions",
-		// type = a reference to the ObjC constant NSPasteboardTypeString
-		// which is needed to indentify clioboard contents as text
-		"-e", "set type to current application's NSPasteboardTypeString",
-		// pb = a reference to the system's pasteboard
-		"-e", "set pb to current application's NSPasteboard's generalPasteboard()",
-		// Must clear contents before adding a new item to pasteboard
-		"-e", "pb's clearContents()",
-		// Set the flag ConcealedType so clipboard history managers don't record the password.
-		// The first argument can by anything, but an empty string will do fine.
-		"-e", `pb's setString:"" forType:"org.nspasteboard.ConcealedType"`,
-		// AppleScript cannot read from stdin, so pipe fd#3 to stdin of cat and read the output.
-		// This output is put in the clipboard, setting type = string type
-		"-e", `pb's setString:(do shell script "cat 0<&3") forType:type`,
-	}
-	cmd := exec.CommandContext(ctx, "osascript", args...)
-	r, w, err := os.Pipe()
-	if err != nil {
-		return err
-	}
-
-	// This connects the pipe to stdin of the osascript command, see the "do shell script"
-	// part around line 46. The pipe is created and written to before the osascript command
-	// is run so we shouldn't need to worry about partial writes (I hope!).
-	//
-	// TODO: We might be able to use `cmd.Stdin = strings.NewReader(password)` instead
-	cmd.ExtraFiles = []*os.File{r} // Receiving end of pipes is connected to fd#3
-	go func() {
-		defer w.Close()                    //nolint:errcheck
-		_, _ = io.WriteString(w, password) // Write the password to fd#3
-	}()
-
-	out, err := cmd.Output()
-	if err != nil {
-		return err
-	}
-
-	// osascript should print true (return value of the last setString call) on success
-	if string(out) != "true\n" {
-		// Fallback to using attoto's pbcopy
-		return fmt.Errorf("osascript failed to set password: %s", string(out))
-	}
-	debug.Log("copied via osascript")
-
-	return nil
-}
diff --git a/pkg/clipboard/copy_others.go b/pkg/clipboard/copy_others.go
deleted file mode 100644
index 145d6ae6..00000000
--- a/pkg/clipboard/copy_others.go
+++ /dev/null
@@ -1,19 +0,0 @@
-//go:build !darwin
-// +build !darwin
-
-package clipboard
-
-import (
-	"context"
-	"fmt"
-
-	"github.com/atotto/clipboard"
-)
-
-func copyToClipboard(ctx context.Context, content []byte) error {
-	if err := clipboard.WriteAll(string(content)); err != nil {
-		return fmt.Errorf("failed to write to clipboard: %w", err)
-	}
-
-	return nil
-}
diff --git a/pkg/clipboard/unclip.go b/pkg/clipboard/unclip.go
index 1a0edc13..42f8ae9c 100644
--- a/pkg/clipboard/unclip.go
+++ b/pkg/clipboard/unclip.go
@@ -5,7 +5,7 @@ import (
 	"fmt"
 	"os"
 
-	"github.com/atotto/clipboard"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/notify"
 	"github.com/gopasspw/gopass/internal/pwschemes/argon2id"
 	"github.com/gopasspw/gopass/pkg/debug"
@@ -26,11 +26,11 @@ func Clear(ctx context.Context, name string, checksum string, force bool) error
 		return nil
 	}
 
-	if clipboard.Unsupported {
+	if clipboard.IsUnsupported() {
 		return ErrNotSupported
 	}
 
-	cur, err := clipboard.ReadAll()
+	cur, err := clipboard.ReadAllString(ctx)
 	if err != nil {
 		return fmt.Errorf("failed to read clipboard: %w", err)
 	}
@@ -46,7 +46,7 @@ func Clear(ctx context.Context, name string, checksum string, force bool) error
 		return nil
 	}
 
-	if err := clipboard.WriteAll(""); err != nil {
+	if err := clipboard.WriteAllString(ctx, ""); err != nil {
 		_ = notify.Notify(ctx, "gopass - clipboard", "Failed to clear clipboard")
 
 		return fmt.Errorf("failed to write clipboard: %w", err)


Test patch:
diff --git a/internal/action/create_test.go b/internal/action/create_test.go
index 961f04d0..ac0acae4 100644
--- a/internal/action/create_test.go
+++ b/internal/action/create_test.go
@@ -5,7 +5,7 @@ import (
 	"os"
 	"testing"
 
-	aclip "github.com/atotto/clipboard"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/config"
 	"github.com/gopasspw/gopass/internal/out"
 	"github.com/gopasspw/gopass/pkg/ctxutil"
@@ -16,7 +16,7 @@ import (
 func TestCreate(t *testing.T) {
 	u := gptest.NewUnitTester(t)
 
-	aclip.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	ctx := config.NewContextInMemory()
 	ctx = ctxutil.WithAlwaysYes(ctx, true)
diff --git a/internal/action/show_test.go b/internal/action/show_test.go
index ef368392..536ad098 100644
--- a/internal/action/show_test.go
+++ b/internal/action/show_test.go
@@ -6,8 +6,8 @@ import (
 	"os"
 	"testing"
 
-	"github.com/atotto/clipboard"
 	"github.com/fatih/color"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/config"
 	"github.com/gopasspw/gopass/internal/out"
 	"github.com/gopasspw/gopass/pkg/ctxutil"
@@ -224,11 +224,11 @@ func TestShowMulti(t *testing.T) {
 
 func TestShowAutoClip(t *testing.T) {
 	// make sure we consistently get the unsupported error message
-	ov := clipboard.Unsupported
+	ov := clipboard.ForceUnsupported
 	defer func() {
-		clipboard.Unsupported = ov
+		clipboard.ForceUnsupported = ov
 	}()
-	clipboard.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	u := gptest.NewUnitTester(t)
 
diff --git a/main_test.go b/main_test.go
index 808602b8..3d3d2789 100644
--- a/main_test.go
+++ b/main_test.go
@@ -8,9 +8,9 @@ import (
 	"runtime"
 	"testing"
 
-	"github.com/atotto/clipboard"
 	"github.com/blang/semver/v4"
 	"github.com/fatih/color"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/action"
 	"github.com/gopasspw/gopass/internal/backend"
 	"github.com/gopasspw/gopass/internal/backend/crypto/gpg"
@@ -30,7 +30,13 @@ func TestVersionPrinter(t *testing.T) {
 	buf := &bytes.Buffer{}
 	vp := makeVersionPrinter(buf, semver.Version{Major: 1})
 	vp(nil)
-	assert.Equal(t, fmt.Sprintf("gopass 1.0.0 %s %s %s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH), buf.String())
+
+	commitStr := ""
+	if commit, _, _ := parseBuildInfo(); commit != "" {
+		commitStr = "(" + commit + ") "
+	}
+
+	assert.Equal(t, fmt.Sprintf("gopass 1.0.0 %s%s %s %s\n", commitStr, runtime.Version(), runtime.GOOS, runtime.GOARCH), buf.String())
 }
 
 func TestGetVersion(t *testing.T) {
@@ -112,7 +118,7 @@ func TestGetCommands(t *testing.T) {
 	cfg := config.NewInMemory()
 	require.NoError(t, cfg.SetPath(u.StoreDir("")))
 
-	clipboard.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	ctx := config.NewContextInMemory()
 	ctx = ctxutil.WithAlwaysYes(ctx, true)
diff --git a/pkg/clipboard/clipboard_test.go b/pkg/clipboard/clipboard_test.go
index 34abc644..d6a8ca8b 100644
--- a/pkg/clipboard/clipboard_test.go
+++ b/pkg/clipboard/clipboard_test.go
@@ -10,7 +10,7 @@ import (
 	"testing"
 	"time"
 
-	"github.com/atotto/clipboard"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/internal/config"
 	"github.com/gopasspw/gopass/internal/out"
 	"github.com/mitchellh/go-ps"
@@ -36,7 +36,7 @@ func TestUnsupportedCopyToClipboard(t *testing.T) {
 	ctx, cancel := context.WithCancel(config.NewContextInMemory())
 	defer cancel()
 
-	clipboard.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	buf := &bytes.Buffer{}
 	out.Stderr = buf
@@ -53,7 +53,7 @@ func TestClearClipboard(t *testing.T) {
 }
 
 func BenchmarkWalkProc(b *testing.B) {
-	for i := 0; i < b.N; i++ { //nolint:intrange // b.N is evaluated at each iteration.
+	for b.Loop() {
 		_ = filepath.Walk("/proc", func(path string, info os.FileInfo, err error) error {
 			if err != nil {
 				return nil
@@ -76,7 +76,7 @@ func BenchmarkWalkProc(b *testing.B) {
 }
 
 func BenchmarkListProc(b *testing.B) {
-	for i := 0; i < b.N; i++ { //nolint:intrange // b.N is evaluated at each iteration.
+	for b.Loop() {
 		procs, err := ps.Processes()
 		if err != nil {
 			b.Fatalf("err: %s", err)
diff --git a/tests/gptest/gunit.go b/tests/gptest/gunit.go
index 149b0802..10f3a3e9 100644
--- a/tests/gptest/gunit.go
+++ b/tests/gptest/gunit.go
@@ -11,7 +11,7 @@ import (
 	"github.com/ProtonMail/go-crypto/openpgp"
 	"github.com/ProtonMail/go-crypto/openpgp/armor"
 	"github.com/ProtonMail/go-crypto/openpgp/packet"
-	aclip "github.com/atotto/clipboard"
+	"github.com/gopasspw/clipboard"
 	"github.com/gopasspw/gopass/tests/can"
 	"github.com/stretchr/testify/require"
 )
@@ -41,7 +41,7 @@ func (u GUnit) GPGHome() string {
 func NewGUnitTester(t *testing.T) *GUnit {
 	t.Helper()
 
-	aclip.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	td := t.TempDir()
 	u := &GUnit{
diff --git a/tests/gptest/unit.go b/tests/gptest/unit.go
index 7ae82b13..f7228bf5 100644
--- a/tests/gptest/unit.go
+++ b/tests/gptest/unit.go
@@ -7,7 +7,7 @@ import (
 	"strings"
 	"testing"
 
-	aclip "github.com/atotto/clipboard"
+	"github.com/gopasspw/clipboard"
 	"github.com/stretchr/testify/require"
 )
 
@@ -50,7 +50,7 @@ func (u Unit) GPGHome() string {
 func NewUnitTester(t *testing.T) *Unit {
 	t.Helper()
 
-	aclip.Unsupported = true
+	clipboard.ForceUnsupported = true
 
 	td := t.TempDir()
 	u := &Unit{