mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-09 15:58:48 +00:00
Merge pull request #776 from PR0M3TH3AN/codex/raise-exception-if-xclip/xsel-is-missing
Gracefully handle missing clipboard utilities
This commit is contained in:
18
src/main.py
18
src/main.py
@@ -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)
|
||||||
|
try:
|
||||||
if 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"))
|
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):
|
||||||
|
@@ -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))
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@@ -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,6 +70,7 @@ 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)
|
||||||
|
try:
|
||||||
matches = service.search_entries(query)
|
matches = service.search_entries(query)
|
||||||
if len(matches) == 0:
|
if len(matches) == 0:
|
||||||
typer.echo("No matching entries found")
|
typer.echo("No matching entries found")
|
||||||
@@ -95,6 +97,13 @@ def entry_get(ctx: typer.Context, query: str) -> None:
|
|||||||
else:
|
else:
|
||||||
typer.echo("Unsupported entry type")
|
typer.echo("Unsupported entry type")
|
||||||
raise typer.Exit(code=1)
|
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)
|
||||||
|
|
||||||
|
|
||||||
@app.command("add")
|
@app.command("add")
|
||||||
|
44
src/tests/test_cli_clipboard_flag.py
Normal file
44
src/tests/test_cli_clipboard_flag.py
Normal 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
|
@@ -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)
|
||||||
|
|
||||||
|
with pytest.raises(ClipboardUnavailableError):
|
||||||
copy_to_clipboard("secret", 1)
|
copy_to_clipboard("secret", 1)
|
||||||
out = capsys.readouterr().out
|
|
||||||
assert "install xclip" in out.lower()
|
|
||||||
|
@@ -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",
|
||||||
|
@@ -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
|
||||||
|
raise ClipboardUnavailableError(
|
||||||
|
"No clipboard mechanism available. Install a supported clipboard tool or "
|
||||||
|
"run SeedPass with --no-clipboard."
|
||||||
) from exc
|
) from exc
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
_ensure_clipboard()
|
_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"]
|
||||||
|
Reference in New Issue
Block a user