Merge pull request #776 from PR0M3TH3AN/codex/raise-exception-if-xclip/xsel-is-missing

Gracefully handle missing clipboard utilities
This commit is contained in:
thePR0M3TH3AN
2025-08-05 22:35:34 -04:00
committed by GitHub
8 changed files with 142 additions and 54 deletions

View File

@@ -32,6 +32,7 @@ from utils import (
pause, pause,
clear_header_with_notification, clear_header_with_notification,
) )
from utils.clipboard import ClipboardUnavailableError
from utils.atomic_write import atomic_write from utils.atomic_write import atomic_write
import queue import queue
from local_bip85.bip85 import Bip85Error from local_bip85.bip85 import Bip85Error
@@ -1233,6 +1234,11 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
load_global_config() load_global_config()
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--fingerprint") parser.add_argument("--fingerprint")
parser.add_argument(
"--no-clipboard",
action="store_true",
help="Disable clipboard support and print secrets",
)
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
exp = sub.add_parser("export") exp = sub.add_parser("export")
@@ -1264,6 +1270,9 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red")) print(colored(f"Error: Failed to initialize PasswordManager: {e}", "red"))
return 1 return 1
if args.no_clipboard:
password_manager.secret_mode_enabled = False
if args.command == "export": if args.command == "export":
password_manager.handle_export_database(Path(args.file)) password_manager.handle_export_database(Path(args.file))
return 0 return 0
@@ -1311,8 +1320,17 @@ def main(argv: list[str] | None = None, *, fingerprint: str | None = None) -> in
idx, password_manager.parent_seed idx, password_manager.parent_seed
) )
print(code) print(code)
if copy_to_clipboard(code, password_manager.clipboard_clear_delay): try:
print(colored("Code copied to clipboard", "green")) if copy_to_clipboard(code, password_manager.clipboard_clear_delay):
print(colored("Code copied to clipboard", "green"))
except ClipboardUnavailableError as exc:
print(
colored(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print codes instead.",
"yellow",
)
)
return 0 return 0
def signal_handler(sig, _frame): def signal_handler(sig, _frame):

View File

@@ -23,6 +23,13 @@ fingerprint_option = typer.Option(
help="Specify which seed profile to use", help="Specify which seed profile to use",
) )
no_clipboard_option = typer.Option(
False,
"--no-clipboard",
help="Disable clipboard support and print secrets instead",
is_flag=True,
)
# Sub command groups # Sub command groups
from . import entry, vault, nostr, config, fingerprint, util, api from . import entry, vault, nostr, config, fingerprint, util, api
@@ -44,12 +51,16 @@ def _gui_backend_available() -> bool:
@app.callback(invoke_without_command=True) @app.callback(invoke_without_command=True)
def main(ctx: typer.Context, fingerprint: Optional[str] = fingerprint_option) -> None: def main(
ctx: typer.Context,
fingerprint: Optional[str] = fingerprint_option,
no_clipboard: bool = no_clipboard_option,
) -> None:
"""SeedPass CLI entry point. """SeedPass CLI entry point.
When called without a subcommand this launches the interactive TUI. When called without a subcommand this launches the interactive TUI.
""" """
ctx.obj = {"fingerprint": fingerprint} ctx.obj = {"fingerprint": fingerprint, "no_clipboard": no_clipboard}
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
tui = importlib.import_module("main") tui = importlib.import_module("main")
raise typer.Exit(tui.main(fingerprint=fingerprint)) raise typer.Exit(tui.main(fingerprint=fingerprint))

View File

@@ -27,6 +27,8 @@ def _get_pm(ctx: typer.Context) -> PasswordManager:
pm = PasswordManager() pm = PasswordManager()
else: else:
pm = PasswordManager(fingerprint=fp) pm = PasswordManager(fingerprint=fp)
if ctx.obj.get("no_clipboard"):
pm.secret_mode_enabled = False
return pm return pm

View File

