diff --git a/README.md b/README.md index e04325a..41733a5 100644 --- a/README.md +++ b/README.md @@ -205,12 +205,14 @@ After reinstalling, run `which seedpass` on Linux/macOS or `where seedpass` on W #### Linux Clipboard Support -On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass will attempt to install **xclip** automatically if neither tool is available. If the automatic installation fails, you can install it manually: +On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. SeedPass no longer installs these tools automatically. To enable clipboard features such as secret mode, install **xclip** manually: ```bash -sudo apt-get install xclip +sudo apt install xclip ``` +After installing `xclip`, restart SeedPass to enable clipboard support. + ## Quick Start After installing dependencies and activating your virtual environment, install the package in editable mode so the `seedpass` command is available: diff --git a/docs/docs/content/index.md b/docs/docs/content/index.md index 5df4021..15eef6b 100644 --- a/docs/docs/content/index.md +++ b/docs/docs/content/index.md @@ -192,13 +192,15 @@ python -m pip install -e . #### Linux Clipboard Support On Linux, `pyperclip` relies on external utilities like `xclip` or `xsel`. -SeedPass will attempt to install **xclip** automatically if neither tool is -available. If the automatic installation fails, you can install it manually: +SeedPass does not install these tools automatically. To use clipboard features +such as secret mode, install **xclip** manually: ```bash -sudo apt-get install xclip +sudo apt install xclip ``` +After installing `xclip`, restart SeedPass to enable clipboard support. + ## Quick Start After installing dependencies, activate your virtual environment and install diff --git a/scripts/install.sh b/scripts/install.sh index bd73343..8794f59 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -26,26 +26,27 @@ print_error() { echo -e "\033[1;31m[ERROR]\033[0m $1" >&2; exit 1; } install_dependencies() { print_info "Installing system packages required for Gtk bindings..." if command -v apt-get &>/dev/null; then - sudo apt-get update && sudo apt-get install -y \ - build-essential pkg-config libcairo2 libcairo2-dev \ - libgirepository1.0-dev gobject-introspection \ - gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev xclip + sudo apt-get update && sudo apt-get install -y \\ + build-essential pkg-config libcairo2 libcairo2-dev \\ + libgirepository1.0-dev gobject-introspection \\ + gir1.2-gtk-3.0 python3-dev libffi-dev libssl-dev elif command -v yum &>/dev/null; then - sudo yum install -y @'Development Tools' cairo cairo-devel \ - gobject-introspection-devel gtk3-devel python3-devel \ - libffi-devel openssl-devel xclip + sudo yum install -y @'Development Tools' cairo cairo-devel \\ + gobject-introspection-devel gtk3-devel python3-devel \\ + libffi-devel openssl-devel elif command -v dnf &>/dev/null; then - sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \ - cairo cairo-devel gobject-introspection-devel gtk3-devel \ - python3-devel libffi-devel openssl-devel xclip + sudo dnf groupinstall -y "Development Tools" && sudo dnf install -y \\ + cairo cairo-devel gobject-introspection-devel gtk3-devel \\ + python3-devel libffi-devel openssl-devel elif command -v pacman &>/dev/null; then - sudo pacman -Syu --noconfirm base-devel pkgconf cairo \ - gobject-introspection gtk3 python xclip + sudo pacman -Syu --noconfirm base-devel pkgconf cairo \\ + gobject-introspection gtk3 python elif command -v brew &>/dev/null; then brew install pkg-config cairo gobject-introspection gtk+3 else print_warning "Unsupported package manager. Please install Gtk/GObject dependencies manually." fi + print_warning "Install 'xclip' manually to enable clipboard features in secret mode." } usage() { echo "Usage: $0 [-b | --branch ] [-h | --help]" diff --git a/src/main.py b/src/main.py index c387eed..c3abd19 100644 --- a/src/main.py +++ b/src/main.py @@ -1251,11 +1251,8 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in idx, password_manager.parent_seed ) print(code) - try: - copy_to_clipboard(code, password_manager.clipboard_clear_delay) + if copy_to_clipboard(code, password_manager.clipboard_clear_delay): print(colored("Code copied to clipboard", "green")) - except Exception as exc: - logging.warning(f"Clipboard copy failed: {exc}") return 0 def signal_handler(sig, _frame): diff --git a/src/seedpass/core/manager.py b/src/seedpass/core/manager.py index b3a3244..821a7f9 100644 --- a/src/seedpass/core/manager.py +++ b/src/seedpass/core/manager.py @@ -1504,13 +1504,13 @@ class PasswordManager: ) ) if self.secret_mode_enabled: - copy_to_clipboard(password, self.clipboard_clear_delay) - print( - colored( - f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(password, self.clipboard_clear_delay): + print( + colored( + f"[+] Password copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(colored(f"Password for {label}: {password}\n", "yellow")) @@ -2017,13 +2017,13 @@ class PasswordManager: print(colored(f"\n[+] Nostr key entry added with ID {index}.\n", "green")) print(colored(f"npub: {npub}", "cyan")) if self.secret_mode_enabled: - copy_to_clipboard(nsec, self.clipboard_clear_delay) - print( - colored( - f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(nsec, self.clipboard_clear_delay): + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(f"nsec: {nsec}", "deterministic")) if confirm_action("Show QR code for npub? (Y/N): "): @@ -2104,13 +2104,13 @@ class PasswordManager: if notes: print(colored(f"Notes: {notes}", "cyan")) if self.secret_mode_enabled: - copy_to_clipboard(value, self.clipboard_clear_delay) - print( - colored( - f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(value, self.clipboard_clear_delay): + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(f"Value: {value}", "deterministic")) try: @@ -2162,13 +2162,13 @@ class PasswordManager: ) if confirm_action("Reveal seed now? (y/N): "): if self.secret_mode_enabled: - copy_to_clipboard(seed, self.clipboard_clear_delay) - print( - colored( - f"[+] Seed copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(seed, self.clipboard_clear_delay): + print( + colored( + f"[+] Seed copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(seed, "deterministic")) if confirm_action("Show Compact Seed QR? (Y/N): "): @@ -2521,13 +2521,13 @@ class PasswordManager: while True: code = self.entry_manager.get_totp_code(index, self.parent_seed) if self.secret_mode_enabled: - copy_to_clipboard(code, self.clipboard_clear_delay) - print( - colored( - f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(code, self.clipboard_clear_delay): + print( + colored( + f"[+] 2FA code for '{label}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(colored("\n[+] Retrieved 2FA Code:\n", "green")) print(colored(f"Label: {label}", "cyan")) @@ -2593,13 +2593,13 @@ class PasswordManager: print(colored("Public Key:", "cyan")) print(color_text(pub_pem, "default")) if self.secret_mode_enabled: - copy_to_clipboard(priv_pem, self.clipboard_clear_delay) - print( - colored( - f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(priv_pem, self.clipboard_clear_delay): + print( + colored( + f"[+] SSH private key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(colored("Private Key:", "cyan")) print(color_text(priv_pem, "deterministic")) @@ -2628,13 +2628,13 @@ class PasswordManager: if tags: print(colored(f"Tags: {', '.join(tags)}", "cyan")) if self.secret_mode_enabled: - copy_to_clipboard(phrase, self.clipboard_clear_delay) - print( - colored( - f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(phrase, self.clipboard_clear_delay): + print( + colored( + f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(phrase, "deterministic")) if confirm_action("Show derived entropy as hex? (Y/N): "): @@ -2679,13 +2679,13 @@ class PasswordManager: print(colored(f"Tags: {', '.join(tags)}", "cyan")) print(colored(f"Fingerprint: {fingerprint}", "cyan")) if self.secret_mode_enabled: - copy_to_clipboard(priv_key, self.clipboard_clear_delay) - print( - colored( - f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(priv_key, self.clipboard_clear_delay): + print( + colored( + f"[+] PGP key copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(priv_key, "deterministic")) except Exception as e: # pragma: no cover - best effort @@ -2704,13 +2704,13 @@ class PasswordManager: print(colored(f"Label: {label}", "cyan")) print(colored(f"npub: {npub}", "cyan")) if self.secret_mode_enabled: - copy_to_clipboard(nsec, self.clipboard_clear_delay) - print( - colored( - f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(nsec, self.clipboard_clear_delay): + print( + colored( + f"[+] nsec copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(f"nsec: {nsec}", "deterministic")) if notes: @@ -2740,13 +2740,13 @@ class PasswordManager: ) ) if self.secret_mode_enabled: - copy_to_clipboard(value, self.clipboard_clear_delay) - print( - colored( - f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(value, self.clipboard_clear_delay): + print( + colored( + f"[+] Value copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(f"Value: {value}", "deterministic")) @@ -2767,13 +2767,15 @@ class PasswordManager: if show == "y": for f_label, f_value in hidden_fields: if self.secret_mode_enabled: - copy_to_clipboard(f_value, self.clipboard_clear_delay) - print( - colored( - f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard( + f_value, self.clipboard_clear_delay + ): + print( + colored( + f"[+] {f_label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(colored(f" {f_label}: {f_value}", "cyan")) return @@ -2808,13 +2810,13 @@ class PasswordManager: index, self.parent_seed ) if self.secret_mode_enabled: - copy_to_clipboard(seed, self.clipboard_clear_delay) - print( - colored( - f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(seed, self.clipboard_clear_delay): + print( + colored( + f"[+] Seed phrase copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(color_text(seed, "deterministic")) return @@ -2852,13 +2854,13 @@ class PasswordManager: if password: if self.secret_mode_enabled: - copy_to_clipboard(password, self.clipboard_clear_delay) - print( - colored( - f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard(password, self.clipboard_clear_delay): + print( + colored( + f"[+] Password for '{website_name}' copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print( colored( @@ -2897,13 +2899,15 @@ class PasswordManager: if show == "y": for label, value in hidden_fields: if self.secret_mode_enabled: - copy_to_clipboard(value, self.clipboard_clear_delay) - print( - colored( - f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", - "green", + if copy_to_clipboard( + value, self.clipboard_clear_delay + ): + print( + colored( + f"[+] {label} copied to clipboard. Will clear in {self.clipboard_clear_delay} seconds.", + "green", + ) ) - ) else: print(colored(f" {label}: {value}", "cyan")) else: @@ -3847,10 +3851,10 @@ class PasswordManager: filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" if self.secret_mode_enabled: - copy_to_clipboard(code, self.clipboard_clear_delay) - print( - f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" - ) + if copy_to_clipboard(code, self.clipboard_clear_delay): + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) else: print( f"[{idx}] {label}: {color_text(code, 'deterministic')} {bar} {remaining:2d}s" @@ -3863,10 +3867,10 @@ class PasswordManager: filled = int(20 * (period - remaining) / period) bar = "[" + "#" * filled + "-" * (20 - filled) + "]" if self.secret_mode_enabled: - copy_to_clipboard(code, self.clipboard_clear_delay) - print( - f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" - ) + if copy_to_clipboard(code, self.clipboard_clear_delay): + print( + f"[{idx}] {label}: [HIDDEN] {bar} {remaining:2d}s - copied to clipboard" + ) else: print( f"[{idx}] {label}: {color_text(code, 'imported')} {bar} {remaining:2d}s" diff --git a/src/tests/test_cli_subcommands.py b/src/tests/test_cli_subcommands.py index 6c3d944..6e9a744 100644 --- a/src/tests/test_cli_subcommands.py +++ b/src/tests/test_cli_subcommands.py @@ -60,7 +60,7 @@ def test_totp_command(monkeypatch, capsys): monkeypatch.setattr(main, "initialize_app", lambda: None) monkeypatch.setattr(main.signal, "signal", lambda *a, **k: None) monkeypatch.setattr( - main, "copy_to_clipboard", lambda v, d: called.setdefault("val", v) + main, "copy_to_clipboard", lambda v, d: (called.setdefault("val", v), True)[1] ) rc = main.main(["totp", "ex"]) assert rc == 0 diff --git a/src/tests/test_clipboard_utils.py b/src/tests/test_clipboard_utils.py index 5aa5068..3ea5442 100644 --- a/src/tests/test_clipboard_utils.py +++ b/src/tests/test_clipboard_utils.py @@ -1,6 +1,7 @@ from pathlib import Path import pyperclip import threading +import shutil import sys @@ -66,3 +67,16 @@ def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch): fake_copy("other") callbacks["func"]() assert clipboard["text"] == "other" + + +def test_copy_to_clipboard_missing_dependency(monkeypatch, capsys): + def fail_copy(*args, **kwargs): + raise pyperclip.PyperclipException("no copy") + + monkeypatch.setattr(pyperclip, "copy", fail_copy) + monkeypatch.setattr(pyperclip, "paste", lambda: "") + monkeypatch.setattr(shutil, "which", lambda cmd: None) + + copy_to_clipboard("secret", 1) + out = capsys.readouterr().out + assert "install xclip" in out.lower() diff --git a/src/tests/test_manager_add_password.py b/src/tests/test_manager_add_password.py index 0b09a8a..92bc12c 100644 --- a/src/tests/test_manager_add_password.py +++ b/src/tests/test_manager_add_password.py @@ -140,7 +140,7 @@ def test_handle_add_password_secret_mode(monkeypatch, dummy_nostr_client, capsys called = [] monkeypatch.setattr( "seedpass.core.manager.copy_to_clipboard", - lambda text, delay: called.append((text, delay)), + lambda text, delay: (called.append((text, delay)), True)[1], ) pm.handle_add_password() diff --git a/src/tests/test_manager_list_entries.py b/src/tests/test_manager_list_entries.py index b687c24..49e4086 100644 --- a/src/tests/test_manager_list_entries.py +++ b/src/tests/test_manager_list_entries.py @@ -301,7 +301,7 @@ def test_show_entry_details_sensitive(monkeypatch, capsys, entry_type): "seedpass.core.manager.confirm_action", lambda *a, **k: True ) monkeypatch.setattr( - "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: None + "seedpass.core.manager.copy_to_clipboard", lambda *a, **k: True ) monkeypatch.setattr("seedpass.core.manager.timed_input", lambda *a, **k: "b") monkeypatch.setattr("seedpass.core.manager.time.sleep", lambda *a, **k: None) diff --git a/src/tests/test_secret_mode.py b/src/tests/test_secret_mode.py index 6c4339d..4e3c0b1 100644 --- a/src/tests/test_secret_mode.py +++ b/src/tests/test_secret_mode.py @@ -46,7 +46,7 @@ def test_password_retrieve_secret_mode(monkeypatch, capsys): called = [] monkeypatch.setattr( "seedpass.core.manager.copy_to_clipboard", - lambda text, t: called.append((text, t)), + lambda text, t: (called.append((text, t)), True)[1], ) pm.handle_retrieve_entry() @@ -73,7 +73,7 @@ def test_totp_display_secret_mode(monkeypatch, capsys): called = [] monkeypatch.setattr( "seedpass.core.manager.copy_to_clipboard", - lambda text, t: called.append((text, t)), + lambda text, t: (called.append((text, t)), True)[1], ) pm.handle_display_totp_codes() @@ -95,7 +95,7 @@ def test_password_retrieve_no_secret_mode(monkeypatch, capsys): called = [] monkeypatch.setattr( "seedpass.core.manager.copy_to_clipboard", - lambda *a, **k: called.append((a, k)), + lambda *a, **k: (called.append((a, k)), True)[1], ) pm.handle_retrieve_entry() @@ -123,7 +123,7 @@ def test_totp_display_no_secret_mode(monkeypatch, capsys): called = [] monkeypatch.setattr( "seedpass.core.manager.copy_to_clipboard", - lambda *a, **k: called.append((a, k)), + lambda *a, **k: (called.append((a, k)), True)[1], ) pm.handle_display_totp_codes() diff --git a/src/utils/clipboard.py b/src/utils/clipboard.py index b5b5e60..66621a6 100644 --- a/src/utils/clipboard.py +++ b/src/utils/clipboard.py @@ -1,8 +1,7 @@ -import threading import logging -import subprocess import shutil import sys +import threading import pyperclip @@ -10,31 +9,38 @@ logger = logging.getLogger(__name__) def _ensure_clipboard() -> None: - """Attempt to ensure a clipboard mechanism is available.""" + """Ensure a clipboard mechanism is available or raise an informative error.""" try: pyperclip.copy("") except pyperclip.PyperclipException as exc: if sys.platform.startswith("linux"): if shutil.which("xclip") is None and shutil.which("xsel") is None: - apt = shutil.which("apt-get") or shutil.which("apt") - if apt: - try: - subprocess.run( - ["sudo", apt, "install", "-y", "xclip"], check=True - ) - pyperclip.copy("") - return - except Exception as install_exc: # pragma: no cover - system dep - logger.warning( - "Automatic xclip installation failed: %s", install_exc - ) - raise exc + raise pyperclip.PyperclipException( + "Clipboard support requires the 'xclip' package. " + "Install it with 'sudo apt install xclip' and restart SeedPass." + ) from exc + raise -def copy_to_clipboard(text: str, timeout: int) -> None: - """Copy text to the clipboard and clear after timeout seconds if unchanged.""" +def copy_to_clipboard(text: str, timeout: int) -> bool: + """Copy text to the clipboard and clear after timeout seconds if unchanged. + + Returns True if the text was successfully copied, False otherwise. + """ + + try: + _ensure_clipboard() + except pyperclip.PyperclipException as exc: + warning = ( + "Clipboard unavailable: " + + str(exc) + + "\nSeedPass secret mode requires clipboard support. " + "Install xclip or disable secret mode to view secrets." + ) + logger.warning(warning) + print(warning) + return False - _ensure_clipboard() pyperclip.copy(text) def clear_clipboard() -> None: @@ -44,3 +50,4 @@ def copy_to_clipboard(text: str, timeout: int) -> None: timer = threading.Timer(timeout, clear_clipboard) timer.daemon = True timer.start() + return True