From bf1399ce8d6e39d04d3cdad2d99ae787c88effdf Mon Sep 17 00:00:00 2001 From: Randall Winkhart Date: Sun, 9 Nov 2025 23:23:47 -0500 Subject: [PATCH] Better modularize clipboard code (add "clip" package; break up TOTP code --- {core => clip}/clipClearProcessDROID.go | 2 +- {core => clip}/clipClearProcessGeneric.go | 2 +- clip/copyArgument.go | 76 +++++++++++ {core => clip}/copyDARWIN.go | 6 +- {core => clip}/copyMOBILE.go | 6 +- {core => clip}/copyTERMUX.go | 6 +- {core => clip}/copyUNIX.go | 6 +- {core => clip}/copyWIN.go | 6 +- .../launchClipClearProcessCLIGeneric.go | 2 +- .../launchClipClearProcessCLIUNIX.go | 2 +- .../launchClipClearProcessGUIGeneric.go | 2 +- .../launchClipClearProcessGUIUNIX.go | 2 +- clip/totp.go | 65 ++++++++++ core/copy.go | 119 ------------------ 14 files changed, 162 insertions(+), 140 deletions(-) rename {core => clip}/clipClearProcessDROID.go (98%) rename {core => clip}/clipClearProcessGeneric.go (98%) create mode 100644 clip/copyArgument.go rename {core => clip}/copyDARWIN.go (83%) rename {core => clip}/copyMOBILE.go (63%) rename {core => clip}/copyTERMUX.go (84%) rename {core => clip}/copyUNIX.go (91%) rename {core => clip}/copyWIN.go (84%) rename {core => clip}/launchClipClearProcessCLIGeneric.go (97%) rename {core => clip}/launchClipClearProcessCLIUNIX.go (98%) rename {core => clip}/launchClipClearProcessGUIGeneric.go (97%) rename {core => clip}/launchClipClearProcessGUIUNIX.go (97%) create mode 100644 clip/totp.go delete mode 100644 core/copy.go diff --git a/core/clipClearProcessDROID.go b/clip/clipClearProcessDROID.go similarity index 98% rename from core/clipClearProcessDROID.go rename to clip/clipClearProcessDROID.go index e5bcebb..b4b0657 100644 --- a/core/clipClearProcessDROID.go +++ b/clip/clipClearProcessDROID.go @@ -1,6 +1,6 @@ //go:build (android && !termux) || ios -package core +package clip import ( "strings" diff --git a/core/clipClearProcessGeneric.go b/clip/clipClearProcessGeneric.go similarity index 98% rename from core/clipClearProcessGeneric.go rename to clip/clipClearProcessGeneric.go index 2326c08..b827791 100644 --- a/core/clipClearProcessGeneric.go +++ b/clip/clipClearProcessGeneric.go @@ -1,6 +1,6 @@ //go:build (!android && !ios) || termux -package core +package clip import ( "errors" diff --git a/clip/copyArgument.go b/clip/copyArgument.go new file mode 100644 index 0000000..2d04cba --- /dev/null +++ b/clip/copyArgument.go @@ -0,0 +1,76 @@ +package clip + +import ( + "errors" + "fmt" + "os" + + "github.com/rwinkhart/go-boilerplate/back" + "github.com/rwinkhart/libmutton/crypt" +) + +// CopyArgument copies a field from an entry to the clipboard. +// If field is -1, it will one-time copy the TOTP code +// (instead of keeping the clipboard up-to-date). +func CopyArgument(targetLocation string, field int) error { + // ensure targetLocation exists and is a file + _, err := back.TargetIsFile(targetLocation, true) + if err != nil { + return err + } + + decSlice, err := crypt.DecryptFileToSlice(targetLocation) + if err != nil { + return errors.New("unable to decrypt entry: " + err.Error()) + } + + // handle non-persistent TOTP copy + var copySubject string + var realField int + if field == -1 { + realField = 2 + } else { + realField = field + } + + // if field exists in entry... + if len(decSlice) > realField { + if decSlice[realField] == "" { + return errors.New("field is empty") + } + + if realField == 2 { // TOTP mode + if field != -1 { + fmt.Println("Clipboard refreshing with the current TOTP code until this process is closed") + } + errorChan := make(chan error) + go TOTPCopier(decSlice[2], field, errorChan, nil) // "done" is not needed because the process runs until the program is killed + err = <-errorChan + if err != nil { // block until first successful copy + return errors.New("error encountered in TOTP refresh process: " + err.Error()) + } + } else { // other + copySubject = decSlice[realField] + } + } else { + return errors.New("field does not exist in entry") + } + + // copy field to clipboard; launch clipboard clearing process + err = CopyString(false, copySubject) + if err != nil { + return err + } + + return nil +} + +// ClipClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess. +func ClipClearArgument() error { + assignedContents := back.ReadFromStdin() + if assignedContents == "" { + os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment) + } + err := clipClearProcess(assignedContents) + return err +} diff --git a/core/copyDARWIN.go b/clip/copyDARWIN.go similarity index 83% rename from core/copyDARWIN.go rename to clip/copyDARWIN.go index 38d7651..057908f 100644 --- a/core/copyDARWIN.go +++ b/clip/copyDARWIN.go @@ -1,6 +1,6 @@ //go:build darwin && !ios -package core +package clip import ( "errors" @@ -9,8 +9,8 @@ import ( "github.com/rwinkhart/go-boilerplate/back" ) -// copyString copies a string to the clipboard. -func copyString(continuous bool, copySubject string) error { +// CopyString copies a string to the clipboard. +func CopyString(continuous bool, copySubject string) error { cmd := exec.Command("pbcopy") _ = back.WriteToStdin(cmd, copySubject) err := cmd.Run() diff --git a/core/copyMOBILE.go b/clip/copyMOBILE.go similarity index 63% rename from core/copyMOBILE.go rename to clip/copyMOBILE.go index b02a0b9..bd5c161 100644 --- a/core/copyMOBILE.go +++ b/clip/copyMOBILE.go @@ -1,13 +1,13 @@ //go:build (android && !termux) || ios -package core +package clip import ( "golang.design/x/clipboard" ) -// copyString copies a string to the clipboard. -func copyString(continuous bool, copySubject string) error { +// CopyString copies a string to the clipboard. +func CopyString(continuous bool, copySubject string) error { clipboard.Write(clipboard.FmtText, []byte(copySubject)) if !continuous { LaunchClipClearProcess(copySubject) diff --git a/core/copyTERMUX.go b/clip/copyTERMUX.go similarity index 84% rename from core/copyTERMUX.go rename to clip/copyTERMUX.go index 0625701..5f251ea 100644 --- a/core/copyTERMUX.go +++ b/clip/copyTERMUX.go @@ -1,6 +1,6 @@ //go:build android && termux -package core +package clip import ( "errors" @@ -9,8 +9,8 @@ import ( "github.com/rwinkhart/go-boilerplate/back" ) -// copyString copies a string to the clipboard. -func copyString(continuous bool, copySubject string) error { +// CopyString copies a string to the clipboard. +func CopyString(continuous bool, copySubject string) error { cmd := exec.Command("termux-clipboard-set") _ = back.WriteToStdin(cmd, copySubject) err := cmd.Run() diff --git a/core/copyUNIX.go b/clip/copyUNIX.go similarity index 91% rename from core/copyUNIX.go rename to clip/copyUNIX.go index e8db445..2293b50 100644 --- a/core/copyUNIX.go +++ b/clip/copyUNIX.go @@ -1,6 +1,6 @@ //go:build !windows && !darwin && !android && !ios && !termux && !wsl -package core +package clip import ( "errors" @@ -10,8 +10,8 @@ import ( "github.com/rwinkhart/go-boilerplate/back" ) -// copyString copies a string to the clipboard. -func copyString(continuous bool, copySubject string) error { +// CopyString copies a string to the clipboard. +func CopyString(continuous bool, copySubject string) error { // determine whether to use wl-copy (Wayland) or xclip (X11) var envSet, isWayland bool // track whether environment variables are set var cmdCopy *exec.Cmd diff --git a/core/copyWIN.go b/clip/copyWIN.go similarity index 84% rename from core/copyWIN.go rename to clip/copyWIN.go index a105d81..399212c 100644 --- a/core/copyWIN.go +++ b/clip/copyWIN.go @@ -1,6 +1,6 @@ //go:build windows || (linux && wsl) -package core +package clip import ( "errors" @@ -9,8 +9,8 @@ import ( "strings" ) -// copyString copies a string to the clipboard. -func copyString(continuous bool, copySubject string) error { +// CopyString copies a string to the clipboard. +func CopyString(continuous bool, copySubject string) error { cmd := exec.Command("powershell.exe", "-c", fmt.Sprintf("echo '%s' | Set-Clipboard", strings.ReplaceAll(copySubject, "'", "''"))) err := cmd.Run() if err != nil { diff --git a/core/launchClipClearProcessCLIGeneric.go b/clip/launchClipClearProcessCLIGeneric.go similarity index 97% rename from core/launchClipClearProcessCLIGeneric.go rename to clip/launchClipClearProcessCLIGeneric.go index 76d48e8..7e18d96 100644 --- a/core/launchClipClearProcessCLIGeneric.go +++ b/clip/launchClipClearProcessCLIGeneric.go @@ -1,6 +1,6 @@ //go:build (windows || darwin || android || ios || termux || wsl) && !interactive -package core +package clip import ( "os" diff --git a/core/launchClipClearProcessCLIUNIX.go b/clip/launchClipClearProcessCLIUNIX.go similarity index 98% rename from core/launchClipClearProcessCLIUNIX.go rename to clip/launchClipClearProcessCLIUNIX.go index 9b81f45..67fec03 100644 --- a/core/launchClipClearProcessCLIUNIX.go +++ b/clip/launchClipClearProcessCLIUNIX.go @@ -1,6 +1,6 @@ //go:build !windows && !darwin && !android && !ios && !termux && !wsl && !interactive -package core +package clip import ( "os" diff --git a/core/launchClipClearProcessGUIGeneric.go b/clip/launchClipClearProcessGUIGeneric.go similarity index 97% rename from core/launchClipClearProcessGUIGeneric.go rename to clip/launchClipClearProcessGUIGeneric.go index e7a1ce8..c3bf917 100644 --- a/core/launchClipClearProcessGUIGeneric.go +++ b/clip/launchClipClearProcessGUIGeneric.go @@ -1,6 +1,6 @@ //go:build (windows || darwin || android || ios || termux || wsl) && interactive -package core +package clip // LaunchClipClearProcess launches the timed clipboard clearing process. // For interactive GUI/TUI implementations, the clipboard clearing process is launched as a goroutine. diff --git a/core/launchClipClearProcessGUIUNIX.go b/clip/launchClipClearProcessGUIUNIX.go similarity index 97% rename from core/launchClipClearProcessGUIUNIX.go rename to clip/launchClipClearProcessGUIUNIX.go index 637747d..01dc568 100644 --- a/core/launchClipClearProcessGUIUNIX.go +++ b/clip/launchClipClearProcessGUIUNIX.go @@ -1,6 +1,6 @@ //go:build !windows && !darwin && !android && !ios && !termux && !wsl && interactive -package core +package clip import ( "os" diff --git a/clip/totp.go b/clip/totp.go new file mode 100644 index 0000000..66439c9 --- /dev/null +++ b/clip/totp.go @@ -0,0 +1,65 @@ +package clip + +import ( + "errors" + "strings" + "time" + + "github.com/pquerna/otp" + "github.com/pquerna/otp/totp" +) + +// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP). +func GenTOTP(secret string, time time.Time, forSteam bool) (string, error) { + var totpToken string + var err error + + if forSteam { + totpToken, err = totp.GenerateCodeCustom(secret, time, totp.ValidateOpts{Period: 30, Digits: 5, Encoder: otp.EncoderSteam}) + } else { + totpToken, err = totp.GenerateCode(secret, time) + } + + if err != nil { + return "", errors.New("unable to generate TOTP token: " + err.Error()) + } + + return totpToken, nil +} + +// TOTPCopier is meant to be run as a goroutine to keep +// the clipboard up-to-date with the latest TOTP token. +// Set oneTime to -1 for a one-time (non-continuous) TOTP copy. +func TOTPCopier(secret string, oneTime int, errorChan chan<- error, done <-chan bool) { + var forSteam bool + if strings.HasPrefix(secret, "steam@") { + secret = secret[6:] + forSteam = true + } + + for { + currentTime := time.Now() + token, err := GenTOTP(secret, currentTime, forSteam) + if err != nil { + errorChan <- err + } + err = CopyString(true, token) + if err != nil { + errorChan <- err + } + if oneTime != -1 { + errorChan <- nil + time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second) + } else { + return + } + + // exit after sleep if indicated (will not update clipboard again) + select { + case <-done: + errorChan <- nil + return + default: + } + } +} diff --git a/core/copy.go b/core/copy.go deleted file mode 100644 index c2dc71f..0000000 --- a/core/copy.go +++ /dev/null @@ -1,119 +0,0 @@ -package core - -import ( - "errors" - "fmt" - "os" - "strings" - "time" - - "github.com/pquerna/otp" - "github.com/pquerna/otp/totp" - "github.com/rwinkhart/go-boilerplate/back" - "github.com/rwinkhart/libmutton/crypt" -) - -// CopyArgument copies a field from an entry to the clipboard. -// If field is -1, it will one-time copy the TOTP code -// (instead of keeping the clipboard up-to-date). -func CopyArgument(targetLocation string, field int) error { - // ensure targetLocation exists and is a file - _, err := back.TargetIsFile(targetLocation, true) - if err != nil { - return err - } - - decryptedEntry, err := crypt.DecryptFileToSlice(targetLocation) - if err != nil { - return errors.New("unable to decrypt entry: " + err.Error()) - } - var copySubject string // will store data to be copied - - // handle non-persistent TOTP copy - var realField int - if field == -1 { - realField = 2 - } else { - realField = field - } - - // ensure field exists in entry - if len(decryptedEntry) > realField { - - // ensure field is not empty - if decryptedEntry[realField] == "" { - return errors.New("field is empty") - } - - if realField != 2 { - copySubject = decryptedEntry[realField] - } else { // TOTP mode - var secret string // stores secret for TOTP generation - var forSteam bool // indicates whether to generate TOTP in Steam format - - if strings.HasPrefix(decryptedEntry[2], "steam@") { - secret = decryptedEntry[2][6:] - forSteam = true - } else { - secret = decryptedEntry[2] - } - - fmt.Println("Clipboard will be kept up to date with the current TOTP code until this process is closed") - - for { // keep token copied to clipboard, refresh on 30-second intervals - currentTime := time.Now() - token, err := GenTOTP(secret, currentTime, forSteam) - if err != nil { - return err - } - err = copyString(true, token) - if err != nil { - return err - } - if field == -1 { - return nil // return early (for interactive clients) - } - // sleep until next 30-second interval - time.Sleep(time.Duration(30-(currentTime.Second()%30)) * time.Second) - } - } - } else { - return errors.New("field does not exist in entry") - } - - // copy field to clipboard, launch clipboard clearing process - err = copyString(false, copySubject) - if err != nil { - return err - } - - return nil -} - -// ClipClearArgument reads the assigned clipboard contents from stdin and passes them to clipClearProcess. -func ClipClearArgument() error { - assignedContents := back.ReadFromStdin() - if assignedContents == "" { - os.Exit(0) // use os.Exit instead of core.Exit, as this function runs out of a background subprocess that is invisible to the user (will never appear in GUI/TUI environment) - } - err := clipClearProcess(assignedContents) - return err -} - -// GenTOTP generates a TOTP token from a secret (supports standard and Steam TOTP). -func GenTOTP(secret string, time time.Time, forSteam bool) (string, error) { - var totpToken string - var err error - - if forSteam { - totpToken, err = totp.GenerateCodeCustom(secret, time, totp.ValidateOpts{Period: 30, Digits: 5, Encoder: otp.EncoderSteam}) - } else { - totpToken, err = totp.GenerateCode(secret, time) - } - - if err != nil { - return "", errors.New("unable to generate TOTP token: " + err.Error()) - } - - return totpToken, nil -}