@@ -8,6 +8,7 @@ from typing import List, Optional
import typer import typer
from .common import _get_entry_service, EntryType from .common import _get_entry_service, EntryType
from utils.clipboard import ClipboardUnavailableError
app = typer.Typer(help="Manage individual entries") app = typer.Typer(help="Manage individual entries")
@@ -69,31 +70,39 @@ def entry_search(
def entry_get(ctx: typer.Context, query: str) -> None: def entry_get(ctx: typer.Context, query: str) -> None:
"""Retrieve a single entry's secret.""" """Retrieve a single entry's secret."""
service = _get_entry_service(ctx) service = _get_entry_service(ctx)
matches = service.search_entries(query) try:
if len(matches) == 0: matches = service.search_entries(query)
typer.echo("No matching entries found") if len(matches) == 0:
raise typer.Exit(code=1) typer.echo("No matching entries found")
if len(matches) > 1: raise typer.Exit(code=1)
typer.echo("Matches:") if len(matches) > 1:
for idx, label, username, _url, _arch, etype in matches: typer.echo("Matches:")
name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}" for idx, label, username, _url, _arch, etype in matches:
if username: name = f"{idx}: {etype.value.replace('_', ' ').title()} - {label}"
name += f" ({username})" if username:
typer.echo(name) name += f" ({username})"
raise typer.Exit(code=1) typer.echo(name)
raise typer.Exit(code=1)
index = matches[0][0] index = matches[0][0]
entry = service.retrieve_entry(index) entry = service.retrieve_entry(index)
etype = entry.get("type", entry.get("kind")) etype = entry.get("type", entry.get("kind"))
if etype == EntryType.PASSWORD.value: if etype == EntryType.PASSWORD.value:
length = int(entry.get("length", 12)) length = int(entry.get("length", 12))
password = service.generate_password(length, index) password = service.generate_password(length, index)
typer.echo(password) typer.echo(password)
elif etype == EntryType.TOTP.value: elif etype == EntryType.TOTP.value:
code = service.get_totp_code(index) code = service.get_totp_code(index)
typer.echo(code) typer.echo(code)
else: else:
typer.echo("Unsupported entry type") typer.echo("Unsupported entry type")
raise typer.Exit(code=1)
except ClipboardUnavailableError as exc:
typer.echo(
f"Clipboard unavailable: {exc}\n"
"Re-run with '--no-clipboard' to print secrets instead.",
err=True,
)
raise typer.Exit(code=1) raise typer.Exit(code=1)

View File

@@ -0,0 +1,44 @@
from typer.testing import CliRunner
from seedpass.cli import app, entry as cli_entry
from seedpass.core.entry_types import EntryType
from utils.clipboard import ClipboardUnavailableError
runner = CliRunner()
def _stub_service(ctx, raise_error=True):
class Service:
def search_entries(self, query, kinds=None):
return [(1, "label", None, None, False, EntryType.PASSWORD)]
def retrieve_entry(self, idx):
return {"type": EntryType.PASSWORD.value, "length": 12}
def generate_password(self, length, index):
if raise_error and not ctx.obj.get("no_clipboard"):
raise ClipboardUnavailableError("missing")
return "pwd"
return Service()
def test_entry_get_handles_missing_clipboard(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(app, ["entry", "get", "label"], catch_exceptions=False)
assert result.exit_code == 1
assert "no-clipboard" in result.stderr.lower()
def test_entry_get_no_clipboard_flag(monkeypatch):
monkeypatch.setattr(
cli_entry, "_get_entry_service", lambda ctx: _stub_service(ctx, True)
)
result = runner.invoke(
app, ["--no-clipboard", "entry", "get", "label"], catch_exceptions=False
)
assert result.exit_code == 0
assert "pwd" in result.stdout

View File

@@ -7,7 +7,9 @@ import sys
sys.path.append(str(Path(__file__).resolve().parents[1])) sys.path.append(str(Path(__file__).resolve().parents[1]))
from utils.clipboard import copy_to_clipboard import pytest
from utils.clipboard import ClipboardUnavailableError, copy_to_clipboard
def test_copy_to_clipboard_clears(monkeypatch): def test_copy_to_clipboard_clears(monkeypatch):
@@ -69,7 +71,7 @@ def test_copy_to_clipboard_does_not_clear_if_changed(monkeypatch):
assert clipboard["text"] == "other" assert clipboard["text"] == "other"
def test_copy_to_clipboard_missing_dependency(monkeypatch, capsys): def test_copy_to_clipboard_missing_dependency(monkeypatch):
def fail_copy(*args, **kwargs): def fail_copy(*args, **kwargs):
raise pyperclip.PyperclipException("no copy") raise pyperclip.PyperclipException("no copy")
@@ -77,6 +79,5 @@ def test_copy_to_clipboard_missing_dependency(monkeypatch, capsys):
monkeypatch.setattr(pyperclip, "paste", lambda: "") monkeypatch.setattr(pyperclip, "paste", lambda: "")
monkeypatch.setattr(shutil, "which", lambda cmd: None) monkeypatch.setattr(shutil, "which", lambda cmd: None)
copy_to_clipboard("secret", 1) with pytest.raises(ClipboardUnavailableError):
out = capsys.readouterr().out copy_to_clipboard("secret", 1)
assert "install xclip" in out.lower()

View File

@@ -38,13 +38,16 @@ from .atomic_write import atomic_write
# Optional clipboard support # Optional clipboard support
try: # pragma: no cover - exercised when dependency missing try: # pragma: no cover - exercised when dependency missing
from .clipboard import copy_to_clipboard from .clipboard import ClipboardUnavailableError, copy_to_clipboard
except Exception as exc: # pragma: no cover - executed only if pyperclip missing except Exception as exc: # pragma: no cover - executed only if pyperclip missing
class ClipboardUnavailableError(RuntimeError):
"""Stub exception when clipboard support is unavailable."""
def copy_to_clipboard(*_args, **_kwargs): def copy_to_clipboard(*_args, **_kwargs):
"""Stub when clipboard support is unavailable.""" """Stub when clipboard support is unavailable."""
logger.warning("Clipboard support unavailable: %s", exc) logger.warning("Clipboard support unavailable: %s", exc)
return False raise ClipboardUnavailableError(str(exc))
__all__ = [ __all__ = [
@@ -69,6 +72,7 @@ __all__ = [
"timed_input", "timed_input",
"InMemorySecret", "InMemorySecret",
"copy_to_clipboard", "copy_to_clipboard",
"ClipboardUnavailableError",
"clear_screen", "clear_screen",
"clear_and_print_fingerprint", "clear_and_print_fingerprint",
"clear_header_with_notification", "clear_header_with_notification",

View File

@@ -1,3 +1,5 @@
"""Clipboard utility helpers."""
import logging import logging
import shutil import shutil
import sys import sys
@@ -5,6 +7,11 @@ import threading
import pyperclip import pyperclip
class ClipboardUnavailableError(RuntimeError):
"""Raised when required clipboard utilities are not installed."""
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -15,31 +22,20 @@ def _ensure_clipboard() -> None:
except pyperclip.PyperclipException as exc: except pyperclip.PyperclipException as exc:
if sys.platform.startswith("linux"): if sys.platform.startswith("linux"):
if shutil.which("xclip") is None and shutil.which("xsel") is None: if shutil.which("xclip") is None and shutil.which("xsel") is None:
raise pyperclip.PyperclipException( raise ClipboardUnavailableError(
"Clipboard support requires the 'xclip' package. " "Clipboard support requires the 'xclip' package. "
"Install it with 'sudo apt install xclip' and restart SeedPass." "Install it with 'sudo apt install xclip' and restart SeedPass.",
) from exc ) from exc
raise raise ClipboardUnavailableError(
"No clipboard mechanism available. Install a supported clipboard tool or "
"run SeedPass with --no-clipboard."
) from exc
def copy_to_clipboard(text: str, timeout: int) -> bool: def copy_to_clipboard(text: str, timeout: int) -> bool:
"""Copy text to the clipboard and clear after timeout seconds if unchanged. """Copy text to the clipboard and clear after ``timeout`` seconds if unchanged."""
Returns True if the text was successfully copied, False otherwise. _ensure_clipboard()
"""
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
pyperclip.copy(text) pyperclip.copy(text)
@@ -51,3 +47,6 @@ def copy_to_clipboard(text: str, timeout: int) -> bool:
timer.daemon = True timer.daemon = True
timer.start() timer.start()
return True return True
__all__ = ["copy_to_clipboard", "ClipboardUnavailableError"]